web designer: Queue engine test
[published via task queue]
This commit is contained in:
parent
faef1c3229
commit
d4ee3ff960
6 changed files with 154 additions and 0 deletions
42
api/cli/tasks-finish.php
Normal file
42
api/cli/tasks-finish.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Mark a Web Designer task terminal. Used by the console runner after it runs
|
||||||
|
* the design agent + build + commit (or on failure).
|
||||||
|
*
|
||||||
|
* php api/cli/tasks-finish.php --id=42 --status=published --result='{"commit":"abc123","summary":"...","live":"/about"}'
|
||||||
|
* php api/cli/tasks-finish.php --id=42 --status=failed --result='{"error":"build failed"}'
|
||||||
|
*
|
||||||
|
* status must be one of: published, failed. `result` is stored verbatim as JSON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$opts = getopt('', ['id:', 'status:', 'result::']);
|
||||||
|
$id = (int) ($opts['id'] ?? 0);
|
||||||
|
$status = (string) ($opts['status'] ?? '');
|
||||||
|
$result = (string) ($opts['result'] ?? '{}');
|
||||||
|
|
||||||
|
if ($id <= 0 || !in_array($status, ['published', 'failed'], true)) {
|
||||||
|
fwrite(STDERR, "usage: tasks-finish.php --id=<n> --status=published|failed --result=<json>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the result payload is JSON; fall back to empty object if not.
|
||||||
|
if (json_decode($result) === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$result = '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE cja_tasks SET status = ?, result = ?, finished_at = NOW() WHERE task_id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$status, $result, $id]);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'id' => $id, 'status' => $status]);
|
||||||
53
api/cli/tasks-next.php
Normal file
53
api/cli/tasks-next.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Atomically claim the oldest queued Web Designer task and print it as JSON.
|
||||||
|
* Prints an empty JSON object ({}) when the queue is empty. Used by the console
|
||||||
|
* runner's drain loop (agents/console/server.mjs) — the runner has no DB driver,
|
||||||
|
* so it shells out to this.
|
||||||
|
*
|
||||||
|
* php api/cli/tasks-next.php
|
||||||
|
*
|
||||||
|
* The claim is a single conditional UPDATE (status queued -> running) guarded by
|
||||||
|
* task_id, so two concurrent callers can never claim the same row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find + claim in a loop: SELECT the next candidate, then a guarded UPDATE.
|
||||||
|
// If the UPDATE affects 0 rows (someone else took it), try the next candidate.
|
||||||
|
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||||
|
$row = $pdo->query(
|
||||||
|
"SELECT task_id FROM cja_tasks WHERE status = 'queued'
|
||||||
|
ORDER BY sort_order ASC, task_id ASC LIMIT 1"
|
||||||
|
)->fetch();
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
echo "{}";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $row['task_id'];
|
||||||
|
$upd = $pdo->prepare(
|
||||||
|
"UPDATE cja_tasks SET status = 'running', started_at = NOW()
|
||||||
|
WHERE task_id = ? AND status = 'queued'"
|
||||||
|
);
|
||||||
|
$upd->execute([$id]);
|
||||||
|
|
||||||
|
if ($upd->rowCount() === 1) {
|
||||||
|
$task = $pdo->prepare('SELECT task_id, title, target_page, prompt, draft, assets FROM cja_tasks WHERE task_id = ?');
|
||||||
|
$task->execute([$id]);
|
||||||
|
echo json_encode($task->fetch(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// lost the race — loop and grab the next one
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "{}";
|
||||||
35
api/db/migrations/011_create_cja_tasks.sql
Normal file
35
api/db/migrations/011_create_cja_tasks.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- Web Designer task queue — design requests the agent drains one at a time.
|
||||||
|
--
|
||||||
|
-- The admin submits a brief (target page, prompt, draft, assets) which lands
|
||||||
|
-- here as `queued`. The console runner claims the oldest queued row, runs the
|
||||||
|
-- design agent, builds, and auto-publishes (git commit), then marks it
|
||||||
|
-- `published` (or `failed`). The queue is FIFO by (sort_order, task_id).
|
||||||
|
--
|
||||||
|
-- `assets` is JSON: { "images": [{ "url": "/media/x.webp", "alt": "" }],
|
||||||
|
-- "videos": ["https://instagram.com/reel/..."] }
|
||||||
|
-- `result` is JSON, written on completion: { commit, summary, live, error }.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `cja_tasks` (
|
||||||
|
`task_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
|
||||||
|
`title` varchar(200) NOT NULL DEFAULT '',
|
||||||
|
-- "/about", "/services", or "new:<slug>" to create a brand-new page
|
||||||
|
`target_page` varchar(200) NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
`prompt` text DEFAULT NULL, -- the request / special instructions
|
||||||
|
`draft` mediumtext DEFAULT NULL, -- optional draft content
|
||||||
|
`assets` longtext DEFAULT NULL CHECK (json_valid(`assets`)),
|
||||||
|
|
||||||
|
`status` enum('queued','running','published','failed','canceled')
|
||||||
|
NOT NULL DEFAULT 'queued',
|
||||||
|
`result` longtext DEFAULT NULL CHECK (`result` IS NULL OR json_valid(`result`)),
|
||||||
|
|
||||||
|
`sort_order` int(11) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`started_at` datetime DEFAULT NULL,
|
||||||
|
`finished_at` datetime DEFAULT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`task_id`),
|
||||||
|
KEY `idx_cja_tasks_queue` (`status`, `sort_order`, `task_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
@ -70,6 +70,7 @@ const { flush = false } = Astro.props;
|
||||||
<li><a href="/resume" class="hover:text-foreground">Resume</a></li>
|
<li><a href="/resume" class="hover:text-foreground">Resume</a></li>
|
||||||
<li><a href="/contact" class="hover:text-foreground">Contact</a></li>
|
<li><a href="/contact" class="hover:text-foreground">Contact</a></li>
|
||||||
<li><a href="/changelog" class="hover:text-foreground">Changelog</a></li>
|
<li><a href="/changelog" class="hover:text-foreground">Changelog</a></li>
|
||||||
|
<li><a href="/zzz-queue-test" class="hover:text-foreground">Queue Test</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const nav = [
|
||||||
{ to: "/about", label: "About" },
|
{ to: "/about", label: "About" },
|
||||||
{ to: "/resume", label: "Resume" },
|
{ to: "/resume", label: "Resume" },
|
||||||
{ to: "/contact", label: "Contact Me" },
|
{ to: "/contact", label: "Contact Me" },
|
||||||
|
{ to: "/zzz-queue-test", label: "Queue Test" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const current = Astro.url.pathname.replace(/\/$/, "") || "/";
|
const current = Astro.url.pathname.replace(/\/$/, "") || "/";
|
||||||
|
|
|
||||||
22
app/src/pages/zzz-queue-test.astro
Normal file
22
app/src/pages/zzz-queue-test.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
import { SITE } from "../lib/blog-data.js";
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title={`Queue Test — ${SITE.name}`}
|
||||||
|
description="Temporary test page for queue validation."
|
||||||
|
canonical="/zzz-queue-test"
|
||||||
|
>
|
||||||
|
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-14 md:pt-28">
|
||||||
|
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">Internal</p>
|
||||||
|
<h1
|
||||||
|
class="mt-6 max-w-[18ch] font-serif text-[clamp(2.2rem,5.6vw,4.2rem)] leading-[1.04] tracking-tight text-balance"
|
||||||
|
>
|
||||||
|
Queue Test
|
||||||
|
</h1>
|
||||||
|
<p class="mt-7 max-w-[54ch] font-serif text-[clamp(1.1rem,2.2vw,1.4rem)] leading-snug text-[var(--ca-ink-2)]">
|
||||||
|
This is a temporary placeholder page used to validate the publishing queue. It will be removed once testing is complete.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
Loading…
Reference in a new issue