@nanobpm/nano-workforce 0.97.1 → 0.98.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/capabilityNeed.test.ts +156 -0
- package/app/capabilityNeed.ts +200 -0
- package/app/capsWait.test.ts +39 -0
- package/app/capsWait.ts +33 -0
- package/app/contracts.ts +8 -0
- package/app/plan.ts +63 -0
- package/app/service.test.ts +375 -1
- package/app/service.ts +239 -0
- package/app/waitGateVisibility.test.ts +5 -2
- package/db/migrations/049_plan_task_needs.sql +30 -0
- package/db/migrations/050_capability_gates.sql +40 -0
- package/e2e/plan-fanout.e2e.ts +145 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +347 -194
- package/resources/prompts/plan.md +29 -1
- package/workers/caps-prepare/worker.test.ts +62 -0
- package/workers/caps-prepare/worker.ts +38 -0
- package/workers/record-plan/worker.test.ts +33 -1
- package/workers/record-plan/worker.ts +24 -1
- package/workers/select-wave/worker.test.ts +34 -2
- package/workers/select-wave/worker.ts +26 -2
|
@@ -204,7 +204,14 @@ Write a JSON object of **result variables** to the file named by the
|
|
|
204
204
|
"id": "short-stable-slug",
|
|
205
205
|
"title": "One-line summary of the slice",
|
|
206
206
|
"prompt": "Full, self-contained instructions for the implementing agent: what to build, where, acceptance criteria.",
|
|
207
|
-
"dependsOn": ["id-of-a-task-this-one-builds-on"]
|
|
207
|
+
"dependsOn": ["id-of-a-task-this-one-builds-on"],
|
|
208
|
+
"needs": [
|
|
209
|
+
{
|
|
210
|
+
"capabilityRef": "owner/repo#274",
|
|
211
|
+
"package": "@nanobpm/urban",
|
|
212
|
+
"verifyCommand": "optional shell probe, exit 0 == capability present"
|
|
213
|
+
}
|
|
214
|
+
]
|
|
208
215
|
}
|
|
209
216
|
]
|
|
210
217
|
}
|
|
@@ -223,6 +230,27 @@ Rules:
|
|
|
223
230
|
sub-issue tasks (Step 0), derive `dependsOn` from any `Depends-on: #N` /
|
|
224
231
|
`Blocked by #N` directive in the sub-issue body (mapping each prerequisite `#M`
|
|
225
232
|
to `issue-M`) — otherwise leave it empty.
|
|
233
|
+
- `needs` — an optional array of **cross-repo capability edges**. Use it (and only
|
|
234
|
+
it) when a slice consumes an upstream capability that ships as a **published
|
|
235
|
+
package version from another repo** — e.g. it needs a new `@nanobpm/urban` API
|
|
236
|
+
that lands in some future release. This is different from `dependsOn` (which
|
|
237
|
+
orders slices *within this epic*): `needs` blocks the task until the capability
|
|
238
|
+
is **published**, then late-binds and pins the exact `package@version` into the
|
|
239
|
+
agent's prompt automatically. Each entry:
|
|
240
|
+
- `capabilityRef` — the **stable upstream handle**, never a version: the
|
|
241
|
+
issue/PR that introduces the capability, written as **`owner/repo#NNN`** (the
|
|
242
|
+
`owner/repo` names the repo whose GitHub Releases the gate polls for publish
|
|
243
|
+
provenance). Only set this when the sub-issue text explicitly references such
|
|
244
|
+
an upstream capability (a `Needs:`/`Consumes:` line, a "requires
|
|
245
|
+
`@pkg` ≥ the release carrying #NNN" note). **Do not** invent one, and **do not**
|
|
246
|
+
put a version here.
|
|
247
|
+
- `package` — the npm package whose releases carry that provenance (e.g.
|
|
248
|
+
`@nanobpm/urban`).
|
|
249
|
+
- `verifyCommand` — OPTIONAL. A shell probe (exit 0 == capability present) used
|
|
250
|
+
only as a gated empirical fallback when provenance is inconclusive; omit it for
|
|
251
|
+
the common deterministic case.
|
|
252
|
+
Omit `needs` entirely for the overwhelmingly common case of a slice with no
|
|
253
|
+
cross-repo capability dependency.
|
|
226
254
|
- `prompt` — must stand alone: the implementing agent sees only this prompt plus
|
|
227
255
|
the issue reference, not your reasoning.
|
|
228
256
|
- Emit `{ "tasks": [] }` if the issue needs no code (and say why in a
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Unit coverage for `pr.caps-prepare` (issue #289). The worker derives the per-task capability
|
|
2
|
+
// barrier key + gate flag from the MI child's `task` (surfaced as `planKey`/`taskId`/`needs` job
|
|
3
|
+
// vars), which the model hoists into the child scope so the following gateway + catch can read them.
|
|
4
|
+
// The key MUST equal `capabilityTaskBarrierKey` (the host publishes `caps-resolved` on it), and the
|
|
5
|
+
// flag MUST reflect whether any VALID need survives the tolerant parse.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { capabilityTaskBarrierKey } from "../../app/capabilityNeed.ts";
|
|
9
|
+
import handler from "./worker.ts";
|
|
10
|
+
|
|
11
|
+
test("caps-prepare: a task with needs yields the barrier key and hasNeeds=true", async () => {
|
|
12
|
+
const out = await handler(
|
|
13
|
+
{
|
|
14
|
+
variables: {
|
|
15
|
+
planKey: "owner/repo#7",
|
|
16
|
+
taskId: "gap-a",
|
|
17
|
+
needs: [{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" }],
|
|
18
|
+
},
|
|
19
|
+
} as any,
|
|
20
|
+
{} as any,
|
|
21
|
+
);
|
|
22
|
+
assertEquals(out.capsGateKey, capabilityTaskBarrierKey("owner/repo#7", "gap-a"));
|
|
23
|
+
assertEquals(out.capsGateKey, "owner/repo#7:gap-a");
|
|
24
|
+
assertEquals(out.hasNeeds, true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("caps-prepare: a task with no needs yields hasNeeds=false (takes the no-needs shortcut)", async () => {
|
|
28
|
+
const out = await handler(
|
|
29
|
+
{ variables: { planKey: "owner/repo#7", taskId: "gap-b", needs: [] } } as any,
|
|
30
|
+
{} as any,
|
|
31
|
+
);
|
|
32
|
+
assertEquals(out.capsGateKey, "owner/repo#7:gap-b");
|
|
33
|
+
assertEquals(out.hasNeeds, false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("caps-prepare: a missing/undefined needs list is treated as no needs", async () => {
|
|
37
|
+
const out = await handler(
|
|
38
|
+
{ variables: { planKey: "owner/repo#7", taskId: "gap-c" } } as any,
|
|
39
|
+
{} as any,
|
|
40
|
+
);
|
|
41
|
+
assertEquals(out.hasNeeds, false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("caps-prepare: a malformed need is dropped — only a valid remainder gates the task", async () => {
|
|
45
|
+
const onlyBad = await handler(
|
|
46
|
+
{ variables: { planKey: "p", taskId: "t", needs: [{ capabilityRef: "", package: "" }] } } as any,
|
|
47
|
+
{} as any,
|
|
48
|
+
);
|
|
49
|
+
assertEquals(onlyBad.hasNeeds, false, "a wholly-malformed need must not gate the fan-out");
|
|
50
|
+
|
|
51
|
+
const mixed = await handler(
|
|
52
|
+
{
|
|
53
|
+
variables: {
|
|
54
|
+
planKey: "p",
|
|
55
|
+
taskId: "t",
|
|
56
|
+
needs: [{ capabilityRef: "", package: "" }, { capabilityRef: "o/r#1", package: "pkg" }],
|
|
57
|
+
},
|
|
58
|
+
} as any,
|
|
59
|
+
{} as any,
|
|
60
|
+
);
|
|
61
|
+
assertEquals(mixed.hasNeeds, true, "a valid need still gates even alongside a malformed one");
|
|
62
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// pr.caps-prepare — hoist a task's capability-barrier key + gate flag into the MI-child scope
|
|
2
|
+
// (issue #289).
|
|
3
|
+
//
|
|
4
|
+
// The `implement` multi-instance subprocess must decide, PER child, whether the task declared
|
|
5
|
+
// cross-repo capability `needs` and — if so — park at the `wait-caps-resolved` message barrier keyed
|
|
6
|
+
// on a per-task correlation key. The MI `inputElement` `task` is only readable inside a *service
|
|
7
|
+
// task's* ioMapping in the WASM testkit (an in-subprocess gateway / catch reads a stale/empty value
|
|
8
|
+
// — see AGENTS.md "Testing flows against the testkit"). So this worker runs first in the subprocess,
|
|
9
|
+
// derives the barrier key + the boolean from `task`, and RETURNS them; the model hoists both into the
|
|
10
|
+
// child scope with `zeebe:output source="=var" target="var"` so the following gateway (`w_gw_needs`)
|
|
11
|
+
// and the catch event's `correlationKey=capsGateKey` can read them reliably.
|
|
12
|
+
//
|
|
13
|
+
// PURE: no I/O. `capsGateKey` MUST equal `capabilityTaskBarrierKey(planKey, taskId)` — the same key
|
|
14
|
+
// the host reconciler (`pollCapabilityGatesImpl`) publishes `caps-resolved` on — or the barrier never
|
|
15
|
+
// releases.
|
|
16
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
17
|
+
import { capabilityTaskBarrierKey, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
|
|
18
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
19
|
+
|
|
20
|
+
type In = WorkerInputs["pr.caps-prepare"];
|
|
21
|
+
interface Out extends Record<string, unknown> {
|
|
22
|
+
capsGateKey: string;
|
|
23
|
+
hasNeeds: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const handler: AppJobHandler<In, Out> = async (job) => {
|
|
27
|
+
const planKey = String(job.variables.planKey ?? "");
|
|
28
|
+
const taskId = String(job.variables.taskId ?? "");
|
|
29
|
+
// Tolerant re-parse: `needs` arrives as the modelled CapabilityNeed[] but a malformed entry must
|
|
30
|
+
// never wedge the fan-out — drop it and gate only on the valid remainder (mirrors record-plan).
|
|
31
|
+
const needs = parseCapabilityNeeds(job.variables.needs);
|
|
32
|
+
return {
|
|
33
|
+
capsGateKey: capabilityTaskBarrierKey(planKey, taskId),
|
|
34
|
+
hasNeeds: needs.length > 0,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default handler;
|
|
@@ -17,6 +17,7 @@ interface Row extends Record<string, unknown> {
|
|
|
17
17
|
function fakeApp() {
|
|
18
18
|
const planTasks: Row[] = [];
|
|
19
19
|
const planTaskDeps: Row[] = [];
|
|
20
|
+
const planTaskNeeds: Row[] = [];
|
|
20
21
|
const plans: Row[] = [{ plan_key: "owner/repo#137" }];
|
|
21
22
|
let nextId = 1;
|
|
22
23
|
const app = {
|
|
@@ -27,6 +28,8 @@ function fakeApp() {
|
|
|
27
28
|
? planTasks
|
|
28
29
|
: name === "plan_task_deps"
|
|
29
30
|
? planTaskDeps
|
|
31
|
+
: name === "plan_task_needs"
|
|
32
|
+
? planTaskNeeds
|
|
30
33
|
: plans;
|
|
31
34
|
return {
|
|
32
35
|
get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
@@ -53,7 +56,7 @@ function fakeApp() {
|
|
|
53
56
|
},
|
|
54
57
|
},
|
|
55
58
|
} as any;
|
|
56
|
-
return { app, plans, planTasks };
|
|
59
|
+
return { app, plans, planTasks, planTaskNeeds };
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
test("record-plan initializes wave progress fields for a taskful plan", async () => {
|
|
@@ -88,3 +91,32 @@ test("record-plan leaves all three wave progress fields NULL for a taskless plan
|
|
|
88
91
|
assertEquals(plans[0].current_wave, null);
|
|
89
92
|
assertEquals(plans[0].wave_label, null);
|
|
90
93
|
});
|
|
94
|
+
|
|
95
|
+
test("record-plan persists per-task capability needs into plan_task_needs (issue #289)", async () => {
|
|
96
|
+
const { app, planTaskNeeds } = fakeApp();
|
|
97
|
+
await handler(
|
|
98
|
+
{
|
|
99
|
+
variables: {
|
|
100
|
+
planKey: "owner/repo#137",
|
|
101
|
+
tasks: [
|
|
102
|
+
{
|
|
103
|
+
id: "a",
|
|
104
|
+
prompt: "do A",
|
|
105
|
+
needs: [
|
|
106
|
+
{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban", verifyCommand: "v.sh" },
|
|
107
|
+
{ capabilityRef: " ", package: "dropme" }, // malformed -> dropped by parse
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{ id: "b", prompt: "do B", dependsOn: ["a"] }, // no needs
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
} as any,
|
|
114
|
+
app,
|
|
115
|
+
);
|
|
116
|
+
assertEquals(planTaskNeeds.length, 1);
|
|
117
|
+
assertEquals(planTaskNeeds[0].plan_key, "owner/repo#137");
|
|
118
|
+
assertEquals(planTaskNeeds[0].task_id, "a");
|
|
119
|
+
assertEquals(planTaskNeeds[0].capability_ref, "nanobpm/nano-ide#274");
|
|
120
|
+
assertEquals(planTaskNeeds[0].package, "@nanobpm/urban");
|
|
121
|
+
assertEquals(planTaskNeeds[0].verify_command, "v.sh");
|
|
122
|
+
});
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// warning; the ordering is lost but every task still runs. No `plan_task_deps` are recorded
|
|
17
17
|
// in that case (the edges were invalid).
|
|
18
18
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
19
|
+
import { type CapabilityNeed, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
|
|
19
20
|
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
20
|
-
import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
21
|
+
import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
|
|
21
22
|
import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
|
|
22
23
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
23
24
|
|
|
@@ -30,6 +31,10 @@ interface NormalTask {
|
|
|
30
31
|
title: string;
|
|
31
32
|
prompt: string;
|
|
32
33
|
dependsOn: string[];
|
|
34
|
+
// Cross-repo capability edges declared on the task (issue #289). Normalised + de-duped; empty when
|
|
35
|
+
// the task consumes no upstream capability. Levelized into `plan_task_needs`, NOT the wave DAG —
|
|
36
|
+
// a capability edge gates a task on an EXTERNAL publish, never on a sibling task's wave.
|
|
37
|
+
needs: CapabilityNeed[];
|
|
33
38
|
}
|
|
34
39
|
interface Out extends Record<string, unknown> {
|
|
35
40
|
currentWave: number;
|
|
@@ -54,6 +59,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
54
59
|
// Dedupe: a planner-emitted `["a","a"]` would otherwise violate the
|
|
55
60
|
// `plan_task_deps` PK on the second edge insert and fail the job.
|
|
56
61
|
dependsOn: [...new Set(strList(t?.dependsOn))],
|
|
62
|
+
// Cross-repo capability edges (issue #289): tolerant-parse + de-dupe; a malformed need is
|
|
63
|
+
// dropped, never fatal. Independent of the wave DAG — these gate on an external publish.
|
|
64
|
+
needs: parseCapabilityNeeds(t?.needs),
|
|
57
65
|
};
|
|
58
66
|
});
|
|
59
67
|
|
|
@@ -90,6 +98,10 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
90
98
|
// `plan_task_deps` is keyed on `plan_key`, so one delete clears the plan's whole edge set.
|
|
91
99
|
const depTable = planTaskDeps(app.data);
|
|
92
100
|
await depTable.delete(planKey);
|
|
101
|
+
// `plan_task_needs` is likewise keyed on `plan_key` — one delete clears the plan's capability
|
|
102
|
+
// edges before rewrite (issue #289). Independent of the DAG-degrade path below.
|
|
103
|
+
const needTable = planTaskNeeds(app.data);
|
|
104
|
+
await needTable.delete(planKey);
|
|
93
105
|
|
|
94
106
|
for (let i = 0; i < tasks.length; i++) {
|
|
95
107
|
const t = tasks[i];
|
|
@@ -113,6 +125,17 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
113
125
|
});
|
|
114
126
|
}
|
|
115
127
|
}
|
|
128
|
+
// Capability edges persist regardless of `depsValid` — they gate on an external publish, not on
|
|
129
|
+
// the (possibly malformed) intra-epic DAG. The PK dedupes a task listing the same edge twice.
|
|
130
|
+
for (const need of t.needs) {
|
|
131
|
+
await needTable.insert({
|
|
132
|
+
plan_key: planKey,
|
|
133
|
+
task_id: t.id,
|
|
134
|
+
capability_ref: need.capabilityRef,
|
|
135
|
+
package: need.package,
|
|
136
|
+
verify_command: need.verifyCommand ?? null,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
const patch: Record<string, unknown> = {
|
|
@@ -25,12 +25,18 @@ interface DepRow {
|
|
|
25
25
|
depends_on_task_id: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function fakeApp(rows: Row[], deps: DepRow[], plans: Record<string, unknown>[] = []) {
|
|
28
|
+
function fakeApp(rows: Row[], deps: DepRow[], plans: Record<string, unknown>[] = [], needs: Record<string, unknown>[] = []) {
|
|
29
29
|
return {
|
|
30
30
|
log: { error() {}, info() {}, warn() {} },
|
|
31
31
|
data: {
|
|
32
32
|
table(name: string, key: string) {
|
|
33
|
-
const store = name === "plan_tasks"
|
|
33
|
+
const store = name === "plan_tasks"
|
|
34
|
+
? rows
|
|
35
|
+
: name === "plans"
|
|
36
|
+
? plans
|
|
37
|
+
: name === "plan_task_needs"
|
|
38
|
+
? needs
|
|
39
|
+
: deps;
|
|
34
40
|
return {
|
|
35
41
|
find: (q: any) =>
|
|
36
42
|
Promise.resolve(
|
|
@@ -196,3 +202,29 @@ test("select-wave still skips dependents behind failed or otherwise non-open dep
|
|
|
196
202
|
});
|
|
197
203
|
}
|
|
198
204
|
});
|
|
205
|
+
|
|
206
|
+
test("select-wave attaches each dispatched task's capability needs, [] when none (issue #289)", async () => {
|
|
207
|
+
const rows: Row[] = [
|
|
208
|
+
{ id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 1 },
|
|
209
|
+
{ id: 2, plan_key: "owner/repo#63", task_id: "b", title: "B", prompt: "do B", status: "pending", wave: 1 },
|
|
210
|
+
];
|
|
211
|
+
const needs: Record<string, unknown>[] = [
|
|
212
|
+
{
|
|
213
|
+
plan_key: "owner/repo#63",
|
|
214
|
+
task_id: "a",
|
|
215
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
216
|
+
package: "@nanobpm/urban",
|
|
217
|
+
verify_command: "verify.sh",
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
const out = await handler(
|
|
221
|
+
{ variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
|
|
222
|
+
fakeApp(rows, [], [], needs),
|
|
223
|
+
);
|
|
224
|
+
const waveTasks = (out as { waveTasks: any[] }).waveTasks;
|
|
225
|
+
const byId = new Map(waveTasks.map((t) => [t.id, t]));
|
|
226
|
+
assertEquals(byId.get("a").needs, [
|
|
227
|
+
{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban", verifyCommand: "verify.sh" },
|
|
228
|
+
]);
|
|
229
|
+
assertEquals(byId.get("b").needs, []);
|
|
230
|
+
});
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
// Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
|
|
16
16
|
// immediately (the same 0-task path the flat fan-out already relied on).
|
|
17
17
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
18
|
+
import type { CapabilityNeed } from "../../app/capabilityNeed.ts";
|
|
18
19
|
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
19
|
-
import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
20
|
+
import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
|
|
20
21
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
21
22
|
|
|
22
23
|
// Input typed off the model data envelope (`SelectWaveIn` in plan-fanout.bpmn) — ADR 0040.
|
|
@@ -25,6 +26,10 @@ interface WaveTaskOut {
|
|
|
25
26
|
id: string;
|
|
26
27
|
title: string;
|
|
27
28
|
prompt: string;
|
|
29
|
+
// Cross-repo capability edges to gate this task on before its agent starts (issue #289). Empty
|
|
30
|
+
// for a task with no upstream capability dependency; carried through so the fan-out can gate on
|
|
31
|
+
// each need (readiness-gate) and late-bind the resolved `pkg@version` into the agent's prompt.
|
|
32
|
+
needs: CapabilityNeed[];
|
|
28
33
|
}
|
|
29
34
|
interface Out extends Record<string, unknown> {
|
|
30
35
|
waveTasks: WaveTaskOut[];
|
|
@@ -96,6 +101,20 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
96
101
|
depsByTask.set(d.task_id, list);
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
// Cross-repo capability edges (issue #289): loaded once and grouped per task so each dispatched
|
|
105
|
+
// wave task carries its `needs[]` for the fan-out to gate on. A task with no needs gets [].
|
|
106
|
+
const needRows = await planTaskNeeds(app.data).find({ plan_key: planKey });
|
|
107
|
+
const needsByTask = new Map<string, CapabilityNeed[]>();
|
|
108
|
+
for (const n of needRows) {
|
|
109
|
+
const list = needsByTask.get(n.task_id) ?? [];
|
|
110
|
+
list.push({
|
|
111
|
+
capabilityRef: n.capability_ref,
|
|
112
|
+
package: n.package,
|
|
113
|
+
...(n.verify_command ? { verifyCommand: n.verify_command } : {}),
|
|
114
|
+
});
|
|
115
|
+
needsByTask.set(n.task_id, list);
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
const waveTasks: WaveTaskOut[] = [];
|
|
100
119
|
for (const r of rows) {
|
|
101
120
|
if ((r.wave ?? 0) !== currentWave) continue;
|
|
@@ -116,7 +135,12 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
116
135
|
continue;
|
|
117
136
|
}
|
|
118
137
|
if (depIds.some((d) => statusById.get(d) === "waiting-for-lane")) continue;
|
|
119
|
-
waveTasks.push({
|
|
138
|
+
waveTasks.push({
|
|
139
|
+
id: r.task_id,
|
|
140
|
+
title: r.title ?? r.task_id,
|
|
141
|
+
prompt: r.prompt ?? "",
|
|
142
|
+
needs: needsByTask.get(r.task_id) ?? [],
|
|
143
|
+
});
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
return { waveTasks };
|