@nanobpm/nano-workforce 0.95.0 → 0.96.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 +7 -0
- package/app/agentic/vocab/crew-vocab.test.ts +12 -0
- package/app/agentic/vocab/crew-vocab.ts +18 -0
- package/app/agentic/vocab/demand-report.test.ts +34 -0
- package/app/agentic/vocab/demand-report.ts +21 -1
- package/app/agentic/vocab/job-types.test.ts +107 -0
- package/app/agentic/vocab/job-types.ts +70 -0
- package/app/feature.ts +43 -0
- package/app/interEpicRegression.test.ts +516 -0
- package/app/plan.ts +15 -0
- package/app/pollUserTasks.test.ts +31 -0
- package/app/service.ts +54 -3
- package/app/userTasks.test.ts +14 -0
- package/app/userTasks.ts +15 -1
- package/app/waitGate.test.ts +176 -0
- package/app/waitGate.ts +199 -0
- package/app/waitGatePoll.test.ts +143 -0
- package/app/waitGateVisibility.test.ts +55 -0
- package/db/migrations/047_plan_wait_gate.sql +34 -0
- package/db/migrations/048_feature_escalations.sql +51 -0
- package/e2e/inter-epic-dependency.e2e.ts +227 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +28 -0
- package/pages/epic.page.json +1 -0
- package/pages/tasks.page.json +133 -3
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/workers/record-feature-escalation/worker.test.ts +27 -3
- package/workers/record-feature-escalation/worker.ts +7 -1
- package/workers/select-wave/worker.test.ts +37 -0
- package/workers/select-wave/worker.ts +11 -0
package/pages/tasks.page.json
CHANGED
|
@@ -110,6 +110,22 @@
|
|
|
110
110
|
}
|
|
111
111
|
],
|
|
112
112
|
"rowKey": "user_task_key",
|
|
113
|
+
"rowActions": [
|
|
114
|
+
{
|
|
115
|
+
"label": "Abandon",
|
|
116
|
+
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
117
|
+
"showWhenField": "user_task_key",
|
|
118
|
+
"action": {
|
|
119
|
+
"path": "/app/api/actions/complete-user-task",
|
|
120
|
+
"body": {
|
|
121
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
122
|
+
"variables": {
|
|
123
|
+
"resolution": "abandon"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
],
|
|
113
129
|
"detail": {
|
|
114
130
|
"linkField": "subject_url",
|
|
115
131
|
"fields": [
|
|
@@ -117,7 +133,26 @@
|
|
|
117
133
|
"field": "question",
|
|
118
134
|
"label": "Escalation question"
|
|
119
135
|
}
|
|
120
|
-
]
|
|
136
|
+
],
|
|
137
|
+
"form": {
|
|
138
|
+
"showWhenField": "user_task_key",
|
|
139
|
+
"title": "Answer escalation",
|
|
140
|
+
"promptField": "question",
|
|
141
|
+
"inputKey": "answer",
|
|
142
|
+
"inputLabel": "Guidance for the implementation agent",
|
|
143
|
+
"submitLabel": "Answer & re-dispatch",
|
|
144
|
+
"action": {
|
|
145
|
+
"path": "/app/api/actions/complete-user-task",
|
|
146
|
+
"successLabel": "Answered — the agent will resume",
|
|
147
|
+
"body": {
|
|
148
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
149
|
+
"variables": {
|
|
150
|
+
"resolution": "answer",
|
|
151
|
+
"answer": "{{form.answer}}"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
121
156
|
},
|
|
122
157
|
"refreshMs": 5000
|
|
123
158
|
}
|
|
@@ -176,6 +211,22 @@
|
|
|
176
211
|
}
|
|
177
212
|
],
|
|
178
213
|
"rowKey": "user_task_key",
|
|
214
|
+
"rowActions": [
|
|
215
|
+
{
|
|
216
|
+
"label": "Proceed",
|
|
217
|
+
"confirm": "Proceed with the current plan as-is? It is dispatched unchanged.",
|
|
218
|
+
"showWhenField": "user_task_key",
|
|
219
|
+
"action": {
|
|
220
|
+
"path": "/app/api/actions/complete-user-task",
|
|
221
|
+
"body": {
|
|
222
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
223
|
+
"variables": {
|
|
224
|
+
"directive": "proceed"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
],
|
|
179
230
|
"detail": {
|
|
180
231
|
"linkField": "subject_url",
|
|
181
232
|
"fields": [
|
|
@@ -183,7 +234,26 @@
|
|
|
183
234
|
"field": "question",
|
|
184
235
|
"label": "Review findings"
|
|
185
236
|
}
|
|
186
|
-
]
|
|
237
|
+
],
|
|
238
|
+
"form": {
|
|
239
|
+
"showWhenField": "user_task_key",
|
|
240
|
+
"title": "Revise — send the plan back to the planner",
|
|
241
|
+
"promptField": "question",
|
|
242
|
+
"inputKey": "notes",
|
|
243
|
+
"inputLabel": "Revision guidance for the planner",
|
|
244
|
+
"submitLabel": "Revise & re-plan",
|
|
245
|
+
"action": {
|
|
246
|
+
"path": "/app/api/actions/complete-user-task",
|
|
247
|
+
"successLabel": "Sent back — the planner will revise",
|
|
248
|
+
"body": {
|
|
249
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
250
|
+
"variables": {
|
|
251
|
+
"directive": "revise",
|
|
252
|
+
"notes": "{{form.notes}}"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
187
257
|
},
|
|
188
258
|
"refreshMs": 5000
|
|
189
259
|
}
|
|
@@ -242,6 +312,50 @@
|
|
|
242
312
|
}
|
|
243
313
|
],
|
|
244
314
|
"rowKey": "user_task_key",
|
|
315
|
+
"rowActions": [
|
|
316
|
+
{
|
|
317
|
+
"label": "Proceed",
|
|
318
|
+
"confirm": "Accept the red trial merge and continue?",
|
|
319
|
+
"showWhenField": "user_task_key",
|
|
320
|
+
"action": {
|
|
321
|
+
"path": "/app/api/actions/complete-user-task",
|
|
322
|
+
"body": {
|
|
323
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
324
|
+
"variables": {
|
|
325
|
+
"action": "proceed"
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"label": "Rebase",
|
|
332
|
+
"confirm": "Re-run the trial merge?",
|
|
333
|
+
"showWhenField": "user_task_key",
|
|
334
|
+
"action": {
|
|
335
|
+
"path": "/app/api/actions/complete-user-task",
|
|
336
|
+
"body": {
|
|
337
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
338
|
+
"variables": {
|
|
339
|
+
"action": "rebase"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"label": "Abandon",
|
|
346
|
+
"confirm": "Stop and finalize the plan?",
|
|
347
|
+
"showWhenField": "user_task_key",
|
|
348
|
+
"action": {
|
|
349
|
+
"path": "/app/api/actions/complete-user-task",
|
|
350
|
+
"body": {
|
|
351
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
352
|
+
"variables": {
|
|
353
|
+
"action": "abandon"
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
],
|
|
245
359
|
"detail": {
|
|
246
360
|
"linkField": "subject_url",
|
|
247
361
|
"fields": [
|
|
@@ -400,7 +514,23 @@
|
|
|
400
514
|
"field": "question",
|
|
401
515
|
"label": "Disposition"
|
|
402
516
|
}
|
|
403
|
-
]
|
|
517
|
+
],
|
|
518
|
+
"form": {
|
|
519
|
+
"showWhenField": "user_task_key",
|
|
520
|
+
"title": "Acknowledge blocked run",
|
|
521
|
+
"promptField": "question",
|
|
522
|
+
"inputKey": "note",
|
|
523
|
+
"inputLabel": "Disposition note (what you did / why)",
|
|
524
|
+
"submitLabel": "Acknowledge & close",
|
|
525
|
+
"action": {
|
|
526
|
+
"path": "/app/api/actions/acknowledge-blocked",
|
|
527
|
+
"successLabel": "Acknowledged — the run will close",
|
|
528
|
+
"body": {
|
|
529
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
530
|
+
"note": "{{form.note}}"
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
404
534
|
},
|
|
405
535
|
"refreshMs": 5000
|
|
406
536
|
}
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
<nano:shape id="SelectWaveIn" name="Select wave — input">
|
|
71
71
|
<nano:extend name="planKey" type="string" />
|
|
72
72
|
<nano:extend name="currentWave" type="integer" />
|
|
73
|
+
<nano:extend name="resolvedArtifacts" type="string" list="true" optional="true" />
|
|
73
74
|
</nano:shape>
|
|
74
75
|
<nano:shape id="ResolveTrialAttentionIn" name="Clear trial attention — input">
|
|
75
76
|
<nano:extend name="planKey" type="string" />
|
|
@@ -13,6 +13,7 @@ import handler from "./worker.ts";
|
|
|
13
13
|
function fakeApp(rows: Record<string, unknown>[]): any {
|
|
14
14
|
const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
|
|
15
15
|
return {
|
|
16
|
+
stores,
|
|
16
17
|
data: {
|
|
17
18
|
table(name: string, key: string) {
|
|
18
19
|
const store = (stores[name] ??= []);
|
|
@@ -22,6 +23,9 @@ function fakeApp(rows: Record<string, unknown>[]): any {
|
|
|
22
23
|
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
23
24
|
find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
24
25
|
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
26
|
+
findOne: (q: any) =>
|
|
27
|
+
Promise.resolve(store.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
25
29
|
insert: (row: any) => {
|
|
26
30
|
store.push(row);
|
|
27
31
|
return Promise.resolve(store.length);
|
|
@@ -43,7 +47,7 @@ test("record-feature-escalation: flips the run to escalated and persists the que
|
|
|
43
47
|
const rows = [{ feature_key: "owner/repo#7", status: "running", escalation_question: null, escalation_user_task_key: null }];
|
|
44
48
|
const app = fakeApp(rows);
|
|
45
49
|
const out = await handler(
|
|
46
|
-
{ variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
|
|
50
|
+
{ jobKey: "job-1", variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
|
|
47
51
|
app,
|
|
48
52
|
);
|
|
49
53
|
assertEquals(out, {});
|
|
@@ -51,6 +55,26 @@ test("record-feature-escalation: flips the run to escalated and persists the que
|
|
|
51
55
|
assertEquals(rows[0].escalation_question, "Which API should I use?");
|
|
52
56
|
// The user task does not exist yet — the pointer is cleared to NULL here (poller fills the real key).
|
|
53
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.
|
|
60
|
+
assertEquals(app.stores.feature_escalations.length, 1);
|
|
61
|
+
assertEquals(app.stores.feature_escalations[0].feature_key, "owner/repo#7");
|
|
62
|
+
assertEquals(app.stores.feature_escalations[0].question, "Which API should I use?");
|
|
63
|
+
assertEquals(app.stores.feature_escalations[0].job_key, "job-1");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("record-feature-escalation: a retried job (same jobKey) reuses its audit row, never duplicating", async () => {
|
|
67
|
+
// `record-feature-escalation` is at-least-once: a job that crashed/timed out AFTER the insert but
|
|
68
|
+
// before job completion re-runs with the SAME jobKey. The `job_key` idempotency guard must reuse the
|
|
69
|
+
// existing `feature_escalations` row rather than append a duplicate (which would bloat the append-only
|
|
70
|
+
// 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", escalation_question: null, escalation_user_task_key: null }];
|
|
72
|
+
const app = fakeApp(rows);
|
|
73
|
+
const job = { jobKey: "job-retry", variables: { featureKey: "owner/repo#7", question: "Which API?" } } as never;
|
|
74
|
+
await handler(job, app);
|
|
75
|
+
await handler(job, app); // retry with the same jobKey
|
|
76
|
+
assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
|
|
77
|
+
assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
|
|
54
78
|
});
|
|
55
79
|
|
|
56
80
|
test("record-feature-escalation: clears a stale completable-task pointer at escalation entry", async () => {
|
|
@@ -59,7 +83,7 @@ test("record-feature-escalation: clears a stale completable-task pointer at esca
|
|
|
59
83
|
// to the wrong task until the poller overwrites it; clear it so the row reads "key unknown here".
|
|
60
84
|
const rows = [{ feature_key: "owner/repo#9", status: "running", escalation_question: null, escalation_user_task_key: "stale-ut" }];
|
|
61
85
|
const app = fakeApp(rows);
|
|
62
|
-
await handler({ variables: { featureKey: "owner/repo#9", question: "Q?" } } as never, app);
|
|
86
|
+
await handler({ jobKey: "job-9", variables: { featureKey: "owner/repo#9", question: "Q?" } } as never, app);
|
|
63
87
|
assertEquals(rows[0].status, "escalated");
|
|
64
88
|
assertEquals(rows[0].escalation_question, "Q?");
|
|
65
89
|
assertEquals(rows[0].escalation_user_task_key, null);
|
|
@@ -68,7 +92,7 @@ test("record-feature-escalation: clears a stale completable-task pointer at esca
|
|
|
68
92
|
test("record-feature-escalation: a blank/absent question is persisted as NULL (badge/affordance stay off)", async () => {
|
|
69
93
|
const rows = [{ feature_key: "owner/repo#8", status: "running", escalation_question: null }];
|
|
70
94
|
const app = fakeApp(rows);
|
|
71
|
-
await handler({ variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
|
|
95
|
+
await handler({ jobKey: "job-8", variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
|
|
72
96
|
assertEquals(rows[0].status, "escalated");
|
|
73
97
|
assertEquals(rows[0].escalation_question, null);
|
|
74
98
|
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// variables through `searchUserTasks`, so the process variable must be persisted while it is still
|
|
14
14
|
// in scope on the job.
|
|
15
15
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
16
|
-
import { featureRuns } from "../../app/feature.ts";
|
|
16
|
+
import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
|
|
17
17
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
18
18
|
|
|
19
19
|
// Input typed off the model data envelope (`RecordFeatureEscalationIn` in feature.bpmn) — ADR 0040.
|
|
@@ -35,6 +35,12 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
35
35
|
escalation_user_task_key: null,
|
|
36
36
|
updated_at: new Date().toISOString(),
|
|
37
37
|
});
|
|
38
|
+
// Append the question to the canonical `feature_escalations` audit log (issue #305) so the poller
|
|
39
|
+
// can source it from a SURVIVING table once the denormalised `feature_runs.escalation_question`
|
|
40
|
+
// column is dropped in the contract phase — the feature analogue of `record-plan-review` writing
|
|
41
|
+
// `plan_reviews`. Dual-write for now (the column above still feeds the legacy page reads); the log
|
|
42
|
+
// is authoritative for `pollUserTasks`.
|
|
43
|
+
await recordFeatureEscalation(app.data, { featureKey, question, jobKey: job.jobKey });
|
|
38
44
|
app.log.info("record-feature-escalation", { featureKey, hasQuestion: question !== null });
|
|
39
45
|
return {};
|
|
40
46
|
};
|
|
@@ -81,6 +81,43 @@ test("select-wave projects the active wave onto plans.current_wave", async () =>
|
|
|
81
81
|
assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test("select-wave captures the preflight's resolvedArtifacts onto plans.bound_artifacts (#292 S4)", async () => {
|
|
85
|
+
// A dependent epic reaching select-wave proves its S3 capability preflight went green; the bound
|
|
86
|
+
// pkg@version(s) ride `resolvedArtifacts` and must be captured for the S4 operator projection.
|
|
87
|
+
const rows: Row[] = [
|
|
88
|
+
{ id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 0 },
|
|
89
|
+
];
|
|
90
|
+
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63" }];
|
|
91
|
+
await handler(
|
|
92
|
+
{
|
|
93
|
+
variables: {
|
|
94
|
+
planKey: "owner/repo#63",
|
|
95
|
+
currentWave: 0,
|
|
96
|
+
// MI output collection: one entry per producer probe; null/empty/whitespace-only entries
|
|
97
|
+
// are filtered out (a probe may publish without a bind).
|
|
98
|
+
resolvedArtifacts: ["@scope/api@1.4.0", null, "", " ", "@scope/core@2.0.0"],
|
|
99
|
+
},
|
|
100
|
+
elementId: "select-wave",
|
|
101
|
+
} as any,
|
|
102
|
+
fakeApp(rows, [], plans),
|
|
103
|
+
);
|
|
104
|
+
assertEquals(plans[0].bound_artifacts, JSON.stringify(["@scope/api@1.4.0", "@scope/core@2.0.0"]));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("select-wave leaves bound_artifacts untouched for a root epic (no resolvedArtifacts)", async () => {
|
|
108
|
+
// A root fans out with no preflight -> resolvedArtifacts is null; the stamp must not write an
|
|
109
|
+
// empty/garbage bound_artifacts (the S4 projection reads NULL as "no bound version").
|
|
110
|
+
const rows: Row[] = [
|
|
111
|
+
{ id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 0 },
|
|
112
|
+
];
|
|
113
|
+
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63" }];
|
|
114
|
+
await handler(
|
|
115
|
+
{ variables: { planKey: "owner/repo#63", currentWave: 0 }, elementId: "select-wave" } as any,
|
|
116
|
+
fakeApp(rows, [], plans),
|
|
117
|
+
);
|
|
118
|
+
assertEquals(plans[0].bound_artifacts, undefined);
|
|
119
|
+
});
|
|
120
|
+
|
|
84
121
|
test("select-wave nulls all three progress fields when there are no levelized rows", async () => {
|
|
85
122
|
// No plan_tasks rows => waveCount 0. current_wave must be NULL too (not a stray index against a
|
|
86
123
|
// NULL wave_count/wave_label), matching the documented "NULL until dispatched with tasks".
|
|
@@ -61,6 +61,16 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
61
61
|
const epicPhase = waveCount > 0
|
|
62
62
|
? deriveEpicPhase(job.elementId, { current: currentWave, total: waveCount })
|
|
63
63
|
: null;
|
|
64
|
+
// Inter-epic gate projection (#292 slice S4): reaching select-wave proves this epic's leading
|
|
65
|
+
// capability PREFLIGHT (S3) already went GREEN, so capture the `resolvedArtifacts` the preflight
|
|
66
|
+
// bound — the exact `pkg@version`s first carrying each producer's awaited capability — onto the
|
|
67
|
+
// plan row. `pollWaitGate` (app/service.ts) reads this to show a satisfied dependent's bound
|
|
68
|
+
// version. NULL for a root (no preflight → no resolved artifacts); display-only, control flow is
|
|
69
|
+
// unchanged. Filter the MI output to non-blank strings (a probe may publish without a bind),
|
|
70
|
+
// mirroring the `resolvedArtifacts[item != null]` filter the implement-task prompt uses.
|
|
71
|
+
const boundArtifacts = Array.isArray(job.variables.resolvedArtifacts)
|
|
72
|
+
? job.variables.resolvedArtifacts.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
|
73
|
+
: [];
|
|
64
74
|
try {
|
|
65
75
|
await plans(app.data).update(planKey, {
|
|
66
76
|
// Keep the three progress fields consistent: with no levelized rows (waveCount 0) there is
|
|
@@ -69,6 +79,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
69
79
|
wave_count: waveCount > 0 ? waveCount : null,
|
|
70
80
|
wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
|
|
71
81
|
...(epicPhase ? { epic_phase: epicPhase } : {}),
|
|
82
|
+
...(boundArtifacts.length > 0 ? { bound_artifacts: JSON.stringify(boundArtifacts) } : {}),
|
|
72
83
|
updated_at: ts,
|
|
73
84
|
});
|
|
74
85
|
} catch (err) {
|