@nanobpm/nano-workforce 0.56.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 (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -2,8 +2,20 @@
2
2
  // row for a human to answer. Handles both the agent-raised path (status = needs_input | blocked)
3
3
  // and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
4
4
  // `escalationId` for the UI.
5
+ //
6
+ // A BLANK question is a NON-escalation (nano-workforce ADR 0002 §1): it must never open an
7
+ // answerable escalation. The convergence-loop `gw-status` gateway enforces this upstream — its
8
+ // `f_escalate` arm now requires a non-blank question, so a blank-question round re-enters the
9
+ // durable review wait instead of routing here (see app/roundResultDefault.ts). This worker keeps
10
+ // the same rule as defence-in-depth via the canonical taxonomy: if a blank-question job ever
11
+ // reaches it, it opens NO escalation and reports `escalated:false` rather than FABRICATING a
12
+ // question (the retired failure mode) or throwing an un-remediable incident. The convergence-loop
13
+ // model branches on that `escalated` output (`gw-escalated`): a `false` return re-enters the loop
14
+ // via `gw-guard` instead of flowing into the `wait-answer` catch, so a non-escalation can never
15
+ // wedge a token on a durable wait that has no escalation for a human to answer.
5
16
  import type { AppJobHandler } from "@nanobpm/urban";
6
17
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
18
+ import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
7
19
  import { ensurePr, parsePr } from "../../app/service.ts";
8
20
 
9
21
  // Extends Record so the declared fields are typed while the job may still carry
@@ -51,42 +63,28 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
51
63
  return nonBlank(vars.agent);
52
64
  }
53
65
 
54
- // Synthesize a concrete, answerable question when the agent left one blank. A blank question is
55
- // almost always a *no-result* round: a prompt-less agent that never wrote its result file, so
56
- // `status` is empty and `gw-status` falls through its default `f_escalate` arm (the empty
57
- // "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). Throwing here parked a
58
- // `JobNoRetries` incident that could NOT be diagnosed or remediated from the UI. Instead we open
59
- // an escalation a human can actually answer, with the agent's transcript attached below it.
60
- function fabricateQuestion(rawStatus: string | undefined, hasTranscript: boolean): string {
61
- const tail = hasTranscript
62
- ? " Review the agent's response shown below, then reply with how it should proceed — or cancel and resubmit."
63
- : " No agent response was captured. Reply with how it should proceed, or cancel and resubmit.";
64
- if (!rawStatus) {
65
- return "The review agent finished without a machine-readable result (no status was reported), " +
66
- "so this round could not be classified as converged, addressed, or a specific request." + tail;
67
- }
68
- return `The review agent reported status "${rawStatus}" without a question, so this round ` +
69
- "could not be resolved automatically." + tail;
70
- }
71
-
72
66
  const handler: AppJobHandler<In> = async (job, app) => {
73
67
  const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
74
68
  // `status` drives the escalation kind (control flow); a blank/absent status is an
75
- // unclassified escalation -> a question needing input. `question` is denormalised
76
- // onto pull_requests below and bound by the UI answer form, so it must be a
77
- // concrete, non-blank value. `summary` is left undefined so the write boundary
78
- // omits it and the nullable column stays NULL.
69
+ // unclassified escalation -> a question needing input. `question` is returned as a
70
+ // process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
71
+ // can display it, so it must be a concrete, non-blank value. `summary` is left undefined
72
+ // so the write boundary omits it and the nullable column stays NULL.
79
73
  const rawStatus = nonBlank(job.variables.status);
80
74
  const status = rawStatus ?? "needs_input";
81
75
  const transcript = transcriptOf(job.variables);
82
76
  const worker = workerOf(job.variables);
83
- // A blank question must never open an unanswerable escalation. Every legitimate arm sets a
84
- // concrete question the agent contract requires one for needs_input/blocked, and the
85
- // max-rounds + review-timeout arms set a literal via the model. When one is still missing
86
- // (a no-result round through the `gw-status` default), fabricate an actionable question that
87
- // references the attached transcript rather than throwing (which parked an un-remediable
88
- // incident). This keeps the loop recoverable entirely from the UI.
89
- const question = nonBlank(job.variables.question) ?? fabricateQuestion(rawStatus, transcript != null);
77
+ // Classify this job through the single canonical taxonomy (ADR 0002 §1). Only a
78
+ // decision-required escalation opens an answerable escalation row. A blank question (or any
79
+ // non-human-blocking status that reached here defensively) is a NON-escalation: open nothing
80
+ // and report `escalated:false` — never FABRICATE a question and never throw. The `gw-status`
81
+ // gateway already routes blank-question rounds back into the durable review wait, so this is
82
+ // defence-in-depth, not the primary guard.
83
+ const question = nonBlank(job.variables.question);
84
+ const disposition = classifyEscalation({ kind: "review-round", status: rawStatus, question });
85
+ if (disposition !== "decision-required" || question === undefined) {
86
+ return { escalationId: null, escalated: false };
87
+ }
90
88
  const kind = status === "needs_input" ? "question" : "blocker";
91
89
  const now = new Date().toISOString();
92
90
 
@@ -137,11 +135,9 @@ const handler: AppJobHandler<In> = async (job, app) => {
137
135
  status: "escalated",
138
136
  current_round: round,
139
137
  updated_at: now,
140
- open_escalation_id: Number(escalationId),
141
- open_escalation_question: question,
142
138
  });
