@nanobpm/nano-workforce 0.91.0 → 0.92.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 +12 -0
- package/SPEC.md +10 -4
- package/app/admittedStaging.test.ts +98 -0
- package/app/contracts.ts +10 -1
- package/app/delivery.ts +61 -0
- package/app/epicBucket.test.ts +57 -0
- package/app/epicSetValidation.test.ts +169 -0
- package/app/migration045.test.ts +97 -0
- package/app/plan.ts +401 -12
- package/app/planGateway.test.ts +106 -0
- package/app/service.ts +13 -1
- package/db/migrations/044_plan_list_bucket.sql +32 -0
- package/db/migrations/045_epic_set_admission_staging.sql +56 -0
- package/e2e/support/engine-client.ts +24 -8
- package/openapi.yaml +245 -0
- package/operations/acknowledgeEpic.test.ts +130 -0
- package/operations/acknowledgeEpic.ts +63 -0
- package/operations/startEpicSet.admission.integration.test.ts +572 -0
- package/operations/startEpicSet.ts +217 -0
- package/operations/startPlanFanout.ts +5 -56
- package/package.json +1 -1
- package/pages/epic.page.json +15 -3
- package/pages/overview.page.json +13 -1
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/record-plan/worker.test.ts +1 -0
- package/workers/record-plan/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +19 -1
- package/workers/record-results/worker.ts +3 -3
package/app/plan.ts
CHANGED
|
@@ -11,9 +11,16 @@
|
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
+
import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
14
15
|
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
15
16
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BaseBranchMustExistError,
|
|
19
|
+
coalesceTitle,
|
|
20
|
+
ensureBaseBranch,
|
|
21
|
+
fetchDefaultBranch,
|
|
22
|
+
fetchIssueTitle,
|
|
23
|
+
} from "./github.ts";
|
|
17
24
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
18
25
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
19
26
|
|
|
@@ -100,6 +107,19 @@ export interface Plan {
|
|
|
100
107
|
// which has nothing to promote). Display-only; projected by the poller.
|
|
101
108
|
promotion_pr: string | null;
|
|
102
109
|
promotion_state: string | null;
|
|
110
|
+
// Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). Derived,
|
|
111
|
+
// write-time-projected by the `plans` gateway (below) from the pure `deriveEpicBucket` /
|
|
112
|
+
// `epicIsAcknowledgeable` helpers (app/delivery.ts) — never written by the plan lifecycle or a
|
|
113
|
+
// poller directly. Bucket epics on the derived `delivery` rollup, not raw `status`, so a `done`
|
|
114
|
+
// epic still converging — or landed-but-unpromoted — does not vanish from Active.
|
|
115
|
+
// • acknowledged_at — NULL until an operator dismisses a resolved `done` epic (acknowledge-epic).
|
|
116
|
+
// • list_bucket — 'active' | 'history': the page tabs filter on this flat column.
|
|
117
|
+
// • ack_open — 1 | 0: 1 iff a resolved (`done`, not converging) but unacknowledged epic,
|
|
118
|
+
// gating the Dismiss button's `showWhenField`. NULL only on pre-#298 rows
|
|
119
|
+
// until `backfillPlanBuckets`.
|
|
120
|
+
acknowledged_at: string | null;
|
|
121
|
+
list_bucket: string | null;
|
|
122
|
+
ack_open: number | null;
|
|
103
123
|
created_at: string;
|
|
104
124
|
updated_at: string;
|
|
105
125
|
}
|
|
@@ -136,7 +156,85 @@ export const PLAN_TASK_STATUSES = [
|
|
|
136
156
|
] as const;
|
|
137
157
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
138
158
|
|
|
139
|
-
export const plans = (data: DataLayer) =>
|
|
159
|
+
export const plans = (data: DataLayer) => {
|
|
160
|
+
const table = data.table<Plan>("plans", "plan_key");
|
|
161
|
+
return new Proxy(table, {
|
|
162
|
+
get(target, prop) {
|
|
163
|
+
if (prop === "insert") {
|
|
164
|
+
return (row: Partial<Plan>) => target.insert({ ...row, ...projectPlanBucket(row) });
|
|
165
|
+
}
|
|
166
|
+
if (prop === "update") {
|
|
167
|
+
return async (id: unknown, patch: Partial<Plan>) => {
|
|
168
|
+
// Only re-read + reproject when the patch changes a projection input (status / delivery /
|
|
169
|
+
// acknowledged_at) or writes a derived column directly. A projection-irrelevant patch (e.g.
|
|
170
|
+
// an `updated_at`- or `wave_label`-only write — including the direct `data.table` writes in
|
|
171
|
+
// e.g. `app/retro.ts` that stamp `retro_started_at`) leaves the stored projection correct, so
|
|
172
|
+
// skip the extra `get` roundtrip and delegate straight. Any bucket-relevant write
|
|
173
|
+
// (status/delivery/acknowledged_at or a derived column) MUST go through this gateway to stay
|
|
174
|
+
// reprojected.
|
|
175
|
+
if (!patchAffectsPlanProjection(patch)) return target.update(id, patch);
|
|
176
|
+
const existing = await target.get(id);
|
|
177
|
+
const merged: Partial<Plan> = { ...existing, ...patch };
|
|
178
|
+
return target.update(id, { ...patch, ...projectPlanBucket(merged) });
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Delegate every other method straight through. Bind functions to the real target so the
|
|
182
|
+
// gateway's private class fields resolve — a Proxy `this` would not carry them.
|
|
183
|
+
const value = Reflect.get(target, prop, target);
|
|
184
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** The `plans` fields the bucket projection READS: a patch touching none of these (and none it
|
|
190
|
+
* writes) cannot change `list_bucket`/`ack_open`, so the gateway skips the read-back+reproject. Kept
|
|
191
|
+
* adjacent to {@link projectPlanBucket} so the two stay in lockstep. */
|
|
192
|
+
const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "delivery", "acknowledged_at"];
|
|
193
|
+
|
|
194
|
+
/** The `plans` fields the bucket projection WRITES. Included in the reproject trigger so a caller who
|
|
195
|
+
* writes a derived column directly (e.g. `list_bucket`/`ack_open`) can never bypass derivation: the
|
|
196
|
+
* gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one. */
|
|
197
|
+
const PLAN_PROJECTION_OUTPUT_KEYS: readonly (keyof Plan)[] = ["list_bucket", "ack_open"];
|
|
198
|
+
|
|
199
|
+
/** True when a patch changes at least one field the bucket projection derives from OR one it writes —
|
|
200
|
+
* i.e. the projection must be recomputed (mirrors feature.ts `patchAffectsProjection`). */
|
|
201
|
+
function patchAffectsPlanProjection(patch: Partial<Plan>): boolean {
|
|
202
|
+
return (
|
|
203
|
+
PLAN_PROJECTION_INPUT_KEYS.some((k) => k in patch) ||
|
|
204
|
+
PLAN_PROJECTION_OUTPUT_KEYS.some((k) => k in patch)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Compute the write-time bucket projection columns for a merged `plans` row. Centralised so the
|
|
209
|
+
* gateway is the ONE place `deriveEpicBucket` / `epicIsAcknowledgeable` are applied — the page, SQL,
|
|
210
|
+
* pollers and workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
|
|
211
|
+
function projectPlanBucket(row: Partial<Plan>): Partial<Plan> {
|
|
212
|
+
if (!row.status) return {};
|
|
213
|
+
return {
|
|
214
|
+
list_bucket: deriveEpicBucket(row.status, row.delivery ?? null, row.acknowledged_at ?? null),
|
|
215
|
+
ack_open:
|
|
216
|
+
epicIsAcknowledgeable(row.status, row.delivery ?? null) && (row.acknowledged_at ?? null) === null
|
|
217
|
+
? 1
|
|
218
|
+
: 0,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Re-project every `plans` row through the gateway so rows written before migration 042 (whose
|
|
223
|
+
* `list_bucket`/`ack_open` are NULL) get a correct Active/History bucket. Idempotent and safe to
|
|
224
|
+
* re-run: it re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at
|
|
225
|
+
* boot (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy
|
|
226
|
+
* rows. Returns the count actually stamped. Mirrors `backfillFeatureStages` (app/feature.ts). */
|
|
227
|
+
export async function backfillPlanBuckets(data: DataLayer): Promise<number> {
|
|
228
|
+
const table = plans(data);
|
|
229
|
+
let stamped = 0;
|
|
230
|
+
for (const row of await table.all()) {
|
|
231
|
+
// Only touch rows the projection has never reached — a legacy row whose `list_bucket` is NULL.
|
|
232
|
+
if (row.list_bucket != null) continue;
|
|
233
|
+
await table.update(row.plan_key, projectPlanBucket(row));
|
|
234
|
+
stamped++;
|
|
235
|
+
}
|
|
236
|
+
return stamped;
|
|
237
|
+
}
|
|
140
238
|
export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
|
|
141
239
|
|
|
142
240
|
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
|
|
@@ -173,18 +271,25 @@ export const planDeps = (data: DataLayer) => data.table<PlanDep>("plan_deps", "p
|
|
|
173
271
|
/** The fields an admission caller supplies for one inter-epic edge; `created_at` is stamped here. */
|
|
174
272
|
export type PlanDepInput = Omit<PlanDep, "created_at">;
|
|
175
273
|
|
|
176
|
-
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
274
|
+
/** The record-gateway shape both the durable `plan_deps` table and its FK-free admission-staging
|
|
275
|
+
* twin `admitted_plan_deps` expose. Both hold the identical {@link PlanDep} row, so the idempotent
|
|
276
|
+
* edge-insert below is written once against this shape and reused for both. */
|
|
277
|
+
type PlanDepTable = ReturnType<typeof planDeps>;
|
|
278
|
+
|
|
279
|
+
/** Insert one inter-epic edge into `table` idempotently, enforcing the schema's two invariants at
|
|
280
|
+
* the app layer too (the durable table backstops both, but the in-memory test data layer does not):
|
|
281
|
+
* an epic may not depend on itself, and a consumer→producer edge is recorded at most once. A
|
|
282
|
+
* duplicate re-submission is a no-op that returns the existing row rather than throwing, so batch
|
|
283
|
+
* admission (S2) stays idempotent; a self-edge is a programming/validation error and throws. `label`
|
|
284
|
+
* only names the offending table in the self-edge error. */
|
|
285
|
+
async function insertEdgeIdempotent(
|
|
286
|
+
table: PlanDepTable,
|
|
287
|
+
label: string,
|
|
288
|
+
edge: PlanDepInput,
|
|
289
|
+
): Promise<PlanDep> {
|
|
182
290
|
if (edge.plan_key === edge.depends_on_plan_key) {
|
|
183
|
-
throw new Error(
|
|
184
|
-
`plan_deps: self-edge rejected — epic ${edge.plan_key} cannot depend on itself`,
|
|
185
|
-
);
|
|
291
|
+
throw new Error(`${label}: self-edge rejected — epic ${edge.plan_key} cannot depend on itself`);
|
|
186
292
|
}
|
|
187
|
-
const table = planDeps(data);
|
|
188
293
|
const match = { plan_key: edge.plan_key, depends_on_plan_key: edge.depends_on_plan_key };
|
|
189
294
|
const existing = (await table.find(match))[0];
|
|
190
295
|
if (existing) return existing;
|
|
@@ -204,6 +309,71 @@ export async function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promis
|
|
|
204
309
|
}
|
|
205
310
|
}
|
|
206
311
|
|
|
312
|
+
/** Record one inter-epic dependency edge into the durable `plan_deps` graph. Idempotent on the
|
|
313
|
+
* consumer→producer pair; a self-edge throws. NOTE: `plan_deps.plan_key` foreign-keys to an admitted
|
|
314
|
+
* `plans` row, so this is written by slice S3 (planner lowering), NOT by the S2 admission door —
|
|
315
|
+
* S2 stages edges FK-free via {@link recordAdmittedPlanDep} instead. */
|
|
316
|
+
export function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promise<PlanDep> {
|
|
317
|
+
return insertEdgeIdempotent(planDeps(data), "plan_deps", edge);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** One STAGED admitted epic (issue #292 slice S2, db/migrations/045_epic_set_admission_staging.sql):
|
|
321
|
+
* the FK-free record the set/batch admission door persists per admitted epic so a crash between
|
|
322
|
+
* admission and lowering does not lose the set. It carries exactly what slice S3 (lowering) needs to
|
|
323
|
+
* MATERIALIZE the durable `plans` row — repo, issue number/url, and the normalized integration base
|
|
324
|
+
* branch admitPlan resolved. Keyed on `plan_key`, one staged row per epic (idempotent re-submit). */
|
|
325
|
+
export interface AdmittedEpic {
|
|
326
|
+
plan_key: string;
|
|
327
|
+
repo: string;
|
|
328
|
+
issue_number: number;
|
|
329
|
+
issue_url: string;
|
|
330
|
+
base_branch: string;
|
|
331
|
+
created_at: string;
|
|
332
|
+
}
|
|
333
|
+
export const admittedEpics = (data: DataLayer) =>
|
|
334
|
+
data.table<AdmittedEpic>("admitted_epics", "plan_key");
|
|
335
|
+
|
|
336
|
+
/** The FK-free staging twin of `plan_deps` (db/migrations/045_epic_set_admission_staging.sql): the
|
|
337
|
+
* validated inter-epic edges the S2 admission door stages before any `plans` row exists. Same row
|
|
338
|
+
* shape as {@link PlanDep}; slice S3 reads it to materialize the durable `plan_deps` edges. */
|
|
339
|
+
export const admittedPlanDeps = (data: DataLayer) =>
|
|
340
|
+
data.table<PlanDep>("admitted_plan_deps", "plan_key");
|
|
341
|
+
|
|
342
|
+
/** The fields an admission caller supplies for one staged admitted epic; `created_at` is stamped
|
|
343
|
+
* here (mirrors {@link PlanDepInput}). */
|
|
344
|
+
export type AdmittedEpicInput = Omit<AdmittedEpic, "created_at">;
|
|
345
|
+
|
|
346
|
+
/** Stage one admitted epic (issue #292 slice S2). Idempotent on `plan_key`: a re-submitted set that
|
|
347
|
+
* re-admits the same epic is a no-op returning the existing staged row, so the whole set door stays
|
|
348
|
+
* idempotent (mirroring {@link recordAdmittedPlanDep}). */
|
|
349
|
+
export async function recordAdmittedEpic(
|
|
350
|
+
data: DataLayer,
|
|
351
|
+
epic: AdmittedEpicInput,
|
|
352
|
+
): Promise<AdmittedEpic> {
|
|
353
|
+
const table = admittedEpics(data);
|
|
354
|
+
const existing = await table.get(epic.plan_key);
|
|
355
|
+
if (existing) return existing;
|
|
356
|
+
const row: AdmittedEpic = { ...epic, created_at: now() };
|
|
357
|
+
try {
|
|
358
|
+
await table.insert(row);
|
|
359
|
+
return row;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
// Concurrent re-admission of the same epic races on the PRIMARY KEY (plan_key); honour the
|
|
362
|
+
// idempotent no-op contract by returning the winning row rather than surfacing the collision.
|
|
363
|
+
const raced = await table.get(epic.plan_key);
|
|
364
|
+
if (raced) return raced;
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Stage one validated inter-epic edge (issue #292 slice S2) into the FK-free `admitted_plan_deps`
|
|
370
|
+
* table. Idempotent on the consumer→producer pair; a self-edge throws. This is the S2 door's
|
|
371
|
+
* persistence for edges — it does NOT touch `plan_deps` (whose FK requires a `plans` row S2 has not
|
|
372
|
+
* created); slice S3 materializes the durable edge from this staging. */
|
|
373
|
+
export function recordAdmittedPlanDep(data: DataLayer, edge: PlanDepInput): Promise<PlanDep> {
|
|
374
|
+
return insertEdgeIdempotent(admittedPlanDeps(data), "admitted_plan_deps", edge);
|
|
375
|
+
}
|
|
376
|
+
|
|
207
377
|
/** All INBOUND edges for `planKey` — i.e. every producer epic this dependent waits on. Empty for a
|
|
208
378
|
* root epic (no inter-epic dependencies). */
|
|
209
379
|
export function inboundPlanDeps(data: DataLayer, planKey: string): Promise<PlanDep[]> {
|
|
@@ -484,6 +654,225 @@ export async function admitPlan(
|
|
|
484
654
|
return base;
|
|
485
655
|
}
|
|
486
656
|
|
|
657
|
+
/** Map an error thrown by {@link admitPlan} to the HTTP status + message the admission-door
|
|
658
|
+
* operations return at the edge, or `null` when the error is not an admission decision and must be
|
|
659
|
+
* re-raised (a genuine 500). Shared by the single-issue door (`startPlanFanout`) and the set/batch
|
|
660
|
+
* door (`startEpicSet`, issue #292 S2) so both map an epic's admission failure identically — a base
|
|
661
|
+
* rule reject is a 400, the shared-base conflict a 409. Keeping this in ONE place stops the two doors
|
|
662
|
+
* drifting on which admission failure maps to which status. */
|
|
663
|
+
export function admitPlanErrorResponse(err: unknown): { status: number; error: string } | null {
|
|
664
|
+
if (err instanceof MissingBaseBranchError) {
|
|
665
|
+
return {
|
|
666
|
+
status: 400,
|
|
667
|
+
error: "baseBranch is required (name the integration branch, e.g. epic/agent-protocol)",
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
if (err instanceof InvalidBaseBranchError) {
|
|
671
|
+
return {
|
|
672
|
+
status: 400,
|
|
673
|
+
error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)",
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
if (err instanceof BaseBranchMustExistError) {
|
|
677
|
+
return {
|
|
678
|
+
status: 400,
|
|
679
|
+
error:
|
|
680
|
+
`baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
|
|
681
|
+
`auto-created — create it first, or use the epic/* convention`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (err instanceof DefaultBaseNotConfirmedError) {
|
|
685
|
+
return {
|
|
686
|
+
status: 400,
|
|
687
|
+
error:
|
|
688
|
+
`baseBranch "${err.branch}" is the repository default branch — every task would land ` +
|
|
689
|
+
`directly on it with no integration branch. Re-submit with confirmDefaultBase: true to proceed`,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
if (err instanceof SharedBaseError) {
|
|
693
|
+
return {
|
|
694
|
+
status: 409,
|
|
695
|
+
error:
|
|
696
|
+
`baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
|
|
697
|
+
`allowSharedBase: true to stack on it, or name a distinct epic/* branch`,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Set/batch admission (issue #292, slice S2) ───────────────────────────────
|
|
704
|
+
// The set-admission door (`operations/startEpicSet.ts`) admits a WHOLE set of epics plus the
|
|
705
|
+
// inter-epic dependency edges between them in one all-or-nothing call. The pure validation below
|
|
706
|
+
// (reference integrity + DAG check) runs BEFORE any `admitPlan` side effect, so a malformed set is a
|
|
707
|
+
// clean 4xx with nothing half-started (no base branch created, no edge persisted). The door's only
|
|
708
|
+
// durable write is staging the admitted epics + validated edges FK-free into `admitted_epics` /
|
|
709
|
+
// `admitted_plan_deps`; materializing them into `plans` / `plan_deps` and scheduling/lowering
|
|
710
|
+
// (starting roots, seeding the capability readiness-gate, version binding) is slice S3.
|
|
711
|
+
|
|
712
|
+
/** One inter-epic dependency edge as SUBMITTED to the set door: the `consumer` epic waits for the
|
|
713
|
+
* `producer` epic to publish the `{ package, capabilityRef }` capability. `consumer`/`producer` are
|
|
714
|
+
* epic references (`owner/repo#N` or an issue URL); the door resolves them to plan keys. This type
|
|
715
|
+
* documents the wire contract only — the actual `deps[]` arrives untyped, so {@link validateEpicSet}
|
|
716
|
+
* validates each entry against this shape at runtime rather than trusting the type. */
|
|
717
|
+
export interface EpicSetDepInput {
|
|
718
|
+
consumer: string;
|
|
719
|
+
producer: string;
|
|
720
|
+
package: string;
|
|
721
|
+
capabilityRef: string;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** A validated inter-epic edge — both endpoints resolved to plan keys, ready to persist as a
|
|
725
|
+
* {@link PlanDep} (`plan_key = consumer`, `depends_on_plan_key = producer`). */
|
|
726
|
+
export interface ResolvedEpicSetDep {
|
|
727
|
+
consumer: string;
|
|
728
|
+
producer: string;
|
|
729
|
+
package: string;
|
|
730
|
+
capabilityRef: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** A set-admission validation failure that carries the HTTP status the door returns at the edge
|
|
734
|
+
* (always a 4xx — a malformed set is the caller's error, not a server fault). */
|
|
735
|
+
export class EpicSetValidationError extends Error {
|
|
736
|
+
readonly status: number;
|
|
737
|
+
constructor(status: number, message: string) {
|
|
738
|
+
super(message);
|
|
739
|
+
this.name = "EpicSetValidationError";
|
|
740
|
+
this.status = status;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
|
|
745
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
746
|
+
return typeof value === "object" && value !== null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Pure, side-effect-free validation of a submitted epic set's SHAPE and DAG — run BEFORE any
|
|
750
|
+
* `admitPlan` call so a cycle or dangling edge is rejected with nothing half-started. Given the
|
|
751
|
+
* submitted set's plan keys (already parsed, in submission order) and the raw edges, it:
|
|
752
|
+
* • rejects an empty set;
|
|
753
|
+
* • rejects a duplicate epic in the set;
|
|
754
|
+
* • parses each edge endpoint and rejects an unparseable/self/dangling edge (an endpoint not in
|
|
755
|
+
* the submitted set);
|
|
756
|
+
* • rejects a blank `package`/`capabilityRef`;
|
|
757
|
+
* • rejects a cycle in the consumer→producer graph, naming the edge that closes it.
|
|
758
|
+
* Returns the edges with both endpoints resolved to plan keys. Throws {@link EpicSetValidationError}
|
|
759
|
+
* (status 400) at the first offending input. Idempotent-friendly: a duplicate EDGE (same
|
|
760
|
+
* consumer→producer submitted twice) is collapsed, not rejected, so a retried set validates.
|
|
761
|
+
*
|
|
762
|
+
* `deps` is accepted as `unknown[]` because it arrives straight from an untyped request body
|
|
763
|
+
* (`startEpicSet` forwards `body.deps` verbatim). Every entry's shape is therefore validated
|
|
764
|
+
* defensively here — a non-object entry (`null`, `{}`), or a non-string endpoint / `package` /
|
|
765
|
+
* `capabilityRef`, maps to a clean {@link EpicSetValidationError} (400), never an uncaught
|
|
766
|
+
* TypeError (500). */
|
|
767
|
+
export function validateEpicSet(planKeys: string[], deps: readonly unknown[]): ResolvedEpicSetDep[] {
|
|
768
|
+
if (planKeys.length === 0) {
|
|
769
|
+
throw new EpicSetValidationError(400, "epic set is empty — submit at least one epic");
|
|
770
|
+
}
|
|
771
|
+
const inSet = new Set<string>();
|
|
772
|
+
for (const key of planKeys) {
|
|
773
|
+
if (inSet.has(key)) {
|
|
774
|
+
throw new EpicSetValidationError(400, `epic ${key} appears more than once in the submitted set`);
|
|
775
|
+
}
|
|
776
|
+
inSet.add(key);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const resolveEndpoint = (ref: string, role: "consumer" | "producer"): string => {
|
|
780
|
+
// Trim like the epic-member path (`parseIssue(ref.trim())` in startEpicSet) so an otherwise-valid
|
|
781
|
+
// padded endpoint (" owner/repo#1 ") from an untyped JSON payload is not rejected as unparseable.
|
|
782
|
+
const parsed = parseIssue(ref.trim());
|
|
783
|
+
if (!parsed) {
|
|
784
|
+
throw new EpicSetValidationError(
|
|
785
|
+
400,
|
|
786
|
+
`dependency ${role} "${ref}" could not be parsed (use owner/repo#123 or an issue URL)`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
if (!inSet.has(parsed.planKey)) {
|
|
790
|
+
throw new EpicSetValidationError(
|
|
791
|
+
400,
|
|
792
|
+
`dependency ${role} ${parsed.planKey} is not one of the submitted epics — every edge must ` +
|
|
793
|
+
`connect two epics in the set`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
return parsed.planKey;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const resolved: ResolvedEpicSetDep[] = [];
|
|
800
|
+
const seenEdges = new Set<string>();
|
|
801
|
+
// consumer plan key → set of producer plan keys it depends on (for the DAG / cycle check).
|
|
802
|
+
const adjacency = new Map<string, Set<string>>();
|
|
803
|
+
for (const rawDep of deps) {
|
|
804
|
+
if (!isRecord(rawDep)) {
|
|
805
|
+
throw new EpicSetValidationError(
|
|
806
|
+
400,
|
|
807
|
+
"each dependency must be an object with consumer, producer, package and capabilityRef fields",
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (typeof rawDep.consumer !== "string" || typeof rawDep.producer !== "string") {
|
|
811
|
+
throw new EpicSetValidationError(
|
|
812
|
+
400,
|
|
813
|
+
"each dependency needs string consumer and producer endpoints (owner/repo#123 or an issue URL)",
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
const consumer = resolveEndpoint(rawDep.consumer, "consumer");
|
|
817
|
+
const producer = resolveEndpoint(rawDep.producer, "producer");
|
|
818
|
+
if (consumer === producer) {
|
|
819
|
+
throw new EpicSetValidationError(400, `epic ${consumer} cannot depend on itself`);
|
|
820
|
+
}
|
|
821
|
+
const pkg = (typeof rawDep.package === "string" ? rawDep.package : "").trim();
|
|
822
|
+
const capabilityRef = (typeof rawDep.capabilityRef === "string" ? rawDep.capabilityRef : "").trim();
|
|
823
|
+
if (pkg.length === 0) {
|
|
824
|
+
throw new EpicSetValidationError(
|
|
825
|
+
400,
|
|
826
|
+
`dependency ${consumer} → ${producer} is missing a package (the producer's published package)`,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
if (capabilityRef.length === 0) {
|
|
830
|
+
throw new EpicSetValidationError(
|
|
831
|
+
400,
|
|
832
|
+
`dependency ${consumer} → ${producer} is missing a capabilityRef (the producer's issue handle)`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
const edgeId = `${consumer}\u0000${producer}`;
|
|
836
|
+
if (seenEdges.has(edgeId)) continue; // duplicate edge in one submission → collapse (idempotent)
|
|
837
|
+
seenEdges.add(edgeId);
|
|
838
|
+
resolved.push({ consumer, producer, package: pkg, capabilityRef });
|
|
839
|
+
const producers = adjacency.get(consumer) ?? new Set<string>();
|
|
840
|
+
producers.add(producer);
|
|
841
|
+
adjacency.set(consumer, producers);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
assertAcyclic(adjacency);
|
|
845
|
+
return resolved;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Depth-first cycle check over the consumer→producer graph. Throws {@link EpicSetValidationError}
|
|
849
|
+
* (400) naming an edge on the cycle the moment one is found — the "reject at the offending edge"
|
|
850
|
+
* guarantee. A pure in-memory walk (no I/O), so it runs before any admission side effect. */
|
|
851
|
+
function assertAcyclic(adjacency: Map<string, Set<string>>): void {
|
|
852
|
+
const VISITING = 1;
|
|
853
|
+
const DONE = 2;
|
|
854
|
+
const state = new Map<string, number>();
|
|
855
|
+
const visit = (node: string, stack: string[]): void => {
|
|
856
|
+
state.set(node, VISITING);
|
|
857
|
+
stack.push(node);
|
|
858
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
859
|
+
const s = state.get(next);
|
|
860
|
+
if (s === VISITING) {
|
|
861
|
+
throw new EpicSetValidationError(
|
|
862
|
+
400,
|
|
863
|
+
`dependency cycle detected: ${[...stack, next].join(" → ")} — the edge set must be a DAG`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
if (s !== DONE) visit(next, stack);
|
|
867
|
+
}
|
|
868
|
+
stack.pop();
|
|
869
|
+
state.set(node, DONE);
|
|
870
|
+
};
|
|
871
|
+
for (const node of adjacency.keys()) {
|
|
872
|
+
if (state.get(node) !== DONE) visit(node, []);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
487
876
|
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
488
877
|
* planKey: a plan already in flight is not restarted. */
|
|
489
878
|
export async function startPlan(
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Tests for the `plans` gateway's write-time epic-bucket projection (issue #298) and its one-shot
|
|
2
|
+
// backfill. The gateway wraps the plain table so EVERY writer — startPlan, the record workers, the
|
|
3
|
+
// delivery poller, the acknowledge-epic op — automatically gets a fresh `list_bucket`/`ack_open`
|
|
4
|
+
// projection without passing them: the single write path is the only place `deriveEpicBucket` /
|
|
5
|
+
// `epicIsAcknowledgeable` are applied. Mirrors app/featureGateway.test.ts.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { backfillPlanBuckets, plans } from "./plan.ts";
|
|
10
|
+
|
|
11
|
+
// In-memory record gateway with the same semantics the real Table exposes. The `plans` proxy wraps
|
|
12
|
+
// whatever data.table returns, so this exercises the real proxy.
|
|
13
|
+
function memData(): { data: DataLayer; rows: any[] } {
|
|
14
|
+
const rows: any[] = [];
|
|
15
|
+
function tbl(_name: string, pk = "id") {
|
|
16
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
17
|
+
return {
|
|
18
|
+
async all() {
|
|
19
|
+
return rows.slice();
|
|
20
|
+
},
|
|
21
|
+
async get(id: any) {
|
|
22
|
+
return rows.find((r) => r[pk] === id);
|
|
23
|
+
},
|
|
24
|
+
async find(where: any = {}) {
|
|
25
|
+
return rows.filter((r) => match(r, where));
|
|
26
|
+
},
|
|
27
|
+
async insert(row: any) {
|
|
28
|
+
rows.push({ ...row });
|
|
29
|
+
return row[pk];
|
|
30
|
+
},
|
|
31
|
+
async update(id: any, patch: any) {
|
|
32
|
+
const r = rows.find((row) => row[pk] === id);
|
|
33
|
+
if (r) Object.assign(r, patch);
|
|
34
|
+
return r ? 1 : 0;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
39
|
+
return { data, rows };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("insert projects list_bucket/ack_open from status without the caller passing them", async () => {
|
|
43
|
+
const { data, rows } = memData();
|
|
44
|
+
await plans(data).insert({ plan_key: "o/r#1", status: "planning" });
|
|
45
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
46
|
+
assertEquals(rows[0].ack_open, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("status flip to done+converging keeps the epic Active (no vanish)", async () => {
|
|
50
|
+
const { data, rows } = memData();
|
|
51
|
+
rows.push({ plan_key: "o/r#2", status: "dispatched", delivery: null });
|
|
52
|
+
// record-results marks the epic done; the delivery poller then sets converging.
|
|
53
|
+
await plans(data).update("o/r#2", { status: "done" });
|
|
54
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
55
|
+
await plans(data).update("o/r#2", { delivery: "converging", delivery_label: "1/2 slices merged, 1 converging" });
|
|
56
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
57
|
+
assertEquals(rows[0].ack_open, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("delivery landing opens the Dismiss affordance (ack_open=1) but keeps it Active until acknowledged", async () => {
|
|
61
|
+
const { data, rows } = memData();
|
|
62
|
+
rows.push({ plan_key: "o/r#3", status: "done", delivery: "converging" });
|
|
63
|
+
await plans(data).update("o/r#3", { delivery: "landed", delivery_label: "2/2 slices merged" });
|
|
64
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
65
|
+
assertEquals(rows[0].ack_open, 1);
|
|
66
|
+
// Operator dismisses → gateway reprojects to History, ack_open closes.
|
|
67
|
+
await plans(data).update("o/r#3", { acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
68
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
69
|
+
assertEquals(rows[0].ack_open, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a projection-irrelevant patch (wave_label only) does not disturb the stored bucket", async () => {
|
|
73
|
+
const { data, rows } = memData();
|
|
74
|
+
rows.push({ plan_key: "o/r#4", status: "done", delivery: "landed", list_bucket: "active", ack_open: 1 });
|
|
75
|
+
await plans(data).update("o/r#4", { wave_label: "2/2" });
|
|
76
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
77
|
+
assertEquals(rows[0].ack_open, 1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a direct write to list_bucket is overridden by the canonical derivation (no bypass)", async () => {
|
|
81
|
+
const { data, rows } = memData();
|
|
82
|
+
rows.push({ plan_key: "o/r#5", status: "done", delivery: "converging" });
|
|
83
|
+
// A caller tries to force History; the gateway re-derives from status+delivery and overrides it.
|
|
84
|
+
await plans(data).update("o/r#5", { list_bucket: "history" });
|
|
85
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("backfillPlanBuckets stamps only legacy (NULL list_bucket) rows, idempotently", async () => {
|
|
89
|
+
const { data, rows } = memData();
|
|
90
|
+
rows.push({ plan_key: "o/r#legacy", status: "done", delivery: "converging", list_bucket: null, ack_open: null });
|
|
91
|
+
rows.push({ plan_key: "o/r#fresh", status: "planning", list_bucket: "active", ack_open: 0 });
|
|
92
|
+
const stamped = await backfillPlanBuckets(data);
|
|
93
|
+
assertEquals(stamped, 1);
|
|
94
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
95
|
+
assertEquals(rows[0].ack_open, 0);
|
|
96
|
+
// Second pass is a no-op: every row is now projected.
|
|
97
|
+
assertEquals(await backfillPlanBuckets(data), 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("terminal failed epic buckets to History", async () => {
|
|
101
|
+
const { data, rows } = memData();
|
|
102
|
+
rows.push({ plan_key: "o/r#6", status: "dispatched" });
|
|
103
|
+
await plans(data).update("o/r#6", { status: "failed" });
|
|
104
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
105
|
+
assert(!rows[0].ack_open);
|
|
106
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { pollLineage } from "./lineage.ts";
|
|
|
33
33
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
34
34
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
35
35
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
36
|
-
import { planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
36
|
+
import { backfillPlanBuckets, planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
37
37
|
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
38
38
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
39
39
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
@@ -1773,6 +1773,11 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1773
1773
|
* on every poll. */
|
|
1774
1774
|
let featureStagesBackfilled = false;
|
|
1775
1775
|
|
|
1776
|
+
/** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
|
|
1777
|
+
* process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
|
|
1778
|
+
* `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
|
|
1779
|
+
let planBucketsBackfilled = false;
|
|
1780
|
+
|
|
1776
1781
|
export async function pollOnce(
|
|
1777
1782
|
data: DataLayer,
|
|
1778
1783
|
engine: EngineClient,
|
|
@@ -1789,6 +1794,13 @@ export async function pollOnce(
|
|
|
1789
1794
|
await backfillFeatureStages(data);
|
|
1790
1795
|
featureStagesBackfilled = true;
|
|
1791
1796
|
}
|
|
1797
|
+
// One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
|
|
1798
|
+
// epic buckets correctly into Active/History from the first pass. Guard armed only after success so
|
|
1799
|
+
// a transient failure retries next pass (mirrors the feature-stage backfill above).
|
|
1800
|
+
if (!planBucketsBackfilled) {
|
|
1801
|
+
await backfillPlanBuckets(data);
|
|
1802
|
+
planBucketsBackfilled = true;
|
|
1803
|
+
}
|
|
1792
1804
|
await pollReviews(data, engine, token);
|
|
1793
1805
|
await pollMerges(data, engine, token);
|
|
1794
1806
|
await pollDelivery(data);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- 044_plan_list_bucket.sql — issue #298: bucket EPICS on the derived `delivery` rollup, not raw
|
|
2
|
+
-- `plan.status`, so a `done` epic whose slice PRs are still CONVERGING — or one that has fully LANDED
|
|
3
|
+
-- but still needs its integration→main promotion PR — does NOT silently vanish from the Active epic
|
|
4
|
+
-- lists the instant `status = done`. Mirrors the feature-run Active/History tick-off partition
|
|
5
|
+
-- (038/039 `feature_runs`): reify the partition as a derived, write-time-projected column so the
|
|
6
|
+
-- declarative epic/overview page tabs can filter with only stored `{"field":…}` `in` clauses (the
|
|
7
|
+
-- dataGrid page DSL has no OR / IS NULL), and give epics the same operator "Dismiss" affordance
|
|
8
|
+
-- feature runs already have.
|
|
9
|
+
--
|
|
10
|
+
-- Columns (all maintained by the `plans` gateway — app/plan.ts — from the pure `deriveEpicBucket` /
|
|
11
|
+
-- `epicIsAcknowledgeable` helpers in app/delivery.ts on every write, never hand-derived in SQL, the
|
|
12
|
+
-- page, or a poller):
|
|
13
|
+
-- • acknowledged_at — NULL until an operator dismisses a RESOLVED epic (acknowledge-epic) — landed
|
|
14
|
+
-- or resolved-not-landed (`delivery=null`); only still-`converging` epics are
|
|
15
|
+
-- rejected. The twin of feature_runs.acknowledged_at (039).
|
|
16
|
+
-- • list_bucket — 'active' | 'history': deriveEpicBucket(status, delivery, acknowledged_at).
|
|
17
|
+
-- Active = live epics (planning/dispatched) + `done` epics not yet acknowledged
|
|
18
|
+
-- (still converging, landed-but-unpromoted, or resolved-not-landed); History =
|
|
19
|
+
-- acknowledged `done` epics and terminal failed/abandoned epics.
|
|
20
|
+
-- • ack_open — 1 | 0: 1 iff the epic is a RESOLVED (`done`, not `converging`) but
|
|
21
|
+
-- unacknowledged epic — the Active states that carry the Dismiss affordance — so
|
|
22
|
+
-- the page's `showWhenField` gates the button precisely. Mirrors
|
|
23
|
+
-- feature_runs.escalation_open (040).
|
|
24
|
+
--
|
|
25
|
+
-- Forward-only, additive (expand): all nullable with no default, so pre-#298 rows grandfather in as
|
|
26
|
+
-- NULL and never gate control flow. `backfillPlanBuckets` (app/plan.ts) stamps legacy rows once at
|
|
27
|
+
-- boot, and the gateway keeps every future write fresh. Numbered after the current highest prefix on
|
|
28
|
+
-- origin/main (041); the runner wraps each file in its own transaction, so this file must NOT contain
|
|
29
|
+
-- BEGIN/COMMIT.
|
|
30
|
+
ALTER TABLE plans ADD COLUMN acknowledged_at TEXT;
|
|
31
|
+
ALTER TABLE plans ADD COLUMN list_bucket TEXT;
|
|
32
|
+
ALTER TABLE plans ADD COLUMN ack_open INTEGER;
|