@nanobpm/nano-workforce 0.99.1 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/contracts.ts +18 -2
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/migration049.test.ts +112 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/service.test.ts +16 -0
- package/app/service.ts +107 -136
- package/app/stage.test.ts +7 -53
- package/app/stage.ts +5 -37
- package/app/userTasks.test.ts +1 -1
- package/app/userTasks.ts +3 -3
- package/app/world/checkpoint.test.ts +193 -0
- package/app/world/checkpoint.ts +142 -0
- package/app/world/effect-ledger.test.ts +86 -0
- package/app/world/effect-ledger.ts +103 -0
- package/app/world/git.ts +53 -0
- package/app/world/index.ts +26 -0
- package/app/world/store.test.ts +443 -0
- package/app/world/store.ts +320 -0
- package/app/world-marker.test.ts +79 -0
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/db/migrations/049_world_checkpoint.sql +84 -0
- package/e2e/feature-run.e2e.ts +52 -41
- package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
- package/openapi.yaml +7 -108
- package/operations/agentCompleteEscalation.ts +2 -2
- package/operations/completeUserTask.test.ts +25 -6
- package/operations/completeUserTask.ts +12 -10
- package/package.json +2 -2
- package/pages/feature.page.json +1 -37
- package/pages/overview.page.json +1 -39
- package/pages/tasks.page.json +78 -93
- package/resources/forms/feature-escalation.form +3 -0
- package/test/worldDb.ts +103 -0
- package/workers/persist-round/worker.ts +68 -0
- package/workers/record-blocked-ack/worker.test.ts +1 -4
- package/workers/record-blocked-ack/worker.ts +0 -5
- package/workers/record-feature/worker.ts +0 -6
- package/workers/record-feature-escalation/worker.test.ts +14 -27
- package/workers/record-feature-escalation/worker.ts +16 -22
- package/app/featureBlocked.test.ts +0 -182
- package/app/featureEscalation.test.ts +0 -235
- package/operations/acknowledgeBlocked.test.ts +0 -111
- package/operations/acknowledgeBlocked.ts +0 -62
- package/operations/answerFeatureEscalation.ts +0 -68
package/pages/tasks.page.json
CHANGED
|
@@ -119,22 +119,6 @@
|
|
|
119
119
|
}
|
|
120
120
|
],
|
|
121
121
|
"rowKey": "user_task_key",
|
|
122
|
-
"rowActions": [
|
|
123
|
-
{
|
|
124
|
-
"label": "Abandon",
|
|
125
|
-
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
126
|
-
"showWhenField": "user_task_key",
|
|
127
|
-
"action": {
|
|
128
|
-
"path": "/app/api/actions/complete-user-task",
|
|
129
|
-
"body": {
|
|
130
|
-
"userTaskKey": "{{row.user_task_key}}",
|
|
131
|
-
"variables": {
|
|
132
|
-
"resolution": "abandon"
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
],
|
|
138
122
|
"detail": {
|
|
139
123
|
"linkField": "subject_url",
|
|
140
124
|
"fields": [
|
|
@@ -147,18 +131,32 @@
|
|
|
147
131
|
"showWhenField": "user_task_key",
|
|
148
132
|
"title": "Answer escalation",
|
|
149
133
|
"promptField": "question",
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
|
|
134
|
+
"submitLabel": "Submit decision",
|
|
135
|
+
"fields": [
|
|
136
|
+
{
|
|
137
|
+
"inputKey": "resolution",
|
|
138
|
+
"type": "select",
|
|
139
|
+
"inputLabel": "Resolution",
|
|
140
|
+
"options": [
|
|
141
|
+
{ "value": "answer", "label": "Answer — re-dispatch the task with this guidance" },
|
|
142
|
+
{ "value": "abandon", "label": "Abandon — give up on this task" }
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"inputKey": "answer",
|
|
147
|
+
"type": "textarea",
|
|
148
|
+
"inputLabel": "Answer / guidance for the implementation agent",
|
|
149
|
+
"conditional": {
|
|
150
|
+
"hide": "=resolution != \"answer\""
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
],
|
|
153
154
|
"action": {
|
|
154
155
|
"path": "/app/api/actions/complete-user-task",
|
|
155
|
-
"successLabel": "
|
|
156
|
+
"successLabel": "Decision submitted — the process will continue",
|
|
156
157
|
"body": {
|
|
157
158
|
"userTaskKey": "{{row.user_task_key}}",
|
|
158
|
-
"variables": {
|
|
159
|
-
"resolution": "answer",
|
|
160
|
-
"answer": "{{form.answer}}"
|
|
161
|
-
}
|
|
159
|
+
"variables": "{{form}}"
|
|
162
160
|
}
|
|
163
161
|
}
|
|
164
162
|
}
|
|
@@ -220,22 +218,6 @@
|
|
|
220
218
|
}
|
|
221
219
|
],
|
|
222
220
|
"rowKey": "user_task_key",
|
|
223
|
-
"rowActions": [
|
|
224
|
-
{
|
|
225
|
-
"label": "Proceed",
|
|
226
|
-
"confirm": "Proceed with the current plan as-is? It is dispatched unchanged.",
|
|
227
|
-
"showWhenField": "user_task_key",
|
|
228
|
-
"action": {
|
|
229
|
-
"path": "/app/api/actions/complete-user-task",
|
|
230
|
-
"body": {
|
|
231
|
-
"userTaskKey": "{{row.user_task_key}}",
|
|
232
|
-
"variables": {
|
|
233
|
-
"directive": "proceed"
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
],
|
|
239
221
|
"detail": {
|
|
240
222
|
"linkField": "subject_url",
|
|
241
223
|
"fields": [
|
|
@@ -246,20 +228,34 @@
|
|
|
246
228
|
],
|
|
247
229
|
"form": {
|
|
248
230
|
"showWhenField": "user_task_key",
|
|
249
|
-
"title": "
|
|
231
|
+
"title": "Review the plan",
|
|
250
232
|
"promptField": "question",
|
|
251
|
-
"
|
|
252
|
-
"
|
|
253
|
-
|
|
233
|
+
"submitLabel": "Submit decision",
|
|
234
|
+
"fields": [
|
|
235
|
+
{
|
|
236
|
+
"inputKey": "directive",
|
|
237
|
+
"type": "select",
|
|
238
|
+
"inputLabel": "Directive",
|
|
239
|
+
"options": [
|
|
240
|
+
{ "value": "proceed", "label": "Proceed — dispatch the current plan as-is" },
|
|
241
|
+
{ "value": "revise", "label": "Revise — send the plan back to the planner" }
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"inputKey": "notes",
|
|
246
|
+
"type": "textarea",
|
|
247
|
+
"inputLabel": "Revision guidance for the planner",
|
|
248
|
+
"conditional": {
|
|
249
|
+
"hide": "=directive != \"revise\""
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
],
|
|
254
253
|
"action": {
|
|
255
254
|
"path": "/app/api/actions/complete-user-task",
|
|
256
|
-
"successLabel": "
|
|
255
|
+
"successLabel": "Decision recorded",
|
|
257
256
|
"body": {
|
|
258
257
|
"userTaskKey": "{{row.user_task_key}}",
|
|
259
|
-
"variables": {
|
|
260
|
-
"directive": "revise",
|
|
261
|
-
"notes": "{{form.notes}}"
|
|
262
|
-
}
|
|
258
|
+
"variables": "{{form}}"
|
|
263
259
|
}
|
|
264
260
|
}
|
|
265
261
|
}
|
|
@@ -321,58 +317,45 @@
|
|
|
321
317
|
}
|
|
322
318
|
],
|
|
323
319
|
"rowKey": "user_task_key",
|
|
324
|
-
"
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
"path": "/app/api/actions/complete-user-task",
|
|
331
|
-
"body": {
|
|
332
|
-
"userTaskKey": "{{row.user_task_key}}",
|
|
333
|
-
"variables": {
|
|
334
|
-
"action": "proceed"
|
|
335
|
-
}
|
|
336
|
-
}
|
|
320
|
+
"detail": {
|
|
321
|
+
"linkField": "subject_url",
|
|
322
|
+
"fields": [
|
|
323
|
+
{
|
|
324
|
+
"field": "question",
|
|
325
|
+
"label": "Trial merge"
|
|
337
326
|
}
|
|
338
|
-
|
|
339
|
-
{
|
|
340
|
-
"label": "Rebase",
|
|
341
|
-
"confirm": "Re-run the trial merge?",
|
|
327
|
+
],
|
|
328
|
+
"form": {
|
|
342
329
|
"showWhenField": "user_task_key",
|
|
343
|
-
"
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
330
|
+
"title": "Trial-merge decision",
|
|
331
|
+
"promptField": "question",
|
|
332
|
+
"submitLabel": "Submit decision",
|
|
333
|
+
"fields": [
|
|
334
|
+
{
|
|
335
|
+
"inputKey": "action",
|
|
336
|
+
"type": "select",
|
|
337
|
+
"inputLabel": "Action",
|
|
338
|
+
"options": [
|
|
339
|
+
{ "value": "proceed", "label": "Proceed — accept the red trial merge and continue" },
|
|
340
|
+
{ "value": "rebase", "label": "Rebase — re-run the trial merge" },
|
|
341
|
+
{ "value": "abandon", "label": "Abandon — stop and finalize the plan" }
|
|
342
|
+
]
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"inputKey": "notes",
|
|
346
|
+
"type": "textarea",
|
|
347
|
+
"inputLabel": "Notes"
|
|
350
348
|
}
|
|
351
|
-
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
"label": "Abandon",
|
|
355
|
-
"confirm": "Stop and finalize the plan?",
|
|
356
|
-
"showWhenField": "user_task_key",
|
|
349
|
+
],
|
|
357
350
|
"action": {
|
|
358
351
|
"path": "/app/api/actions/complete-user-task",
|
|
352
|
+
"successLabel": "Decision recorded",
|
|
359
353
|
"body": {
|
|
360
354
|
"userTaskKey": "{{row.user_task_key}}",
|
|
361
|
-
"variables": {
|
|
362
|
-
"action": "abandon"
|
|
363
|
-
}
|
|
355
|
+
"variables": "{{form}}"
|
|
364
356
|
}
|
|
365
357
|
}
|
|
366
358
|
}
|
|
367
|
-
],
|
|
368
|
-
"detail": {
|
|
369
|
-
"linkField": "subject_url",
|
|
370
|
-
"fields": [
|
|
371
|
-
{
|
|
372
|
-
"field": "question",
|
|
373
|
-
"label": "Trial merge"
|
|
374
|
-
}
|
|
375
|
-
]
|
|
376
359
|
},
|
|
377
360
|
"refreshMs": 5000
|
|
378
361
|
}
|
|
@@ -532,11 +515,13 @@
|
|
|
532
515
|
"inputLabel": "Disposition note (what you did / why)",
|
|
533
516
|
"submitLabel": "Acknowledge & close",
|
|
534
517
|
"action": {
|
|
535
|
-
"path": "/app/api/actions/
|
|
518
|
+
"path": "/app/api/actions/complete-user-task",
|
|
536
519
|
"successLabel": "Acknowledged — the run will close",
|
|
537
520
|
"body": {
|
|
538
521
|
"userTaskKey": "{{row.user_task_key}}",
|
|
539
|
-
"
|
|
522
|
+
"variables": {
|
|
523
|
+
"note": "{{form.note}}"
|
|
524
|
+
}
|
|
540
525
|
}
|
|
541
526
|
}
|
|
542
527
|
}
|
package/test/worldDb.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// A test-only DataLayer over a real in-memory `node:sqlite` db with the world-restore schema
|
|
2
|
+
// (`db/migrations/049_world_checkpoint.sql`) applied, for exercising `app/world/store.ts` and the
|
|
3
|
+
// `app/world/checkpoint.ts` orchestration against a REAL SQLite engine — so the durable fence (the
|
|
4
|
+
// `UNIQUE(pr_key, idempotency_key)` constraint) and the monotonic offset are proven, not mocked.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
|
7
|
+
import { afterEach } from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
10
|
+
|
|
11
|
+
const openDbs = new Set<DatabaseSync>();
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const raw of openDbs) {
|
|
14
|
+
if (openDbs.delete(raw)) raw.close();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function coerce(p: unknown): SQLInputValue {
|
|
19
|
+
if (p === null || p === undefined) return null;
|
|
20
|
+
if (typeof p === "boolean") return p ? 1 : 0;
|
|
21
|
+
return p as SQLInputValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
25
|
+
|
|
26
|
+
/** A minimal async `Table<T>`-shaped gateway over one real SQLite table — the insert/find/findOne/
|
|
27
|
+
* update/get subset the world store uses. Mirrors the runtime `Table<T>` semantics: `insert` omits
|
|
28
|
+
* `undefined` keys (schema default governs), `update` skips `undefined` keys and clears on `null`. */
|
|
29
|
+
function gateway(db: DatabaseSync, name: string, pk: string) {
|
|
30
|
+
return {
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
32
|
+
async insert(row: any): Promise<number | bigint> {
|
|
33
|
+
const keys = Object.keys(row).filter((k) => row[k] !== undefined);
|
|
34
|
+
const cols = keys.map(quote).join(", ");
|
|
35
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
36
|
+
const r = db.prepare(`INSERT INTO ${quote(name)} (${cols}) VALUES (${placeholders})`).run(...keys.map((k) => coerce(row[k])));
|
|
37
|
+
return pk === "id" ? r.lastInsertRowid : (row[pk] as number);
|
|
38
|
+
},
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
40
|
+
async find(where: any = {}): Promise<any[]> {
|
|
41
|
+
const keys = Object.keys(where);
|
|
42
|
+
const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
|
|
43
|
+
return db.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => coerce(where[k])));
|
|
44
|
+
},
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
46
|
+
async findOne(where: any = {}): Promise<any> {
|
|
47
|
+
return (await this.find(where))[0];
|
|
48
|
+
},
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
50
|
+
async get(id: any): Promise<any> {
|
|
51
|
+
return db.prepare(`SELECT * FROM ${quote(name)} WHERE ${quote(pk)} = ?`).get(coerce(id));
|
|
52
|
+
},
|
|
53
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
54
|
+
async update(id: any, patch: any): Promise<number> {
|
|
55
|
+
const keys = Object.keys(patch).filter((k) => patch[k] !== undefined);
|
|
56
|
+
if (keys.length === 0) return 0;
|
|
57
|
+
const set = keys.map((k) => `${quote(k)} = ?`).join(", ");
|
|
58
|
+
const r = db.prepare(`UPDATE ${quote(name)} SET ${set} WHERE ${quote(pk)} = ?`).run(...keys.map((k) => coerce(patch[k])), coerce(id));
|
|
59
|
+
return Number(r.changes);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A minimal self-referential `DataSource`-shaped gateway over the real db: the `table` surface plus
|
|
65
|
+
* a real `tx()` that BEGIN/COMMIT/ROLLBACKs on the underlying SQLite connection, so the world store's
|
|
66
|
+
* atomic `recordCheckpoint` (checkpoint + effects in one transaction) is exercised against genuine
|
|
67
|
+
* transaction semantics rather than mocked. `tx(fn)` passes the SAME source object to `fn`, so a test
|
|
68
|
+
* that decorates `table` on the returned source sees its decoration inside the transaction too. */
|
|
69
|
+
type MemDataSource = {
|
|
70
|
+
table: (name: string, pk?: string) => ReturnType<typeof gateway>;
|
|
71
|
+
tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function openDataSource(db: DatabaseSync): MemDataSource {
|
|
75
|
+
const ds: MemDataSource = {
|
|
76
|
+
table: (name, pk = "id") => gateway(db, name, pk),
|
|
77
|
+
async tx(fn) {
|
|
78
|
+
db.exec("BEGIN");
|
|
79
|
+
try {
|
|
80
|
+
const r = await fn(ds);
|
|
81
|
+
db.exec("COMMIT");
|
|
82
|
+
return r;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
db.exec("ROLLBACK");
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return ds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
|
|
93
|
+
export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
|
|
94
|
+
const db = new DatabaseSync(":memory:");
|
|
95
|
+
openDbs.add(db);
|
|
96
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/049_world_checkpoint.sql", import.meta.url)), "utf8");
|
|
97
|
+
db.exec(sql);
|
|
98
|
+
const data = {
|
|
99
|
+
table: (name: string, pk = "id") => gateway(db, name, pk),
|
|
100
|
+
open: () => openDataSource(db),
|
|
101
|
+
} as unknown as DataLayer;
|
|
102
|
+
return { data, db };
|
|
103
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
8
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
9
9
|
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
10
|
+
import { type Effect, isCommitSha, isEffectKind, recordWorldCheckpoint, WorldStore } from "../../app/world/index.ts";
|
|
10
11
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
11
12
|
|
|
12
13
|
// Input is typed off the model data envelope (`PrPersistRoundIn` in convergence-loop.bpmn),
|
|
@@ -32,6 +33,54 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
|
32
33
|
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** The world-restore marker (issue #324, ADR 0062 Slice 4/5, the WORLD half). When a round pushed
|
|
37
|
+
* changes, the c8ctl harness reports `{commitSha, effects?}` under this reserved key so the app
|
|
38
|
+
* records a durable push-checkpoint: the pushed SHA a replacement activation reconstructs the working
|
|
39
|
+
* tree to (inverting `git push` → `git fetch && git checkout <sha>`), plus the round's irreversible
|
|
40
|
+
* effect ledger (each fence-keyed) so a resume skips an already-applied effect. Absent (a `waiting`
|
|
41
|
+
* round, or a harness predating #324) → no checkpoint is recorded (nothing was pushed). */
|
|
42
|
+
const WORLD_MARKER_KEY = "worldMarker";
|
|
43
|
+
|
|
44
|
+
interface WorldMarker {
|
|
45
|
+
readonly commitSha: string;
|
|
46
|
+
readonly effects?: readonly Effect[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Normalize one externally-supplied effect from the harness `worldMarker`, or `null` when it is not
|
|
50
|
+
* a usable effect. This is a CONTRACT BOUNDARY: the marker arrives from out-of-process, so both the
|
|
51
|
+
* fence key AND the effect kind are untrusted. We (a) TRIM `idempotencyKey` so whitespace variants of
|
|
52
|
+
* one real effect collapse to a single fence key rather than manufacturing distinct ledger rows that
|
|
53
|
+
* defeat the fence, and (b) validate `kind` against the canonical {@link EFFECT_KINDS} so an unknown
|
|
54
|
+
* kind can never enter the durable ledger. `description` is trimmed to a non-empty note or dropped. */
|
|
55
|
+
function normalizeEffect(raw: unknown): Effect | null {
|
|
56
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
57
|
+
const e = raw as { kind?: unknown; idempotencyKey?: unknown; description?: unknown } | null | undefined;
|
|
58
|
+
if (!e || !isEffectKind(e.kind)) return null;
|
|
59
|
+
if (typeof e.idempotencyKey !== "string") return null;
|
|
60
|
+
const idempotencyKey = e.idempotencyKey.trim();
|
|
61
|
+
if (idempotencyKey === "") return null;
|
|
62
|
+
const description = typeof e.description === "string" ? e.description.trim() : "";
|
|
63
|
+
return { kind: e.kind, idempotencyKey, ...(description !== "" ? { description } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function worldMarkerOf(vars: Record<string, unknown>): WorldMarker | null {
|
|
67
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
68
|
+
const m = vars[WORLD_MARKER_KEY] as { commitSha?: unknown; effects?: unknown } | undefined;
|
|
69
|
+
if (!m || typeof m.commitSha !== "string") return null;
|
|
70
|
+
// `commitSha` is used as an EXACT checkout target on restore (`git fetch && git checkout <sha>`),
|
|
71
|
+
// so validate it is a well-formed 40-hex SHA — the SAME guard the emit boundary `repoEnvelopeVars`
|
|
72
|
+
// applies (via `isCommitSha`) so the two boundaries can't drift. An arbitrary ref (e.g. `main`) or
|
|
73
|
+
// an abbreviated SHA would reconstruct to a moved branch tip or fail restore, undermining the
|
|
74
|
+
// "reconstruct the exact tree at <sha>" contract. Trim first so a whitespace-tainted valid SHA
|
|
75
|
+
// still passes; a value that isn't a full object name degrades to no checkpoint (a `waiting` round).
|
|
76
|
+
const commitSha = m.commitSha.trim();
|
|
77
|
+
if (!isCommitSha(commitSha)) return null;
|
|
78
|
+
const effects = Array.isArray(m.effects)
|
|
79
|
+
? m.effects.map(normalizeEffect).filter((e): e is Effect => e !== null)
|
|
80
|
+
: undefined;
|
|
81
|
+
return { commitSha, ...(effects && effects.length > 0 ? { effects } : {}) };
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
36
85
|
// This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
|
|
37
86
|
// domain values. `summary` is left undefined when absent: the write boundary omits it so the
|
|
@@ -75,6 +124,25 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
75
124
|
updated_at: now,
|
|
76
125
|
});
|
|
77
126
|
|
|
127
|
+
// World checkpoint (issue #324, ADR 0062 Slice 4/5): when this round pushed, record the durable
|
|
128
|
+
// push-checkpoint — the pushed SHA a replacement activation reconstructs the tree to, plus the
|
|
129
|
+
// round's fence-keyed effect ledger. The JOIN to the mind's `session.checkpoint(...)` (Slice 1)
|
|
130
|
+
// happens harness-side (out of process); here we persist the WORLD marker so restore can invert
|
|
131
|
+
// the push. Best-effort: a checkpoint-store failure must not fail an already-recorded round.
|
|
132
|
+
const marker = worldMarkerOf(job.variables);
|
|
133
|
+
if (marker) {
|
|
134
|
+
try {
|
|
135
|
+
await recordWorldCheckpoint(new WorldStore(app.data), {
|
|
136
|
+
prKey,
|
|
137
|
+
roundNo: round,
|
|
138
|
+
commitSha: marker.commitSha,
|
|
139
|
+
...(marker.effects ? { effects: marker.effects } : {}),
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
app.log.warn("world checkpoint record failed", { prKey, round, err: String(err) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
78
146
|
return {};
|
|
79
147
|
};
|
|
80
148
|
|
|
@@ -32,15 +32,12 @@ function fakeApp(rows: Record<string, unknown>[]) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
test("record-blocked-ack: settles the parked run at terminal blocked and records the operator note", async () => {
|
|
35
|
-
const rows = [{ feature_key: "owner/repo#7", status: "awaiting_operator", delivery_label: null
|
|
35
|
+
const rows = [{ feature_key: "owner/repo#7", status: "awaiting_operator", delivery_label: null }];
|
|
36
36
|
const app = fakeApp(rows);
|
|
37
37
|
const out = await handler({ variables: { featureKey: "owner/repo#7", note: "reassigned to a human" } } as any, app);
|
|
38
38
|
assertEquals(out, {});
|
|
39
39
|
assertEquals(rows[0].status, "blocked");
|
|
40
40
|
assertEquals(rows[0].delivery_label, "operator: reassigned to a human");
|
|
41
|
-
// The completable-task pointer is cleared on the terminal-ward transition so the pages stop offering
|
|
42
|
-
// the acknowledge affordance for a now-completed task (pollFeatureBlocked no longer sweeps this row).
|
|
43
|
-
assertEquals(rows[0].blocked_user_task_key, null);
|
|
44
41
|
});
|
|
45
42
|
|
|
46
43
|
test("record-blocked-ack: a blank note falls back to an 'acknowledged' label", async () => {
|
|
@@ -26,11 +26,6 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
|
26
26
|
await featureRuns(app.data).update(featureKey, {
|
|
27
27
|
status: "blocked",
|
|
28
28
|
delivery_label: note ? `operator: ${note}` : "acknowledged",
|
|
29
|
-
// The run has left the `feature-blocked` wait, so clear the denormalised completable-task pointer
|
|
30
|
-
// the pages gate the "Acknowledge blocked" affordance on. pollFeatureBlocked only sweeps
|
|
31
|
-
// `awaiting_operator` runs, so this terminal-ward transition must clear it itself or a stale pointer
|
|
32
|
-
// would linger on the now-terminal row.
|
|
33
|
-
blocked_user_task_key: null,
|
|
34
29
|
updated_at: ts,
|
|
35
30
|
});
|
|
36
31
|
app.log.info("record-blocked-ack", { featureKey, note: note ?? null });
|
|
@@ -54,12 +54,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
54
54
|
status: rowStatus,
|
|
55
55
|
pr_key: prKey,
|
|
56
56
|
outcome: summary ?? null,
|
|
57
|
-
// The run has left the `feature-escalation` wait (answered → abandon, SLA auto-abandon, or the
|
|
58
|
-
// agent finished without escalating), so clear the denormalised escalation pointer the pages gate
|
|
59
|
-
// on. pollFeatureEscalations only sweeps `running`/`escalated` runs, so a terminal-ward transition
|
|
60
|
-
// through here must clear it itself or the stale question would linger on the row.
|
|
61
|
-
escalation_question: null,
|
|
62
|
-
escalation_user_task_key: null,
|
|
63
57
|
updated_at: ts,
|
|
64
58
|
});
|
|
65
59
|
app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Unit coverage for pr.record-feature-escalation — a feature run parked on the native
|
|
2
2
|
// `feature-escalation` user task (issue #210). Runs on the `escalated` arm, before the user task
|
|
3
|
-
// exists, and must flip the row to the non-terminal `escalated` status and
|
|
4
|
-
// `question` so the nwf UI can surface it (the poller
|
|
5
|
-
// question's source of truth).
|
|
6
|
-
//
|
|
3
|
+
// exists, and must flip the row to the non-terminal `escalated` status and append the agent's
|
|
4
|
+
// `question` to the canonical `feature_escalations` audit log so the nwf UI can surface it (the poller
|
|
5
|
+
// can't read task-local vars, so this is the question's source of truth). Issue #332 dropped the
|
|
6
|
+
// denormalised `feature_runs.escalation_question` / `escalation_user_task_key` columns, so this worker
|
|
7
|
+
// now only flips the status and appends the audit row.
|
|
7
8
|
import { test } from "node:test";
|
|
8
9
|
import { assertEquals } from "#test-assert";
|
|
9
10
|
import { noopLog } from "../../test/log.ts";
|
|
@@ -43,8 +44,8 @@ function fakeApp(rows: Record<string, unknown>[]): any {
|
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
test("record-feature-escalation: flips the run to escalated and
|
|
47
|
-
const rows = [{ feature_key: "owner/repo#7", status: "running"
|
|
47
|
+
test("record-feature-escalation: flips the run to escalated and appends the question to the audit log", async () => {
|
|
48
|
+
const rows = [{ feature_key: "owner/repo#7", status: "running" }];
|
|
48
49
|
const app = fakeApp(rows);
|
|
49
50
|
const out = await handler(
|
|
50
51
|
{ jobKey: "job-1", variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
|
|
@@ -52,11 +53,8 @@ test("record-feature-escalation: flips the run to escalated and persists the que
|
|
|
52
53
|
);
|
|
53
54
|
assertEquals(out, {});
|
|
54
55
|
assertEquals(rows[0].status, "escalated");
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
assertEquals(rows[0].escalation_user_task_key, null);
|
|
58
|
-
// Dual-write (issue #305): the question is ALSO appended to the canonical `feature_escalations`
|
|
59
|
-
// audit log so `pollUserTasks` can source it from a surviving table once the denormalised column drops.
|
|
56
|
+
// Issue #305/#332: the question is the sole responsibility of the canonical `feature_escalations`
|
|
57
|
+
// audit log so `pollUserTasks` can source it from a surviving table (the denormalised column is gone).
|
|
60
58
|
assertEquals(app.stores.feature_escalations.length, 1);
|
|
61
59
|
assertEquals(app.stores.feature_escalations[0].feature_key, "owner/repo#7");
|
|
62
60
|
assertEquals(app.stores.feature_escalations[0].question, "Which API should I use?");
|
|
@@ -68,7 +66,7 @@ test("record-feature-escalation: a retried job (same jobKey) reuses its audit ro
|
|
|
68
66
|
// before job completion re-runs with the SAME jobKey. The `job_key` idempotency guard must reuse the
|
|
69
67
|
// existing `feature_escalations` row rather than append a duplicate (which would bloat the append-only
|
|
70
68
|
// log and could skew "latest question" selection). Mirrors `record-plan-review`'s `plan_reviews` guard.
|
|
71
|
-
const rows = [{ feature_key: "owner/repo#7", status: "running"
|
|
69
|
+
const rows = [{ feature_key: "owner/repo#7", status: "running" }];
|
|
72
70
|
const app = fakeApp(rows);
|
|
73
71
|
const job = { jobKey: "job-retry", variables: { featureKey: "owner/repo#7", question: "Which API?" } } as never;
|
|
74
72
|
await handler(job, app);
|
|
@@ -77,22 +75,11 @@ test("record-feature-escalation: a retried job (same jobKey) reuses its audit ro
|
|
|
77
75
|
assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
|
|
78
76
|
});
|
|
79
77
|
|
|
80
|
-
test("record-feature-escalation:
|
|
81
|
-
|
|
82
|
-
// left behind, a manual DB repair, etc). Leaving it would bind the pages' answer/abandon affordance
|
|
83
|
-
// to the wrong task until the poller overwrites it; clear it so the row reads "key unknown here".
|
|
84
|
-
const rows = [{ feature_key: "owner/repo#9", status: "running", escalation_question: null, escalation_user_task_key: "stale-ut" }];
|
|
85
|
-
const app = fakeApp(rows);
|
|
86
|
-
await handler({ jobKey: "job-9", variables: { featureKey: "owner/repo#9", question: "Q?" } } as never, app);
|
|
87
|
-
assertEquals(rows[0].status, "escalated");
|
|
88
|
-
assertEquals(rows[0].escalation_question, "Q?");
|
|
89
|
-
assertEquals(rows[0].escalation_user_task_key, null);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("record-feature-escalation: a blank/absent question is persisted as NULL (badge/affordance stay off)", async () => {
|
|
93
|
-
const rows = [{ feature_key: "owner/repo#8", status: "running", escalation_question: null }];
|
|
78
|
+
test("record-feature-escalation: a blank/absent question is appended as NULL (badge/affordance stay off)", async () => {
|
|
79
|
+
const rows = [{ feature_key: "owner/repo#8", status: "running" }];
|
|
94
80
|
const app = fakeApp(rows);
|
|
95
81
|
await handler({ jobKey: "job-8", variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
|
|
96
82
|
assertEquals(rows[0].status, "escalated");
|
|
97
|
-
assertEquals(
|
|
83
|
+
assertEquals(app.stores.feature_escalations.length, 1);
|
|
84
|
+
assertEquals(app.stores.feature_escalations[0].question, null);
|
|
98
85
|
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// pr.record-feature-escalation — a feature run has parked on the native `feature-escalation`
|
|
2
2
|
// user task (the agent reported `status:"escalated"` with a non-blank `question`). This service
|
|
3
|
-
// task runs on the `escalated` arm, immediately BEFORE the user task is created, and
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// •
|
|
3
|
+
// task runs on the `escalated` arm, immediately BEFORE the user task is created, and:
|
|
4
|
+
// • flips `feature_runs.status` to the non-terminal `escalated` so status-based views and counts
|
|
5
|
+
// flag it (and a re-dispatch of the same issue short-circuits while it is parked), and
|
|
6
|
+
// • appends the agent's `question` to the append-only `feature_escalations` audit log (issue #305),
|
|
7
|
+
// the canonical, poller-readable source for a parked run's question.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// in scope on the job.
|
|
9
|
+
// The completable `userTaskKey` is NOT recorded here — the task does not exist yet at this point.
|
|
10
|
+
// `pollUserTasks` (app/service.ts) reads the engine's open `feature-escalation` task directly once it
|
|
11
|
+
// is observable and projects it onto the `user_tasks` Tasks inbox, pairing it with the audit log's
|
|
12
|
+
// question. Capturing the question HERE (not in the poller) is required because the WASM testkit
|
|
13
|
+
// engine does not surface a user task's ioMapping-mapped local variables through the user-task query,
|
|
14
|
+
// so the process variable must be persisted while it is still in scope on the job.
|
|
15
15
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
16
16
|
import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
|
|
17
17
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
@@ -27,19 +27,13 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
27
27
|
const question = nonBlank(job.variables.question);
|
|
28
28
|
await featureRuns(app.data).update(featureKey, {
|
|
29
29
|
status: "escalated",
|
|
30
|
-
escalation_question: question,
|
|
31
|
-
// The user task does not exist yet, so any non-null pointer here can only be stale (a prior
|
|
32
|
-
// escalation's key, a manual DB repair). Clear it so the row reads "key unknown until observed"
|
|
33
|
-
// and the pages don't bind the answer/abandon affordance to a dead task; the poller re-fills the
|
|
34
|
-
// real key (it can always re-derive it from `searchUserTasks`), so this clear is never lossy.
|
|
35
|
-
escalation_user_task_key: null,
|
|
36
30
|
updated_at: new Date().toISOString(),
|
|
37
31
|
});
|
|
38
|
-
// Append the question to the canonical `feature_escalations` audit log (issue #305)
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// `
|
|
42
|
-
// is
|
|
32
|
+
// Append the question to the canonical `feature_escalations` audit log (issue #305) — the SURVIVING
|
|
33
|
+
// table `pollUserTasks` reads to enrich the parked `feature-escalation` task's question on the Tasks
|
|
34
|
+
// inbox (the feature analogue of `record-plan-review` writing `plan_reviews`). The denormalised
|
|
35
|
+
// `feature_runs.escalation_question` column it used to dual-write was dropped in the contract phase
|
|
36
|
+
// (issue #332), so this log is now the sole source of the question text.
|
|
43
37
|
await recordFeatureEscalation(app.data, { featureKey, question, jobKey: job.jobKey });
|
|
44
38
|
app.log.info("record-feature-escalation", { featureKey, hasQuestion: question !== null });
|
|
45
39
|
return {};
|