@nanobpm/nano-workforce 0.57.0 → 0.58.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.
Files changed (82) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -1
  5. package/SPEC.md +21 -22
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/answer-escalation.test.ts +106 -0
  9. package/app/answerEscalation.test.ts +67 -0
  10. package/app/baseGuard.test.ts +9 -1
  11. package/app/baseGuard.ts +11 -0
  12. package/app/escalationSla.test.ts +39 -0
  13. package/app/escalationSla.ts +28 -0
  14. package/app/escalationTaxonomy.test.ts +115 -0
  15. package/app/escalationTaxonomy.ts +115 -0
  16. package/app/feature.test.ts +161 -0
  17. package/app/feature.ts +173 -0
  18. package/app/mergeProtocol.test.ts +15 -0
  19. package/app/mergeProtocol.ts +10 -0
  20. package/app/persist-escalation.test.ts +34 -36
  21. package/app/plan.test.ts +0 -294
  22. package/app/plan.ts +26 -216
  23. package/app/reviewWait.ts +12 -4
  24. package/app/roundResultDefault.test.ts +111 -2
  25. package/app/roundResultDefault.ts +35 -0
  26. package/app/service.test.ts +6 -7
  27. package/app/service.ts +52 -35
  28. package/db/migrations/026_agent_completion.sql +36 -0
  29. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  30. package/db/migrations/028_feature_runs.sql +28 -0
  31. package/e2e/agent-answerable.e2e.ts +185 -0
  32. package/e2e/convergence-escalation.e2e.ts +180 -0
  33. package/e2e/convergence-loop.e2e.ts +1 -1
  34. package/e2e/feature-run.e2e.ts +231 -0
  35. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  36. package/e2e/plan-fanout.e2e.ts +303 -0
  37. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  38. package/e2e/support/github-admit.ts +99 -0
  39. package/e2e/user-task-spine.e2e.ts +155 -0
  40. package/nano.app.json +37 -11
  41. package/openapi.yaml +181 -73
  42. package/operations/agentCompleteEscalation.ts +53 -0
  43. package/operations/listActivePrs.test.ts +39 -6
  44. package/operations/postMessage.ts +10 -41
  45. package/operations/revertEscalationCompletion.ts +44 -0
  46. package/operations/startAndMessage.test.ts +0 -58
  47. package/operations/startFeature.ts +127 -0
  48. package/package.json +4 -1
  49. package/pages/cockpit.page.json +1 -0
  50. package/pages/epic-detail.page.json +11 -37
  51. package/pages/epic.page.json +1 -1
  52. package/pages/feature.page.json +82 -0
  53. package/pages/home.page.json +6 -18
  54. package/resources/agent-guide.md +52 -24
  55. package/resources/forms/feature-escalation.form +27 -0
  56. package/resources/forms/plan-review-decision.form +27 -0
  57. package/resources/forms/pr-escalation.form +23 -0
  58. package/resources/forms/spine-demo.form +15 -0
  59. package/resources/forms/trial-merge-decision.form +25 -0
  60. package/resources/processes/convergence-loop.bpmn +127 -75
  61. package/resources/processes/feature.bpmn +240 -0
  62. package/resources/processes/plan-fanout.bpmn +304 -223
  63. package/resources/processes/spine-demo.bpmn +72 -0
  64. package/scripts/check-migrations.ts +68 -0
  65. package/workers/answer-escalation/worker.ts +78 -0
  66. package/workers/converge-feature/worker.ts +51 -0
  67. package/workers/finalize/worker.ts +0 -2
  68. package/workers/mark-merged/worker.ts +0 -2
  69. package/workers/merge/worker.ts +6 -5
  70. package/workers/persist-escalation/worker.ts +28 -32
  71. package/workers/record-feature/worker.ts +61 -0
  72. package/workers/record-plan-review/worker.test.ts +9 -10
  73. package/workers/record-plan-review/worker.ts +15 -5
  74. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  75. package/workers/resolve-trial-attention/worker.ts +43 -0
  76. package/operations/answerFeatureEscalation.test.ts +0 -112
  77. package/operations/answerFeatureEscalation.ts +0 -58
  78. package/operations/answerPlanEscalation.test.ts +0 -115
  79. package/operations/answerPlanEscalation.ts +0 -41
  80. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  81. package/workers/persist-plan-escalation/worker.ts +0 -73
  82. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -0,0 +1,77 @@
