web designer: task queue engine + CLI-native design skills
- .claude/skills/: brand skill (from BRAND.md) + ui-ux symlink, so the CLI loads design skills natively - cja_tasks queue (migration 011) + admintasks controller (create/list/get/cancel) - api/cli/tasks-next.php (atomic claim) + tasks-finish.php - runner: buildDesignPrompt, runDesignTask (clean-tree guard, build-gate, auto-publish commit or revert), drainTasks loop, POST /devconsole/tasks/run
This commit is contained in:
parent
ad0892a539
commit
7fc99415b4
7 changed files with 537 additions and 0 deletions
101
.claude/skills/brand/SKILL.md
Normal file
101
.claude/skills/brand/SKILL.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
---
|
||||||
|
name: brand
|
||||||
|
description: The Carlos Arias website brand system (carlosarias.co) — a sumi-e identity: ink on washi paper with a single vermillion seal. Use whenever designing, building, restyling, or reviewing any page, component, or image for this site, so the work stays on-brand. Covers the palette, the seal rule (how sparingly red may appear), the type scale, radii, spacing, imagery direction, and the do/don't list. Source of truth: brand/BRAND.md and the tokens in app/src/styles.css.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Carlos Arias brand system (sumi-e)
|
||||||
|
|
||||||
|
You are designing for **carlosarias.co** — the portfolio of an Agentic AI & Automation
|
||||||
|
Engineer whose audience is engineers, CTOs, and technical hiring managers. Technical
|
||||||
|
credibility comes first; projects lead, marketing is supporting context.
|
||||||
|
|
||||||
|
The identity is **sumi-e**: traditional Japanese ink painting — white paper, black sumi ink,
|
||||||
|
and one red *hanko* seal pressed at the end. That is the whole system. Adjacent to wabi-sabi
|
||||||
|
(negative space, asymmetry, restraint, natural imperfection) but permits exactly one
|
||||||
|
saturated color.
|
||||||
|
|
||||||
|
**Always match the existing design system first.** Read `app/src/styles.css` (the `--ca-*`
|
||||||
|
tokens and the `.ca-*` component classes) and the nearby components before inventing anything.
|
||||||
|
Reuse existing `.ca-*` classes rather than adding new styles. Full guide: `brand/BRAND.md`.
|
||||||
|
|
||||||
|
## The one rule that matters most — the seal rule
|
||||||
|
|
||||||
|
**Red is a seal, not a color. Target 1–2% coverage per screen.** This single discipline is
|
||||||
|
what makes the site read as Japanese minimalism instead of a generic startup page with a red
|
||||||
|
button. Same hex, entirely different design language.
|
||||||
|
|
||||||
|
Red (`--ca-seal`, `#D0342C` light / `#E8564A` dark) is permitted **only** on:
|
||||||
|
- the seal mark / logo lozenge (the 印),
|
||||||
|
- **one** inline link or emphasis per view,
|
||||||
|
- **one** active state (current nav item, selected tag),
|
||||||
|
- hairline accents at small scale.
|
||||||
|
|
||||||
|
Red is **never** allowed on:
|
||||||
|
- primary buttons — these are **ink** (`--ca-ink`), never red,
|
||||||
|
- headlines or body copy,
|
||||||
|
- filled bands, blocks, or backgrounds,
|
||||||
|
- more than one element competing for attention in the same view.
|
||||||
|
|
||||||
|
If a screen has two things fighting to be the red thing, one of them is wrong. When in doubt,
|
||||||
|
make it ink and leave the seal for the single most important accent.
|
||||||
|
|
||||||
|
## Palette (use the tokens, never raw hex)
|
||||||
|
|
||||||
|
| Token | Light | Dark | Use |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--ca-paper` | `#F6F5F2` | `#121317` | Page ground — **never pure `#FFF`** |
|
||||||
|
| `--ca-paper-2` | `#EFEEEA` | `#191A1F` | Raised surface |
|
||||||
|
| `--ca-rule` | `#DFDDD7` | `#2A2C33` | Hairlines, borders |
|
||||||
|
| `--ca-ink-4` | `#9A9CA3` | `#5E606A` | Disabled, faint meta |
|
||||||
|
| `--ca-ink-3` | `#6E7078` | `#8B8D95` | Secondary text |
|
||||||
|
| `--ca-ink-2` | `#3D3F47` | `#C2C1BD` | Body text |
|
||||||
|
| `--ca-ink` | `#14151A` | `#EDECE8` | Headings, primary, buttons |
|
||||||
|
| `--ca-seal` | `#D0342C` | `#E8564A` | The seal — see the rule above |
|
||||||
|
|
||||||
|
Two choices to preserve: the ground is **washi-toned, never pure white**; the near-black is
|
||||||
|
**blue-black, not neutral grey** (sumi ink has a cool cast, and that bias runs through the
|
||||||
|
whole grey ramp). Dark mode uses a more luminous seal on purpose — it is not a naive inversion.
|
||||||
|
|
||||||
|
## Type
|
||||||
|
|
||||||
|
1.333 modular scale, wide intervals. Serif for display/headings, sans for body, mono for
|
||||||
|
eyebrows/meta. Eyebrows/meta are uppercase with `0.12em` tracking. Running text caps at
|
||||||
|
`62ch`; headings get `text-wrap: balance`. Use the existing `--ca-s1`…`--ca-s8` scale tokens
|
||||||
|
and the `.ca-*` type classes — don't introduce new sizes.
|
||||||
|
|
||||||
|
## Form & layout
|
||||||
|
|
||||||
|
- **Near-square radius (2px).** No rounded cards.
|
||||||
|
- **Hairline rules** for separation. No drop shadows, no elevation.
|
||||||
|
- **Asymmetric, off-grid** placement. Not centered-everything.
|
||||||
|
- Density: the homepage runs sparse and airy; project index and detail pages are intentionally
|
||||||
|
denser (that's where evaluators dig in). Restraint at the front, substance behind it.
|
||||||
|
|
||||||
|
## Imagery
|
||||||
|
|
||||||
|
Generated imagery is fine for texture, environment, and project visuals — **never for
|
||||||
|
portraits** (a detectably synthetic photo of the person is an unrecoverable credibility hit on
|
||||||
|
an AI-expertise site). Portraits are real photography, graded to the palette. On-palette
|
||||||
|
generation fragment:
|
||||||
|
|
||||||
|
> washi-paper ground, sumi ink blacks, single muted vermillion accent (#D0342C), natural
|
||||||
|
> directional light, soft film grain, generous negative space, asymmetric off-center
|
||||||
|
> composition, low saturation, matte finish, no gloss
|
||||||
|
|
||||||
|
Explicitly avoid (these read as "AI imagery" and destroy the identity): blue/cyan/purple
|
||||||
|
glowing circuitry or neural-network motifs, holographic UI, saturated gradients, lens flares,
|
||||||
|
bokeh bursts, glossy chrome tech-product rendering, high-key studio lighting, symmetrical
|
||||||
|
centered compositions, or more than one accent color.
|
||||||
|
|
||||||
|
## Do / don't
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|---|---|
|
||||||
|
| Ink primary buttons | Red primary buttons |
|
||||||
|
| One red element per view | Red as a general accent |
|
||||||
|
| Washi ground `#F6F5F2` | Pure white `#FFFFFF` |
|
||||||
|
| Near-square radius (2px) | Rounded cards |
|
||||||
|
| Asymmetric, off-grid placement | Centered everything |
|
||||||
|
| Hairline rules for separation | Drop shadows and elevation |
|
||||||
|
| Real photography for portraits | Generated portraits |
|
||||||
|
| Reuse existing `.ca-*` classes/tokens | Inventing new one-off styles |
|
||||||
1
.claude/skills/ui-ux
Symbolic link
1
.claude/skills/ui-ux
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../app/.astroagent/skills/ui-ux
|
||||||
|
|
@ -42,6 +42,7 @@ const APP = join(REPO, "app");
|
||||||
const PREVIEW_DIR = join(REPO, "public-preview");
|
const PREVIEW_DIR = join(REPO, "public-preview");
|
||||||
const ENV_FILE = join(REPO, "agents", ".env");
|
const ENV_FILE = join(REPO, "agents", ".env");
|
||||||
const CLAUDE = "/usr/local/bin/claude";
|
const CLAUDE = "/usr/local/bin/claude";
|
||||||
|
const PHP = "/usr/bin/php";
|
||||||
const PORT = Number(process.env.ADMIN_PORT || 3011);
|
const PORT = Number(process.env.ADMIN_PORT || 3011);
|
||||||
const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch";
|
const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch";
|
||||||
const DEFAULT_MODEL = "claude-sonnet-4-6";
|
const DEFAULT_MODEL = "claude-sonnet-4-6";
|
||||||
|
|
@ -399,6 +400,171 @@ function buildProject({ name, category, draft, prompt, images, instagram }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Web Designer task queue -------------------------------------------------
|
||||||
|
// A durable design queue (cja_tasks) drained one task at a time. Each task runs
|
||||||
|
// the full-builder agent on any page (skills + brief), then auto-publishes:
|
||||||
|
// build must pass, then a scoped git commit. A failing task reverts itself and
|
||||||
|
// the queue moves on. The runner has no DB driver, so it claims/finishes rows
|
||||||
|
// via the api/cli/tasks-*.php helpers.
|
||||||
|
const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"];
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
|
function phpCli(args) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() });
|
||||||
|
let out = "", err = "";
|
||||||
|
child.stdout.on("data", (d) => (out += d));
|
||||||
|
child.stderr.on("data", (d) => (err += d));
|
||||||
|
child.on("close", () => resolve({ out, err }));
|
||||||
|
child.on("error", () => resolve({ out: "", err: "php spawn failed" }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimNextTask() {
|
||||||
|
const { out } = await phpCli(["api/cli/tasks-next.php"]);
|
||||||
|
try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
async function finishTask(id, status, result) {
|
||||||
|
await phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveUrl(target) {
|
||||||
|
if (!target) return "/";
|
||||||
|
if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, "");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDesignPrompt(task) {
|
||||||
|
let assets = { images: [], videos: [] };
|
||||||
|
try { assets = JSON.parse(task.assets || "{}") || {}; } catch {}
|
||||||
|
const images = Array.isArray(assets.images) ? assets.images : [];
|
||||||
|
const videos = Array.isArray(assets.videos) ? assets.videos : [];
|
||||||
|
const target = task.target_page || "/";
|
||||||
|
const isNew = target.startsWith("new:");
|
||||||
|
const slug = isNew ? target.slice(4).replace(/^\/+/, "") : "";
|
||||||
|
|
||||||
|
const p = [];
|
||||||
|
p.push(
|
||||||
|
"You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.",
|
||||||
|
"You are a real designer: make considered, on-brand design decisions, not just literal edits.",
|
||||||
|
"",
|
||||||
|
"Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.",
|
||||||
|
"The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.",
|
||||||
|
"Do NOT run builds or git — the console builds and publishes for you.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
if (isNew) {
|
||||||
|
p.push(
|
||||||
|
`TASK: create a NEW page at /${slug}.`,
|
||||||
|
`- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`,
|
||||||
|
"- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
p.push(
|
||||||
|
`TASK: work on the existing page ${target}.`,
|
||||||
|
"- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
p.push("What to do:", task.prompt || "(no instructions given)", "");
|
||||||
|
if (task.draft && String(task.draft).trim()) {
|
||||||
|
p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, "");
|
||||||
|
}
|
||||||
|
if (images.length) {
|
||||||
|
p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:");
|
||||||
|
for (const im of images) {
|
||||||
|
p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`);
|
||||||
|
}
|
||||||
|
p.push("");
|
||||||
|
}
|
||||||
|
if (videos.length) {
|
||||||
|
p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):");
|
||||||
|
for (const v of videos) p.push(`- ${v}`);
|
||||||
|
p.push("");
|
||||||
|
}
|
||||||
|
p.push("Keep the change scoped to what's asked and leave the working tree with only your intended edits.");
|
||||||
|
return p.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDesignTask(task) {
|
||||||
|
const prompt = buildDesignPrompt(task);
|
||||||
|
|
||||||
|
// Pre-flight: the auto-publish commit is scoped to GIT_SCOPE, so a dirty tree
|
||||||
|
// would get swept into this task's commit. Refuse rather than clobber WIP.
|
||||||
|
const pre = await git(["status", "--porcelain", "--", ...GIT_SCOPE]);
|
||||||
|
if (pre.tail.trim() !== "") {
|
||||||
|
return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const args = [
|
||||||
|
"-p", prompt,
|
||||||
|
"--output-format", "json",
|
||||||
|
"--allowedTools", "Read Write Edit Glob Grep WebSearch Skill",
|
||||||
|
"--model", DEFAULT_MODEL,
|
||||||
|
];
|
||||||
|
const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
|
||||||
|
let out = "";
|
||||||
|
child.stdout.on("data", (d) => (out += d));
|
||||||
|
child.on("close", async () => {
|
||||||
|
let summary = "";
|
||||||
|
try {
|
||||||
|
const env = JSON.parse(out);
|
||||||
|
if (typeof env.result === "string") summary = env.result.trim().slice(0, 500);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Any source changes to publish?
|
||||||
|
const status = await git(["status", "--porcelain", "--", ...GIT_SCOPE]);
|
||||||
|
const changed = status.tail.trim() !== "";
|
||||||
|
if (!changed) {
|
||||||
|
return resolve({ ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build must pass before anything is committed.
|
||||||
|
const built = await build({});
|
||||||
|
if (!built.ok) {
|
||||||
|
await git(["checkout", "--", ...GIT_SCOPE]);
|
||||||
|
await git(["clean", "-fd", ...GIT_SCOPE]); // drop any new files the agent added
|
||||||
|
return resolve({ ok: false, error: "The change broke the build, so it was reverted." });
|
||||||
|
}
|
||||||
|
await git(["add", "--", ...GIT_SCOPE]);
|
||||||
|
const title = (task.title || "task").toString().slice(0, 120);
|
||||||
|
await git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]);
|
||||||
|
const head = await git(["rev-parse", "--short", "HEAD"]);
|
||||||
|
resolve({ ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) });
|
||||||
|
});
|
||||||
|
child.on("error", () => resolve({ ok: false, error: "Could not start the agent." }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the queue sequentially. Shares the global `busy` lock so it never runs
|
||||||
|
// concurrently with the in-page console. Re-entrant-safe via `draining`.
|
||||||
|
async function drainTasks() {
|
||||||
|
if (draining) return;
|
||||||
|
draining = true;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
if (busy) break; // in-page console is mid-edit; a later kick resumes the queue
|
||||||
|
const task = await claimNextTask();
|
||||||
|
if (!task) break;
|
||||||
|
busy = true;
|
||||||
|
let res;
|
||||||
|
try { res = await runDesignTask(task); }
|
||||||
|
catch (e) { res = { ok: false, error: String((e && e.message) || e) }; }
|
||||||
|
busy = false;
|
||||||
|
await finishTask(
|
||||||
|
task.task_id,
|
||||||
|
res.ok ? "published" : "failed",
|
||||||
|
res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- request helpers ---------------------------------------------------------
|
// ---- request helpers ---------------------------------------------------------
|
||||||
function json(res, status, obj) {
|
function json(res, status, obj) {
|
||||||
const body = JSON.stringify(obj);
|
const body = JSON.stringify(obj);
|
||||||
|
|
@ -493,6 +659,15 @@ const server = createServer(async (req, res) => {
|
||||||
return json(res, 200, d);
|
return json(res, 200, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick the Web Designer task queue: drain any queued design tasks. Returns
|
||||||
|
// immediately; the admin UI polls /api/admintasks/list for status.
|
||||||
|
if (path === "/tasks/run" && method === "POST") {
|
||||||
|
const env = loadEnv();
|
||||||
|
if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });
|
||||||
|
drainTasks(); // fire-and-forget
|
||||||
|
return json(res, 200, { ok: true, draining, busy });
|
||||||
|
}
|
||||||
|
|
||||||
if (path === "/stream") {
|
if (path === "/stream") {
|
||||||
const id = url.searchParams.get("conversationId");
|
const id = url.searchParams.get("conversationId");
|
||||||
const job = id && jobs.get(id);
|
const job = id && jobs.get(id);
|
||||||
|
|
|
||||||
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;
|
||||||
130
api/public/controllers/admintasks.php
Normal file
130
api/public/controllers/admintasks.php
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Controllers\PublicController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Designer task queue. The admin submits a design brief for any page; it
|
||||||
|
* lands in cja_tasks as `queued`. The console runner (agents/console/server.mjs)
|
||||||
|
* drains the queue: claims the oldest queued row, runs the design agent, builds,
|
||||||
|
* and auto-publishes (git commit), then marks it published/failed.
|
||||||
|
*
|
||||||
|
* POST /api/admintasks/create -> { title, target_page, prompt, draft, assets }
|
||||||
|
* GET /api/admintasks/list -> [{ task_id, title, status, ... }]
|
||||||
|
* GET /api/admintasks/get?id= -> full row + result
|
||||||
|
* POST /api/admintasks/cancel -> { id } (only if still queued)
|
||||||
|
*
|
||||||
|
* All admin-gated. This controller only does CRUD on the queue; the runner owns
|
||||||
|
* execution. The Node runner claims/finishes rows via api/cli/tasks-*.php.
|
||||||
|
*/
|
||||||
|
class AdminTasks extends PublicController
|
||||||
|
{
|
||||||
|
private const STATUSES = ['queued', 'running', 'published', 'failed', 'canceled'];
|
||||||
|
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$this->guardPublic('admintasks_create', 60, 300);
|
||||||
|
|
||||||
|
$in = json_decode((string) file_get_contents('php://input'), true) ?: [];
|
||||||
|
|
||||||
|
$title = mb_substr(trim((string) ($in['title'] ?? '')), 0, 200);
|
||||||
|
$target = mb_substr(trim((string) ($in['target_page'] ?? '')), 0, 200);
|
||||||
|
$prompt = trim((string) ($in['prompt'] ?? ''));
|
||||||
|
$draft = (string) ($in['draft'] ?? '');
|
||||||
|
|
||||||
|
if ($prompt === '') {
|
||||||
|
$this->json(null, 400, ['code' => 'no_prompt', 'message' => 'Describe what you want done.']);
|
||||||
|
}
|
||||||
|
if ($target === '') {
|
||||||
|
$this->json(null, 400, ['code' => 'no_target', 'message' => 'Pick a page (or a new page) for the task.']);
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
$title = mb_substr($prompt, 0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets: only known-safe local /media/ images and real http(s) video URLs.
|
||||||
|
$images = [];
|
||||||
|
foreach ((array) ($in['assets']['images'] ?? []) as $img) {
|
||||||
|
$url = trim((string) ($img['url'] ?? ''));
|
||||||
|
if (preg_match('#^/media/[\w./-]+$#', $url)) {
|
||||||
|
$images[] = ['url' => $url, 'alt' => mb_substr(trim((string) ($img['alt'] ?? '')), 0, 200)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$videos = [];
|
||||||
|
foreach ((array) ($in['assets']['videos'] ?? []) as $v) {
|
||||||
|
$url = trim((string) $v);
|
||||||
|
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL) && preg_match('#^https?://#i', $url)) {
|
||||||
|
$videos[] = mb_substr($url, 0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$assets = json_encode(['images' => $images, 'videos' => $videos], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_tasks');
|
||||||
|
\Db::insert('cja_tasks', [
|
||||||
|
'title' => $title,
|
||||||
|
'target_page' => $target,
|
||||||
|
'prompt' => $prompt,
|
||||||
|
'draft' => $draft,
|
||||||
|
'assets' => $assets,
|
||||||
|
'status' => 'queued',
|
||||||
|
'sort_order' => $order,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->json(['ok' => true, 'id' => (int) \Db::getValue('SELECT LAST_INSERT_ID()')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$rows = \Db::select(
|
||||||
|
'SELECT task_id, title, target_page, status, result, created_at, finished_at
|
||||||
|
FROM cja_tasks ORDER BY sort_order DESC, task_id DESC LIMIT 100'
|
||||||
|
);
|
||||||
|
$out = array_map(function ($r) {
|
||||||
|
$result = json_decode($r['result'] ?? '', true) ?: [];
|
||||||
|
return [
|
||||||
|
'id' => (int) $r['task_id'],
|
||||||
|
'title' => $r['title'],
|
||||||
|
'target' => $r['target_page'],
|
||||||
|
'status' => $r['status'],
|
||||||
|
'commit' => $result['commit'] ?? '',
|
||||||
|
'summary' => $result['summary'] ?? '',
|
||||||
|
'live' => $result['live'] ?? '',
|
||||||
|
'error' => $result['error'] ?? '',
|
||||||
|
'created' => $r['created_at'],
|
||||||
|
'finished' => $r['finished_at'],
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
$this->json(['tasks' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
$r = \Db::getRow('SELECT * FROM cja_tasks WHERE task_id = ?', [$id]);
|
||||||
|
if (!$r) {
|
||||||
|
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such task.']);
|
||||||
|
}
|
||||||
|
$r['assets'] = json_decode($r['assets'] ?? '{}', true) ?: [];
|
||||||
|
$r['result'] = json_decode($r['result'] ?? '{}', true) ?: [];
|
||||||
|
$this->json($r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a task that has not started yet. */
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$in = json_decode((string) file_get_contents('php://input'), true) ?: [];
|
||||||
|
$id = (int) ($in['id'] ?? 0);
|
||||||
|
$status = (string) \Db::getValue('SELECT status FROM cja_tasks WHERE task_id = ?', [$id]);
|
||||||
|
if ($status === '') {
|
||||||
|
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such task.']);
|
||||||
|
}
|
||||||
|
if ($status !== 'queued') {
|
||||||
|
$this->json(null, 409, ['code' => 'not_queued', 'message' => 'Only queued tasks can be canceled.']);
|
||||||
|
}
|
||||||
|
\Db::update('cja_tasks', ['status' => 'canceled', 'finished_at' => date('Y-m-d H:i:s')], 'task_id = ?', [$id]);
|
||||||
|
$this->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue