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 (
+
+
+ {label}
+
+ );
+}
+
+/* ------------------------------------------------------------- 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 (
+
+
+
+ Checking on the agent…
+
+
+ );
+ }
+
+ 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.
+
+ );
+}
+
+/* ------------------------------------------------------------ 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 && (
+
+ )}