1
+ import { test } from "node:test";
2
+ import { assertEquals } from "#test-assert";
3
+ import { noopLog } from "../../test/log.ts";
4
+ import { recordTrialMergeAudit, resolveTrialMergeAttention } from "../../app/trialMerge.ts";
5
+ import handler from "./worker.ts";
6
+
7
+ // In-memory DataLayer.table shim backing `plan_trial_merges` for the audit helpers.
8
+ function fakeApp() {
9
+ const rows: any[] = [];
10
+ let nextId = 1;
11
+ const table = {
12
+ async find(q: Record<string, unknown>) {
13
+ return rows.filter((r) => Object.entries(q).every(([k, v]) => r[k] === v));
14
+ },
15
+ async insert(row: Record<string, unknown>) {
16
+ const id = nextId++;
17
+ rows.push({ id, ...row });
18
+ return id;
19
+ },
20
+ async update(id: number, patch: Record<string, unknown>) {
21
+ const r = rows.find((x) => x.id === id);
22
+ if (r) Object.assign(r, patch);
23
+ },
24
+ };
25
+ const app = {
26
+ data: { table: (_name: string, _key: string) => table },
27
+ log: noopLog(),
28
+ };
29
+ return { app, rows };
30
+ }
31
+
32
+ // Red/green regression: a `proceed` override on a trial-merge escalation records NO re-run row, so
33
+ // without this serviceTask the wave's red `plan_trial_merges` audit row stays unresolved forever in
34
+ // the epic page's "Needs attention" tab (the caller was dropped in the userTask refactor). The
35
+ // worker must clear it. (Confirmed red against a no-op handler.)
36
+ test("resolve-trial-attention clears the wave's unresolved audit rows", async () => {
37
+ const { app, rows } = fakeApp();
38
+ await recordTrialMergeAudit(app.data as any, {
39
+ planKey: "o/r#69",
40
+ wave: 2,
41
+ result: "suite-failed",
42
+ summary: "combined suite red",
43
+ });
44
+ // A different wave must be left untouched.
45
+ await recordTrialMergeAudit(app.data as any, { planKey: "o/r#69", wave: 3, result: "suite-failed" });
46
+
47
+ assertEquals(rows.filter((r) => r.wave === 2 && r.resolved !== 1).length, 1);
48
+
49
+ await handler(
50
+ { key: 1, variables: { planKey: "o/r#69", currentWave: 2 } } as any,
51
+ app as any,
52
+ );
53
+
54
+ assertEquals(rows.filter((r) => r.wave === 2 && r.resolved !== 1).length, 0);
55
+ // Wave 3 is a distinct escalation and must remain unresolved.
56
+ assertEquals(rows.filter((r) => r.wave === 3 && r.resolved !== 1).length, 1);
57
+ // Idempotent: a follow-up (e.g. rebase re-entry) clears nothing new.
58
+ assertEquals(await resolveTrialMergeAttention(app.data as any, "o/r#69", 2), 0);
59
+ });
60
+
61
+ test("resolve-trial-attention never throws when cleanup fails", async () => {
62
+ const app = {
63
+ data: {
64
+ table: () => ({
65
+ find: async () => {
66
+ throw new Error("db down");
67
+ },
68
+ insert: async () => 1,
69
+ update: async () => {},
70
+ }),
71
+ },
72
+ log: noopLog(),
73
+ };
74
+ // Best-effort/cosmetic: a transient failure must not wedge the plan.
75
+ const out = await handler({ key: 2, variables: { planKey: "o/r#1", currentWave: 0 } } as any, app as any);
76
+ assertEquals(out, {});
77
+ });
@@ -0,0 +1,43 @@
1
+ // pr.resolve-trial-attention — clear the wave's trial-merge "Needs attention" audit rows once a
2
+ // human has made a decision on the trial-merge escalation (issue #69 / #131 follow-up).
3
+ //
4
+ // Before the escalations were converted to native userTasks, the app-side answer path called
5
+ // `resolveTrialMergeAttention` so that a `proceed` override (which records no re-run row and so
6
+ // would otherwise pin the red `plan_trial_merges` row in the epic page's "Needs attention" tab
7
+ // forever) cleared the wave. The userTask refactor retired that app-side path, leaving this
8
+ // cleanup uncalled. This serviceTask restores it in the process: it runs on EVERY answer variant
9
+ // (proceed / rebase / abandon) between the `trial-merge-decision` userTask and the answer gateway,
10
+ // so the human's decision always clears the wave. It is idempotent w.r.t. a `rebase` re-run, which
11
+ // records a fresh unresolved row that supersedes any prior (so re-resolving here changes nothing).
12
+ //
13
+ // Cleanup is best-effort/cosmetic: a transient failure must never wedge the plan, so it is caught
14
+ // and logged rather than thrown.
15
+ import type { AppJobHandler } from "@nanobpm/urban";
16
+ import { resolveTrialMergeAttention } from "../../app/trialMerge.ts";
17
+
18
+ interface In extends Record<string, unknown> {
19
+ planKey: string;
20
+ currentWave?: unknown;
21
+ trialMergeWave?: unknown;
22
+ }
23
+
24
+ const waveNo = (v: unknown): number => {
25
+ const n = Math.trunc(Number(v));
26
+ return Number.isFinite(n) && n >= 0 ? n : 0;
27
+ };
28
+
29
+ const handler: AppJobHandler<In> = async (job, app) => {
30
+ const planKey = job.variables.planKey;
31
+ const wave = waveNo(job.variables.trialMergeWave ?? job.variables.currentWave);
32
+ try {
33
+ const cleared = await resolveTrialMergeAttention(app.data, planKey, wave);
34
+ if (cleared > 0) {
35
+ app.log.info(`resolve-trial-attention: cleared ${cleared} row(s) for ${planKey} wave ${wave}`);
36
+ }
37
+ } catch (err) {
38
+ app.log.error(`resolve-trial-attention: cleanup failed for ${planKey} wave ${wave}`, { err: String(err) });
39
+ }
40
+ return {};
41
+ };
42
+
43
+ export default handler;
@@ -1,112 +0,0 @@
1
- import { test } from "node:test";
2
- import { assertEquals } from "#test-assert";
3
- import type { AppApi } from "@nanobpm/urban";
4
- import { noopLog } from "../test/log.ts";
5
-
6
- const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
7
- const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
8
- let answerFeatureEscalation: typeof import("./answerFeatureEscalation.ts").default;
9
- try {
10
- process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
11
- answerFeatureEscalation = (await import("./answerFeatureEscalation.ts")).default;
12
- } finally {
13
- if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
14
- else delete process.env.NANO_PR_WEBHOOK_SECRET;
15
- }
16
-
17
- function memTable(rows: any[], key: string) {
18
- return {
19
- find: (where: Record<string, unknown>) =>
20
- Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
21
- update: (value: unknown, patch: Record<string, unknown>) => {
22
- const row = rows.find((candidate) => candidate[key] === value);
23
- if (row) Object.assign(row, patch);
24
- return Promise.resolve(row);
25
- },
26
- };
27
- }
28
-
29
- function memApp(escalations: any[] = []) {
30
- const stores: Record<string, { rows: any[]; key: string }> = {
31
- plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
32
- plan_escalations: { rows: escalations, key: "id" },
33
- plan_tasks: { rows: [{ id: 1, plan_key: "owner/repo#9", task_id: "task-1" }], key: "id" },
34
- };
35
- const published: Record<string, unknown>[] = [];
36
- const app = {
37
- data: {
38
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
39
- },
40
- engine: {
41
- publishMessage: (message: Record<string, unknown>) => {
42
- published.push(message);
43
- return Promise.resolve();
44
- },
45
- },
46
- log: noopLog(),
47
- } as any as AppApi;
48
- return { app, published };
49
- }
50
-
51
- function input(body: Record<string, unknown>, secret?: string) {
52
- const headers = new Headers();
53
- if (secret !== undefined) headers.set("x-hook-secret", secret);
54
- return {
55
- req: {
56
- method: "POST",
57
- path: "/app/api/hooks/feature-answer",
58
- query: new URLSearchParams(),
59
- headers,
60
- text: async () => "",
61
- } as any,
62
- params: {},
63
- query: {},
64
- body,
65
- };
66
- }
67
-
68
- test("rejects a request without the configured hook secret", async () => {
69
- const { app } = memApp();
70
- const result = await answerFeatureEscalation(input({ corrKey: "owner/repo#9:task-1", answer: "yes" }), app) as any;
71
- assertEquals(result.status, 401);
72
- assertEquals(result.body, { ok: false, error: "unauthorized" });
73
- });
74
-
75
- test("derives corrKey from plan + task and maps an answered escalation to 200", async () => {
76
- const { app, published } = memApp([{
77
- id: 1,
78
- plan_key: "owner/repo#9",
79
- task_id: "task-1",
80
- corr_key: "owner/repo#9:task-1",
81
- question: "Proceed?",
82
- status: "open",
83
- }]);
84
- const result = await answerFeatureEscalation(
85
- input({ plan: "owner/repo#9", task: "task-1", answer: " yes " }, "test-secret"),
86
- app,
87
- ) as any;
88
- assertEquals(result.status, 200);
89
- assertEquals(result.body.ok, true);
90
- assertEquals(published[0]?.correlationKey, "owner/repo#9:task-1");
91
- assertEquals((published[0]?.variables as Record<string, unknown>).answer, "yes");
92
- });
93
-
94
- test("maps an unmatched corrKey to 404", async () => {
95
- const { app } = memApp();
96
- const result = await answerFeatureEscalation(
97
- input({ corrKey: "owner/repo#9:missing", answer: "yes" }, "test-secret"),
98
- app,
99
- ) as any;
100
- assertEquals(result.status, 404);
101
- assertEquals(result.body.ok, false);
102
- });
103
-
104
- test("rejects a missing request body with 400 (not 500)", async () => {
105
- const { app } = memApp();
106
- const result = await answerFeatureEscalation(
107
- { ...input({}, "test-secret"), body: undefined },
108
- app,
109
- ) as any;
110
- assertEquals(result.status, 400);
111
- assertEquals(result.body.ok, false);
112
- });
@@ -1,58 +0,0 @@
1
- // POST /app/api/hooks/feature-answer → operationId `answerFeatureEscalation` (ADR 0059 webhook
2
- // operation; was the `/hooks/feature-answer` action). Answers an implementation-phase task
3
- // escalation out of band (optional shared-secret guard via X-Hook-Secret, enforced only when
4
- // NANO_PR_WEBHOOK_SECRET is set — mirrors the operator control surface), issue #25. Lets an
5
- // external system (a chat relay, a CI job, a human via curl) resume a parked implementation agent
6
- // without the page. Same idempotent `answerTaskEscalation` path the page's answer form uses.
7
- //
8
- // The runtime validates the body shape against openapi.yaml — a `oneOf` of EXACTLY ONE addressing
9
- // form (`{ corrKey, answer }` OR `{ plan, task, answer }`), so a body that supplies neither form (or
10
- // mixes them) is rejected at the edge with a 400 that names the allowed shapes. This delegate narrows
11
- // the validated variant and keeps the semantic normalization the schema can't express (an answer /
12
- // correlation key that is present but blank-after-trim) plus the shared-secret guard.
13
- // { "corrKey": "owner/repo#12:task-3", "answer": "…" }
14
- // { "plan": "owner/repo#12", "task": "task-3", "answer": "…" }
15
-
16
- import { answerTaskEscalation, featureCorrKey } from "../app/plan.ts";
17
- import { envVar } from "../app/version.ts";
18
- import { defineOperation } from "../nano-generated/operations.ts";
19
-
20
- const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
21
-
22
- const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
23
-
24
- export default defineOperation("answerFeatureEscalation", async ({ req, body }, app) => {
25
- if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
26
- app.log.warn("feature-answer rejected: missing/invalid shared secret");
27
- return { status: 401, body: { ok: false, error: "unauthorized" } };
28
- }
29
- // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
30
- // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500.
31
- if (!body || typeof body !== "object") {
32
- app.log.warn("feature-answer rejected: missing request body");
33
- return { status: 400, body: { ok: false, error: "answer is required" } };
34
- }
35
- const answer = str(body.answer);
36
- if (!answer) {
37
- app.log.warn("feature-answer rejected: blank answer");
38
- return { status: 400, body: { ok: false, error: "answer is required" } };
39
- }
40
-
41
- const corrKey = "corrKey" in body
42
- ? str(body.corrKey)
43
- : str(body.plan) && str(body.task)
44
- ? featureCorrKey(str(body.plan), str(body.task))
45
- : "";
46
- if (!corrKey) {
47
- app.log.warn("feature-answer rejected: unresolvable correlation key");
48
- return {
49
- status: 400,
50
- body: { ok: false, error: "provide corrKey, or both plan (owner/repo#N) and task" },
51
- };
52
- }
53
-
54
- const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
55
- if (r.ok) app.log.info("feature escalation answered", { corrKey });
56
- else app.log.warn("feature-answer: no open escalation to answer", { corrKey });
57
- return { status: r.ok ? 200 : 404, body: r };
58
- });
@@ -1,115 +0,0 @@
1
- import { test } from "node:test";
2
- import { assertEquals } from "#test-assert";
3
- import type { AppApi } from "@nanobpm/urban";
4
- import { noopLog } from "../test/log.ts";
5
-
6
- const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
7
- const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
8
- let answerPlanEscalation: typeof import("./answerPlanEscalation.ts").default;
9
- try {
10
- process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
11
- answerPlanEscalation = (await import("./answerPlanEscalation.ts")).default;
12
- } finally {
13
- if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
14
- else delete process.env.NANO_PR_WEBHOOK_SECRET;
15
- }
16
-
17
- function memTable(rows: any[], key: string) {
18
- return {
19
- find: (where: Record<string, unknown>) =>
20
- Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
21
- update: (value: unknown, patch: Record<string, unknown>) => {
22
- const row = rows.find((candidate) => candidate[key] === value);
23
- if (row) Object.assign(row, patch);
24
- return Promise.resolve(row);
25
- },
26
- };
27
- }
28
-
29
- function memApp(escalations: any[] = []) {
30
- const stores: Record<string, { rows: any[]; key: string }> = {
31
- plans: { rows: [{ plan_key: "owner/repo#9", open_plan_escalation_id: 1 }], key: "plan_key" },
32
- plan_review_escalations: { rows: escalations, key: "id" },
33
- };
34
- const published: Record<string, unknown>[] = [];
35
- const app = {
36
- data: {
37
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
38
- },
39
- engine: {
40
- publishMessage: (message: Record<string, unknown>) => {
41
- published.push(message);
42
- return Promise.resolve();
43
- },
44
- },
45
- log: noopLog(),
46
- } as any as AppApi;
47
- return { app, published, stores };
48
- }
49
-
50
- function input(body: Record<string, unknown>, secret?: string) {
51
- const headers = new Headers();
52
- if (secret !== undefined) headers.set("x-hook-secret", secret);
53
- return {
54
- req: {
55
- method: "POST",
56
- path: "/app/api/hooks/plan-answer",
57
- query: new URLSearchParams(),
58
- headers,
59
- text: async () => "",
60
- } as any,
61
- params: {},
62
- query: {},
63
- body,
64
- };
65
- }
66
-
67
- test("rejects a request without the configured hook secret", async () => {
68
- const { app } = memApp();
69
- const result = await answerPlanEscalation(input({ plan: "owner/repo#9", directive: "revise" }), app) as any;
70
- assertEquals(result.status, 401);
71
- assertEquals(result.body, { ok: false, error: "unauthorized" });
72
- });
73
-
74
- test("answers an open plan escalation and publishes the plan correlation message", async () => {
75
- const { app, published, stores } = memApp([{
76
- id: 1,
77
- plan_key: "owner/repo#9",
78
- epoch: 0,
79
- round: 2,
80
- findings: "needs wave 0",
81
- status: "open",
82
- }]);
83
- const result = await answerPlanEscalation(
84
- input({ plan: "owner/repo#9", directive: "revise", note: " use issue-1 first " }, "test-secret"),
85
- app,
86
- ) as any;
87
- assertEquals(result.status, 200);
88
- assertEquals(result.body.ok, true);
89
- assertEquals(stores.plan_review_escalations.rows[0].status, "answered");
90
- assertEquals(stores.plan_review_escalations.rows[0].directive, "revise");
91
- assertEquals(stores.plan_review_escalations.rows[0].note, "use issue-1 first");
92
- assertEquals(published[0]?.name, "plan-escalation-answered");
93
- assertEquals(published[0]?.correlationKey, "owner/repo#9");
94
- assertEquals((published[0]?.variables as Record<string, unknown>).planEscalationDirective, "revise");
95
- });
96
-
97
- test("maps an unmatched plan to 404", async () => {
98
- const { app } = memApp();
99
- const result = await answerPlanEscalation(
100
- input({ plan: "owner/repo#missing", directive: "revise" }, "test-secret"),
101
- app,
102
- ) as any;
103
- assertEquals(result.status, 404);
104
- assertEquals(result.body.ok, false);
105
- });
106
-
107
- test("rejects an invalid directive with 400", async () => {
108
- const { app } = memApp();
109
- const result = await answerPlanEscalation(
110
- input({ plan: "owner/repo#9", directive: "ship-it" }, "test-secret"),
111
- app,
112
- ) as any;
113
- assertEquals(result.status, 400);
114
- assertEquals(result.body.ok, false);
115
- });
@@ -1,41 +0,0 @@
1
- // POST /app/api/hooks/plan-answer → operationId `answerPlanEscalation`. Answers a plan-review
2
- // cap escalation out of band. The process is parked on `plan-escalation-answered`, correlated by
3
- // planKey; the answer records the human directive and resumes the plan.
4
- import {
5
- answerPlanEscalation as answerPlanEscalationState,
6
- parsePlanEscalationDirective,
7
- } from "../app/plan.ts";
8
- import { envVar } from "../app/version.ts";
9
- import { defineOperation } from "../nano-generated/operations.ts";
10
-
11
- const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
12
-
13
- const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
14
-
15
- export default defineOperation("answerPlanEscalation", async ({ req, body }, app) => {
16
- if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
17
- app.log.warn("plan-answer rejected: missing/invalid shared secret");
18
- return { status: 401, body: { ok: false, error: "unauthorized" } };
19
- }
20
- if (!body || typeof body !== "object") {
21
- app.log.warn("plan-answer rejected: missing request body");
22
- return { status: 400, body: { ok: false, error: "plan and directive are required" } };
23
- }
24
-
25
- const planKey = str(body.plan);
26
- const directive = parsePlanEscalationDirective(body.directive);
27
- const note = str(body.note);
28
- if (!planKey) {
29
- app.log.warn("plan-answer rejected: missing plan");
30
- return { status: 400, body: { ok: false, error: "plan is required" } };
31
- }
32
- if (!directive) {
33
- app.log.warn("plan-answer rejected: invalid directive", { directive: str(body.directive) });
34
- return { status: 400, body: { ok: false, error: "directive must be proceed or revise" } };
35
- }
36
-
37
- const r = await answerPlanEscalationState(app.data, app.engine, planKey, directive, note);
38
- if (r.ok) app.log.info("plan escalation answered", { planKey, directive });
39
- else app.log.warn("plan-answer: no open plan escalation to answer", { planKey });
40
- return { status: r.ok ? 200 : 404, body: r };
41
- });
@@ -1,80 +0,0 @@
1
- import { test } from "node:test";
2
- import { assertEquals, assertRejects } from "#test-assert";
3
- import { noopLog } from "../../test/log.ts";
4
- import handler from "./worker.ts";
5
-
6
- function fakeApp(escalations: any[] = []) {
7
- const plans = [{ plan_key: "owner/repo#12", status: "dispatched" }];
8
- const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
9
- Object.entries(q).every(([f, v]) => r[f] === v);
10
- const table = (rows: any[], key: string) => ({
11
- find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
12
- insert: (row: any) => {
13
- row.id = row.id ?? rows.length + 1;
14
- rows.push(row);
15
- return Promise.resolve(row.id);
16
- },
17
- update: (id: any, patch: any) => {
18
- const row = rows.find((r) => r[key] === id);
19
- if (row) Object.assign(row, patch);
20
- return Promise.resolve(row);
21
- },
22
- });
23
- return {
24
- data: {
25
- table(name: string) {
26
- return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
27
- },
28
- },
29
- log: noopLog(),
30
- _plans: plans,
31
- _escalations: escalations,
32
- } as any;
33
- }
34
-
35
- test("records an open plan-review escalation and surfaces it on the plan", async () => {
36
- const app = fakeApp();
37
- const out = await handler({
38
- variables: {
39
- planKey: "owner/repo#12",
40
- planReviewEpoch: 1,
41
- planReviewRound: 2,
42
- planFindings: "needs a seam",
43
- },
44
- jobKey: "j1",
45
- } as any, app as any);
46
-
47
- assertEquals(out.planEscalationId, 1);
48
- assertEquals(app._escalations[0].plan_key, "owner/repo#12");
49
- assertEquals(app._escalations[0].epoch, 1);
50
- assertEquals(app._escalations[0].round, 2);
51
- assertEquals(app._escalations[0].findings, "needs a seam");
52
- assertEquals(app._plans[0].open_plan_escalation_id, 1);
53
- assertEquals(app._plans[0].open_plan_findings, "needs a seam");
54
- assertEquals(app._plans[0].status, "planning");
55
- });
56
-
57
- test("missing planKey fails loudly instead of parking an unanswerable escalation", async () => {
58
- const app = fakeApp();
59
- await assertRejects(
60
- () => handler({
61
- variables: { planKey: " ", planFindings: "needs a seam" },
62
- jobKey: "j-missing",
63
- } as any, app as any),
64
- Error,
65
- "persist-plan-escalation: missing planKey in process scope",
66
- );
67
- });
68
-
69
- test("non-string planKey is treated as missing rather than coerced to a bogus key", async () => {
70
- const app = fakeApp();
71
- await assertRejects(
72
- () => handler({
73
- variables: { planKey: 123, planFindings: "needs a seam" },
74
- jobKey: "j-nonstring",
75
- } as any, app as any),
76
- Error,
77
- "persist-plan-escalation: missing planKey in process scope",
78
- );
79
- assertEquals(app._escalations.length, 0);
80
- });
@@ -1,73 +0,0 @@
1
- // pr.persist-plan-escalation — the adversarial plan-review loop reached its per-epoch budget
2
- // without approval. Record a plan-level human escalation and park the process until an operator
3
- // answers with either `revise` (default: loop back to the planner with guidance and a fresh epoch)
4
- // or `proceed` (explicit override: dispatch the current plan as-is).
5
- import type { AppJobHandler } from "@nanobpm/urban";
6
- import { planReviewEscalations, plans } from "../../app/plan.ts";
7
-
8
- interface In extends Record<string, unknown> {
9
- planKey: string;
10
- planFindings?: unknown;
11
- planReviewEpoch?: unknown;
12
- planReviewRound?: unknown;
13
- }
14
- interface Out extends Record<string, unknown> {
15
- planEscalationId: number;
16
- }
17
-
18
- // Accept only genuine strings (trimmed); anything else is treated as missing so
19
- // the explicit planKey guard fires instead of silently coercing (e.g. 123 -> "123")
20
- // an escalation that BPMN correlation ("owner/repo#N") could never resume.
21
- const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
22
- const int = (v: unknown): number => {
23
- const n = Math.trunc(Number(v));
24
- return Number.isFinite(n) && n >= 0 ? n : 0;
25
- };
26
-
27
- const handler: AppJobHandler<In, Out> = async (job, app) => {
28
- const planKey = str(job.variables.planKey);
29
- if (!planKey) {
30
- // No plan binding means we cannot correlate a resume — fail loudly rather
31
- // than silently parking a token that can never be answered.
32
- throw new Error("persist-plan-escalation: missing planKey in process scope");
33
- }
34
- const epoch = int(job.variables.planReviewEpoch);
35
- const round = int(job.variables.planReviewRound);
36
- const findings = str(job.variables.planFindings) || null;
37
- const ts = new Date().toISOString();
38
-
39
- const escTable = planReviewEscalations(app.data);
40
- const existing = (await escTable.find({ plan_key: planKey, status: "open" }))
41
- .sort((a, b) => b.id - a.id)[0];
42
- let escalationId: number;
43
- if (existing) {
44
- await escTable.update(existing.id, { epoch, round, findings });
45
- escalationId = existing.id;
46
- } else {
47
- escalationId = Number(
48
- await escTable.insert({
49
- plan_key: planKey,
50
- epoch,
51
- round,
52
- findings,
53
- status: "open",
54
- directive: null,
55
- note: null,
56
- asked_at: ts,
57
- answered_at: null,
58
- }),
59
- );
60
- }
61
-
62
- await plans(app.data).update(planKey, {
63
- status: "planning",
64
- open_plan_escalation_id: escalationId,
65
- open_plan_findings: findings,
66
- open_plan_round: round,
67
- updated_at: ts,
68
- });
69
-
70
- return { planEscalationId: escalationId };
71
- };
72
-
73
- export default handler;