diff --git a/conformance/harness/src/toolkits.ts b/conformance/harness/src/toolkits.ts index a086ad3..6b88df2 100644 --- a/conformance/harness/src/toolkits.ts +++ b/conformance/harness/src/toolkits.ts @@ -47,13 +47,13 @@ const TOOLKITS: ToolkitSpec[] = [ noBuild: true, incompleteBecause: "ships the requirements template unfilled; the spec is the author's to write", }, - // Ships the adapter hooks and a React View, but its `tasks` collection is - // created by hand in PocketBase, and `install` prints "download the - // pocketbase binary" — so a fresh scaffold has neither data model nor runtime. + // Ships the adapter hooks, the starter migration and a React View, but + // `install` prints "download the pocketbase binary" rather than fetching it, + // so a fresh scaffold has no runtime until someone does. { id: "blueprint-pocketbase-react", noBuild: true, - incompleteBecause: "ships adapter hooks and a React View; collections and the binary are added by hand", + incompleteBecause: "ships adapter hooks, a starter migration and a React View; the PocketBase binary is added by hand", }, // The native-port blueprints (Go, Rust, Ruby): runtime, model and View are // complete, but each ships the requirements template unfilled — the spec is diff --git a/framework/cli/src/lib/agentState.ts b/framework/cli/src/lib/agentState.ts index 740629b..d491e8a 100644 --- a/framework/cli/src/lib/agentState.ts +++ b/framework/cli/src/lib/agentState.ts @@ -55,7 +55,7 @@ export const RULE_POINTER = const SKIP_DIRS = new Set([ "node_modules", ".git", ".a2app", ".lui", "dist", "build", "out", "target", "public", - "data", "pb_data", "vendor", "tmp", "log", "logs", "coverage", "__pycache__", ".venv", + "data", "pb_data", "pb_public", "vendor", "tmp", "log", "logs", "coverage", "__pycache__", ".venv", "venv", ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", "test", "tests", "__tests__", "spec", ]); diff --git a/framework/cli/test/agent-state.test.mjs b/framework/cli/test/agent-state.test.mjs index 3fd9cda..31d1e0c 100644 --- a/framework/cli/test/agent-state.test.mjs +++ b/framework/cli/test/agent-state.test.mjs @@ -154,10 +154,14 @@ try { const r = app("deps-and-tests", { "node_modules/pkg/index.js": RUNNER, "dist/assets/app.js": RUNNER, + "pb/pb_public/assets/index.js": "fetch('/api/_a2app/tasks/' + id);", "test/ask.test.mjs": RUNNER, "src/App.jsx": "export default () =>