143
139
 
144
- return { escalationId: Number(escalationId) };
140
+ return { escalationId: Number(escalationId), escalated: true, question };
145
141
  };
146
142
 
147
143
  export default handler;
@@ -0,0 +1,61 @@
1
+ // pr.record-feature — the single-issue `implement` block has finished (issue #172).
2
+ //
3
+ // The `senior:feature` agent reported one of `opened` / `blocked` / `skipped` (prompts/feature.md);
4
+ // anything else — including a missing status, or an `escalated` status that fell through to abandon
5
+ // (the human abandoned or the SLA fired) — is treated as `blocked`: we must not assume a PR was
6
+ // opened. This worker:
7
+ // • resolves the run's terminal status from the agent result,
8
+ // • records the PR key + outcome on the `feature_runs` row,
9
+ // • emits `featureStatus` + `prKey` so the `converge?` gateway can decide whether to hand the
10
+ // opened PR off to the convergence loop.
11
+ //
12
+ // Enrollment itself lives in the separate `pr.converge-feature` worker (gated by the `converge?`
13
+ // branch) so the hand-off is legible in the process, not buried in a persistence step.
14
+ import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { type FeatureRunStatus, featureRuns } from "../../app/feature.ts";
16
+ import { parsePr } from "../../app/service.ts";
17
+
18
+ interface In extends Record<string, unknown> {
19
+ featureKey: string;
20
+ status?: unknown;
21
+ pr?: unknown;
22
+ summary?: unknown;
23
+ }
24
+ interface Out extends Record<string, unknown> {
25
+ featureStatus: FeatureRunStatus;
26
+ prKey: string | null;
27
+ }
28
+
29
+ const str = (v: unknown): string | undefined =>
30
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
31
+
32
+ // The implementation agent reports one of these; anything else is `blocked` (we never assume a PR).
33
+ type AgentStatus = "opened" | "blocked" | "skipped";
34
+ const isAgentStatus = (s: string): s is AgentStatus => s === "opened" || s === "blocked" || s === "skipped";
35
+
36
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
37
+ const featureKey = job.variables.featureKey;
38
+ const rawStatus = str(job.variables.status);
39
+ const status: AgentStatus = rawStatus && isAgentStatus(rawStatus) ? rawStatus : "blocked";
40
+ const summary = str(job.variables.summary);
41
+ const prRef = str(job.variables.pr);
42
+ // Only trust a PR ref when the agent reports it actually opened one.
43
+ const parsed = status === "opened" && prRef ? parsePr(prRef) : null;
44
+ // A keyless "opened" cannot be handed off, but a PR was still raised — keep the status `opened`
45
+ // (the run is complete), just with no `pr_key` to converge.
46
+ const prKey = parsed?.prKey ?? null;
47
+ const featureStatus: FeatureRunStatus = status;
48
+ const ts = new Date().toISOString();
49
+
50
+ await featureRuns(app.data).update(featureKey, {
51
+ status: featureStatus,
52
+ pr_key: prKey,
53
+ outcome: summary ?? null,
54
+ updated_at: ts,
55
+ });
56
+ app.log.info("record-feature", { featureKey, featureStatus, prKey });
57
+
58
+ return { featureStatus, prKey };
59
+ };
60
+
61
+ export default handler;
@@ -9,9 +9,8 @@ import { noopLog } from "../../test/log.ts";
9
9
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
10
10
  import handler from "./worker.ts";
11
11
 
