@nanobpm/nano-workforce 0.90.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/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 { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
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) => data.table<Plan>("plans", "plan_key");
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
- /** Record one inter-epic dependency edge, enforcing the schema's two invariants at the app layer too
177
- * (the durable table backstops both, but the in-memory test data layer does not): an epic may not
178
- * depend on itself, and a consumer→producer edge is recorded at most once. A duplicate re-submission
179
- * is a no-op that returns the existing row rather than throwing, so batch admission (S2) stays
180
- * idempotent; a self-edge is a programming/validation error and throws. */
181
- export async function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promise<PlanDep> {
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
+ });
@@ -78,6 +78,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
78
78
  status: "escalated",
79
79
  process_key: "fp-10",
80
80
  issue_url: "https://github.com/o/r/issues/10",
81
+ title: "Add the framework selector",
81
82
  escalation_user_task_key: "ut-feat",
82
83
  escalation_question: "which framework?",
83
84
  blocked_user_task_key: null,
@@ -85,7 +86,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
85
86
  },
86
87
  ],
87
88
  plans: [
88
- { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20" },
89
+ { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
89
90
  { plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
90
91
  ],
91
92
  plan_reviews: [
@@ -96,7 +97,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
96
97
  { id: 1, plan_key: "o/r#20", wave: 0, result: "suite-failed", summary: "wave 0 red", resolved: 0 },
97
98
  ],
98
99
  pull_requests: [
99
- { pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30" },
100
+ { pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30", title: "Resolve the reviews" },
100
101
  ],
101
102
  escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
102
103
  });
@@ -115,13 +116,17 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
115
116
  assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan", "ut-pr", "ut-trial"]);
116
117
  assertEquals(byKey["ut-feat"].kind_label, "Feature escalation");
117
118
  assertEquals(byKey["ut-feat"].question, "which framework?");
119
+ assertEquals(byKey["ut-feat"].subject_title, "Add the framework selector");
118
120
  assertEquals(byKey["ut-plan"].kind_label, "Plan review");
119
121
  assertEquals(byKey["ut-plan"].question, "scope too broad");
122
+ assertEquals(byKey["ut-plan"].subject_title, "Broaden the epic scope");
120
123
  assertEquals(byKey["ut-trial"].kind_label, "Trial merge");
121
124
  assertEquals(byKey["ut-trial"].question, "wave 0 red");
125
+ assertEquals(byKey["ut-trial"].subject_title, "Broaden the epic scope");
122
126
  assertEquals(byKey["ut-pr"].kind_label, "PR review");
123
127
  assertEquals(byKey["ut-pr"].subject_type, "pr");
124
128
  assertEquals(byKey["ut-pr"].question, "conflicting reviews");
129
+ assertEquals(byKey["ut-pr"].subject_title, "Resolve the reviews");
125
130
  });
126
131
 
127
132
  test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {