@nanobpm/nano-workforce 0.64.0 → 0.66.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.
@@ -12,6 +12,55 @@
12
12
  <nano:extend name="repo" type="string" />
13
13
  <nano:extend name="baseBranch" type="string" />
14
14
  </nano:shape>
15
+ <nano:shape id="RecordPlanTask" name="Record plan — raw planner task">
16
+ <nano:extend name="id" type="string" optional="true" />
17
+ <nano:extend name="title" type="string" optional="true" />
18
+ <nano:extend name="prompt" type="string" optional="true" />
19
+ <nano:extend name="dependsOn" type="string" list="true" optional="true" />
20
+ </nano:shape>
21
+ <nano:shape id="RecordPlanIn" name="Record plan — input">
22
+ <nano:extend name="planKey" type="string" />
23
+ <nano:reference name="tasks" ref="RecordPlanTask" list="true" />
24
+ <nano:extend name="note" type="string" optional="true" />
25
+ </nano:shape>
26
+ <nano:shape id="RecordWaveTaskRef" name="Record wave — task ref">
27
+ <nano:extend name="id" type="string" optional="true" />
28
+ </nano:shape>
29
+ <nano:shape id="RecordWaveDelta" name="Record wave — task delta">
30
+ <nano:extend name="contractChange" type="string" optional="true" />
31
+ <nano:extend name="newlyTouches" type="string" list="true" optional="true" />
32
+ <nano:extend name="affectsTasks" type="string" list="true" optional="true" />
33
+ <nano:extend name="constraint" type="string" optional="true" />
34
+ </nano:shape>
35
+ <nano:shape id="RecordWaveResult" name="Record wave — agent result">
36
+ <nano:extend name="status" type="string" optional="true" />
37
+ <nano:extend name="summary" type="string" optional="true" />
38
+ <nano:extend name="pr" type="string" optional="true" />
39
+ <nano:extend name="delta" type="RecordWaveDelta" optional="true" />
40
+ </nano:shape>
41
+ <nano:shape id="RecordWaveIn" name="Record wave — input">
42
+ <nano:extend name="planKey" type="string" />
43
+ <nano:extend name="currentWave" type="integer" />
44
+ <nano:extend name="waveCount" type="integer" />
45
+ <nano:reference name="waveTasks" ref="RecordWaveTaskRef" list="true" />
46
+ <nano:reference name="waveResults" ref="RecordWaveResult" list="true" />
47
+ </nano:shape>
48
+ <nano:shape id="RecordTrialMergeHead" name="Record trial merge — open head">
49
+ <nano:extend name="repo" type="string" />
50
+ <nano:extend name="prNumber" type="integer" />
51
+ <nano:extend name="headRef" type="string" optional="true" />
52
+ <nano:extend name="headSha" type="string" optional="true" />
53
+ </nano:shape>
54
+ <nano:shape id="RecordTrialMergeIn" name="Record trial merge — input">
55
+ <nano:extend name="planKey" type="string" />
56
+ <nano:extend name="currentWave" type="integer" optional="true" />
57
+ <nano:extend name="trialMergeWave" type="integer" optional="true" />
58
+ <nano:reference name="waveOpenHeads" ref="RecordTrialMergeHead" list="true" />
59
+ <nano:extend name="result" type="string" optional="true" />
60
+ <nano:extend name="conflicts" type="string" list="true" optional="true" />
61
+ <nano:extend name="failing" type="string" list="true" optional="true" />
62
+ <nano:extend name="summary" type="string" optional="true" />
63
+ </nano:shape>
15
64
  <nano:shape id="RecordPlanReviewIn" name="Record plan review — input">
16
65
  <nano:extend name="planKey" type="string" />
17
66
  <nano:extend name="approved" type="boolean" optional="true" />
@@ -68,6 +117,9 @@
68
117
  <bpmn:serviceTask id="record-plan" name="Record plan &#38; levelize">
69
118
  <bpmn:extensionElements>
70
119
  <zeebe:taskDefinition type="pr.record-plan" />