12
- function fakeApp(existing: PlanReview[] = [], reviewEscalations: any[] = []) {
12
+ function fakeApp(existing: PlanReview[] = []) {
13
13
  const reviewRows: PlanReview[] = [...existing];
14
- const escalationRows = [...reviewEscalations];
15
14
  const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
16
15
  Object.entries(q).every(([f, v]) => r[f] === v);
17
16
  const table = (rows: any[]) => ({
@@ -25,14 +24,12 @@ function fakeApp(existing: PlanReview[] = [], reviewEscalations: any[] = []) {
25
24
  });
26
25
  return {
27
26
  data: {
28
- table(name: string) {
29
- if (name === "plan_review_escalations") return table(escalationRows);
27
+ table() {
30
28
  return table(reviewRows);
31
29
  },
32
30
  },
33
31
  log: noopLog(),
34
32
  _rows: reviewRows,
35
- _escalations: escalationRows,
36
33
  } as any;
37
34
  }
38
35
 
@@ -85,12 +82,14 @@ test("approved on the FINAL round still proceeds (no escalation)", async () => {
85
82
  assertEquals((out as any).planEscalated, false);
86
83
  });
87
84
 
88
- test("answered plan-review escalation starts a fresh epoch and round budget", async () => {
89
- const app = fakeApp(
90
- priorRounds("o/r#5", MAX_PLAN_REVIEW_ROUNDS, 0),
91
- [{ id: 1, plan_key: "o/r#5", status: "answered" }],
85
+ test("a bumped plan-review epoch starts a fresh round budget", async () => {
86
+ // The `plan-review-decision` user task bumped `planReviewEpoch` to 1 after a prior epoch's
87
+ // rounds; the next planner pass records round 0 of the new epoch, resetting the budget.
88
+ const app = fakeApp(priorRounds("o/r#5", MAX_PLAN_REVIEW_ROUNDS, 0));
89
+ const out = await call(
90
+ app,
91
+ { planKey: "o/r#5", approved: false, findings: "new epoch finding", planReviewEpoch: 1 },
92
92
  );
93
- const out = await call(app, { planKey: "o/r#5", approved: false, findings: "new epoch finding" });
94
93
  assertEquals((out as any).planApproved, false);
95
94
  assertEquals((out as any).planEscalated, false);
96
95
  assertEquals((out as any).planReviewEpoch, 1);
@@ -3,9 +3,10 @@
3
3
  //
4
4
  // The `senior:plan-review` agent critiqued the levelized plan and emitted `{ approved, findings }`.
5
5
  // This worker:
6
- // • derives the current epoch from answered plan-review escalations and the current round from
7
- // the append-only `plan_reviews` log for that epoch (no counter variable), using the engine
8
- // jobKey as an idempotency guard so a retried job reuses its row,
6
+ // • reads the current epoch from the durable `planReviewEpoch` process variable (bumped by the
7
+ // `plan-review-decision` user task each time a human answers a plan-review escalation) and
8
+ // derives the current round from the append-only `plan_reviews` log for that epoch (no counter
9
+ // variable), using the engine jobKey as an idempotency guard so a retried job reuses its row,
9
10
  // • records this round's verdict + findings,
10
11
  // • decides the loop: emits `planApproved` (reviewer said yes → the BPMN gateway proceeds to
11
12
  // `select-wave`) or, when unapproved, re-emits the findings as `planFindings` so a revise
@@ -19,7 +20,6 @@
19
20
 
20
21
  import type { AppJobHandler } from "@nanobpm/urban";
21
22
  import {
22
- currentPlanReviewEpoch,
23
23
  MAX_PLAN_REVIEW_ROUNDS,
24
24
  type PlanReview,
25
25
  planReviews,
@@ -29,6 +29,7 @@ interface In extends Record<string, unknown> {
29
29
  planKey: string;
30
30
  approved?: unknown;
31
31
  findings?: unknown;
32
+ planReviewEpoch?: unknown;
32
33
  }
33
34
  interface Out extends Record<string, unknown> {
34
35
  planApproved: boolean;
@@ -43,6 +44,15 @@ interface Out extends Record<string, unknown> {
43
44
  const isApproved = (v: unknown): boolean =>
44
45
  v === true || (typeof v === "string" && v.trim().toLowerCase() === "true");
45
46
 
47
+ // The plan-review epoch is a durable process variable. `startPlan` seeds it to 0 when creating the
48
+ // instance, and the `plan-review-decision` user task bumps it on each human answer. The 0-fallback
49
+ // here is backwards compatibility for older instances created before the variable was seeded (where
50
+ // it is null/absent): coerce anything unexpected to 0 so a round is always recorded under a valid epoch.
51
+ const coerceEpoch = (v: unknown): number => {
52
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : Number.NaN;
53
+ return Number.isInteger(n) && n >= 0 ? n : 0;
54
+ };
55
+
46
56
  export const str = (v: unknown): string => {
47
57
  if (typeof v === "string") return v;
48
58
  if (v == null) return "";
@@ -63,7 +73,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
63
73
  const jobKey = job.jobKey;
64
74
 
65
75
  const reviews = planReviews(app.data);
66
- const epoch = await currentPlanReviewEpoch(app.data, planKey);
76
+ const epoch = coerceEpoch(job.variables.planReviewEpoch);
67
77
 
68
78
  // Idempotency guard: deriving the epoch/round from count(plan_reviews) is not retry-safe on its
69
79
  // own. A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey —
@@ -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
- });