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:
Carlos Arias 2026-07-24 00:16:39 +00:00
parent ad0892a539
commit 7fc99415b4
7 changed files with 537 additions and 0 deletions

View 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 12% 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
View file

@ -0,0 +1 @@
../../app/.astroagent/skills/ui-ux

View file

@ -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
View 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
View 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 "{}";

View 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;

View 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]);
}
}