120
+ <zeebe:properties>
121
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordPlanIn" />
122
+ </zeebe:properties>
71
123
  </bpmn:extensionElements>
72
124
  <bpmn:incoming>f_toRecordPlan</bpmn:incoming>
73
125
  <bpmn:outgoing>f_toReviewPlan</bpmn:outgoing>
@@ -215,6 +267,9 @@
215
267
  <bpmn:serviceTask id="record-wave" name="Record wave &#38; hand off">
216
268
  <bpmn:extensionElements>
217
269
  <zeebe:taskDefinition type="pr.record-wave" />
270
+ <zeebe:properties>
271
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordWaveIn" />
272
+ </zeebe:properties>
218
273
  </bpmn:extensionElements>
219
274
  <bpmn:incoming>f_toRecordWave</bpmn:incoming>
220
275
  <bpmn:outgoing>f_toTrialNeeded</bpmn:outgoing>
@@ -246,6 +301,9 @@
246
301
  <bpmn:serviceTask id="record-trial-merge" name="Record trial merge">
247
302
  <bpmn:extensionElements>
248
303
  <zeebe:taskDefinition type="pr.record-trial-merge" />
304
+ <zeebe:properties>
305
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordTrialMergeIn" />
306
+ </zeebe:properties>
249
307
  </bpmn:extensionElements>
250
308
  <bpmn:incoming>f_toRecordTrialMerge</bpmn:incoming>
251
309
  <bpmn:outgoing>f_toGwTrial</bpmn:outgoing>
@@ -267,7 +267,7 @@ test("issue #205: overview is the landing page and first nav item", async () =>
267
267
  "merging",
268
268
  ],
269
269
  plans: ["planning", "dispatched"],
270
- feature_runs: ["running", "awaiting_operator"],
270
+ feature_runs: ["running", "escalated", "awaiting_operator"],
271
271
  };
272
272
  const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
273
273
  for (const [table, statuses] of Object.entries(expected)) {
@@ -54,6 +54,12 @@ 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,
57
63
  updated_at: ts,
58
64
  });
59
65
  app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
