@nanobpm/nano-workforce 0.87.0 → 0.88.1
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 +14 -0
- package/SPEC.md +16 -5
- package/app/contracts.ts +17 -0
- package/app/feature.ts +8 -4
- package/app/featureBlocked.test.ts +52 -5
- package/app/featureEscalation.test.ts +60 -5
- package/app/github.ts +15 -11
- package/app/migration041.test.ts +79 -0
- package/app/plan.ts +81 -0
- package/app/planDeps.test.ts +192 -0
- package/app/pollUserTasks.test.ts +62 -5
- package/app/service.test.ts +16 -1
- package/app/service.ts +38 -10
- package/db/migrations/041_inter_epic_plan_deps.sql +38 -0
- package/package.json +1 -1
- package/pages/overview.page.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.88.1](https://github.com/nanobpm/nano-workforce/compare/v0.88.0...v0.88.1) (2026-08-18)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* scope user-task pollers to open tasks so they can't latch a COMPLETED task ([#297](https://github.com/nanobpm/nano-workforce/issues/297)) ([131a796](https://github.com/nanobpm/nano-workforce/commit/131a796dfaed8f9b2eb25526f6583b86b8d7b0df)), closes [#294](https://github.com/nanobpm/nano-workforce/issues/294)
|
|
7
|
+
|
|
8
|
+
# [0.88.0](https://github.com/nanobpm/nano-workforce/compare/v0.87.0...v0.88.0) (2026-08-18)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **service:** branch-scoped treeless clone for review-job repo envelope ([#287](https://github.com/nanobpm/nano-workforce/issues/287)) ([#288](https://github.com/nanobpm/nano-workforce/issues/288)) ([c90dc23](https://github.com/nanobpm/nano-workforce/commit/c90dc23274fbb5e19835e25eef755f88337ab86f)), closes [jwulf/c8ctl-plugin-nano#91](https://github.com/jwulf/c8ctl-plugin-nano/issues/91)
|
|
14
|
+
|
|
1
15
|
# [0.87.0](https://github.com/nanobpm/nano-workforce/compare/v0.86.0...v0.87.0) (2026-08-18)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -193,9 +193,17 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
|
|
|
193
193
|
`io.nanobpm.agentTask.repository.url`; the **app** supplies it — plus the head
|
|
194
194
|
branch as `…repository.ref` — as a process variable at `createInstance`
|
|
195
195
|
(`repoEnvelopeVars` in `app/service.ts`, resolving the head via `fetchPrMeta`/
|
|
196
|
-
`fetchPrHead`). The
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
`fetchPrHead`). The envelope also carries clone-shaping fields so large monorepos
|
|
197
|
+
provision within the c8ctl clone timeout (issue #287): `singleBranch: true` and
|
|
198
|
+
`filter: "blob:none"` request a **branch-scoped, blobless partial clone** (trees are
|
|
199
|
+
still fetched up-front — a *treeless* clone would be `--filter=tree:0`; the full
|
|
200
|
+
commit graph is kept — no `--depth 1` — so `git merge-base` / the review 3-dot diff
|
|
201
|
+
stays correct while blobs fetch lazily), and, when the PR base branch is resolvable,
|
|
202
|
+
an optional `…repository.baseRef` so the harness fetches the base tip alongside the
|
|
203
|
+
head and keeps `origin/<base>` reachable for the diff. The harness is PR-agnostic: it
|
|
204
|
+
does **not** derive the head branch from `prNumber`/`prUrl`. When the head can't be
|
|
205
|
+
resolved the envelope is omitted and the agent falls back to the worker's launch
|
|
206
|
+
directory (the legacy behavior).
|
|
199
207
|
|
|
200
208
|
## 6. Signals
|
|
201
209
|
|
|
@@ -625,8 +633,11 @@ but encode incompatible decisions about a shared contract** — a genuine design
|
|
|
625
633
|
integration provisions the repo and checks out the PR's head branch (it must
|
|
626
634
|
already give the worker repo access to work at all). The **app** resolves the head
|
|
627
635
|
branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
|
|
628
|
-
(a `createInstance` process variable — see `repoEnvelopeVars`)
|
|
629
|
-
|
|
636
|
+
(a `createInstance` process variable — see `repoEnvelopeVars`), along with the
|
|
637
|
+
branch-scoped, blobless clone-shaping fields (`singleBranch`, `filter`, optional
|
|
638
|
+
`baseRef`) that let large monorepos provision within the clone timeout (#287); the
|
|
639
|
+
harness is PR-agnostic and provisions from that envelope. The worker stays a pure
|
|
640
|
+
provisioner.
|
|
630
641
|
- **review-ready via GitHub webhook** — same message, swappable faster trigger,
|
|
631
642
|
when the app is publicly reachable. Deferred (poller-only for v1).
|
|
632
643
|
- **Supervised vs external worker** — the agent runs as an external
|
package/app/contracts.ts
CHANGED
|
@@ -334,6 +334,15 @@ export const WIRE_CONTRACTS = {
|
|
|
334
334
|
"Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
|
|
335
335
|
shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
|
|
336
336
|
},
|
|
337
|
+
"io.nanobpm.agentTask.repository": {
|
|
338
|
+
category: "wire",
|
|
339
|
+
name: "io.nanobpm.agentTask.repository",
|
|
340
|
+
owner: "app/service.ts",
|
|
341
|
+
semantics:
|
|
342
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
343
|
+
shape:
|
|
344
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
|
|
345
|
+
},
|
|
337
346
|
} as const satisfies Record<string, WireContract>;
|
|
338
347
|
|
|
339
348
|
export const TYPE_CONTRACTS = {
|
|
@@ -345,6 +354,14 @@ export const TYPE_CONTRACTS = {
|
|
|
345
354
|
"The snake_case, agent-facing view of a blackboard entry — the HTTP-hook boundary shape every caller and agent consumes. Both the read and write halves import this ONE definition.",
|
|
346
355
|
module: "app/blackboard.ts",
|
|
347
356
|
},
|
|
357
|
+
PlanDep: {
|
|
358
|
+
category: "type",
|
|
359
|
+
name: "PlanDep",
|
|
360
|
+
owner: "app/plan.ts",
|
|
361
|
+
semantics:
|
|
362
|
+
"One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. Set admission (S2), planner lowering (S3), and operator visibility (S4) all import this ONE row shape from app/plan.ts — no re-declared synonym.",
|
|
363
|
+
module: "app/plan.ts",
|
|
364
|
+
},
|
|
348
365
|
} as const satisfies Record<string, TypeContract>;
|
|
349
366
|
|
|
350
367
|
export const CAPABILITY_URL_CONTRACTS = {
|
package/app/feature.ts
CHANGED
|
@@ -181,10 +181,12 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
|
|
|
181
181
|
export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
|
|
182
182
|
|
|
183
183
|
/** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
|
|
184
|
-
*
|
|
184
|
+
* `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
|
|
185
|
+
* drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which
|
|
186
|
+
* holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task.
|
|
185
187
|
*
|
|
186
188
|
* The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
|
|
187
|
-
* task's `zeebe:ioMapping`-mapped local variables through
|
|
189
|
+
* task's `zeebe:ioMapping`-mapped local variables through the user-task query, so relying on it would
|
|
188
190
|
* make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
|
|
189
191
|
* persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
|
|
190
192
|
export interface FeatureEscalationParked {
|
|
@@ -240,8 +242,10 @@ export function deriveFeatureEscalationPatch(
|
|
|
240
242
|
* `pollFeatureBlocked` reconciles it onto the read model. */
|
|
241
243
|
export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
|
|
242
244
|
|
|
243
|
-
/** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `
|
|
244
|
-
* the completable user-task key the pages drive an
|
|
245
|
+
/** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `openUserTasks`
|
|
246
|
+
* (the open-task-scoped query — issue #294): the completable user-task key the pages drive an
|
|
247
|
+
* attributed acknowledgement against. Scoping to `state:"CREATED"` keeps a re-blocked run — which
|
|
248
|
+
* holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task. */
|
|
245
249
|
export interface FeatureBlockedParked {
|
|
246
250
|
userTaskKey: string;
|
|
247
251
|
}
|
|
@@ -49,12 +49,22 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
49
49
|
return { data, stores };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** A
|
|
53
|
-
*
|
|
54
|
-
|
|
52
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
53
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
54
|
+
* A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
55
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
56
|
+
|
|
57
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field
|
|
58
|
+
* pollFeatureBlocked queries on). It models the real engine's two accessors from ONE fixture so a test
|
|
59
|
+
* genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
60
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
61
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
62
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
63
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
55
64
|
return {
|
|
56
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
57
|
-
|
|
65
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
66
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
67
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
58
68
|
} as unknown as EngineClient;
|
|
59
69
|
}
|
|
60
70
|
|
|
@@ -133,3 +143,40 @@ test("pollFeatureBlocked: a parked non-blocked task (feature-escalation) does no
|
|
|
133
143
|
|
|
134
144
|
assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
|
|
135
145
|
});
|
|
146
|
+
|
|
147
|
+
// ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-blocked tasks ────────────
|
|
148
|
+
// The blocked wait sits on a blocked→ack loop, so a re-blocked run holds a COMPLETED feature-blocked
|
|
149
|
+
// task from a prior round alongside the live CREATED one; the engine returns the COMPLETED task first.
|
|
150
|
+
// Scoping the query to open (CREATED) tasks records the live key, never the terminal one.
|
|
151
|
+
test("pollFeatureBlocked: a looping run records the CREATED task, never the COMPLETED one", async () => {
|
|
152
|
+
const { data, stores } = memData();
|
|
153
|
+
stores.feature_runs = [
|
|
154
|
+
{ feature_key: "o/r#6", status: "awaiting_operator", process_key: "600", blocked_user_task_key: null },
|
|
155
|
+
];
|
|
156
|
+
const engine = fakeEngine({
|
|
157
|
+
"600": [
|
|
158
|
+
{ userTaskKey: "ut-completed", elementId: "feature-blocked", state: "COMPLETED" },
|
|
159
|
+
{ userTaskKey: "ut-live", elementId: "feature-blocked", state: "CREATED" },
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await pollFeatureBlocked(data, engine);
|
|
164
|
+
|
|
165
|
+
assertEquals(stores.feature_runs[0].blocked_user_task_key, "ut-live");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Self-heal reached: a run past the blocked wait whose only feature-blocked task is COMPLETED must
|
|
169
|
+
// clear the stale pointer (open-task query returns [] → `parked=null`), not latch it on the dead key.
|
|
170
|
+
test("pollFeatureBlocked: a run whose only feature-blocked task is COMPLETED clears the stale pointer", async () => {
|
|
171
|
+
const { data, stores } = memData();
|
|
172
|
+
stores.feature_runs = [
|
|
173
|
+
{ feature_key: "o/r#7", status: "awaiting_operator", process_key: "700", blocked_user_task_key: "ut-7" },
|
|
174
|
+
];
|
|
175
|
+
const engine = fakeEngine({
|
|
176
|
+
"700": [{ userTaskKey: "ut-7", elementId: "feature-blocked", state: "COMPLETED" }],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await pollFeatureBlocked(data, engine);
|
|
180
|
+
|
|
181
|
+
assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
|
|
182
|
+
});
|
|
@@ -50,12 +50,22 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
50
50
|
return { data, stores };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/** A
|
|
54
|
-
*
|
|
55
|
-
|
|
53
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
54
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
55
|
+
* A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
56
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
57
|
+
|
|
58
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field
|
|
59
|
+
* pollFeatureEscalations queries on). It models the real engine's two accessors from ONE fixture so a
|
|
60
|
+
* test genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
61
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
62
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
63
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
64
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
56
65
|
return {
|
|
57
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
58
|
-
|
|
66
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
67
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
68
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
59
69
|
} as unknown as EngineClient;
|
|
60
70
|
}
|
|
61
71
|
|
|
@@ -178,3 +188,48 @@ test("pollFeatureEscalations: a parked non-escalation task (feature-blocked) doe
|
|
|
178
188
|
assertEquals(stores.feature_runs[0].status, "running");
|
|
179
189
|
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
180
190
|
});
|
|
191
|
+
|
|
192
|
+
// ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-escalation tasks ─────────
|
|
193
|
+
// A run in an escalate→answer→implement→re-escalate loop holds a COMPLETED feature-escalation task
|
|
194
|
+
// from a prior round alongside the live CREATED one. The engine returns the COMPLETED task first, so a
|
|
195
|
+
// poller that reads UNFILTERED tasks and `.find`s by element latches onto the terminal key — pinning
|
|
196
|
+
// the pointer at a dead task. Scoping the query to open (CREATED) tasks resolves the live one.
|
|
197
|
+
test("pollFeatureEscalations: a looping run resolves the CREATED task, never the COMPLETED one", async () => {
|
|
198
|
+
const { data, stores } = memData();
|
|
199
|
+
stores.feature_runs = [
|
|
200
|
+
{ feature_key: "o/r#7", status: "running", process_key: "700", escalation_question: null, escalation_user_task_key: null },
|
|
201
|
+
];
|
|
202
|
+
// Engine returns the COMPLETED (prior-round) task FIRST, then the live CREATED one (issue #294).
|
|
203
|
+
const engine = fakeEngine({
|
|
204
|
+
"700": [
|
|
205
|
+
{ userTaskKey: "ut-completed", elementId: "feature-escalation", state: "COMPLETED" },
|
|
206
|
+
{ userTaskKey: "ut-live", elementId: "feature-escalation", state: "CREATED" },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pollFeatureEscalations(data, engine);
|
|
211
|
+
|
|
212
|
+
assertEquals(stores.feature_runs[0].status, "escalated");
|
|
213
|
+
// Must be the live CREATED task, not the COMPLETED prior-round one.
|
|
214
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-live");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Self-heal reached: once a run finally exits escalation for good, only a lingering COMPLETED
|
|
218
|
+
// feature-escalation task remains. The open-task query returns [], so `parked=null` and the run
|
|
219
|
+
// resets escalated→running — instead of a false-positive pointer built from the terminal task pinning
|
|
220
|
+
// `status='escalated'` forever.
|
|
221
|
+
test("pollFeatureEscalations: a run whose only feature-escalation task is COMPLETED self-heals to running", async () => {
|
|
222
|
+
const { data, stores } = memData();
|
|
223
|
+
stores.feature_runs = [
|
|
224
|
+
{ feature_key: "o/r#8", status: "escalated", process_key: "800", escalation_question: "Q", escalation_user_task_key: "ut-8" },
|
|
225
|
+
];
|
|
226
|
+
const engine = fakeEngine({
|
|
227
|
+
"800": [{ userTaskKey: "ut-8", elementId: "feature-escalation", state: "COMPLETED" }],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await pollFeatureEscalations(data, engine);
|
|
231
|
+
|
|
232
|
+
assertEquals(stores.feature_runs[0].status, "running");
|
|
233
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
234
|
+
assertEquals(stores.feature_runs[0].escalation_question, null);
|
|
235
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -406,6 +406,10 @@ export interface PrMeta {
|
|
|
406
406
|
* workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
|
|
407
407
|
* PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
|
|
408
408
|
headRef: string | null;
|
|
409
|
+
/** The PR's base branch name (e.g. `main`). Emitted in the repository envelope so the c8ctl
|
|
410
|
+
* harness fetches the base tip alongside the single-branch head clone, keeping `git diff
|
|
411
|
+
* origin/<base>...HEAD` (the review 3-dot diff) computable. `null` when GitHub doesn't return it. */
|
|
412
|
+
baseRef: string | null;
|
|
409
413
|
}
|
|
410
414
|
|
|
411
415
|
export async function fetchPrMeta(
|
|
@@ -414,10 +418,10 @@ export async function fetchPrMeta(
|
|
|
414
418
|
token: string,
|
|
415
419
|
): Promise<PrMeta | null> {
|
|
416
420
|
if (await useGh()) {
|
|
417
|
-
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
|
|
421
|
+
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName,baseRefName"]);
|
|
418
422
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
419
|
-
const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
|
|
420
|
-
return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
|
|
423
|
+
const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null; baseRefName?: string | null };
|
|
424
|
+
return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null, baseRef: j.baseRefName ?? null };
|
|
421
425
|
}
|
|
422
426
|
if (!token) return null;
|
|
423
427
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
|
|
@@ -425,8 +429,8 @@ export async function fetchPrMeta(
|
|
|
425
429
|
});
|
|
426
430
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
427
431
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
428
|
-
const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
|
|
429
|
-
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
|
|
432
|
+
const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null }; base?: { ref?: string | null } };
|
|
433
|
+
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null, baseRef: j.base?.ref ?? null };
|
|
430
434
|
}
|
|
431
435
|
|
|
432
436
|
/** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
|
|
@@ -666,12 +670,12 @@ export async function fetchPrHead(
|
|
|
666
670
|
repo: string,
|
|
667
671
|
number: number | string,
|
|
668
672
|
token: string,
|
|
669
|
-
): Promise<{ headRef: string | null; headSha: string | null } | null> {
|
|
673
|
+
): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
|
|
670
674
|
if (await useGh()) {
|
|
671
|
-
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
|
|
675
|
+
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
|
|
672
676
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
673
|
-
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
|
|
674
|
-
return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
|
|
677
|
+
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null };
|
|
678
|
+
return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null };
|
|
675
679
|
}
|
|
676
680
|
if (!token) return null;
|
|
677
681
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
|
|
@@ -679,8 +683,8 @@ export async function fetchPrHead(
|
|
|
679
683
|
});
|
|
680
684
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
681
685
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
682
|
-
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
|
|
683
|
-
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
|
|
686
|
+
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null }; base?: { ref?: string | null } };
|
|
687
|
+
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null };
|
|
684
688
|
}
|
|
685
689
|
|
|
686
690
|
/** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Regression guard for migration 041 (issue #292 slice S1): the durable constraints on the INTER-epic
|
|
2
|
+
// `plan_deps` table. The app-layer accessors (app/planDeps.test.ts) enforce self-edge / duplicate
|
|
3
|
+
// rejection for the in-memory data layer; this test proves the SCHEMA itself is the backstop — the
|
|
4
|
+
// migration applies cleanly, a self-edge trips the CHECK, a duplicate consumer→producer pair trips
|
|
5
|
+
// the PRIMARY KEY, and the consumer `plan_key` foreign-keys to an admitted plan.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
|
+
|
|
12
|
+
function migratedDb(): DatabaseSync {
|
|
13
|
+
const db = new DatabaseSync(":memory:");
|
|
14
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
15
|
+
// Minimal `plans` shape the migration's FK references.
|
|
16
|
+
db.exec("CREATE TABLE plans (plan_key TEXT PRIMARY KEY);");
|
|
17
|
+
for (const k of ["o/r#1", "o/r#2", "o/r#3"]) {
|
|
18
|
+
db.prepare("INSERT INTO plans (plan_key) VALUES (?)").run(k);
|
|
19
|
+
}
|
|
20
|
+
const sql = readFileSync(
|
|
21
|
+
fileURLToPath(new URL("../db/migrations/041_inter_epic_plan_deps.sql", import.meta.url)),
|
|
22
|
+
"utf8",
|
|
23
|
+
);
|
|
24
|
+
db.exec(sql);
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const insert = (db: DatabaseSync, planKey: string, dependsOn: string) =>
|
|
29
|
+
db
|
|
30
|
+
.prepare(
|
|
31
|
+
`INSERT INTO plan_deps (plan_key, depends_on_plan_key, package, capability_ref, created_at)
|
|
32
|
+
VALUES (?, ?, '@nanobpm/p', ?, 't')`,
|
|
33
|
+
)
|
|
34
|
+
.run(planKey, dependsOn, dependsOn);
|
|
35
|
+
|
|
36
|
+
test("migration 041 applies cleanly and records a valid inter-epic edge", () => {
|
|
37
|
+
const db = migratedDb();
|
|
38
|
+
insert(db, "o/r#2", "o/r#1");
|
|
39
|
+
const row = db
|
|
40
|
+
.prepare("SELECT plan_key, depends_on_plan_key, package FROM plan_deps WHERE plan_key = ?")
|
|
41
|
+
.get("o/r#2") as { plan_key: string; depends_on_plan_key: string; package: string };
|
|
42
|
+
assertEquals(row.plan_key, "o/r#2");
|
|
43
|
+
assertEquals(row.depends_on_plan_key, "o/r#1");
|
|
44
|
+
assertEquals(row.package, "@nanobpm/p");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("migration 041 CHECK rejects a self-edge", () => {
|
|
48
|
+
const db = migratedDb();
|
|
49
|
+
let threw = false;
|
|
50
|
+
try {
|
|
51
|
+
insert(db, "o/r#1", "o/r#1");
|
|
52
|
+
} catch {
|
|
53
|
+
threw = true;
|
|
54
|
+
}
|
|
55
|
+
assertEquals(threw, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("migration 041 PRIMARY KEY rejects a duplicate consumer→producer edge", () => {
|
|
59
|
+
const db = migratedDb();
|
|
60
|
+
insert(db, "o/r#2", "o/r#1");
|
|
61
|
+
let threw = false;
|
|
62
|
+
try {
|
|
63
|
+
insert(db, "o/r#2", "o/r#1");
|
|
64
|
+
} catch {
|
|
65
|
+
threw = true;
|
|
66
|
+
}
|
|
67
|
+
assertEquals(threw, true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("migration 041 FK ties the consumer plan_key to an admitted plan", () => {
|
|
71
|
+
const db = migratedDb();
|
|
72
|
+
let threw = false;
|
|
73
|
+
try {
|
|
74
|
+
insert(db, "o/r#404", "o/r#1");
|
|
75
|
+
} catch {
|
|
76
|
+
threw = true;
|
|
77
|
+
}
|
|
78
|
+
assertEquals(threw, true);
|
|
79
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -138,6 +138,87 @@ export interface PlanTaskDep {
|
|
|
138
138
|
export const planTaskDeps = (data: DataLayer) =>
|
|
139
139
|
data.table<PlanTaskDep>("plan_task_deps", "plan_key");
|
|
140
140
|
|
|
141
|
+
/** One INTER-epic dependency edge in the plan-set DAG (issue #292, slice S1): the epic `plan_key`
|
|
142
|
+
* waits for the producer epic `depends_on_plan_key` to publish a capability before it may fan out.
|
|
143
|
+
*
|
|
144
|
+
* This is the coarser sibling of {@link PlanTaskDep} (which orders TASKS *within* one epic into
|
|
145
|
+
* waves). A `PlanDep` orders whole EPICS relative to each other. The edge additionally carries the
|
|
146
|
+
* gating contract descriptor the capability probe (slice S3) resolves against:
|
|
147
|
+
* • `package` — the producer epic's published package name, and
|
|
148
|
+
* • `capability_ref` — the producer epic's issue handle, used to resolve which published
|
|
149
|
+
* `pkg@version` FIRST carries the awaited capability (late-bound into the dependent's build).
|
|
150
|
+
* Keyed on `plan_key` (the dependent) so a single delete clears a dependent's whole inbound edge set
|
|
151
|
+
* — mirroring how `plan_task_deps` is keyed on `plan_key`. See db/migrations/041_inter_epic_plan_deps.sql
|
|
152
|
+
* for the durable constraints (one edge per consumer→producer pair; no self-edge). */
|
|
153
|
+
export interface PlanDep {
|
|
154
|
+
plan_key: string;
|
|
155
|
+
depends_on_plan_key: string;
|
|
156
|
+
package: string;
|
|
157
|
+
capability_ref: string;
|
|
158
|
+
created_at: string;
|
|
159
|
+
}
|
|
160
|
+
export const planDeps = (data: DataLayer) => data.table<PlanDep>("plan_deps", "plan_key");
|
|
161
|
+
|
|
162
|
+
/** The fields an admission caller supplies for one inter-epic edge; `created_at` is stamped here. */
|
|
163
|
+
export type PlanDepInput = Omit<PlanDep, "created_at">;
|
|
164
|
+
|
|
165
|
+
/** Record one inter-epic dependency edge, enforcing the schema's two invariants at the app layer too
|
|
166
|
+
* (the durable table backstops both, but the in-memory test data layer does not): an epic may not
|
|
167
|
+
* depend on itself, and a consumer→producer edge is recorded at most once. A duplicate re-submission
|
|
168
|
+
* is a no-op that returns the existing row rather than throwing, so batch admission (S2) stays
|
|
169
|
+
* idempotent; a self-edge is a programming/validation error and throws. */
|
|
170
|
+
export async function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promise<PlanDep> {
|
|
171
|
+
if (edge.plan_key === edge.depends_on_plan_key) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`plan_deps: self-edge rejected — epic ${edge.plan_key} cannot depend on itself`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const table = planDeps(data);
|
|
177
|
+
const match = { plan_key: edge.plan_key, depends_on_plan_key: edge.depends_on_plan_key };
|
|
178
|
+
const existing = (await table.find(match))[0];
|
|
179
|
+
if (existing) return existing;
|
|
180
|
+
const row: PlanDep = { ...edge, created_at: now() };
|
|
181
|
+
try {
|
|
182
|
+
await table.insert(row);
|
|
183
|
+
return row;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// A concurrent caller may have inserted the same consumer→producer pair between our find and
|
|
186
|
+
// our insert (classic check-then-insert race); the composite PRIMARY KEY is the durable
|
|
187
|
+
// backstop that rejects the loser. Honour the "duplicate re-submission is a no-op" contract by
|
|
188
|
+
// re-reading and returning the winning row rather than surfacing the constraint error. Only a
|
|
189
|
+
// genuine non-collision failure (the pair still absent after the re-read) is re-raised.
|
|
190
|
+
const raced = (await table.find(match))[0];
|
|
191
|
+
if (raced) return raced;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** All INBOUND edges for `planKey` — i.e. every producer epic this dependent waits on. Empty for a
|
|
197
|
+
* root epic (no inter-epic dependencies). */
|
|
198
|
+
export function inboundPlanDeps(data: DataLayer, planKey: string): Promise<PlanDep[]> {
|
|
199
|
+
return planDeps(data).find({ plan_key: planKey });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every inter-epic edge whose dependent is in `planKeys` — the whole DAG for a submitted plan set.
|
|
203
|
+
* Reads per-key (not a table scan) so it composes with the same equality-filtered data layer the
|
|
204
|
+
* unit tests exercise. Producers outside the set are still returned as edge fields; the set
|
|
205
|
+
* validator (S3) is what rejects an edge naming an unsubmitted epic. */
|
|
206
|
+
export async function planDepsForSet(data: DataLayer, planKeys: string[]): Promise<PlanDep[]> {
|
|
207
|
+
const seen = new Set<string>();
|
|
208
|
+
const out: PlanDep[] = [];
|
|
209
|
+
// De-duplicate the keys first so a repeated key (retries / accidental repeats) does not trigger a
|
|
210
|
+
// redundant per-key inbound read; the edge de-dup below still guards against any overlap.
|
|
211
|
+
for (const key of new Set(planKeys)) {
|
|
212
|
+
for (const edge of await inboundPlanDeps(data, key)) {
|
|
213
|
+
const id = `${edge.plan_key}\u0000${edge.depends_on_plan_key}`;
|
|
214
|
+
if (seen.has(id)) continue;
|
|
215
|
+
seen.add(id);
|
|
216
|
+
out.push(edge);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
141
222
|
/** One adversarial plan-review round (006_plan_review.sql): the `senior:plan-review` agent's
|
|
142
223
|
* verdict on the plan before fan-out. Append-only within a plan run; the current round is
|
|
143
224
|
* `count(plan_reviews)`. Re-planning a finished issue clears the prior rows (see startPlan) so
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Red/green regression for the INTER-epic dependency edge (PlanDep) data layer — issue #292 slice S1.
|
|
2
|
+
//
|
|
3
|
+
// This slice adds `plan_deps` (db/migrations/041_inter_epic_plan_deps.sql) and its typed read/write
|
|
4
|
+
// surface in app/plan.ts, mirroring the intra-epic `plan_task_deps` accessors. The durable table
|
|
5
|
+
// enforces "one edge per consumer→producer pair" (PRIMARY KEY) and "no self-edge" (CHECK); these
|
|
6
|
+
// tests pin the app-layer accessors that admission (S2) and the planner (S3) build on, driven against
|
|
7
|
+
// the same in-memory data layer the rest of app/plan's tests use.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
10
|
+
import {
|
|
11
|
+
inboundPlanDeps,
|
|
12
|
+
type PlanDep,
|
|
13
|
+
planDepsForSet,
|
|
14
|
+
recordPlanDep,
|
|
15
|
+
} from "./plan.ts";
|
|
16
|
+
|
|
17
|
+
// Minimal in-memory data layer, matching the helper style in app/plan.test.ts: equality-filtered
|
|
18
|
+
// `find`, append `insert`, and a `delete(planKey)` that clears every row keyed on `plan_key` (so a
|
|
19
|
+
// re-seed of a plan's inbound edge set is one delete, exactly as `plan_task_deps` is cleared).
|
|
20
|
+
function memData() {
|
|
21
|
+
const rows: any[] = [];
|
|
22
|
+
const key = "plan_key";
|
|
23
|
+
const table = {
|
|
24
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
25
|
+
find: (q: any) =>
|
|
26
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
27
|
+
insert: (r: any) => {
|
|
28
|
+
rows.push(r);
|
|
29
|
+
return Promise.resolve(r);
|
|
30
|
+
},
|
|
31
|
+
count: (q: any) =>
|
|
32
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
33
|
+
delete: (k: any) => {
|
|
34
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
rows,
|
|
40
|
+
data: { table: () => table } as any,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EDGE = {
|
|
45
|
+
plan_key: "owner/repo#2",
|
|
46
|
+
depends_on_plan_key: "owner/repo#1",
|
|
47
|
+
package: "@nanobpm/producer",
|
|
48
|
+
capability_ref: "owner/repo#1",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
test("recordPlanDep persists an edge with a stamped created_at", async () => {
|
|
52
|
+
const { data, rows } = memData();
|
|
53
|
+
const row = await recordPlanDep(data, EDGE);
|
|
54
|
+
assertEquals(rows.length, 1);
|
|
55
|
+
assertEquals(row.plan_key, "owner/repo#2");
|
|
56
|
+
assertEquals(row.depends_on_plan_key, "owner/repo#1");
|
|
57
|
+
assertEquals(row.package, "@nanobpm/producer");
|
|
58
|
+
assertEquals(row.capability_ref, "owner/repo#1");
|
|
59
|
+
assertEquals(typeof row.created_at, "string");
|
|
60
|
+
assertEquals(row.created_at.length > 0, true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("recordPlanDep rejects a self-edge (an epic cannot depend on itself)", async () => {
|
|
64
|
+
const { data, rows } = memData();
|
|
65
|
+
await assertRejects(() =>
|
|
66
|
+
recordPlanDep(data, { ...EDGE, plan_key: "owner/repo#1", depends_on_plan_key: "owner/repo#1" }),
|
|
67
|
+
);
|
|
68
|
+
assertEquals(rows.length, 0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("recordPlanDep is idempotent on a duplicate edge (no second row)", async () => {
|
|
72
|
+
const { data, rows } = memData();
|
|
73
|
+
const first = await recordPlanDep(data, EDGE);
|
|
74
|
+
const again = await recordPlanDep(data, { ...EDGE, package: "@nanobpm/ignored-on-dupe" });
|
|
75
|
+
assertEquals(rows.length, 1);
|
|
76
|
+
// The existing row wins — a re-submission does not overwrite nor append.
|
|
77
|
+
assertEquals(again.created_at, first.created_at);
|
|
78
|
+
assertEquals(again.package, "@nanobpm/producer");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recordPlanDep treats a concurrent PK collision as idempotent (no throw)", async () => {
|
|
82
|
+
// Simulate the check-then-insert race the durable composite PRIMARY KEY backstops: a sibling
|
|
83
|
+
// caller wins between our find and our insert, so `find` sees nothing but `insert` collides.
|
|
84
|
+
const rows: any[] = [];
|
|
85
|
+
const key = "plan_key";
|
|
86
|
+
let raceArmed = true;
|
|
87
|
+
const table = {
|
|
88
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
89
|
+
find: (q: any) =>
|
|
90
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
91
|
+
insert: (r: any) => {
|
|
92
|
+
// On the first insert, a concurrent writer has already landed the same pair: append the
|
|
93
|
+
// rival row and reject this one with a UNIQUE/PK constraint error, as SQLite would.
|
|
94
|
+
if (raceArmed) {
|
|
95
|
+
raceArmed = false;
|
|
96
|
+
rows.push({ ...r, package: "@nanobpm/winner", created_at: "1999-01-01T00:00:00.000Z" });
|
|
97
|
+
return Promise.reject(new Error("UNIQUE constraint failed: plan_deps.plan_key"));
|
|
98
|
+
}
|
|
99
|
+
rows.push(r);
|
|
100
|
+
return Promise.resolve(r);
|
|
101
|
+
},
|
|
102
|
+
count: (q: any) =>
|
|
103
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
104
|
+
delete: (k: any) => {
|
|
105
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const data = { table: () => table } as any;
|
|
110
|
+
|
|
111
|
+
const row = await recordPlanDep(data, EDGE);
|
|
112
|
+
// The rival row is returned rather than the constraint error surfacing, and no duplicate lands.
|
|
113
|
+
assertEquals(rows.length, 1);
|
|
114
|
+
assertEquals(row.package, "@nanobpm/winner");
|
|
115
|
+
assertEquals(row.created_at, "1999-01-01T00:00:00.000Z");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("recordPlanDep re-raises a non-collision insert failure", async () => {
|
|
119
|
+
// A genuine failure (the pair is still absent after the re-read) must not be swallowed.
|
|
120
|
+
const table = {
|
|
121
|
+
get: () => Promise.resolve(null),
|
|
122
|
+
find: () => Promise.resolve([] as any[]),
|
|
123
|
+
insert: () => Promise.reject(new Error("disk I/O error")),
|
|
124
|
+
count: () => Promise.resolve(0),
|
|
125
|
+
delete: () => Promise.resolve(),
|
|
126
|
+
};
|
|
127
|
+
const data = { table: () => table } as any;
|
|
128
|
+
await assertRejects(() => recordPlanDep(data, EDGE), Error, "disk I/O error");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("inboundPlanDeps returns every producer a dependent waits on; empty for a root", async () => {
|
|
132
|
+
const { data } = memData();
|
|
133
|
+
await recordPlanDep(data, EDGE);
|
|
134
|
+
await recordPlanDep(data, { ...EDGE, depends_on_plan_key: "owner/repo#3", capability_ref: "owner/repo#3" });
|
|
135
|
+
|
|
136
|
+
const inbound = await inboundPlanDeps(data, "owner/repo#2");
|
|
137
|
+
assertEquals(inbound.length, 2);
|
|
138
|
+
assertEquals(
|
|
139
|
+
inbound.map((e: PlanDep) => e.depends_on_plan_key).sort(),
|
|
140
|
+
["owner/repo#1", "owner/repo#3"],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const root = await inboundPlanDeps(data, "owner/repo#1");
|
|
144
|
+
assertEquals(root.length, 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("planDepsForSet returns the whole DAG for a submitted set, de-duplicated", async () => {
|
|
148
|
+
const { data } = memData();
|
|
149
|
+
// #3 -> #1, #3 -> #2, #2 -> #1 : a small DAG across three epics.
|
|
150
|
+
await recordPlanDep(data, {
|
|
151
|
+
plan_key: "owner/repo#3",
|
|
152
|
+
depends_on_plan_key: "owner/repo#1",
|
|
153
|
+
package: "@nanobpm/a",
|
|
154
|
+
capability_ref: "owner/repo#1",
|
|
155
|
+
});
|
|
156
|
+
await recordPlanDep(data, {
|
|
157
|
+
plan_key: "owner/repo#3",
|
|
158
|
+
depends_on_plan_key: "owner/repo#2",
|
|
159
|
+
package: "@nanobpm/b",
|
|
160
|
+
capability_ref: "owner/repo#2",
|
|
161
|
+
});
|
|
162
|
+
await recordPlanDep(data, {
|
|
163
|
+
plan_key: "owner/repo#2",
|
|
164
|
+
depends_on_plan_key: "owner/repo#1",
|
|
165
|
+
package: "@nanobpm/a",
|
|
166
|
+
capability_ref: "owner/repo#1",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const edges = await planDepsForSet(data, ["owner/repo#1", "owner/repo#2", "owner/repo#3"]);
|
|
170
|
+
assertEquals(edges.length, 3);
|
|
171
|
+
// A root (#1) contributes no inbound edges; passing overlapping keys never double-counts an edge.
|
|
172
|
+
const overlapped = await planDepsForSet(data, ["owner/repo#3", "owner/repo#3"]);
|
|
173
|
+
assertEquals(overlapped.length, 2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("planDepsForSet reads each key once even when planKeys repeats", async () => {
|
|
177
|
+
// Prove the key de-dup: a repeated key must not drive a redundant inbound read.
|
|
178
|
+
const findKeys: string[] = [];
|
|
179
|
+
const table = {
|
|
180
|
+
get: () => Promise.resolve(null),
|
|
181
|
+
find: (q: any) => {
|
|
182
|
+
findKeys.push(q.plan_key);
|
|
183
|
+
return Promise.resolve([] as any[]);
|
|
184
|
+
},
|
|
185
|
+
insert: (r: any) => Promise.resolve(r),
|
|
186
|
+
count: () => Promise.resolve(0),
|
|
187
|
+
delete: () => Promise.resolve(),
|
|
188
|
+
};
|
|
189
|
+
const data = { table: () => table } as any;
|
|
190
|
+
await planDepsForSet(data, ["owner/repo#3", "owner/repo#3", "owner/repo#4", "owner/repo#3"]);
|
|
191
|
+
assertEquals(findKeys.sort(), ["owner/repo#3", "owner/repo#4"]);
|
|
192
|
+
});
|
|
@@ -51,12 +51,22 @@ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: R
|
|
|
51
51
|
return { data, stores };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** A
|
|
55
|
-
*
|
|
56
|
-
|
|
54
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
55
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
56
|
+
* A looping instance holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
57
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
58
|
+
|
|
59
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field the poller queries on
|
|
60
|
+
* for plan / PR instances). It models the real engine's two accessors from ONE fixture so a test
|
|
61
|
+
* genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
62
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
63
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
64
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
65
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
57
66
|
return {
|
|
58
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
59
|
-
|
|
67
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
68
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
69
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
60
70
|
} as unknown as EngineClient;
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -172,3 +182,50 @@ test("pollUserTasks: skips terminal plans and PRs without a process key", async
|
|
|
172
182
|
|
|
173
183
|
assertEquals(stores.user_tasks ?? [], []);
|
|
174
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── Defect-class guard (issue #294): a looping instance holds MULTIPLE tasks for one element ───────
|
|
187
|
+
// The plan-review (review→revise→review) and PR-wait (escalate→answer→re-escalate) elements sit on a
|
|
188
|
+
// loop, so a looping instance holds a COMPLETED task from a prior round alongside the live CREATED one,
|
|
189
|
+
// and the engine returns the COMPLETED one first. Scoping the query to open (CREATED) tasks projects
|
|
190
|
+
// only the live completable key onto `user_tasks`, never a terminal one the page could not complete.
|
|
191
|
+
test("pollUserTasks: a looping plan/PR projects only the CREATED task, never the COMPLETED one", async () => {
|
|
192
|
+
const { data, stores } = memData({
|
|
193
|
+
plans: [{ plan_key: "o/r#50", status: "dispatched", process_key: "pp-50", issue_url: "https://github.com/o/r/issues/50" }],
|
|
194
|
+
plan_reviews: [{ plan_key: "o/r#50", epoch: 0, round: 0, approved: 0, findings: "scope too broad", created_at: "2025-01-01T00:00:00.000Z" }],
|
|
195
|
+
pull_requests: [{ pr_key: "o/r#51", status: "escalated", process_key: "rp-51", url: "https://github.com/o/r/pull/51" }],
|
|
196
|
+
escalations: [{ id: 1, pr_key: "o/r#51", status: "open", question: "conflicting reviews" }],
|
|
197
|
+
});
|
|
198
|
+
// Each looping instance returns its COMPLETED prior-round task FIRST, then the live CREATED one.
|
|
199
|
+
const engine = fakeEngine({
|
|
200
|
+
"pp-50": [
|
|
201
|
+
{ userTaskKey: "ut-plan-completed", elementId: "plan-review-decision", state: "COMPLETED" },
|
|
202
|
+
{ userTaskKey: "ut-plan-live", elementId: "plan-review-decision", state: "CREATED" },
|
|
203
|
+
],
|
|
204
|
+
"rp-51": [
|
|
205
|
+
{ userTaskKey: "ut-pr-completed", elementId: "wait-answer", state: "COMPLETED" },
|
|
206
|
+
{ userTaskKey: "ut-pr-live", elementId: "wait-answer", state: "CREATED" },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pollUserTasks(data, engine);
|
|
211
|
+
|
|
212
|
+
const keys = (stores.user_tasks ?? []).map((r) => r.user_task_key).sort();
|
|
213
|
+
// Only the live CREATED keys — the COMPLETED prior-round tasks must never surface a dead affordance.
|
|
214
|
+
assertEquals(keys, ["ut-plan-live", "ut-pr-live"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Self-heal reached: an instance whose only task for an element is COMPLETED yields no open task, so
|
|
218
|
+
// its row is removed (open-task query returns []), rather than pinning a dead completable pointer.
|
|
219
|
+
test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row", async () => {
|
|
220
|
+
const { data, stores } = memData({
|
|
221
|
+
plans: [{ plan_key: "o/r#52", status: "dispatched", process_key: "pp-52", issue_url: "https://github.com/o/r/issues/52" }],
|
|
222
|
+
plan_reviews: [{ plan_key: "o/r#52", epoch: 0, round: 0, approved: 0, findings: "scope too broad", created_at: "2025-01-01T00:00:00.000Z" }],
|
|
223
|
+
});
|
|
224
|
+
const engine = fakeEngine({
|
|
225
|
+
"pp-52": [{ userTaskKey: "ut-plan-completed", elementId: "plan-review-decision", state: "COMPLETED" }],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await pollUserTasks(data, engine);
|
|
229
|
+
|
|
230
|
+
assertEquals(stores.user_tasks ?? [], []);
|
|
231
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -439,11 +439,26 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
|
|
|
439
439
|
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
|
440
440
|
// falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
|
|
441
441
|
test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
|
|
442
|
-
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
|
|
442
|
+
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main");
|
|
443
443
|
const env = (vars as any)["io.nanobpm.agentTask"];
|
|
444
444
|
assertEquals(env.repository.url, "https://github.com/owner/repo.git");
|
|
445
445
|
assertEquals(env.repository.ref, "feat/issue-12");
|
|
446
446
|
assertEquals(env.repository.provider, "github");
|
|
447
|
+
// Branch-scoped, blobless partial clone (issue #287): large monorepos provision within the clone
|
|
448
|
+
// timeout while the full commit graph is kept so `git diff origin/<base>...HEAD` has a merge-base.
|
|
449
|
+
assertEquals(env.repository.singleBranch, true);
|
|
450
|
+
assertEquals(env.repository.filter, "blob:none");
|
|
451
|
+
// The base branch is emitted so the harness fetches its tip, keeping `origin/<base>` reachable.
|
|
452
|
+
assertEquals(env.repository.baseRef, "main");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("repoEnvelopeVars omits baseRef when the base branch is unresolved", () => {
|
|
456
|
+
const env = (repoEnvelopeVars("owner/repo", "feat/issue-12") as any)["io.nanobpm.agentTask"];
|
|
457
|
+
// The single-branch/blobless partial-clone request still stands without a base ref…
|
|
458
|
+
assertEquals(env.repository.singleBranch, true);
|
|
459
|
+
assertEquals(env.repository.filter, "blob:none");
|
|
460
|
+
// …but `baseRef` is omitted entirely rather than emitted as null (no key at all).
|
|
461
|
+
assertEquals("baseRef" in env.repository, false);
|
|
447
462
|
});
|
|
448
463
|
|
|
449
464
|
test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
|
package/app/service.ts
CHANGED
|
@@ -323,8 +323,18 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
|
323
323
|
* a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
|
|
324
324
|
* is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
|
|
325
325
|
* launch-dir behavior rather than silently cloning the repo's default branch. The static
|
|
326
|
-
* `task.prompt` header on the service task deep-merges with this over the same namespace.
|
|
327
|
-
|
|
326
|
+
* `task.prompt` header on the service task deep-merges with this over the same namespace.
|
|
327
|
+
*
|
|
328
|
+
* The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
|
|
329
|
+
* "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
|
|
330
|
+
* clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
|
|
331
|
+
* partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
|
|
332
|
+
* keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
|
|
333
|
+
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
334
|
+
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
335
|
+
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
336
|
+
* that base reachable for the diff. */
|
|
337
|
+
export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: string | null = null): Record<string, unknown> {
|
|
328
338
|
if (!ref) return {};
|
|
329
339
|
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
330
340
|
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
@@ -336,7 +346,20 @@ export function repoEnvelopeVars(repo: string, ref: string | null): Record<strin
|
|
|
336
346
|
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
337
347
|
return {
|
|
338
348
|
[AGENT_TASK_NS]: {
|
|
339
|
-
repository: {
|
|
349
|
+
repository: {
|
|
350
|
+
provider: "github",
|
|
351
|
+
url: `https://github.com/${repo}.git`,
|
|
352
|
+
ref,
|
|
353
|
+
// Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
|
|
354
|
+
// blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
|
|
355
|
+
// (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
|
|
356
|
+
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
357
|
+
singleBranch: true,
|
|
358
|
+
filter: "blob:none",
|
|
359
|
+
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
360
|
+
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
361
|
+
...(baseRef ? { baseRef } : {}),
|
|
362
|
+
},
|
|
340
363
|
},
|
|
341
364
|
};
|
|
342
365
|
}
|
|
@@ -364,12 +387,14 @@ export async function submitPr(
|
|
|
364
387
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
365
388
|
let title: string | null = null;
|
|
366
389
|
let headRef: string | null = null;
|
|
390
|
+
let baseRef: string | null = null;
|
|
367
391
|
const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
|
|
368
392
|
try {
|
|
369
393
|
const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
|
|
370
394
|
if (meta) {
|
|
371
395
|
title = meta.title;
|
|
372
396
|
headRef = meta.headRef;
|
|
397
|
+
baseRef = meta.baseRef;
|
|
373
398
|
for (const k of parseDependsOn(meta.body)) depKeys.add(k);
|
|
374
399
|
}
|
|
375
400
|
} catch (err) {
|
|
@@ -468,7 +493,7 @@ export async function submitPr(
|
|
|
468
493
|
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
469
494
|
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
470
495
|
// unresolved head (`{}`) leaves the other vars untouched.
|
|
471
|
-
...repoEnvelopeVars(parsed.repo, headRef),
|
|
496
|
+
...repoEnvelopeVars(parsed.repo, headRef, baseRef),
|
|
472
497
|
},
|
|
473
498
|
});
|
|
474
499
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -504,8 +529,11 @@ export async function startMerge(
|
|
|
504
529
|
// means the envelope is omitted and the agent falls back to the worker's launch dir.
|
|
505
530
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
506
531
|
let headRef: string | null = null;
|
|
532
|
+
let baseRef: string | null = null;
|
|
507
533
|
try {
|
|
508
|
-
|
|
534
|
+
const head = await fetchPrHead(pr.repo, pr.number, token);
|
|
535
|
+
headRef = head?.headRef ?? null;
|
|
536
|
+
baseRef = head?.baseRef ?? null;
|
|
509
537
|
} catch (err) {
|
|
510
538
|
console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
|
|
511
539
|
}
|
|
@@ -531,7 +559,7 @@ export async function startMerge(
|
|
|
531
559
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
532
560
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
533
561
|
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
|
|
534
|
-
...repoEnvelopeVars(pr.repo, headRef),
|
|
562
|
+
...repoEnvelopeVars(pr.repo, headRef, baseRef),
|
|
535
563
|
},
|
|
536
564
|
});
|
|
537
565
|
if (processInstanceKey != null) {
|
|
@@ -1382,7 +1410,7 @@ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClie
|
|
|
1382
1410
|
for (const run of candidates) {
|
|
1383
1411
|
if (!run.process_key) continue;
|
|
1384
1412
|
try {
|
|
1385
|
-
const tasks = await engine.
|
|
1413
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
1386
1414
|
const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
|
|
1387
1415
|
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1388
1416
|
const patch = deriveFeatureEscalationPatch(run, parked);
|
|
@@ -1416,7 +1444,7 @@ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient)
|
|
|
1416
1444
|
for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
|
|
1417
1445
|
if (!run.process_key) continue;
|
|
1418
1446
|
try {
|
|
1419
|
-
const tasks = await engine.
|
|
1447
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
1420
1448
|
const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
|
|
1421
1449
|
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1422
1450
|
const patch = deriveFeatureBlockedPatch(run, parked);
|
|
@@ -1549,7 +1577,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1549
1577
|
planSeen.add(plan.plan_key);
|
|
1550
1578
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1551
1579
|
try {
|
|
1552
|
-
tasks = await engine.
|
|
1580
|
+
tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
|
|
1553
1581
|
} catch (err) {
|
|
1554
1582
|
console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
|
|
1555
1583
|
continue;
|
|
@@ -1604,7 +1632,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1604
1632
|
prSeen.add(pr.pr_key);
|
|
1605
1633
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1606
1634
|
try {
|
|
1607
|
-
tasks = await engine.
|
|
1635
|
+
tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
|
|
1608
1636
|
} catch (err) {
|
|
1609
1637
|
console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
|
|
1610
1638
|
continue;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- 041_inter_epic_plan_deps.sql — issue #292 slice S1: first-class INTER-epic dependency edge.
|
|
2
|
+
--
|
|
3
|
+
-- `plan_task_deps` (005_plan_deps.sql) records the INTRA-epic task DAG — edges that order tasks
|
|
4
|
+
-- *within a single plan* into waves. There is no way today to say "epic B depends-on epic A": that
|
|
5
|
+
-- the whole of epic B must wait until epic A has published a capability before B may fan out.
|
|
6
|
+
--
|
|
7
|
+
-- This table adds that second, coarser grain: one row per INTER-epic edge. `plan_key` is the
|
|
8
|
+
-- dependent/consumer epic that waits; `depends_on_plan_key` is the producer epic it waits for. The
|
|
9
|
+
-- edge also carries the GATING CONTRACT DESCRIPTOR the later capability probe (slice S3) needs to
|
|
10
|
+
-- resolve which published `pkg@version` first carries the awaited capability: `package` is the
|
|
11
|
+
-- producer's published package name, and `capability_ref` is the producer epic's issue handle used
|
|
12
|
+
-- to resolve that version. This slice (S1) only lands the durable schema + typed read/write surface;
|
|
13
|
+
-- admission (S2), lowering into a readiness gate (S3), and visibility (S4) build on it later.
|
|
14
|
+
--
|
|
15
|
+
-- Constraints mirror how `plan_task_deps` is constrained:
|
|
16
|
+
-- • PRIMARY KEY (plan_key, depends_on_plan_key) — one edge per consumer→producer pair, so a
|
|
17
|
+
-- re-submitted set cannot duplicate an edge.
|
|
18
|
+
-- • CHECK (plan_key <> depends_on_plan_key) — an epic cannot depend on itself.
|
|
19
|
+
-- • plan_key REFERENCES plans(plan_key) — the consumer is an admitted plan (as plan_task_deps FKs
|
|
20
|
+
-- its plan_key). `depends_on_plan_key` is intentionally NOT FK-constrained: a batch admission
|
|
21
|
+
-- (S2) may insert edges before every producer row exists, and the set validator enforces that
|
|
22
|
+
-- every edge names a submitted epic. An index on plan_key backs the inbound read.
|
|
23
|
+
--
|
|
24
|
+
-- Numbered after the current highest prefix on origin/main (040); the runner wraps each file in its
|
|
25
|
+
-- own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
26
|
+
|
|
27
|
+
CREATE TABLE plan_deps (
|
|
28
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key), -- dependent/consumer epic that waits
|
|
29
|
+
depends_on_plan_key TEXT NOT NULL, -- producer epic it waits for
|
|
30
|
+
package TEXT NOT NULL, -- producer's published package name
|
|
31
|
+
capability_ref TEXT NOT NULL, -- producer epic issue handle → pkg@version
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (plan_key, depends_on_plan_key),
|
|
34
|
+
CHECK (plan_key <> depends_on_plan_key)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX idx_plan_deps_plan ON plan_deps(plan_key);
|
|
38
|
+
CREATE INDEX idx_plan_deps_producer ON plan_deps(depends_on_plan_key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.88.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/overview.page.json
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"columns": [
|
|
68
68
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
69
69
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
70
|
+
{ "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
|
|
70
71
|
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
71
72
|
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
72
73
|
]
|