;", }); check("dependencies, build output and tests are not scanned", r.triggers.length, 0); + // PocketBase's compiled View: it holds whatever the source imported, so it + // must not stand in for View code that follows the task. + check("a built bundle does not count as showing the work", r.followers, []); } /* ------------------------------- the shipped starters pass their own gate */ @@ -167,6 +171,7 @@ try { ["blueprint-go-react", true], ["blueprint-rust-react", true], ["blueprint-rails-vue", true], + ["blueprint-pocketbase-react", true], ["blueprint-python-fastapi", false], ]) { // A raw template has no ownership canon yet; the toolkit's systemPaths are diff --git a/toolkits/blueprint-pocketbase-react/a2app.toolkit.json b/toolkits/blueprint-pocketbase-react/a2app.toolkit.json index 3ffc868..3bb782d 100644 --- a/toolkits/blueprint-pocketbase-react/a2app.toolkit.json +++ b/toolkits/blueprint-pocketbase-react/a2app.toolkit.json @@ -9,6 +9,10 @@ { "name": "hooks syntax + rules self-test", "run": "node --check pb/pb_hooks/_a2app_rules.js && node --check pb/pb_hooks/_a2app_impl.js && node --check pb/pb_hooks/_a2app.pb.js && node pb/pb_hooks/_a2app_rules.js --selftest" + }, + { + "name": "operations resolve (every declared op has a runner)", + "run": "node -e \"const fs=require('fs');const ops=fs.existsSync('operations.json')?(JSON.parse(fs.readFileSync('operations.json','utf8')).operations||[]):[];const has=fs.existsSync('pb/pb_hooks/operations.js');const r=has?(require('./pb/pb_hooks/operations.js').runners||{}):{};const missing=ops.filter(o=>typeof r[o.name]!=='function').map(o=>o.name);if(missing.length){console.error('declared in operations.json with no runner in pb/pb_hooks/operations.js: '+missing.join(', '));process.exit(1)}process.stdout.write('operations resolve ok\\n')\"" } ], "lifecycle": { diff --git a/toolkits/blueprint-pocketbase-react/template/AGENT_APP.md b/toolkits/blueprint-pocketbase-react/template/AGENT_APP.md index a562bd6..3d685df 100644 --- a/toolkits/blueprint-pocketbase-react/template/AGENT_APP.md +++ b/toolkits/blueprint-pocketbase-react/template/AGENT_APP.md @@ -3,11 +3,12 @@ ## Plan PocketBase backend + a React (Vite) frontend, with the A2App adapter as in-process PocketBase JS hooks (`pb/pb_hooks/_a2app*.js`). -PocketBase serves records natively; the adapter adds identity + describe and a -guard that validates the raw body before PocketBase coerces it. The View lives -in `ui/` and is compiled into `pb/pb_public/` by the pipeline build. Evolve the -app by editing collections (migrations) and `ui/src/` — never the hook files -(system-owned, hash-locked). +PocketBase serves records natively; the adapter adds identity + describe, a +guard that validates the raw body before PocketBase coerces it, declared +operations, and the app→agent task queue. The View lives in `ui/` and is +compiled into `pb/pb_public/` by the pipeline build. Evolve the app by editing +collections (migrations), `operations.json` + `pb/pb_hooks/operations.js`, and +`ui/src/` — never the `_a2app*` hook files (system-owned, hash-locked). ## Modules Modules are the organizing unit: every entity and every operation belongs to @@ -17,23 +18,39 @@ cannot carry a module of their own, so `manifest.json` maps them: each - **planning** — tasks and the work in front of you. ## Entities -Define collections in PocketBase (via migrations or the admin UI). `describe` -derives entities from the live collection schema, so it cannot drift. A starter -`tasks` collection is expected (title, status, due, created). +Define collections in PocketBase migrations (`pb/pb_migrations/`). `describe` +derives entities from the live collection schema, so it cannot drift. +- **tasks** — a to-do item, created by the starter migration. Fields: `title` + (string, required), `status` (enum: todo/doing/done), `due` (day key + YYYY-MM-DD), `notes` (string), `agentTask` (the queue task an agent is, or + was last, working on; set by `request-triage`, declared read-only in + `pb/pb_hooks/operations.js`). ## Operations -- **archive-done** — archive every completed task. `destructive`. +Declared in `operations.json`, run by the same-named runner in +`pb/pb_hooks/operations.js`. +- **clear-done** — deletes every task whose status is done. `destructive`. +- **count-tasks** — counts the tasks. `readOnly`, `idempotent`. +- **complete-task** — marks one task done. Acts on `tasks`; available while the + task is not already done. +- **request-triage** — asks an agent to work out what one task needs. Queues a + `triage` task (event `task.needs_triage`) and records its id in `agentTask`; + the View shows the work until it is done. ## Conventions - The adapter is the only agent surface; the guard validates the raw body (`onRecordCreateRequest`/`onRecordUpdateRequest`), before coercion. +- An operation runner is a transaction: write through `a2app.app`, queue agent + work with `a2app.trigger`, and keep slow outbound calls out of it. - Day-key text fields named like dates (≤12 chars) are advertised with `format: YYYY-MM-DD`. -- Migrations are additive; never drop a collection that holds data. +- Migrations are additive; never edit an applied one, never drop a collection + that holds data. - The design system lives in `ui/public/tokens.css` (kit sheet: foundation → semantic bridge); components consume ONLY semantic tokens. Shared pieces - (Icon, `useToast`, `useConfirm` — never `window.confirm`) live in `ui/src/`; - compose them, never re-implement per screen. + (Icon, `useToast`, `useConfirm` — never `window.confirm`, `AgentTask.jsx` for + work queued for an agent) live in `ui/src/`; compose them, never re-implement + per screen. - The View is served BUILT: `npm --prefix ui run build` → `pb/pb_public/`. Editing `ui/src/` changes nothing a browser sees until the next build. diff --git a/toolkits/blueprint-pocketbase-react/template/operations.json b/toolkits/blueprint-pocketbase-react/template/operations.json index 4b94c07..09c0326 100644 --- a/toolkits/blueprint-pocketbase-react/template/operations.json +++ b/toolkits/blueprint-pocketbase-react/template/operations.json @@ -1,11 +1,56 @@ { "operations": [ { - "name": "archive-done", - "description": "Archive every completed task.", + "name": "clear-done", + "description": "Delete every task whose status is done.", "destructive": true, "module": "planning", "params": {} + }, + { + "name": "count-tasks", + "description": "Count the tasks.", + "destructive": false, + "readOnly": true, + "idempotent": true, + "module": "planning", + "params": {} + }, + { + "name": "complete-task", + "description": "Mark one task done.", + "destructive": false, + "module": "planning", + "entity": "tasks", + "appliesWhen": { + "field": "status", + "ne": "done" + }, + "params": { + "task": { + "type": "ref", + "entity": "tasks", + "required": true + } + } + }, + { + "name": "request-triage", + "description": "Ask an agent to work out what this task actually needs.", + "destructive": false, + "module": "planning", + "entity": "tasks", + "appliesWhen": { + "field": "status", + "ne": "done" + }, + "params": { + "task": { + "type": "ref", + "entity": "tasks", + "required": true + } + } } ] } diff --git a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app.pb.js b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app.pb.js index a56b82a..c0cea9c 100644 --- a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app.pb.js +++ b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app.pb.js @@ -8,7 +8,8 @@ * The only things a handler may use are the PocketBase globals and `require()`. * So every handler below is a self-contained one-liner that `require()`s the * implementation module INSIDE its own body and calls into it; all the logic — - * identity, describe, and the create/update guard — lives in `_a2app_impl.js`. + * identity, describe, the record guards, operations, and the app→agent task + * queue and event log — lives in `_a2app_impl.js`. * (`require()` is cached per runtime, so the module loads once per pooled VM.) * * Runtime: PocketBase JSVM (Goja) on the 0.23+ hook API (binary pinned to @@ -16,6 +17,15 @@ * inside `pocketbase serve`. */ +/* -------------------------------------------------------------- bootstrap */ + +// The queue's own tables (tasks, events, approval keys) in data.db. Idempotent, +// and after `e.next()` so the database is open. +onBootstrap((e) => { + e.next(); + require(`${__hooks}/_a2app_impl.js`).ensureStore(e.app); +}); + /* --------------------------------------------------------------- identity */ routerAdd("GET", "/api/_a2app", (e) => require(`${__hooks}/_a2app_impl.js`).identity(e)); @@ -45,6 +55,27 @@ routerAdd("GET", "/api/_a2app/describe/{p1}/{p2}/{p3}/{p4}", (e) => ]), ); +/* ------------------------------------------------------------ operations */ + +// A declared operation (operations.json), run by the app's own runner in +// pb/pb_hooks/operations.js. Destructive ones answer 428 + an approval key first. +routerAdd("POST", "/api/ops/{name}", (e) => + require(`${__hooks}/_a2app_impl.js`).runOperation(e, e.request.pathValue("name")), +); +routerAdd("GET", "/api/ops/{name}", (e) => require(`${__hooks}/_a2app_impl.js`).operationMethod(e)); + +/* --------------------------------------------------- app→agent: tasks, events */ + +routerAdd("GET", "/api/_a2app/tasks", (e) => require(`${__hooks}/_a2app_impl.js`).listTasks(e)); +routerAdd("GET", "/api/_a2app/tasks/{id}", (e) => + require(`${__hooks}/_a2app_impl.js`).getTask(e, e.request.pathValue("id")), +); +routerAdd("POST", "/api/_a2app/tasks/{id}/{action}", (e) => + require(`${__hooks}/_a2app_impl.js`).taskAction(e, e.request.pathValue("id"), e.request.pathValue("action")), +); +routerAdd("GET", "/api/_a2app/events", (e) => require(`${__hooks}/_a2app_impl.js`).listEvents(e)); +routerAdd("GET", "/api/_a2app/context", (e) => require(`${__hooks}/_a2app_impl.js`).context(e)); + /* ----------------------------------------------------------------- guard */ // Validate the RAW submitted body before PocketBase coerces it, on both create diff --git a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_impl.js b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_impl.js index b14663b..9e777ff 100644 --- a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_impl.js +++ b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_impl.js @@ -64,11 +64,12 @@ function identity(e) { } const entities = {}; + const readOnly = readOnlyDeclared(); const collections = $app.findAllCollections("base"); for (let i = 0; i < collections.length; i++) { const col = collections[i]; if (col.name.indexOf("_") === 0) continue; // skip system collections - entities[col.name] = { fields: mapFields(col), module: entityModule[col.name] }; + entities[col.name] = { fields: rules.markReadOnly(mapFields(col), readOnly[col.name]), module: entityModule[col.name] }; } return e.json(200, { @@ -387,6 +388,15 @@ function describe(e, segments) { const ops = operations.filter((o) => o.entity === name).map((o) => { const row = { name: o.name, available: true }; if (o.destructive) row.destructive = true; + // The parameter that receives THIS record when the operation is invoked + // at its path: the one required `ref` to this entity. With none, or more + // than one, nothing is named and the caller passes it explicitly — the + // framework never guesses which record an operation meant. + const targets = Object.keys(o.params || {}).filter((p) => { + const decl = o.params[p] || {}; + return decl.type === "ref" && decl.entity === name && decl.required === true; + }); + if (targets.length === 1) row.targetParam = targets[0]; if (o.appliesWhen && !rules.evaluatePredicate(o.appliesWhen, values, def)) { row.available = false; row.blocked = rules.explainPredicate(o.appliesWhen, values, def); @@ -456,11 +466,12 @@ function describe(e, segments) { function liveEntities() { const out = {}; + const readOnly = readOnlyDeclared(); const collections = $app.findAllCollections("base"); for (let i = 0; i < collections.length; i++) { const col = collections[i]; if (col.name.indexOf("_") === 0) continue; // skip system collections - out[col.name] = mapFields(col); + out[col.name] = rules.markReadOnly(mapFields(col), readOnly[col.name]); } return out; } @@ -534,6 +545,9 @@ function describe(e, segments) { * Validate the RAW submitted body before PocketBase coerces it. Rejecting here, * on the raw values, is the whole point: once PocketBase has coerced, an invalid * date and a deliberately-cleared field are indistinguishable. + * + * Only requests reach this hook. A runner writing through `a2app.app.save()` + * does not, which is how it sets a field the app declares read-only. */ function guard(e) { const rules = require(`${__hooks}/_a2app_rules.js`); @@ -565,6 +579,11 @@ function guard(e) { fields.push(nf); } + // Fields the app declares read-only are written by its own runners, never by + // a client. The PocketBase dashboard is the exception: a signed-in superuser + // is the owner editing by hand, and the dashboard sends every field on save. + if (!e.hasSuperuserAuth()) rules.markReadOnly(fields, readOnlyDeclared()[col.name]); + // The raw submitted data (before coercion). const info = e.requestInfo(); const body = info.body || {}; @@ -681,4 +700,481 @@ function deleteGuard(e) { e.next(); } -module.exports = { identity: identity, describe: describe, guard: guard, deleteGuard: deleteGuard }; +/* ------------------------------------------------- the app→agent plane */ + +/* + * Operations, the task queue and the event log (A2APP-SPEC 5–6), served with + * the same wire format as every other adapter. Parity oracle: adapter-core's + * `handleOperation`/`handleTasks`/`handleEvents`; the lifecycle rules + * themselves live in `_a2app_rules.js`, where the gate's self-test holds them. + * + * STORAGE. Tasks, events and approval keys are adapter state, not records, so + * they are not collections: an agent must not be able to list, edit or delete + * them through the records API, and describe must not publish them. They are + * tables of their own in PocketBase's data.db, created at bootstrap. That puts + * them in the app's data directory — they survive a restart, ride along in a + * backup, and a dev boot (fresh data directory) starts with an empty queue. + * + * ATOMICITY. PocketBase runs hooks on a pool of runtimes, so two requests can + * race. Every read-modify-write runs inside `runInTransaction`, which + * PocketBase serialises on its single write connection: of two simultaneous + * claims, the second reads the first one's result and gets the 409. + */ + +const TOKEN_FILE = ".agent-token"; +const LOCAL_GRANT = { credentialId: "cred_local", agentName: "local", principal: "owner" }; +const CREDENTIAL_HINT = "Read the app's .agent-token file (mode 0600) in the project directory, and send it as X-A2App-Token."; + +/** Create the adapter's own tables. Idempotent; runs on every bootstrap. */ +function ensureStore(app) { + const statements = [ + "CREATE TABLE IF NOT EXISTS _a2app_tasks (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, status TEXT NOT NULL, dedup TEXT, body TEXT NOT NULL)", + "CREATE INDEX IF NOT EXISTS _a2app_tasks_status ON _a2app_tasks (status)", + "CREATE UNIQUE INDEX IF NOT EXISTS _a2app_tasks_dedup ON _a2app_tasks (dedup) WHERE dedup IS NOT NULL", + // AUTOINCREMENT, not a plain rowid: a pruned seq must never be handed out + // again, or a client holding an old cursor would skip the new event. + "CREATE TABLE IF NOT EXISTS _a2app_events (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, body TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS _a2app_approvals (key TEXT PRIMARY KEY, expires INTEGER NOT NULL)", + ]; + for (let i = 0; i < statements.length; i++) app.db().newQuery(statements[i]).execute(); +} + +/* ------------------------------------------------------------- envelopes */ + +function fail(e, status, code, message, extra) { + const body = { a2app: true, ok: false, code: code, message: message }; + const more = extra || {}; + for (const k in more) if (Object.prototype.hasOwnProperty.call(more, k)) body[k] = more[k]; + return e.json(status, body); +} + +/** The request body as plain JS. PocketBase hands it over as a Go map; a JSON + * round trip makes it the same shape the rules were written against. */ +function bodyOf(e) { + let raw; + try { + raw = e.requestInfo().body; + } catch (_e) { + raw = null; + } + if (raw === null || raw === undefined) return {}; + const plain = JSON.parse(JSON.stringify(raw)); + return plain && typeof plain === "object" && !Array.isArray(plain) ? plain : {}; +} + +function header(e, name) { + const v = e.request.header.get(name); + return v === undefined || v === null ? "" : String(v); +} + +function queryParam(e, name) { + const v = e.request.url.query().get(name); + return v === undefined || v === null ? "" : String(v); +} + +function readManifest() { + try { + return JSON.parse(toString($os.readFile(`${__hooks}/../../manifest.json`))); + } catch (_e) { + return null; + } +} + +function readDeclaredOperations() { + try { + return JSON.parse(toString($os.readFile(`${__hooks}/../../operations.json`))).operations || []; + } catch (_e) { + return []; + } +} + +/** The app-owned seam: `pb/pb_hooks/operations.js` exports `{ events, runners, readOnly }`. + * Absent is a valid app (no operations, no events); broken is reported. */ +function appCode() { + try { + if (!$os.stat(`${__hooks}/operations.js`)) return { events: [], runners: {}, readOnly: {}, problem: null }; + } catch (_absent) { + return { events: [], runners: {}, readOnly: {}, problem: null }; + } + try { + const mod = require(`${__hooks}/operations.js`); + return { events: mod.events || [], runners: mod.runners || {}, readOnly: mod.readOnly || {}, problem: null }; + } catch (err) { + return { events: [], runners: {}, readOnly: {}, problem: String(err) }; + } +} + +/** The fields the app declares read-only, as `{ collection: [field, ...] }`. + * PocketBase has no read-only flag for a field, so the app says it here + * (`readOnly` in operations.js), the same thing a schema's `readOnly: true` + * says on the other stacks. Describe publishes it, the fingerprint covers it, + * and the guard refuses a client write to it. */ +function readOnlyDeclared() { + return appCode().readOnly; +} + +/* ---------------------------------------------------------------- access */ + +/** + * Who is calling, decided in a fixed order: host, origin, credential. Returns + * the call context, or null once the refusal has been written — the caller + * must then return without writing anything else. (`e.json` returns nothing to + * pass along, so a refusal cannot be handed back as a value.) + * + * host Only a loopback name. A page on evil.com whose DNS re-resolves + * to 127.0.0.1 is same-origin as far as the browser knows and + * sends no Origin on a GET; its Host header still says evil.com. + * origin A foreign browser origin is refused. The app's own View + * (Origin equal to this host) is the owner on a single-user app; + * on a multi-user app it needs a signed-in PocketBase user. + * credential The agent token from `.agent-token`, sent as X-A2App-Token. A + * write always needs one; a read needs one on a multi-user app. + */ +function authorize(e, isWrite) { + const host = String(e.request.host || "").toLowerCase(); + const name = host.indexOf("[") === 0 ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0]; + const loopback = name === "localhost" || name === "[::1]" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name); + if (!loopback) { + fail(e, 403, "forbidden_host", `Refused: this app answers only on its own host, not "${host}".`); + return null; + } + + const manifest = readManifest(); + const multiUser = !!manifest && manifest.authMode === "multi-user"; + + const origin = header(e, "Origin"); + if (origin !== "") { + if (origin.toLowerCase() !== `http://${host}`) { + fail(e, 403, "forbidden_origin", "Refused: request Origin is not this app's own."); + return null; + } + if (!multiUser) return { credentialId: "ui", agentName: null, principal: "owner" }; + if (e.auth) return { credentialId: "ui", agentName: null, principal: String(e.auth.id) }; + } + + const presented = header(e, "X-A2App-Token") || header(e, "X-LUI-Token"); + let expected = ""; + try { + expected = toString($os.readFile(`${__hooks}/../../${TOKEN_FILE}`)).trim(); + } catch (_none) { + expected = ""; // no credential minted: nothing can match it + } + if (presented !== "" && expected !== "" && $security.equal(presented, expected)) { + return { credentialId: LOCAL_GRANT.credentialId, agentName: LOCAL_GRANT.agentName, principal: LOCAL_GRANT.principal }; + } + if (isWrite || multiUser || presented !== "") { + // A token that does not match is refused even for a read: an agent holding + // a stale credential should learn so, not be served as anonymous. + fail(e, 401, "agent_token_required", + presented !== "" ? "The agent credential presented is not this app's." : "This write requires an agent credential.", + { how: CREDENTIAL_HINT }); + return null; + } + return { credentialId: "anonymous", agentName: null, principal: "owner" }; +} + +/* ----------------------------------------------------------------- store */ + +function rows(db, sql, params, shape) { + const out = arrayOf(new DynamicModel(shape)); + db.newQuery(sql).bind(params || {}).all(out); + return out; +} + +function loadTask(db, id) { + const found = rows(db, "SELECT body FROM _a2app_tasks WHERE id = {:id}", { id: id }, { body: "" }); + return found.length ? JSON.parse(found[0].body) : null; +} + +function saveTask(db, task) { + db.newQuery("UPDATE _a2app_tasks SET status = {:status}, body = {:body} WHERE id = {:id}") + .bind({ id: task.id, status: task.status, body: JSON.stringify(task) }) + .execute(); +} + +/** Return abandoned claims to the queue (or fail them, once exhausted). Read + * first without a transaction: on almost every poll nothing is due, and a poll + * should not take the write lock to find that out. */ +function sweep(app) { + const rules = require(`${__hooks}/_a2app_rules.js`); + const Q = rules.QUEUE; + const nowMs = Date.now(); + const working = rows(app.db(), "SELECT body FROM _a2app_tasks WHERE status = 'working'", {}, { body: "" }); + const due = []; + for (let i = 0; i < working.length; i++) { + const t = JSON.parse(working[i].body); + if (rules.sweepTask(t, nowMs, Q.TASK_TIMEOUT_MS, Q.TASK_MAX_DELIVERIES)) due.push(t.id); + } + if (due.length === 0) return; + app.runInTransaction((tx) => { + for (let i = 0; i < due.length; i++) { + // Re-read inside the transaction: a heartbeat may have landed since. + const current = loadTask(tx.db(), due[i]); + const swept = current && rules.sweepTask(current, Date.now(), Q.TASK_TIMEOUT_MS, Q.TASK_MAX_DELIVERIES); + if (swept) saveTask(tx.db(), swept); + } + }); +} + +/** + * Emit a declared event and, when `capability` is given, queue a task for an + * agent. The same occurrence (type, capability, payload) dedupes to one task, + * even after it finished — a re-ask names the previous task in its payload. + * `app` is the app (or the transaction) to write through; `env` is what + * `triggerEnv()` read, so a runner that fires many times reads it once. + */ +function triggerWith(app, env, type, payload, capability) { + const rules = require(`${__hooks}/_a2app_rules.js`); + if (typeof type !== "string" || type === "") throw new Error("trigger needs an event type"); + if (!rules.eventDeclared(env.events, type)) { + throw new Error(`event type "${type}" is not declared (add it to \`events\` in pb/pb_hooks/operations.js)`); + } + const plain = payload === undefined || payload === null ? {} : JSON.parse(JSON.stringify(payload)); + const appId = env.appId; + const now = new Date().toISOString(); + const hex = "0123456789abcdef"; + + const db = app.db(); + const event = { id: "evt_" + $security.randomStringWithAlphabet(16, hex), app: appId, type: type, payload: plain, createdAt: now }; + db.newQuery("INSERT INTO _a2app_events (id, body) VALUES ({:id}, {:body})") + .bind({ id: event.id, body: JSON.stringify(event) }) + .execute(); + db.newQuery("DELETE FROM _a2app_events WHERE seq <= (SELECT MAX(seq) FROM _a2app_events) - {:keep}") + .bind({ keep: rules.QUEUE.EVENT_RETENTION }) + .execute(); + + let taskId = null; + if (typeof capability === "string" && capability !== "") { + const dedup = "sha256:" + $security.sha256(rules.occurrenceText(type, capability, plain)); + const existing = rows(db, "SELECT id FROM _a2app_tasks WHERE dedup = {:dedup}", { dedup: dedup }, { id: "" }); + if (existing.length) { + taskId = existing[0].id; + } else { + const task = rules.newTask({ + id: "tsk_" + $security.randomStringWithAlphabet(16, hex), + app: appId, event: event.id, capability: capability, payload: plain, dedupKey: dedup, now: now, + }); + db.newQuery("INSERT INTO _a2app_tasks (id, status, dedup, body) VALUES ({:id}, {:status}, {:dedup}, {:body})") + .bind({ id: task.id, status: task.status, dedup: dedup, body: JSON.stringify(task) }) + .execute(); + taskId = task.id; + } + } + return { eventId: event.id, taskId: taskId }; +} + +/** The declared event types and the app id, read from disk once per call site. */ +function triggerEnv() { + const manifest = readManifest(); + return { events: appCode().events, appId: manifest && manifest.id ? String(manifest.id) : "" }; +} + +/** `trigger` for code outside an operation (a record hook, a cron job). Inside + * an operation, use the `a2app.trigger` the runner is handed instead. + * + * It runs in a transaction of its own, so the event and its task land together + * or not at all. Code already inside a transaction passes it as `txApp`: + * opening a second one there would wait forever on the first, because + * PocketBase writes through a single connection. */ +function trigger(type, payload, capability, txApp) { + if (txApp) return triggerWith(txApp, triggerEnv(), type, payload, capability); + let fired = null; + $app.runInTransaction((tx) => { + fired = triggerWith(tx, triggerEnv(), type, payload, capability); + }); + return fired; +} + +/* ------------------------------------------------------------ operations */ + +/** + * POST /api/ops/{name}: guard the arguments, gate a destructive call behind an + * approval key, then run the app's own runner from `pb/pb_hooks/operations.js`. + * + * The runner runs inside ONE transaction with everything it writes — records + * through `a2app.app`, tasks through `a2app.trigger` — so an operation either + * happened or did not: a runner that throws leaves no half-written record and + * no task queued for work that never started. + */ +function runOperation(e, name) { + const rules = require(`${__hooks}/_a2app_rules.js`); + const decls = readDeclaredOperations(); + let decl = null; + for (let i = 0; i < decls.length; i++) if (decls[i].name === name) decl = decls[i]; + if (!decl) return fail(e, 404, "unknown_operation", `No declared operation "${name}".`); + + const ctx = authorize(e, decl.readOnly !== true); + if (ctx === null) return; + const args = bodyOf(e); + + // Guard the RAW args before anything acts on them, and before an approval + // key is minted: a human must never be asked to approve a call that cannot run. + const violations = rules.validateArgs(decl.params || {}, args); + if (violations.length) { + const first = violations[0]; + return e.json(400, { + a2app: true, ok: false, code: first.code, field: first.field, expected: first.expected, got: first.got, + message: rules.describeViolation(first, new Date().toISOString()), violations: violations, + }); + } + + if (decl.destructive) { + const key = $security.sha256(rules.approvalText(name, args)); + const provided = header(e, "X-A2App-Approval") || header(e, "X-LUI-Approval"); + const nowMs = Date.now(); + if (provided === "") { + $app.runInTransaction((tx) => { + tx.db().newQuery("DELETE FROM _a2app_approvals WHERE expires < {:now}").bind({ now: nowMs }).execute(); + tx.db().newQuery("INSERT OR REPLACE INTO _a2app_approvals (key, expires) VALUES ({:key}, {:expires})") + .bind({ key: key, expires: nowMs + rules.QUEUE.APPROVAL_TTL_MS }) + .execute(); + }); + return fail(e, 428, "approval_required", `Operation "${name}" is destructive and requires approval.`, { approvalKey: key }); + } + let consumed = false; + if (provided === key) { + $app.runInTransaction((tx) => { + const held = rows(tx.db(), "SELECT expires FROM _a2app_approvals WHERE key = {:key}", { key: key }, { expires: 0 }); + if (held.length === 0) return; + tx.db().newQuery("DELETE FROM _a2app_approvals WHERE key = {:key}").bind({ key: key }).execute(); + consumed = held[0].expires >= nowMs; + }); + } + if (!consumed) { + return fail(e, 428, "approval_required", "Approval key does not match this exact call (or has expired).", { approvalKey: key }); + } + } + + const code = appCode(); + if (code.problem !== null) { + return fail(e, 500, "operation_failed", `pb/pb_hooks/operations.js could not be loaded: ${code.problem}`); + } + const runner = code.runners[name]; + if (typeof runner !== "function") { + return fail(e, 501, "not_implemented", `This app declares "${name}" but implements no operation runner.`); + } + const manifest = readManifest(); + const env = { events: code.events, appId: manifest && manifest.id ? String(manifest.id) : "" }; + + // A JS exception crossing runInTransaction comes back as a Go error and loses + // its fields, so the runner's own failure is caught inside and carried out. + let result = null; + let thrown = null; + try { + $app.runInTransaction((tx) => { + const a2app = { + app: tx, + trigger: (type, payload, capability) => triggerWith(tx, env, type, payload, capability), + error: operationError, + }; + try { + result = runner(args, ctx, a2app); + } catch (err) { + thrown = err; + throw new Error("a2app: rolled back"); + } + }); + } catch (err) { + if (thrown === null) thrown = err; + } + if (thrown !== null) { + if (thrown && thrown.a2appOperationError) { + return fail(e, thrown.status, thrown.code, thrown.message, thrown.extra); + } + return fail(e, 500, "operation_failed", `Operation "${name}" threw: ${thrown && thrown.message ? thrown.message : String(thrown)}`); + } + return e.json(200, { + a2app: true, ok: true, operation: name, + result: result === undefined ? null : JSON.parse(JSON.stringify(result)), + }); +} + +/** What a runner throws to refuse with its own status and code: + * `throw a2app.error(409, "already_done", "That task is already done.")`. */ +function operationError(status, code, message, extra) { + return { a2appOperationError: true, status: status, code: code, message: message, extra: extra || {} }; +} + +/* ----------------------------------------------------------------- tasks */ + +/** GET /api/_a2app/tasks[?status=] */ +function listTasks(e) { + const rules = require(`${__hooks}/_a2app_rules.js`); + if (authorize(e, false) === null) return; + sweep($app); + const status = queryParam(e, "status"); + const found = status + ? rows($app.db(), "SELECT body FROM _a2app_tasks WHERE status = {:status} ORDER BY seq", { status: status }, { body: "" }) + : rows($app.db(), "SELECT body FROM _a2app_tasks ORDER BY seq", {}, { body: "" }); + const tasks = []; + for (let i = 0; i < found.length; i++) tasks.push(rules.taskWire(JSON.parse(found[i].body))); + return e.json(200, { a2app: true, tasks: tasks, pollAfterMs: rules.QUEUE.POLL_AFTER_MS }); +} + +/** GET /api/_a2app/tasks/{id} */ +function getTask(e, id) { + const rules = require(`${__hooks}/_a2app_rules.js`); + if (authorize(e, false) === null) return; + sweep($app); + const task = loadTask($app.db(), id); + if (!task) return fail(e, 404, "task_not_found", `No task "${id}".`); + return e.json(200, rules.taskWire(task)); +} + +/** POST /api/_a2app/tasks/{id}/{claim|progress|complete|cancel} */ +function taskAction(e, id, action) { + const rules = require(`${__hooks}/_a2app_rules.js`); + const ctx = authorize(e, true); + if (ctx === null) return; + sweep($app); + const body = bodyOf(e); + let outcome = null; + $app.runInTransaction((tx) => { + const task = loadTask(tx.db(), id); + if (!task) { + outcome = { error: { status: 404, code: "task_not_found", message: `No task "${id}".`, extra: {} } }; + return; + } + outcome = rules.applyTaskAction(task, action, body, ctx, new Date().toISOString()); + if (outcome.task) saveTask(tx.db(), outcome.task); + }); + if (outcome.error) return fail(e, outcome.error.status, outcome.error.code, outcome.error.message, outcome.error.extra); + return e.json(200, rules.taskWire(outcome.task)); +} + +/* ---------------------------------------------------------------- events */ + +/** GET /api/_a2app/events[?since=] */ +function listEvents(e) { + const rules = require(`${__hooks}/_a2app_rules.js`); + if (authorize(e, false) === null) return; + const since = Number(queryParam(e, "since")) || 0; + const found = rows($app.db(), "SELECT seq, body FROM _a2app_events WHERE seq > {:since} ORDER BY seq", { since: since }, { seq: 0, body: "" }); + const events = []; + for (let i = 0; i < found.length; i++) { + const ev = JSON.parse(found[i].body); + events.push({ id: ev.id, app: ev.app, type: ev.type, payload: ev.payload, createdAt: ev.createdAt }); + } + const nextCursor = String(found.length ? found[found.length - 1].seq : since); + return e.json(200, { a2app: true, events: events, nextCursor: nextCursor, pollAfterMs: rules.QUEUE.EVENTS_POLL_AFTER_MS }); +} + +/** GET /api/_a2app/context — what the person is looking at. This View does not + * publish a selection, so the answer is the honest empty one. */ +function context(e) { + if (authorize(e, false) === null) return; + return e.json(200, { a2app: true, view: null, selected: [] }); +} + +/** Anything but POST on an operation: say how it is invoked, rather than + * leaving the router's bare 405 or the View's index page to answer. */ +function operationMethod(e) { + return fail(e, 405, "usage", "Operations are POST-only."); +} + +module.exports = { + identity: identity, describe: describe, guard: guard, deleteGuard: deleteGuard, + ensureStore: ensureStore, runOperation: runOperation, trigger: trigger, + listTasks: listTasks, getTask: getTask, taskAction: taskAction, + listEvents: listEvents, context: context, operationMethod: operationMethod, +}; diff --git a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_rules.js b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_rules.js index 6492849..ce44135 100644 --- a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_rules.js +++ b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/_a2app_rules.js @@ -287,7 +287,291 @@ function referencingFields(entities, target) { return out; } -module.exports = { RULES_VERSION: RULES_VERSION, validate: validate, looksLikeDate: looksLikeDate, isDayKey: isDayKey, labelField: labelField, schemaVersion: schemaVersion, describeViolation: describeViolation, describeIncomplete: describeIncomplete, evaluatePredicate: evaluatePredicate, explainPredicate: explainPredicate, referencingFields: referencingFields }; +/* ---------------- operation arguments ---------------- + * An operation IS a write, so its arguments go through the same guard as a + * record write. Parity oracle: `paramFields` + `validate(…, {requireRequired})` + * in adapter-core. Every parameter is supplied fresh on each call, so a declared + * `required` is always enforced: absent or blank is `missing_required`. */ + +function paramFields(params) { + var out = []; + var names = Object.keys(params || {}); + for (var i = 0; i < names.length; i++) { + var p = params[names[i]] || {}; + var f = { name: names[i], type: p.type }; + if (p.required !== undefined) f.required = p.required; + if (p.max !== undefined) f.max = p.max; + if (p.values !== undefined) f.values = p.values; + if (p.entity !== undefined) f.entity = p.entity; + out.push(f); + } + return out; +} + +function validateArgs(params, args) { + var fields = paramFields(params); + var out = []; + var keys = Object.keys(args || {}); + for (var i = 0; i < keys.length; i++) { + var f = null; + for (var j = 0; j < fields.length; j++) if (fields[j].name === keys[i]) f = fields[j]; + if (f && f.required && blank(args[keys[i]])) { + out.push(violation("missing_required", keys[i], "a non-blank value (required field)", args[keys[i]])); + } + } + if (fields.length === 0) { + // "one of: " with nothing after it reads as a truncated message. + for (var k = 0; k < keys.length; k++) { + out.push(violation("unknown_field", keys[k], "nothing — no writable fields are declared", args[keys[k]])); + } + } else { + var found = validate(fields, args || {}, {}); + for (var n = 0; n < found.length; n++) out.push(found[n]); + } + for (var m = 0; m < fields.length; m++) { + if (fields[m].required && !Object.prototype.hasOwnProperty.call(args || {}, fields[m].name)) { + out.push(violation("missing_required", fields[m].name, "a non-blank value (required field)", undefined)); + } + } + return out; +} + +/* ---------------- canonical JSON (RFC 8785) ---------------- + * Parity oracle: adapters/adapter-core/src/canon.ts. The approval key and the + * task dedup key are hashes of this text, so two adapters that canonicalize the + * same call identically mint the same key. Hashing needs the runtime (PocketBase + * `$security.sha256`, Node `crypto`), so this layer stops at the text. */ + +function canonicalize(v) { + if (v === null || v === undefined) return "null"; + var t = typeof v; + if (t === "boolean") return v ? "true" : "false"; + if (t === "number") { + if (!isFinite(v)) throw new Error("cannot canonicalize a non-finite number"); + return String(v); + } + if (t === "string") return canonicalString(v); + if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]"; + if (t === "object") { + var keys = Object.keys(v).sort(); + return "{" + keys.map(function (k) { return canonicalString(k) + ":" + canonicalize(v[k]); }).join(",") + "}"; + } + throw new Error("cannot canonicalize value of type " + t); +} + +function canonicalString(s) { + var out = '"'; + for (var i = 0; i < s.length; i++) { + var ch = s.charAt(i); + var code = s.charCodeAt(i); + if (ch === '"') out += '\\"'; + else if (ch === "\\") out += "\\\\"; + else if (ch === "\b") out += "\\b"; + else if (ch === "\f") out += "\\f"; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (code < 0x20) out += "\\u" + ("0000" + code.toString(16)).slice(-4); + else out += ch; + } + return out + '"'; +} + +/** What a destructive call's approval key is the hash of: this exact operation + * with these exact arguments. */ +function approvalText(operation, args) { + return canonicalize({ operation: operation, args: args === null || args === undefined ? {} : args }); +} + +/** What a task's dedup key is the hash of. Keyed on the OCCURRENCE — the event + * type, the capability asked for, and the payload — never on the event record, + * whose id is fresh per emit and would deduplicate nothing (A2APP-SPEC 6.2). */ +function occurrenceText(type, capability, payload) { + return canonicalize({ type: type, capability: capability, payload: payload === undefined ? null : payload }); +} + +/* ---------------- the app→agent queue (A2APP-SPEC 6) ---------------- + * Parity oracle: `handleTasks` in adapters/adapter-core/src/server.ts and + * `sweep` in store.ts. Storage is the adapter's business; what a task may + * become, and when, is decided here so the gate's self-test can hold it. */ + +var QUEUE = { + /** A `working` task with no update for this long goes back to the queue. */ + TASK_TIMEOUT_MS: 60000, + /** …until it has been handed out this many times; then it fails. */ + TASK_MAX_DELIVERIES: 5, + POLL_AFTER_MS: 2000, + EVENTS_POLL_AFTER_MS: 3000, + /** The event log is a ring; `seq` stays monotonic so cursors survive a prune. */ + EVENT_RETENTION: 5000, + APPROVAL_TTL_MS: 10 * 60 * 1000, +}; + +var TERMINAL = ["completed", "failed", "canceled"]; + +function newTask(input) { + var request = { capability: input.capability }; + if (input.payload !== undefined) request.payload = input.payload; + return { + id: input.id, + app: input.app, + event: input.event === undefined ? null : input.event, + status: "submitted", + request: request, + claim: null, + progress: { step: null, percent: 0 }, + result: null, + reason: null, + ask: null, + createdAt: input.now, + updatedAt: input.now, + dedupKey: input.dedupKey || null, + deliveries: 1, + }; +} + +/** The task as the protocol publishes it — never the dedup key or the delivery + * count, which are the adapter's bookkeeping. */ +function taskWire(t) { + return { + id: t.id, + app: t.app, + event: t.event, + status: t.status, + request: t.request, + claim: t.claim, + progress: t.progress, + result: t.result, + reason: t.reason, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + pollAfterMs: QUEUE.POLL_AFTER_MS, + }; +} + +function refusal(status, code, message, extra) { + return { error: { status: status, code: code, message: message, extra: extra || {} } }; +} + +/** + * One lifecycle step: `claim`, `progress`, `complete` or `cancel`, by `ctx` + * ({credentialId, agentName, principal}) at `now`. Returns `{task}` (a new + * object; the input is not touched) or `{error: {status, code, message}}`. + */ +function applyTaskAction(task, action, body, ctx, now) { + body = body || {}; + var t = JSON.parse(JSON.stringify(task)); + var id = t.id; + switch (action) { + case "claim": { + if (t.status !== "submitted") { + return refusal(409, "task_not_claimable", "Task " + id + " is " + t.status + ", not claimable."); + } + // An explicit principal is honoured ONLY when it names the caller's own + // credential. Naming a different one is a claim-as-someone-else attempt. + var requested = typeof body.agent === "string" && body.agent ? body.agent : null; + if (requested && requested !== ctx.credentialId && requested !== ctx.agentName) { + return refusal(403, "principal_mismatch", + "Cannot claim task " + id + ' as "' + requested + '": the credential presented is ' + ctx.credentialId + ".", + { requested: requested, actual: ctx.credentialId }); + } + t.status = "working"; + t.claim = { credentialId: ctx.credentialId, principal: ctx.principal, claimedAt: now }; + break; + } + case "progress": { + if (t.status === "canceled") return refusal(409, "task_canceled", "Task " + id + " was canceled."); + if (t.status !== "working" && t.status !== "input-required") { + return refusal(409, "task_not_claimable", "Task " + id + " is " + t.status + "."); + } + if (!t.progress) t.progress = { step: null, percent: 0 }; + if (typeof body.step === "string") t.progress.step = body.step; + if (typeof body.percent === "number") t.progress.percent = body.percent; + if (body.ask !== undefined && body.ask !== null) { + t.ask = body.ask; + t.status = "input-required"; + } else if (t.status === "input-required") { + t.status = "working"; + } + break; + } + case "complete": { + if (t.status === "canceled") return refusal(409, "task_canceled", "Task " + id + " was canceled."); + // A terminal write closes a live claim. A task nobody holds — never + // claimed, or swept back to the queue under a run that went quiet — has no + // claim to close, and a late report must not close work someone else may + // now be doing. + if (t.status !== "working" && t.status !== "input-required") { + return refusal(409, "task_not_claimable", "Task " + id + " is " + t.status + ", not in progress."); + } + if (body.status === "completed") { + t.status = "completed"; + t.result = body.result && typeof body.result === "object" ? body.result : {}; + } else if (body.status === "failed") { + t.status = "failed"; + t.reason = typeof body.reason === "string" ? body.reason : "unspecified"; + } else { + return refusal(400, "usage", 'complete requires status "completed" or "failed".'); + } + break; + } + case "cancel": { + // Terminal is terminal: relabelling finished work as canceled would + // rewrite an outcome that already happened. + if (TERMINAL.indexOf(t.status) !== -1) { + return refusal(409, "task_not_claimable", "Task " + id + " already reached " + t.status + "."); + } + t.status = "canceled"; + break; + } + default: + return refusal(404, "usage", 'Unknown task action "' + action + '".'); + } + t.updatedAt = now; + return { task: t }; +} + +/** + * Return an abandoned claim to the queue. A `working` task with no update for + * `timeoutMs` goes back to `submitted` so another run can take it, and fails as + * `redelivery_exhausted` once it has been handed out `maxDeliveries` times — + * never a silent drop. Returns the swept task (a new object), or null when this + * one is not due. + */ +function sweepTask(task, nowMs, timeoutMs, maxDeliveries) { + if (task.status !== "working") return null; + var updated = Date.parse(task.updatedAt); + if (!isNaN(updated) && nowMs - updated < timeoutMs) return null; + var t = JSON.parse(JSON.stringify(task)); + if ((t.deliveries || 1) >= maxDeliveries) { + t.status = "failed"; + t.reason = "redelivery_exhausted"; + } else { + t.status = "submitted"; + t.claim = null; + t.deliveries = (t.deliveries || 1) + 1; + } + t.updatedAt = new Date(nowMs).toISOString(); + return t; +} + +/** Mark the named fields read-only, in place, and return the list. `names` is + * what the app declared for this entity (absent means none). */ +function markReadOnly(fields, names) { + if (!names || !names.length) return fields; + for (var i = 0; i < fields.length; i++) if (names.indexOf(fields[i].name) !== -1) fields[i].readOnly = true; + return fields; +} + +/** Is this event type one the app declared? An app that declares none has not + * opted into the restriction (parity with adapter-core's `trigger`). */ +function eventDeclared(declared, type) { + if (!declared || declared.length === 0) return true; + for (var i = 0; i < declared.length; i++) if (declared[i] && declared[i].type === type) return true; + return false; +} + +module.exports = { RULES_VERSION: RULES_VERSION, validate: validate, looksLikeDate: looksLikeDate, isDayKey: isDayKey, labelField: labelField, schemaVersion: schemaVersion, describeViolation: describeViolation, describeIncomplete: describeIncomplete, evaluatePredicate: evaluatePredicate, explainPredicate: explainPredicate, referencingFields: referencingFields, validateArgs: validateArgs, canonicalize: canonicalize, approvalText: approvalText, occurrenceText: occurrenceText, QUEUE: QUEUE, newTask: newTask, taskWire: taskWire, applyTaskAction: applyTaskAction, sweepTask: sweepTask, eventDeclared: eventDeclared, markReadOnly: markReadOnly }; /* -------- gate self-test: `node _a2app_rules.js --selftest` -------- */ if (typeof process !== "undefined" && process.argv && process.argv.indexOf("--selftest") !== -1) { @@ -351,7 +635,96 @@ if (typeof process !== "undefined" && process.argv && process.argv.indexOf("--se }; if (referencingFields(opted, "clients").length !== 0) failures.push('onDelete "ignore" must not block'); + // Operation arguments are guarded like a write, and required means required. + var params = { task: { type: "ref", entity: "tasks", required: true } }; + var argCodes = function (args) { return validateArgs(params, args).map(function (v) { return v.code + ":" + v.field; }).join(","); }; + if (argCodes({ task: "t1" }) !== "") failures.push("good operation args were rejected: " + argCodes({ task: "t1" })); + if (argCodes({}) !== "missing_required:task") failures.push("an absent required arg: " + argCodes({})); + if (argCodes({ task: "" }) !== "missing_required:task") failures.push("a blank required arg: " + argCodes({ task: "" })); + if (argCodes({ task: "t1", bogus: 1 }) !== "unknown_field:bogus") failures.push("an undeclared arg: " + argCodes({ task: "t1", bogus: 1 })); + if (validateArgs({}, { x: 1 }).length !== 1) failures.push("an op with no params must refuse an arg"); + + // Canonical JSON and the keys hashed from it must match adapter-core byte for + // byte, or "approve this exact call" stops being portable between stacks. + // Expected values computed with adapters/adapter-core/src/canon.ts. + var canon = canonicalize({ b: [1, 'x\n"', null, true], a: { z: 1.5, y: "é" } }); + if (canon !== '{"a":{"y":"é","z":1.5},"b":[1,"x\\n\\"",null,true]}') failures.push("canonical JSON: " + canon); + var sha = function (text) { return require("crypto").createHash("sha256").update(text, "utf8").digest("hex"); }; + if (sha(approvalText("clear-done", {})) !== "7d745c845ebc004f343ea667d5b6e711ae3a8b842858cb0e79e4d3026519c00c") { + failures.push("approval key differs from adapter-core (no args)"); + } + if (sha(approvalText("complete-task", { task: "abc123" })) !== "8e779421bc9b0823c1c9e8f86228561e8091531fa830e5950985a7964f1fe223") { + failures.push("approval key differs from adapter-core (with args)"); + } + if (sha(occurrenceText("task.needs_triage", "triage", { task: "abc123" })) !== "d2d0fe3a22e6080c8c12cd4f064c8741243022ce19c1ab83aac097c8ce99f05e") { + failures.push("dedup key differs from adapter-core"); + } + // The same occurrence dedupes whatever its key order; a different one does not. + if (occurrenceText("e", "c", { a: 1, b: 2 }) !== occurrenceText("e", "c", { b: 2, a: 1 })) failures.push("dedup key depends on key order"); + if (occurrenceText("e", "c", { task: "t1" }) === occurrenceText("e", "c", { task: "t1", previous: "tsk_1" })) { + failures.push("naming the previous task must make a new occurrence"); + } + if (!eventDeclared([{ type: "a" }], "a") || eventDeclared([{ type: "a" }], "b")) failures.push("undeclared event types must be refused"); + + // A field the app declares read-only is refused on a client write, and moves + // the fingerprint (describe publishes it). + var withTask = fields.concat([{ name: "agentTask", type: "string", max: 64 }]); + var guarded = markReadOnly(JSON.parse(JSON.stringify(withTask)), ["agentTask"]); + var roCodes = validate(guarded, { title: "x", agentTask: "tsk_1" }, {}).map(function (v) { return v.code + ":" + v.field; }).join(","); + if (roCodes !== "read_only_field:agentTask") failures.push("a declared read-only field must refuse a client write: " + roCodes); + if (validate(markReadOnly(JSON.parse(JSON.stringify(withTask)), undefined), { agentTask: "tsk_1" }, {}).length !== 0) { + failures.push("with nothing declared, the field stays writable"); + } + if (schemaVersion({ tasks: { fields: withTask, module: "planning" } }) === schemaVersion({ tasks: { fields: guarded, module: "planning" } })) { + failures.push("fingerprint ignores a declared read-only field"); + } + + // The task lifecycle: claim, contention, progress, completion, cancel. + var now = "2026-10-05T10:00:00.000Z"; + var agentCtx = { credentialId: "cred_local", agentName: "local", principal: "owner" }; + var t0 = newTask({ id: "tsk_1", app: "app", event: "evt_1", capability: "triage", payload: { task: "t1" }, dedupKey: "k", now: now }); + var wire = taskWire(t0); + if (wire.status !== "submitted" || wire.request.capability !== "triage" || wire.pollAfterMs !== QUEUE.POLL_AFTER_MS) failures.push("task wire shape"); + if ("dedupKey" in wire || "deliveries" in wire) failures.push("the wire form leaks the adapter's bookkeeping"); + var claimed = applyTaskAction(t0, "claim", { agent: "cred_local" }, agentCtx, now); + if (!claimed.task || claimed.task.status !== "working" || claimed.task.claim.credentialId !== "cred_local") failures.push("claim should move a task to working"); + if (t0.status !== "submitted") failures.push("a lifecycle step must not mutate its input"); + var again = applyTaskAction(claimed.task, "claim", {}, agentCtx, now); + if (!again.error || again.error.status !== 409 || again.error.code !== "task_not_claimable") failures.push("a second claim must be 409 task_not_claimable"); + var imposter = applyTaskAction(t0, "claim", { agent: "cred_other" }, agentCtx, now); + if (!imposter.error || imposter.error.code !== "principal_mismatch") failures.push("claiming as another credential must be refused"); + var early = applyTaskAction(t0, "complete", { status: "completed" }, agentCtx, now); + if (!early.error || early.error.code !== "task_not_claimable") failures.push("an unclaimed task cannot be completed"); + var prog = applyTaskAction(claimed.task, "progress", { step: "reading", percent: 40 }, agentCtx, now); + if (!prog.task || prog.task.progress.step !== "reading" || prog.task.progress.percent !== 40) failures.push("progress should record step and percent"); + var asked = applyTaskAction(prog.task, "progress", { ask: { question: "which?" } }, agentCtx, now); + if (!asked.task || asked.task.status !== "input-required") failures.push("an ask moves the task to input-required"); + var resumed = applyTaskAction(asked.task, "progress", { step: "resumed" }, agentCtx, now); + if (!resumed.task || resumed.task.status !== "working") failures.push("progress without an ask resumes work"); + var done = applyTaskAction(prog.task, "complete", { status: "completed", result: { summary: "ok" } }, agentCtx, now); + if (!done.task || done.task.status !== "completed" || done.task.result.summary !== "ok") failures.push("complete should reach completed"); + var failed = applyTaskAction(prog.task, "complete", { status: "failed" }, agentCtx, now); + if (!failed.task || failed.task.reason !== "unspecified") failures.push("a failure with no reason says so"); + var vague = applyTaskAction(prog.task, "complete", { status: "done" }, agentCtx, now); + if (!vague.error || vague.error.status !== 400) failures.push("complete with an unknown status is a usage error"); + var late = applyTaskAction(done.task, "cancel", {}, agentCtx, now); + if (!late.error || late.error.code !== "task_not_claimable") failures.push("a finished task cannot be canceled"); + var canceled = applyTaskAction(t0, "cancel", {}, agentCtx, now).task; + var afterCancel = applyTaskAction(canceled, "progress", { step: "x" }, agentCtx, now); + if (!afterCancel.error || afterCancel.error.code !== "task_canceled") failures.push("progress on a canceled task is task_canceled"); + + // The sweeper: a quiet claim goes back to the queue, then fails as exhausted. + var stale = Date.parse(now) + QUEUE.TASK_TIMEOUT_MS + 1; + if (sweepTask(claimed.task, Date.parse(now) + 1000, QUEUE.TASK_TIMEOUT_MS, QUEUE.TASK_MAX_DELIVERIES) !== null) failures.push("a live claim must not be swept"); + if (sweepTask(t0, stale, QUEUE.TASK_TIMEOUT_MS, QUEUE.TASK_MAX_DELIVERIES) !== null) failures.push("only working tasks are swept"); + var back = sweepTask(claimed.task, stale, QUEUE.TASK_TIMEOUT_MS, QUEUE.TASK_MAX_DELIVERIES); + if (!back || back.status !== "submitted" || back.claim !== null || back.deliveries !== 2) failures.push("a quiet claim should be redelivered"); + var spent = JSON.parse(JSON.stringify(claimed.task)); + spent.deliveries = QUEUE.TASK_MAX_DELIVERIES; + var exhausted = sweepTask(spent, stale, QUEUE.TASK_TIMEOUT_MS, QUEUE.TASK_MAX_DELIVERIES); + if (!exhausted || exhausted.status !== "failed" || exhausted.reason !== "redelivery_exhausted") failures.push("the last delivery should fail as redelivery_exhausted"); + if (failures.length) console.log("a2app_rules selftest FAILED:\n - " + failures.join("\n - ")); - else console.log("a2app_rules selftest: all rules pass (guard, predicates, fingerprint, referential deletes)"); + else console.log("a2app_rules selftest: all rules pass (guard, predicates, fingerprint, referential deletes, operation args, read-only fields, canonical keys, task lifecycle, sweep)"); process.exit(failures.length ? 1 : 0); } diff --git a/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/operations.js b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/operations.js new file mode 100644 index 0000000..b403f58 --- /dev/null +++ b/toolkits/blueprint-pocketbase-react/template/pb/pb_hooks/operations.js @@ -0,0 +1,120 @@ +/// +/** + * Operation runners and the events this app may emit (AGENT-OWNED — edit this + * to evolve the app). + * + * TWO FILES, ONE TRUTH: every operation is DECLARED in `operations.json` (name, + * module, typed params, entity, appliesWhen, flags; describe and approval read + * that) and RUN here, by a function of the same name. The gate fails a declared + * operation with no runner here. + * + * The adapter serves `POST /api/ops/{name}`: it guards the arguments against the + * declared params, asks for approval on a destructive operation (428 + an + * approval key), and only then calls `runners[name](args, ctx, a2app)`: + * + * args the guarded arguments, as plain JS. + * ctx who is calling: { credentialId, agentName, principal }. + * a2app { app, trigger, error }: + * app the PocketBase app to read and write through — + * `a2app.app.findRecordById(...)`, `a2app.app.save(record)`. + * It is a TRANSACTION: everything the runner writes, and every + * task it queues, lands together or not at all. Use it, not + * `$app`, and do not make slow outbound calls in here — the + * transaction holds the database's write lock until you return. + * trigger (type, payload, capability) — emit a declared event and, when + * a capability is named, queue a task for an agent. Returns + * { eventId, taskId }. An event type missing from `events` + * below is refused. + * error (status, code, message) — `throw a2app.error(409, "already_done", + * "…")` refuses with your own status and code. + * + * Return a plain, JSON-able value; it is the `result` of the call. Anything a + * runner throws becomes `operation_failed` and rolls back what it wrote. + * + * `readOnly` names the fields only runners write; see the note on it below. + * + * This file is loaded with `require()`, never as a hook file (only `*.pb.js` + * files are), so it may declare whatever it likes at its own scope — but touch + * PocketBase only inside a runner: at load time there is no request to serve. + */ + +module.exports = { + // The event types this app may emit (the app→agent direction). Declaring a + // type is what lets `trigger` fire it, so this list is the fixed set of + // things the app can ever ask an agent to react to — decided here by its + // author, not at the moment of firing. + // + // Leave it empty until a feature genuinely needs agent judgment. Plain events + // want plain code; a task is for work a person would otherwise have to think + // about. + events: [{ type: "task.needs_triage" }], + + // Fields only this app's runners write, by collection. PocketBase has no + // read-only flag for a field, so it is declared here: describe publishes it + // as readOnly, and a client write to it (the View, an agent, the records API) + // is refused with `read_only_field`. A runner still sets it with + // `a2app.app.save()`, and so can the owner in the PocketBase dashboard. + // + // `agentTask` is the reason this exists. An agent that saves a record it + // read a minute ago would otherwise write back the old task id, and the View + // would stop showing the run that is actually in progress. + readOnly: { tasks: ["agentTask"] }, + + runners: { + "clear-done": (_args, _ctx, a2app) => { + const done = a2app.app.findRecordsByFilter("tasks", "status = 'done'", "", 0, 0); + for (let i = 0; i < done.length; i++) a2app.app.delete(done[i]); + return { removed: done.length }; + }, + + "count-tasks": (_args, _ctx, a2app) => ({ count: a2app.app.countRecords("tasks") }), + + "complete-task": (args, _ctx, a2app) => { + const task = findTask(a2app, args.task); + if (!task) return { ok: false, reason: "no such task" }; + task.set("status", "done"); + a2app.app.save(task); + return { ok: true, task: task.id, status: "done" }; + }, + + // The app→agent direction, in full. `trigger` emits a DECLARED event and, + // because a capability is named, queues a task on the app's own queue. From + // there an agent takes it — either because a harness is polling + // (`a2app tasks next --wait`) or because `agent-app bridge` is + // running and starts one. + // + // Send IDS, not prose. The agent re-reads the record itself, so the payload + // carries what it needs to find the work — never instructions, and never a + // copy of the data, which would be stale by the time it is read. Nothing + // here can widen what the agent may do: the payload is data on the other + // side, and the capability names the kind of work, not a command. + // + // Identical triggers dedupe to ONE task, even after it has finished, so + // asking again with the same payload would hand back the old failure. + // Naming the previous task makes each request a new occurrence. The View + // disables the control while a run is open, so a double click cannot queue + // two. + // + // The task id goes on the record so the View can show the work until it is + // done (ui/src/AgentTask.jsx). The validate gate checks that it does. + "request-triage": (args, _ctx, a2app) => { + const task = findTask(a2app, args.task); + if (!task) return { ok: false, reason: "no such task" }; + const payload = { task: task.id }; + const previous = task.getString("agentTask"); + if (previous) payload.previous = previous; + const fired = a2app.trigger("task.needs_triage", payload, "triage"); + task.set("agentTask", fired.taskId); + a2app.app.save(task); + return { ok: true, task: task.id, queued: fired.taskId }; + }, + }, +}; + +function findTask(a2app, id) { + try { + return a2app.app.findRecordById("tasks", String(id)); + } catch (_missing) { + return null; + } +} diff --git a/toolkits/blueprint-pocketbase-react/template/pb/pb_migrations/1759622400_created_tasks.js b/toolkits/blueprint-pocketbase-react/template/pb/pb_migrations/1759622400_created_tasks.js new file mode 100644 index 0000000..33eace9 --- /dev/null +++ b/toolkits/blueprint-pocketbase-react/template/pb/pb_migrations/1759622400_created_tasks.js @@ -0,0 +1,52 @@ +/// +/** + * The starter's `tasks` collection (AGENT-OWNED — the first link in YOUR + * migration chain). + * + * Applied once per database: the first live boot, and every fresh dev boot, + * which replays the whole chain from empty. Once it has run anywhere, NEVER edit, + * rename or delete this file — PocketBase knows a migration by its filename and + * would try to apply the "new" one over the existing collection. Change the + * collection with a NEW migration file. + * + * `agentTask` holds the queue task an agent is (or was last) working on for + * this record. The `request-triage` runner (pb/pb_hooks/operations.js) sets it; + * the View follows it, so the person can see the work they asked for until it + * is done. operations.js declares it read-only, so no client can overwrite it. + * + * `created`/`updated` are marked `system`, so the adapter leaves them out of + * the fields it publishes and a client cannot write them. + */ +migrate( + (app) => { + const collection = new Collection({ + type: "base", + name: "tasks", + // Single-user app: the View and the agent read and write the same records. + // A multi-user app replaces these with real rules. + listRule: "", + viewRule: "", + createRule: "", + updateRule: "", + deleteRule: "", + fields: [ + { type: "text", name: "title", required: true, max: 200 }, + { type: "select", name: "status", values: ["todo", "doing", "done"], maxSelect: 1 }, + { type: "text", name: "due", max: 10 }, + { type: "text", name: "notes", max: 2000 }, + { type: "text", name: "agentTask", max: 64 }, + { type: "autodate", name: "created", onCreate: true, system: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true, system: true }, + ], + }); + app.save(collection); + + const welcome = new Record(collection); + welcome.set("title", "Welcome — edit or delete me"); + welcome.set("status", "todo"); + app.save(welcome); + }, + (app) => { + app.delete(app.findCollectionByNameOrId("tasks")); + }, +); diff --git a/toolkits/blueprint-pocketbase-react/template/reference/blueprint.md b/toolkits/blueprint-pocketbase-react/template/reference/blueprint.md index 4cf3560..a15d945 100644 --- a/toolkits/blueprint-pocketbase-react/template/reference/blueprint.md +++ b/toolkits/blueprint-pocketbase-react/template/reference/blueprint.md @@ -2,8 +2,9 @@ A PocketBase backend + a React frontend, with the A2App adapter running as in-process PocketBase JS hooks (`pb/pb_hooks/_a2app*.js`). PocketBase serves -records natively; the adapter adds identity + describe and a guard that validates -the raw request body before PocketBase coerces it. +records natively; the adapter adds identity + describe, a guard that validates +the raw request body before PocketBase coerces it, declared operations +(`POST /api/ops/{name}`), and the app→agent task queue and event log. Where the `creator`/`modify` skill says "per your stack", the answer is here. If this file and the source disagree, the source wins. @@ -13,15 +14,18 @@ this file and the source disagree, the source wins. The scaffold gives you the A2App adapter and the framework files. You build what the app actually does: -- **entities** — PocketBase collections, defined by JavaScript migration files you - add under `pb/pb_migrations/` (a migration is a `.js` file PocketBase runs at - boot to create or alter collections — see **Schema** below); -- **custom operations** — new hook files under `pb/pb_hooks/` (see **Operations**); +- **entities** — PocketBase collections, defined by JavaScript migration files + under `pb/pb_migrations/` (a migration is a `.js` file PocketBase runs at boot + to create or alter collections — see **Schema** below); +- **operations** — declared in `operations.json`, run by a function of the same + name in `pb/pb_hooks/operations.js` (see **Operations**); - **the View** — the React (Vite) starter app in `ui/`, compiled by the pipeline build into `pb/pb_public/`, which PocketBase serves (see **The View**). -A freshly scaffolded app has its modules declared but no collections, so it stores -nothing until you add your first migration file under `pb/pb_migrations/`. +A freshly scaffolded app is the starter to-do list: one migration creates the +`tasks` collection (with a welcome row), `operations.js` runs four operations +including `request-triage`, which queues work for an agent, and the View shows +that work until it is done. Replace all three with your own. ## File map — who owns what @@ -30,27 +34,29 @@ nothing until you add your first migration file under `pb/pb_migrations/`. | Path | Owner | What it is | |---|---|---| | `operations.json` | **YOURS** | the operation declarations `describe` and approval read (name, module, typed params, entity, appliesWhen, flags). | +| `pb/pb_hooks/operations.js` | **YOURS** | the operation RUNNERS (one per declared operation, same name), the `events` this app may emit, and the `readOnly` fields only runners write. Loaded with `require()`, never as a hook file. | +| `pb/pb_migrations/1759622400_created_tasks.js` | **YOURS** | the starter's `tasks` collection and welcome row — the first link in your migration chain. Never edit it once applied; change `tasks` with a new migration. | | `AGENT_APP.md` | **YOURS** | this app's index: plan, modules, entities, operations, conventions, checklist. | | `reference/requirements.md` | **YOURS** | the binding spec — Part A (SRS) + Part B (tech spec + Quality Conformance). | | `reference/tasks.md` | **YOURS** | the build ledger — one task per feature/quality item, ticked as you complete it. | | `reference/blueprint.md` | reference | this file — the stack map. | | `.gitignore` | **YOURS** | ignores `pb/pb_data`, credentials, the binary, and the built View (`pb/pb_public/`). Extend as needed. | -| `ui/index.html` · `ui/src/**` | **YOURS** | the React View source: App, shared pieces (Icon, `useToast`, `useConfirm`, `api`, formatting). Compiled into `pb/pb_public/` by the pipeline build. | +| `ui/index.html` · `ui/src/**` | **YOURS** | the React View source: App, shared pieces (Icon, `useToast`, `useConfirm`, `api`, formatting, `AgentTask.jsx`). Compiled into `pb/pb_public/` by the pipeline build. | | `ui/public/tokens.css` · `ui/public/ui.css` | **YOURS** | the kit design-token sheet + component styles, copied through the build verbatim. Re-point token values; keep the names stable. | | `ui/vite.config.js` · `ui/package.json` | build | the View build: React plugin, output to `../pb/pb_public`, dev-server proxy to the running app. | | `manifest.json` | SYSTEM (hash-locked) | app identity, `modules[]` **and their `entities[]`** (the collection→module map you maintain), `authMode`, `pipeline`. | | `pb-serve.mjs` | SYSTEM (hash-locked) | the cross-platform launcher `pipeline.start` runs. Reads `PORT`/`A2APP_DATA_DIR`/`A2APP_ENV`, ensures the superuser, snapshots the View for live boots, and runs `pocketbase serve` (or `--migrate` for promote). Never edit. | | `pb/pb_hooks/_a2app.pb.js` | SYSTEM (hash-locked) | the adapter hook REGISTRATIONS — thin one-liner handlers that `require()` the impl module (PocketBase runs each handler isolated from file scope). Never edit. | -| `pb/pb_hooks/_a2app_impl.js` | SYSTEM (hash-locked) | the adapter IMPLEMENTATION: identity, describe, and the create/update guard, loaded via `require()` from the handlers. Never edit. | -| `pb/pb_hooks/_a2app_rules.js` | SYSTEM (hash-locked) | the pure rules (guard, predicates, fingerprint) + `--selftest`. Never edit. | +| `pb/pb_hooks/_a2app_impl.js` | SYSTEM (hash-locked) | the adapter IMPLEMENTATION: identity, describe, the record guards, operations, the task queue and event log, loaded via `require()` from the handlers. Never edit. | +| `pb/pb_hooks/_a2app_rules.js` | SYSTEM (hash-locked) | the pure rules (guard, predicates, fingerprint, operation args, approval/dedup keys, task lifecycle, sweep) + `--selftest`. Never edit. | **You create or obtain (nothing ships for these):** | Path | Owner | What it is | |---|---|---| | `pb/pocketbase` (`pb/pocketbase.exe` on Windows) | runtime | the PocketBase binary, **pinned to v0.26.6** (the adapter targets the 0.26 JSVM API). You download it (see pipeline `install`); nothing runs without it. `pb-serve.mjs` picks the right name per platform and refuses to launch a non‑0.26.x build. Git-ignored. | -| `pb/pb_migrations/*.js` | **YOURS** | collection (entity) definitions as PocketBase JS migrations. You write these — none ship, so a fresh app has zero collections. | -| `pb/pb_hooks/.pb.js` | **YOURS** | custom operation implementations — a new hook file per op with a `routerAdd(...)` route. Never touch `_a2app*`. | +| `pb/pb_migrations/*.js` | **YOURS** | every further collection (entity) change, as a new PocketBase JS migration. | +| `pb/pb_hooks/.pb.js` | **YOURS** | your own hooks, if you need any beyond operations: a record event hook, a cron job, an outbound sync. Never touch `_a2app*`. | | `pb/pb_public/**` | build output | the COMPILED React View (`npm --prefix ui run build`); PocketBase serves it statically. Git-ignored; edit `ui/src/`, never this. | | `pb/pb_data/` | runtime | the live database (git-ignored, created on first boot). Never edit by hand; never commit. | @@ -63,6 +69,11 @@ model it as your own collection field, migration, or operation hook instead. - The **adapter is the only agent surface.** PocketBase serves records; the hooks add the protocol surface and validate raw writes via `onRecordCreateRequest`/`onRecordUpdateRequest`. +- **Queue state is the adapter's, not yours.** Tasks, events and approval keys + live in tables of their own in `data.db` (`_a2app_tasks`, `_a2app_events`, + `_a2app_approvals`), created at boot. They are not collections, so they never + appear in describe or the records API, and you never migrate them. They + survive a restart; a dev boot starts with an empty queue. - **`describe`/`schemaVersion` are derived from the LIVE collection schema** — so they can never drift from what the database actually holds. You never hand-write describe. @@ -89,6 +100,9 @@ downloaded rather than guessing. The framework rules on top of it: - **After creating a collection, add it to `manifest.json`** under the right module's `entities[]` (this is a system-owned file — change modules/entities via the CLI where possible; the mapping is what gives the entity a screen). +- **Read-only fields.** PocketBase has no read-only flag for a field. Declare + the ones only your runners may write in `operations.js` as + `readOnly: { : ["", …] }` (see **App→agent triggers**). - **Field → protocol type mapping** the adapter publishes: PocketBase `text` → `string`, `number` → `number`, `bool` → `boolean`, `date` → `datetime`, `json` → `json`, `file` → `binary`, `select` (single) → `enum` / (multi) → @@ -100,12 +114,49 @@ downloaded rather than guessing. The framework rules on top of it: ## Operations — anything beyond plain CRUD Plain create/read/update/delete is served natively by PocketBase — no operation -needed. A declared operation is for behavior beyond CRUD: +needed. A declared operation is for behavior beyond CRUD, and it takes two +edits that must agree: + +1. **Declare it** in `operations.json` with `module`, typed `params` (`{}` if + none), optional `entity` + `appliesWhen`, and flags. +2. **Run it** in `pb/pb_hooks/operations.js`, under `runners`, by the same name: + +```js +// pb/pb_hooks/operations.js +module.exports = { + events: [], + runners: { + "complete-task": (args, ctx, a2app) => { + const task = a2app.app.findRecordById("tasks", args.task); + task.set("status", "done"); + a2app.app.save(task); + return { ok: true, task: task.id }; + }, + }, +}; +``` + +The adapter serves it as `POST /api/ops/{name}`. Before your runner is called it +guards the arguments against the declared `params` (an undeclared or missing +required argument is a 400 with every violation), checks the caller (the View on +a single-user app, or the agent's `.agent-token`; a `readOnly` operation also +answers an uncredentialled caller on a single-user app), and for a `destructive` +operation answers **428 + an `approvalKey`** until the call is repeated with +that key in `X-A2App-Approval`. The key is content-addressed — this operation +with these exact arguments — and good once. + +A runner receives `(args, ctx, a2app)`: -1. Declare it in `operations.json` with `module`, typed `params` (`{}` if none), - optional `entity` + `appliesWhen`, and flags. -2. Implement it as a **new hook file**, e.g. `pb/pb_hooks/.pb.js`, adding a - route with `routerAdd(...)` that does the work. (Never edit `_a2app.pb.js`.) +- `a2app.app` — the PocketBase app to read and write through. **It is a + transaction**: the whole runner, and every task it queues, commits together + or not at all, so a runner that throws leaves nothing half-written. Use it + rather than `$app`, and keep slow outbound calls out of runners — the + transaction holds the database's write lock until you return. +- `a2app.trigger(type, payload, capability)` — see **App→agent triggers**. +- `a2app.error(status, code, message)` — `throw` it to refuse with your own + status and code. Anything else a runner throws is `operation_failed`. + +Return a plain JSON-able value; it is the call's `result`. Declaration fields (same contract as every stack): @@ -117,13 +168,11 @@ Declaration fields (same contract as every stack): - **Flags:** `destructive: true` (requires human approval), `readOnly: true`, `idempotent: true`. -> **This stack does NOT gate operation resolution.** Unlike react-node and -> python-fastapi, this blueprint's gate has no "operations resolve" step — a -> declared operation with no working route will pass `validate` silently. You -> MUST verify each operation for real in the REALITY CHECK: run -> `a2app

` on a live record and confirm the operation -> is available when it should be and blocked (with a sensible reason) when it -> should not. +The gate's **"operations resolve"** step fails a declared operation with no +runner. It cannot tell whether a runner does the right thing: in the REALITY +CHECK, run `a2app ` on a live record and confirm the +operation is available when it should be, blocked (with a sensible reason) when +it should not, and that running it changes what it says it does. ## The View — `ui/` (React, compiled into `pb/pb_public/`) @@ -134,15 +183,19 @@ confirm dialog, keyboard shortcut — built on the kit's design tokens into `pb/pb_public/`, which PocketBase serves statically; no system-owned file is involved. -The starter reads the `tasks` collection. A fresh scaffold has NO collections, -so it renders its error state until your first migration creates one — add the -collection (see **Schema**) or repoint `ui/src/App.jsx` at your own entities. +The starter reads the `tasks` collection its migration creates. When you +replace that collection, repoint `ui/src/App.jsx` at your own entities. For tight iteration `npm --prefix ui run dev` runs Vite's dev server with `/api` proxied to the running app. The View talks to PocketBase's records API: - `GET/POST/PATCH/DELETE /api/collections//records` — the same records the agent operates. Same-origin browser writes are fine; the guard validates every create/update. +- `POST /api/ops/` — run a declared operation (the starter's "Ask an + agent" calls `request-triage`). Same-origin, so no credential on a + single-user app. +- `GET /api/_a2app/tasks/` — follow work queued for an agent (what + `AgentTask.jsx` polls). - `GET /api/_a2app` — identity (app name, versions). Render back what the server **stored**, never what you sent; page large lists; @@ -159,17 +212,101 @@ close over file-scope variables; `require()` inside each handler. ## App→agent triggers -**This blueprint has no app→agent queue in v0.1.** The PocketBase hooks adapter -implements the records, operations and describe surfaces, but not `tasks` / -`events` — there is nothing to fire against, so the creator skill's -"App→agent triggers" section does not apply here. +**This blueprint supports them.** The adapter's hooks are system-owned, so the +handle that queues agent work reaches you as `a2app.trigger` in an operation +runner, beside `a2app.app`: + +```js +// pb/pb_hooks/operations.js +events: [{ type: "task.needs_triage" }], + +runners: { + "request-triage": (args, _ctx, a2app) => { + const task = a2app.app.findRecordById("tasks", args.task); + const payload = { task: task.id }; + if (task.getString("agentTask")) payload.previous = task.getString("agentTask"); + const { taskId } = a2app.trigger("task.needs_triage", payload, "triage"); + task.set("agentTask", taskId); + a2app.app.save(task); + return { ok: true, queued: taskId }; + }, +}, +``` + +`trigger(type, payload, capability)` emits the event and, when a capability is +named, puts a task on the app's queue. Without a capability it only announces +something — nothing is queued and no agent is ever handed it. + +- **The type must be in `events`.** The adapter refuses an undeclared type at + fire time, inside the operation, so a typo works in review and fails in front + of a user — the operation answers `operation_failed` and rolls back. +- **Send ids.** The agent re-reads the record; a copy in the payload is stale by + the time it is read, and prose there is an instruction the app does not get to + give. +- **The same occurrence is one task.** Type + capability + payload is the dedup + key, even after the task has finished, so asking again with the same payload + hands back the old task. Put the previous task id in the payload (as above) to + ask again for real. +- **Outside an operation** — a record hook or a cron job in your own + `pb/pb_hooks/.pb.js` — call + ``require(`${__hooks}/_a2app_impl.js`).trigger(type, payload, capability)`` + inside the handler (Goja handlers cannot see file scope). It runs in a + transaction of its own; inside your own `$app.runInTransaction((txApp) => …)` + pass `txApp` as a fourth argument, or it would wait on itself. + +The queue only moves when something is listening: `agent-app bridge start`, +or a harness polling `a2app tasks next --wait`. A claimed task with no +update for 60 s goes back to the queue; after 5 deliveries it fails as +`redelivery_exhausted`. + +**Show the queued work in the View — with `ui/src/AgentTask.jsx`.** An agent run +takes seconds to minutes, so the control that queued it has to show where it is +until it is done. Keep the returned `taskId` on the record (a text field the +runner sets and the app declares read-only, like the starter's `agentTask`), +then render it: + +```jsx +import { AgentTaskBadge, AgentTaskPanel } from "./AgentTask.jsx"; + + // in the list row, beside the title + +``` + +The panel follows `GET /api/_a2app/tasks/{id}` while the run is unfinished and +renders every state the creator skill lists: waiting with elapsed time (and "no +agent is listening" after ~20 s), the agent's step and running time, the +`result.summary` in its own readable block, and the failure `reason` with "Ask +again". `useAgentTask(id)` gives you the same state to disable a control while a +run is open. The starter's "Ask an agent" button is the worked example. + +Never put the result in a title cell or a narrow column. It is prose of any +length, and the panel is where it goes. + +On a single-user app (`authMode: "none"`) the View needs no credential for the +task read. **On a multi-user app it answers 401** to a caller without the agent +token. There, have the agent write its progress onto the record and pass it as +`progress={{ status, step, summary, reason }}`; the panel and badge render it the +same way. A 404 means the task is gone, so the indicator disappears. + +**Declare that field read-only.** An agent that saves a record it read a +minute earlier would otherwise write back the old task id, and the View would +stop showing the run in progress. PocketBase has no read-only flag for a field, +so declare it in `operations.js`: + +```js +readOnly: { tasks: ["agentTask"] }, +``` + +Describe then publishes it as `readOnly`, and a client write to it (the View, +an agent, the records API) is refused with `read_only_field`, as on the other +stacks. Your runners still set it through `a2app.app.save()`, which does not pass +through the request guard, and the owner can still edit it in the PocketBase +dashboard. -Handle app events with plain hook code. If a feature genuinely needs an agent to -react, record it in `reference/requirements.md` as a known limitation of this -stack rather than inventing a mechanism — every other runtime blueprint -(`blueprint-react-node`, `blueprint-python-fastapi`, `blueprint-go-react`, -`blueprint-rust-react`, `blueprint-rails-vue`) carries the queue, and choosing -one of those is the honest fix. +**`validate` checks this.** Its "agent work shown in the View" step fails an +app whose code queues work (`trigger(…)` with a capability) when no View code +renders the component or reads `/api/_a2app/tasks/`. ## Build, run, gate @@ -190,14 +327,17 @@ one of those is the honest fix. ```bash agent-app dev # boot the candidate on a hidden port: fresh DB from your migration chain, prints the dev URL -agent-app validate # framework files → build → hooks syntax + rules self-test → ownership canon → describe budget (on dev) +agent-app validate # framework files → build → hooks syntax + rules self-test → operations resolve → agent work shown in the View → ownership canon → describe budget (on dev) agent-app serve # launch LIVE as a managed, health-polled background process; prints the URL agent-app promote # requires the gate pass; backup, `pb-serve.mjs --migrate` applies new migrations to live, destroys dev ``` -Toolkit gate step (from `a2app.toolkit.json`): **"hooks syntax + rules -self-test"** (`node --check` both hook files, then -`node pb/pb_hooks/_a2app_rules.js --selftest`). `lifecycle.dataDir` is +Toolkit gate steps (from `a2app.toolkit.json`): **"hooks syntax + rules +self-test"** (`node --check` the three adapter files, then +`node pb/pb_hooks/_a2app_rules.js --selftest`, which covers the guard, the +predicates, the fingerprint, operation arguments, the approval and dedup keys, +the task lifecycle and the sweep), and **"operations resolve"** (every +operation in `operations.json` has a runner in `pb/pb_hooks/operations.js`). `lifecycle.dataDir` is `pb/pb_data`; `lifecycle.promote` is `node pb-serve.mjs --migrate`. **Environments (one tree, redirected inputs).** The launcher reads `PORT`, @@ -217,18 +357,21 @@ pop its admin-installer page. - **Get the binary first, and it must be v0.26.6.** Nothing runs until `pb/pocketbase` exists, and the launcher rejects any non‑0.26.x build (the adapter is written against the 0.26 JSVM API). -- **Zero collections on scaffold.** Write migrations to create your entities; - until you do, `a2app ` shows modules with `0 entities`. +- **The starter migration is applied on the first boot.** From then on change + `tasks` with a new migration file, never by editing + `1759622400_created_tasks.js`. - **Register every new collection in `manifest.json`'s module `entities[]`** or it has no screen and cannot be walked. - **Migrations are identity-locked and additive.** Never edit/rename/delete an applied migration; fix mistakes with a new one; never drop a collection with data. -- **Operation resolution is UNCHECKED here** — verify every declared operation - for real (see Operations above). +- **Two files per operation.** `operations.json` declares it, `operations.js` + runs it. The gate fails a declaration with no runner, not a runner that does + the wrong thing. +- **A runner is a transaction.** Write through `a2app.app`, not `$app`, and keep + slow outbound HTTP out of it. - **Empty-DB first paint.** The View must render loading/empty/error states against a database with no records; the verifier fails any first-paint error. - The starter View shows its error state until the `tasks` collection exists. - **The View is served built.** Editing `ui/src/` changes nothing a browser sees until `npm --prefix ui run build` regenerates `pb/pb_public/` (live also needs promote + serve — it serves a boot-time snapshot). diff --git a/toolkits/blueprint-pocketbase-react/template/ui/public/ui.css b/toolkits/blueprint-pocketbase-react/template/ui/public/ui.css index d795803..ffc9490 100644 --- a/toolkits/blueprint-pocketbase-react/template/ui/public/ui.css +++ b/toolkits/blueprint-pocketbase-react/template/ui/public/ui.css @@ -114,7 +114,7 @@ body { /* ---------------------------------------------------------------- task list */ .task-list { list-style: none; margin: 0; padding: var(--sp-1) 0; } .task { - display: flex; align-items: center; gap: var(--sp-3); + display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; min-height: var(--row-h); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-subtle); transition: background var(--dur-fast) var(--ease-std), opacity var(--dur-mid) var(--ease-std); @@ -126,6 +126,8 @@ body { .task.done .title { color: var(--text-tertiary); text-decoration: line-through; } .task .due { font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } .task .due.overdue { color: var(--danger-text); font-weight: var(--fw-medium); } +/* agent work gets its own full-width line under the row, never a squeeze into the title */ +.task > .agent-task { flex-basis: 100%; margin-bottom: var(--sp-1); } /* status: a labelled 3-state control; colour is never the only channel (text names the state) */ .status-btn { @@ -196,12 +198,68 @@ body { @keyframes fade-in { from { opacity: 0; } } @keyframes dialog-in { from { opacity: 0; transform: translateY(var(--sp-2)) scale(.98); } } +/* ---------------------------------------------------------------- agent work (AgentTask.jsx) + Work queued for an agent stays visible until it is done. The panel takes the + full width of its container; an agent's result is prose of any length and + never goes in a title cell. */ +.agent-badge { + display: inline-flex; align-items: center; gap: var(--sp-1); + font-size: var(--fs-xs); font-weight: var(--fw-medium); white-space: nowrap; + padding: 2px var(--sp-2); border-radius: var(--r-full); border: 1px solid; +} +.agent-dot { width: 8px; height: 8px; border-radius: var(--r-full); background: currentColor; flex: none; } +.live .agent-dot { animation: pulse 1.2s var(--ease-std) infinite; } + +.tone-neutral { --tone-bg: var(--neutral-bg); --tone-text: var(--neutral-text); --tone-border: var(--neutral-border); } +.tone-info { --tone-bg: var(--info-bg); --tone-text: var(--info-text); --tone-border: var(--info-border); } +.tone-warning { --tone-bg: var(--warning-bg); --tone-text: var(--warning-text); --tone-border: var(--warning-border); } +.tone-success { --tone-bg: var(--success-bg); --tone-text: var(--success-text); --tone-border: var(--success-border); } +.tone-danger { --tone-bg: var(--danger-bg); --tone-text: var(--danger-text); --tone-border: var(--danger-border); } +.agent-badge { background: var(--tone-bg); color: var(--tone-text); border-color: var(--tone-border); } + +.agent-task { + display: flex; flex-direction: column; gap: var(--sp-2); + width: 100%; min-width: 0; padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--tone-border); border-left-width: 3px; border-radius: var(--r-lg); + background: var(--bg-surface); +} +.agent-task-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; color: var(--tone-text); } +.agent-task-state { font-size: var(--fs-sm); font-weight: var(--fw-medium); min-width: 0; overflow-wrap: anywhere; } +.agent-task-step { font-weight: var(--fw-regular); color: var(--text-secondary); } +.agent-task-elapsed { margin-left: auto; font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.agent-task-note { margin: 0; font-size: var(--fs-sm); color: var(--text-secondary); overflow-wrap: anywhere; } +.agent-task-note code { font-family: var(--font-mono); font-size: var(--fs-xs); background: var(--bg-sunken); padding: 1px var(--sp-1); border-radius: var(--r-sm); } +.agent-task-failure { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; } +.agent-task-failure .agent-task-note { flex: 1; min-width: 12ch; color: var(--danger-text); } +.agent-task-code { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-tertiary); } +.agent-task-bar { height: 4px; border-radius: var(--r-full); background: var(--bg-sunken); overflow: hidden; } +.agent-task-bar span { display: block; height: 100%; background: var(--info-solid); transition: width var(--dur-mid) var(--ease-std); } + +.agent-result { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); } +.agent-result-text { + margin: 0; max-width: 72ch; white-space: pre-wrap; overflow-wrap: anywhere; + font-size: var(--fs-base); line-height: var(--lh-normal); color: var(--text-primary); +} +.agent-result-text.clamped { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.agent-result-text a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; } +.agent-result-toggle { + font: inherit; font-size: var(--fs-sm); color: var(--accent-text); background: none; border: 0; + padding: var(--sp-1) 0; cursor: pointer; min-height: 24px; +} +.agent-result-toggle:hover { text-decoration: underline; } +@media (prefers-reduced-motion: reduce) { + .live .agent-dot { animation: none; } + .agent-task-bar span { transition: none; } +} + /* ---------------------------------------------------------------- narrow screens */ @media (max-width: 480px) { .app { padding: var(--sp-5) var(--sp-3) var(--sp-10); } .add-form { flex-wrap: wrap; } .add-form .field { flex-basis: 100%; } .task .due { display: none; } + /* the panel under the row says the same thing; the title keeps the room */ + .task .agent-badge { display: none; } /* touch-first: primary controls grow toward the platform norm */ .btn, .input, .select { min-height: 44px; } .btn-icon { width: 40px; height: 40px; } diff --git a/toolkits/blueprint-pocketbase-react/template/ui/src/AgentTask.jsx b/toolkits/blueprint-pocketbase-react/template/ui/src/AgentTask.jsx new file mode 100644 index 0000000..d23ca67 --- /dev/null +++ b/toolkits/blueprint-pocketbase-react/template/ui/src/AgentTask.jsx @@ -0,0 +1,439 @@ +/** + * Agent work, shown until it is done (AGENT-OWNED shared piece — @a2app-kit agent-task). + * + * An agent run takes seconds to minutes. Whatever control queued it has to + * keep showing where it is, or the person is left with no sign anything is + * happening. This is that display, so a feature that queues work does not + * rebuild it: + * + * in a list row, beside the title + * + * + * Both follow `GET /api/_a2app/tasks/{id}` at the task's own `pollAfterMs`, + * only while it is unfinished, and share one poller per id, so a badge and a + * panel for the same task cost one request. Every state renders: + * + * submitted waiting, with elapsed time; after ~20 s unclaimed, says no + * agent is listening and how to start one + * working `progress.step` (and `percent` as a bar) and running time + * input-required the agent is waiting on someone + * completed `result.summary` in its own full-width block: line breaks + * kept, links clickable, long text clamped behind "Show more". + * No summary (the agent never closed the task itself, so the + * bridge did)? The run's printed output, labelled as such. + * failed/canceled the `reason` in words (the queue's and bridge's own codes + * are translated), and "Ask again" when `onRetry` is given + * + * A 404 means the task was pruned, so the indicator disappears. On a + * multi-user app the read answers 401 — the View has no credential for the + * queue. There, have the agent write its progress onto the record and pass it + * as `progress={{ status, step, summary, reason }}`; it renders the same way, + * with or without a `taskId`. + * + * Never put `result.summary` in a title cell or a table column. It is prose of + * any length and belongs in the panel. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { api } from "./api.js"; +import Icon from "./Icon.jsx"; + +const FINISHED = new Set(["completed", "failed", "canceled"]); +/** How long a task may sit unclaimed before we say nobody is listening. */ +export const NO_LISTENER_MS = 20_000; +const DEFAULT_POLL_MS = 2000; +const RETRY_MS = 5000; +/** Slower polling when nothing is likely to change soon: an unclaimed task, or a hidden tab. */ +const SLOW_POLL_MS = 10_000; +const CLAMP_CHARS = 280; +const CLAMP_LINES = 4; + +const LABEL = { + submitted: "Waiting for an agent", + unheard: "No agent is listening", + working: "Agent working", + "input-required": "Agent needs input", + completed: "Agent done", + failed: "Agent failed", + canceled: "Agent run canceled", +}; +const TONE = { + submitted: "neutral", + unheard: "warning", + working: "info", + "input-required": "warning", + completed: "success", + failed: "danger", + canceled: "neutral", +}; + +/** + * Reasons the queue and the bridge write themselves, in words. An agent's own + * reason is prose already; these are codes, and a code is not an explanation. + */ +const REASON_WORDS = { + redelivery_exhausted: + "It was handed to an agent several times and never finished. Check that the bridge can start your agent, then ask again.", + harness_unavailable: "The bridge could not find an agent to run on this machine.", + harness_spawn_failed: "The bridge could not start the agent.", + harness_exited_nonzero: "The agent stopped with an error before reporting back.", + harness_timeout: "The agent took too long and was stopped.", + unspecified: "The agent did not say why.", +}; + +/** A failure reason a person can read, plus the raw code when it was one. */ +export function reasonInWords(reason, canceled = false) { + if (!reason) return { text: canceled ? "The run was canceled." : "The agent did not say why.", code: null }; + if (REASON_WORDS[reason]) return { text: REASON_WORDS[reason], code: reason }; + // An unknown code (snake_case, no spaces) still gets a sentence around it. + if (/^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(reason)) return { text: "The run stopped before it finished.", code: reason }; + return { text: reason, code: null }; +} + +/* ------------------------------------------------------------- the poller */ + +// id -> { snap, listeners, timer, stopped }. `snap` is replaced, never +// mutated, so useSyncExternalStore sees each change. +const watchers = new Map(); +const IDLE = { phase: "idle", task: null }; +const LOADING = { phase: "loading", task: null }; + +function watcherFor(id) { + let w = watchers.get(id); + if (!w) { + w = { snap: LOADING, listeners: new Set(), timer: null, stopped: false }; + watchers.set(id, w); + poll(id, w); + } + return w; +} + +function publish(w, snap) { + w.snap = snap; + for (const l of w.listeners) l(); +} + +async function poll(id, w) { + if (w.stopped) return; + let next = DEFAULT_POLL_MS; + try { + const task = await api(`/api/_a2app/tasks/${encodeURIComponent(id)}`); + if (w.stopped) return; + publish(w, { phase: "ok", task }); + if (FINISHED.has(task.status)) return; // settled: nothing more will change + next = task.pollAfterMs ?? DEFAULT_POLL_MS; + const waited = Date.now() - (ms(task.createdAt) ?? Date.now()); + if (task.status === "submitted" && waited > NO_LISTENER_MS) next = Math.max(next, SLOW_POLL_MS); + } catch (err) { + if (w.stopped) return; + if (err?.status === 404) return publish(w, { phase: "gone", task: null }); + if (err?.status === 401 || err?.status === 403) return publish(w, { phase: "unauthorized", task: w.snap.task }); + // Network trouble or rate limiting: keep what we last knew and try again later. + publish(w, { phase: "stale", task: w.snap.task, error: err?.message }); + next = err?.status === 429 ? SLOW_POLL_MS : RETRY_MS; + } + if (typeof document !== "undefined" && document.hidden) next = Math.max(next, SLOW_POLL_MS); + w.timer = setTimeout(() => poll(id, w), next); +} + +function subscribe(id, listener) { + if (!id) return () => {}; + const w = watcherFor(id); + w.listeners.add(listener); + return () => { + w.listeners.delete(listener); + // Stop only once nobody has re-subscribed by the next tick: a re-render + // (or a row moving between lists) unsubscribes and subscribes again at + // once, and tearing down in between would restart polling from scratch. + setTimeout(() => { + if (w.listeners.size > 0 || watchers.get(id) !== w) return; + w.stopped = true; + clearTimeout(w.timer); + watchers.delete(id); + }, 0); + }; +} + +/** + * Follow one queued task. Returns `{ phase, task }`: phase is loading · ok · + * stale (last known, the read is failing) · gone (404) · unauthorized (401) · + * idle (no id). + */ +export function useAgentTask(taskId) { + const sub = useCallback((l) => subscribe(taskId, l), [taskId]); + const snap = useCallback(() => (taskId ? (watchers.get(taskId)?.snap ?? LOADING) : IDLE), [taskId]); + return useSyncExternalStore(sub, snap); +} + +/* ---------------------------------------------------------- shared logic */ + +/** Seconds ticking while something is unfinished; frozen once it settles. */ +function useNow(running) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!running) return undefined; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [running]); + return now; +} + +/** "8s" · "1m 05s" · "1h 02m". */ +export function fmtElapsed(ms) { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +const ms = (iso) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isNaN(t) ? null : t; +}; + +/** + * What a harness printed, when the agent closed no task itself: the bridge then + * completes it with the run's output instead of a summary. Terminal colour + * codes are noise in a page. + */ +function printedOutput(result) { + if (typeof result?.output !== "string") return null; + // eslint-disable-next-line no-control-regex + const text = result.output.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim(); + return text || null; +} + +/** + * One view of a task, whichever way it arrived: the queue's own record, or the + * progress an agent wrote onto the app's record. + */ +function viewOf(state, progress, now) { + const task = state.task; + if (task) { + const created = ms(task.createdAt); + const claimed = ms(task.claim?.claimedAt); + const updated = ms(task.updatedAt); + let key = task.status; + let elapsed = null; + if (key === "submitted") { + elapsed = created !== null ? now - created : null; + if (elapsed !== null && elapsed >= NO_LISTENER_MS) key = "unheard"; + } else if (key === "working" || key === "input-required") { + const since = claimed ?? created; + elapsed = since !== null ? now - since : null; + } else if (created !== null && updated !== null) { + elapsed = updated - created; + } + return { + key, + elapsed, + step: task.progress?.step ?? null, + // The queue reports 0 until the agent says otherwise; only real progress draws a bar. + percent: task.progress?.percent > 0 ? task.progress.percent : null, + summary: task.result?.summary ?? null, + output: printedOutput(task.result), + reason: task.reason ?? null, + }; + } + if (progress?.status) { + return { + key: progress.status, + elapsed: null, + step: progress.step ?? null, + percent: progress.percent > 0 ? progress.percent : null, + summary: progress.summary ?? null, + output: null, + reason: progress.reason ?? null, + }; + } + return null; +} + +/** Calls `fn(task)` once when a task we watched while unfinished settles. */ +function useSettled(task, fn) { + const prev = useRef(task?.status); + const fnRef = useRef(fn); + fnRef.current = fn; + useEffect(() => { + const was = prev.current; + prev.current = task?.status; + if (task && FINISHED.has(task.status) && was !== undefined && !FINISHED.has(was)) fnRef.current?.(task); + }, [task?.status, task]); +} + +/* ------------------------------------------------------------- the badge */ + +/** A compact state pill for a list row, so someone can leave the screen and come back. */ +export function AgentTaskBadge({ taskId, progress }) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + if (state.phase === "gone") return null; + const v = viewOf(state, progress, now); + if (!v) return null; + const label = LABEL[v.key] ?? v.key; + return ( + + + ); +} + +/* ------------------------------------------------------------- the panel */ + +/** + * The full display: state, elapsed time, progress, and the result in a + * readable block. Give it the whole width of its container — under a list + * row, not inside one of its cells. + */ +export function AgentTaskPanel({ + taskId, + progress, + title = "Agent", + onSettled, + onRetry, + retrying = false, + bridgeHint = "agent-app bridge start", +}) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + useSettled(state.task, onSettled); + + if (state.phase === "gone") return null; + if (state.phase === "loading" && !progress?.status) { + return ( +
+
+
+
+ ); + } + + const v = viewOf(state, progress, now); + if (!v) { + if (state.phase === "unauthorized") { + return ( +
+

+ This app needs sign-in to read the agent queue, so progress shows here only once the agent records it. +

+
+ ); + } + return null; + } + + const tone = TONE[v.key] ?? "neutral"; + const label = LABEL[v.key] ?? v.key; + const finished = FINISHED.has(v.key); + const elapsedText = + v.elapsed === null ? null : finished ? `took ${fmtElapsed(v.elapsed)}` : fmtElapsed(v.elapsed); + + return ( +
+
+
+ + {v.percent !== null && !finished && ( +
+ +
+ )} + + {v.key === "unheard" && ( +

+ Nothing has picked this up. Work is only delivered while an agent is listening — start one with{" "} + {bridgeHint}. +

+ )} + {v.key === "input-required" && ( +

The agent is waiting for someone to answer it before it can continue.

+ )} + + {v.key === "completed" && v.summary && } + {v.key === "completed" && !v.summary && v.output && ( + + )} + + {(v.key === "failed" || v.key === "canceled") && ( +
+ + {onRetry && ( + + )} +
+ )} +
+ ); +} + +function FailureReason({ reason, canceled }) { + const { text, code } = reasonInWords(reason, canceled); + return ( +

+ {text} + {code && ({code})} +

+ ); +} + +/* ------------------------------------------------------------ the result */ + +const URL_RE = /(https?:\/\/[^\s<>"']+)/g; + +/** Plain text with its line breaks kept and its links clickable. */ +function Linkified({ text }) { + return text.split(URL_RE).map((part, i) => { + if (i % 2 === 0) return part; + // Sentence punctuation after a link is not part of it. + const m = part.match(/^(.*?)([.,;:!?)\]]*)$/); + const href = m ? m[1] : part; + return ( + + + {href} + + {m ? m[2] : ""} + + ); + }); +} + +/** An agent's answer: its own block, readable at any length. */ +export function AgentResult({ text, note }) { + const long = text.length > CLAMP_CHARS || text.split("\n").length > CLAMP_LINES; + const [open, setOpen] = useState(false); + return ( +
+ {note &&

{note}

} +

+ +

+ {long && ( + + )} +
+ ); +} diff --git a/toolkits/blueprint-pocketbase-react/template/ui/src/App.jsx b/toolkits/blueprint-pocketbase-react/template/ui/src/App.jsx index 7883820..37e1949 100644 --- a/toolkits/blueprint-pocketbase-react/template/ui/src/App.jsx +++ b/toolkits/blueprint-pocketbase-react/template/ui/src/App.jsx @@ -10,6 +10,11 @@ * This stack ships no update watcher (PocketBase serves static files with no * system-owned watcher route), so external changes show on the next read — * a reload, a filter switch, or your own re-read affordance. + * + * Agent work: "Ask an agent" queues a triage task (the `request-triage` + * operation). The row then shows it until it is done — a badge beside the + * title, and the full-width AgentTaskPanel under the row with the state, the + * elapsed time and, at the end, the agent's answer. */ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "./api.js"; @@ -17,6 +22,7 @@ import Icon from "./Icon.jsx"; import { useToast } from "./toast.jsx"; import { useConfirm } from "./ConfirmDialog.jsx"; import { STATUS_LABEL, NEXT_STATUS, fmtDay, isPastDay } from "./format.js"; +import { AgentTaskBadge, AgentTaskPanel, useAgentTask } from "./AgentTask.jsx"; const API = "/api/collections/tasks/records"; const PER_PAGE = 100; // the client never renders an unbounded collection @@ -40,6 +46,7 @@ export default function App() { const [phase, setPhase] = useState("loading"); // loading | ready | error const [errorMessage, setErrorMessage] = useState(""); const [busy, setBusy] = useState(() => new Set()); // record ids with a write in flight + const [asking, setAsking] = useState(() => new Set()); // record ids with an agent request in flight const [adding, setAdding] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [title, setTitle] = useState(""); @@ -199,6 +206,33 @@ export default function App() { } }; + /** Re-read one record and put the stored version in place. */ + const reloadOne = async (id) => { + try { + const stored = await api(`${API}/${encodeURIComponent(id)}`); + setItems((prev) => prev.map((t) => (t.id === id ? stored : t))); + } catch { + /* the list's next refresh picks it up */ + } + }; + + const askAgent = async (task) => { + setAsking((prev) => new Set(prev).add(task.id)); + try { + const res = await api("/api/ops/request-triage", { method: "POST", body: JSON.stringify({ task: task.id }) }); + if (res?.result?.ok === false) throw new Error(res.result.reason ?? "The app refused the request."); + await reloadOne(task.id); // the stored record carries the task id the panel follows + } catch (err) { + toast("error", `Could not ask an agent about "${task.title}". ${err.message}`); + } finally { + setAsking((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); + } + }; + const showMore = async () => { setLoadingMore(true); try { @@ -302,7 +336,10 @@ export default function App() { key={task.id} task={task} busy={busy.has(task.id)} + asking={asking.has(task.id)} onAdvance={() => advanceStatus(task)} + onAsk={() => askAgent(task)} + onAgentSettled={() => reloadOne(task.id)} onDelete={() => deleteTask(task)} /> ))} @@ -336,9 +373,12 @@ export default function App() { /* ------------------------------------------------------------ components */ -function TaskRow({ task, busy, onAdvance, onDelete }) { +function TaskRow({ task, busy, asking, onAdvance, onAsk, onAgentSettled, onDelete }) { const st = task.status ?? "todo"; const overdue = task.due && isPastDay(task.due) && st !== "done"; + // Shares the panel's poller: knowing whether a run is in flight costs no extra request. + const agent = useAgentTask(task.agentTask); + const agentBusy = asking || (agent.task && !["completed", "failed", "canceled"].includes(agent.task.status)); return (
  • {/* status: a labelled 3-state control; colour is never the only channel */} @@ -354,14 +394,36 @@ function TaskRow({ task, busy, onAdvance, onDelete }) { {task.title} + {task.agentTask && } {task.due && ( {overdue ? `Overdue · ${fmtDay(task.due)}` : `Due ${fmtDay(task.due)}`} )} + {st !== "done" && ( + + )} + {task.agentTask && ( + + )}
  • ); } diff --git a/toolkits/blueprint-pocketbase-react/template/ui/src/Icon.jsx b/toolkits/blueprint-pocketbase-react/template/ui/src/Icon.jsx index 6525eea..f97e6cc 100644 --- a/toolkits/blueprint-pocketbase-react/template/ui/src/Icon.jsx +++ b/toolkits/blueprint-pocketbase-react/template/ui/src/Icon.jsx @@ -11,6 +11,7 @@ const ICON_PATHS = { info: "M8 7.5V11m0-5.5v-.01M14.5 8a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z", inbox: "M1.5 9.5h3l1 2h5l1-2h3M2.5 3.5h11l1 6v3a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-3l1-6Z", refresh: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v2.6h-2.6", + spark: "M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.4 3.4l2.1 2.1M10.5 10.5l2.1 2.1M3.4 12.6l2.1-2.1M10.5 5.5l2.1-2.1", }; export default function Icon({ name, size = 16 }) {