@@ -0,0 +1,74 @@
1
+ // Unit coverage for pr.record-feature-escalation — a feature run parked on the native
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 persist the agent's
4
+ // `question` so the nwf UI can surface it (the poller can't read task-local vars, so this is the
5
+ // question's source of truth). It clears the completable-task pointer to NULL (the task does not
6
+ // exist yet, so any non-null value is stale); the poller fills the real key once it is observable.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import { noopLog } from "../../test/log.ts";
10
+ import handler from "./worker.ts";
11
+
12
+ // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-blocked-ack.worker.test
13
+ function fakeApp(rows: Record<string, unknown>[]): any {
14
+ const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
15
+ return {
16
+ data: {
17
+ table(name: string, key: string) {
18
+ const store = (stores[name] ??= []);
19
+ return {
20
+ // biome-ignore lint/suspicious/noExplicitAny: test double
21
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
22
+ // biome-ignore lint/suspicious/noExplicitAny: test double
23
+ find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
24
+ // biome-ignore lint/suspicious/noExplicitAny: test double
25
+ insert: (row: any) => {
26
+ store.push(row);
27
+ return Promise.resolve(store.length);
28
+ },
29
+ // biome-ignore lint/suspicious/noExplicitAny: test double
30
+ update: (k: any, patch: any) => {
31
+ const row = store.find((r) => r[key] === k);
32
+ if (row) Object.assign(row, patch);
33
+ return Promise.resolve(row);
34
+ },
35
+ };
36
+ },
37
+ },
38
+ log: noopLog(),
39
+ };
40
+ }
41
+
42
+ test("record-feature-escalation: flips the run to escalated and persists the question", async () => {
43
+ const rows = [{ feature_key: "owner/repo#7", status: "running", escalation_question: null, escalation_user_task_key: null }];
44
+ const app = fakeApp(rows);
45
+ const out = await handler(
46
+ { variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
47
+ app,
48
+ );
49
+ assertEquals(out, {});
50
+ assertEquals(rows[0].status, "escalated");
51
+ assertEquals(rows[0].escalation_question, "Which API should I use?");
52
+ // The user task does not exist yet — the pointer is cleared to NULL here (poller fills the real key).
53
+ assertEquals(rows[0].escalation_user_task_key, null);
54
+ });
55
+
56
+ test("record-feature-escalation: clears a stale completable-task pointer at escalation entry", async () => {
57
+ // The task does not exist yet here, so a non-null key can only be stale (a prior escalation's key
58
+ // left behind, a manual DB repair, etc). Leaving it would bind the pages' answer/abandon affordance
59
+ // to the wrong task until the poller overwrites it; clear it so the row reads "key unknown here".
60
+ const rows = [{ feature_key: "owner/repo#9", status: "running", escalation_question: null, escalation_user_task_key: "stale-ut" }];
61
+ const app = fakeApp(rows);
62
+ await handler({ variables: { featureKey: "owner/repo#9", question: "Q?" } } as never, app);
63
+ assertEquals(rows[0].status, "escalated");
64
+ assertEquals(rows[0].escalation_question, "Q?");
65
+ assertEquals(rows[0].escalation_user_task_key, null);
66
+ });
67
+
68
+ test("record-feature-escalation: a blank/absent question is persisted as NULL (badge/affordance stay off)", async () => {
69
+ const rows = [{ feature_key: "owner/repo#8", status: "running", escalation_question: null }];
70
+ const app = fakeApp(rows);
71
+ await handler({ variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
72
+ assertEquals(rows[0].status, "escalated");
73
+ assertEquals(rows[0].escalation_question, null);
74
+ });
@@ -0,0 +1,42 @@
1
+ // pr.record-feature-escalation — a feature run has parked on the native `feature-escalation`
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 persists the
4
+ // escalation onto the `feature_runs` row so the nwf UI can surface it (issue #210):
5
+ // • flips `status` to the non-terminal `escalated` so status-based views and counts flag it, and
6
+ // • denormalises the agent's `question` for the Escalation column + the answer affordance.
7
+ //
8
+ // It deliberately does NOT record a REAL completable `userTaskKey` — the task does not exist yet at
9
+ // this point — and clears any stale pointer so the row reads "key unknown until observed".
10
+ // `pollFeatureEscalations` (app/service.ts) fills that pointer in once the user task is
11
+ // observable via `searchUserTasks`, which is also the reason the question is captured HERE rather
12
+ // than by the poller: the WASM testkit engine does not surface a user task's ioMapping-mapped local
13
+ // variables through `searchUserTasks`, so the process variable must be persisted while it is still
14
+ // in scope on the job.
15
+ import type { AppJobHandler } from "@nanobpm/urban";
16
+ import { featureRuns } from "../../app/feature.ts";
17
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
18
+
19
+ // Input typed off the model data envelope (`RecordFeatureEscalationIn` in feature.bpmn) — ADR 0040.
20
+ type In = WorkerInputs["pr.record-feature-escalation"];
21
+
22
+ const nonBlank = (v: unknown): string | null =>
23
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
24
+
25
+ const handler: AppJobHandler<In> = async (job, app) => {
26
+ const featureKey = job.variables.featureKey;
27
+ const question = nonBlank(job.variables.question);
28
+ await featureRuns(app.data).update(featureKey, {
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
+ updated_at: new Date().toISOString(),
37
+ });
38
+ app.log.info("record-feature-escalation", { featureKey, hasQuestion: question !== null });
39
+ return {};
40
+ };
41
+
42
+ export default handler;
@@ -18,21 +18,12 @@
18
18
  import type { AppJobHandler } from "@nanobpm/urban";
19
19
  import { planTaskDeps, planTasks } from "../../app/plan.ts";
20
20
  import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
21
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
21
22
 
22
- interface RawTask {
23
- id?: unknown;
24
- title?: unknown;
25
- prompt?: unknown;
26
- dependsOn?: unknown;
27
- }
28
- // NOTE (issue #211): this worker reads an ARRAY input (`tasks: RawTask[]`), which the scalar-only
29
- // `nano:dataEnvelope` cannot express, so it keeps a hand-written `interface In` pending a decision
30
- // on extending the envelope schema to arrays. See the PR body's "array inputs" escalation.
31
- interface In extends Record<string, unknown> {
32
- planKey: string;
33
- tasks?: RawTask[];
34
- note?: string;
35
- }
23
+ // Input typed off the model data envelope (`RecordPlanIn` in plan-fanout.bpmn) — ADR 0040. The
24
+ // `tasks[]` array field is a `nano:reference` to the `RecordPlanTask` shape (`list="true"`), so it
25
+ // resolves to `RecordPlanTask[]` with no hand-written interface.
26
+ type In = WorkerInputs["pr.record-plan"];
36
27
  interface NormalTask {
37
28
  id: string;
38
29
  title: string;
@@ -8,21 +8,13 @@ import {
8
8
  trialMergeDecision,
9
9
  trialMergeTaskId,
10
10
  } from "../../app/trialMerge.ts";
11
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
11
12
 
12
- // NOTE (issue #211): this worker reads ARRAY inputs (`waveOpenHeads`, `conflicts`, `failing`), which
13
- // the scalar-only `nano:dataEnvelope` cannot express, so it keeps a hand-written `interface In`
14
- // pending a decision on extending the envelope schema to arrays. See the PR body's "array inputs"
15
- // escalation.
16
- interface In extends Record<string, unknown> {
17
- planKey: string;
18
- currentWave?: unknown;
19
- trialMergeWave?: unknown;
20
- waveOpenHeads?: unknown;
21
- result?: unknown;
22
- conflicts?: unknown;
23
- failing?: unknown;
24
- summary?: unknown;
25
- }
13
+ // Input typed off the model data envelope (`RecordTrialMergeIn` in plan-fanout.bpmn) — ADR 0040.
14
+ // The `waveOpenHeads[]` array field is a `nano:reference` to the `RecordTrialMergeHead` shape, and
15
+ // `conflicts[]` / `failing[]` are scalar `list="true"` extends, so all three resolve from the model
16
+ // with no hand-written interface.
17
+ type In = WorkerInputs["pr.record-trial-merge"];
26
18
  interface Out extends Record<string, unknown> {
27
19
  trialMergeRed: boolean;
28
20
  question?: string;
@@ -28,26 +28,13 @@ import {
28
28
  import { parsePr, submitPr } from "../../app/service.ts";
29
29
  import { parseTaskDelta, readTaskDeltas, recordTaskDelta } from "../../app/taskDelta.ts";
30
30
  import { shouldRunTrialMerge, type TrialMergeHead } from "../../app/trialMerge.ts";
31
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
31
32
 
32
- interface Result {
33
- status?: unknown;
34
- summary?: unknown;
35
- pr?: unknown;
36
- delta?: unknown;
37
- }
38
- interface WaveTaskIn {
39
- id?: unknown;
40
- }
41
- // NOTE (issue #211): this worker reads ARRAY inputs (`waveTasks[]`, `waveResults[]`), which the
42
- // scalar-only `nano:dataEnvelope` cannot express, so it keeps a hand-written `interface In` pending
43
- // a decision on extending the envelope schema to arrays. See the PR body's "array inputs" escalation.
44
- interface In extends Record<string, unknown> {
45
- planKey: string;
46
- currentWave: number;
47
- waveCount: number;
48
- waveTasks?: WaveTaskIn[];
49
- waveResults?: Result[];
50
- }
33
+ // Input typed off the model data envelope (`RecordWaveIn` in plan-fanout.bpmn) — ADR 0040. The
34
+ // `waveTasks[]` / `waveResults[]` array fields are `nano:reference`s to the `RecordWaveTaskRef` /
35
+ // `RecordWaveResult` shapes (`list="true"`), so they resolve to `<Shape>[]` with no hand-written
36
+ // interface.
37
+ type In = WorkerInputs["pr.record-wave"];
51
38
  interface Out extends Record<string, unknown> {
52
39
  currentWave: number;
53
40
  hasMoreWaves: boolean;