@nanobpm/nano-workforce 0.97.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/capabilityNeed.test.ts +156 -0
- package/app/capabilityNeed.ts +200 -0
- package/app/capsWait.test.ts +39 -0
- package/app/capsWait.ts +33 -0
- package/app/contracts.ts +8 -0
- package/app/mergeEscalationQuestion.test.ts +140 -0
- package/app/plan.ts +63 -0
- package/app/service.test.ts +375 -1
- package/app/service.ts +239 -0
- package/app/waitGateVisibility.test.ts +5 -2
- package/db/migrations/049_plan_task_needs.sql +30 -0
- package/db/migrations/050_capability_gates.sql +40 -0
- package/e2e/plan-fanout.e2e.ts +145 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +54 -17
- package/resources/processes/plan-fanout.bpmn +347 -194
- package/resources/prompts/plan.md +29 -1
- package/workers/caps-prepare/worker.test.ts +62 -0
- package/workers/caps-prepare/worker.ts +38 -0
- package/workers/record-plan/worker.test.ts +33 -1
- package/workers/record-plan/worker.ts +24 -1
- package/workers/select-wave/worker.test.ts +34 -2
- package/workers/select-wave/worker.ts +26 -2
package/app/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
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 { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
|
|
14
15
|
import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
15
16
|
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
16
17
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
@@ -37,6 +38,17 @@ export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
|
|
|
37
38
|
DEFAULT_ESCALATION_SLA_TIMEOUT,
|
|
38
39
|
);
|
|
39
40
|
|
|
41
|
+
/** The fleet-wide capability-barrier bound (ISO-8601 duration) seeded onto every plan-fanout instance
|
|
42
|
+
* as the `capsWaitTimeout` process variable and evaluated by the `wait-caps-timeout` timer arm of the
|
|
43
|
+
* `wait-caps-resolved` event-based gateway. An operator sets `NANO_CAPS_WAIT_TIMEOUT`; a malformed
|
|
44
|
+
* value falls back to {@link DEFAULT_CAPS_WAIT_TIMEOUT} so a bad env can never deploy an
|
|
45
|
+
* uninterpretable timer. Bounds a task's wait on an unresolvable cross-repo capability so it escalates
|
|
46
|
+
* to an operator instead of parking forever. */
|
|
47
|
+
export const CAPS_WAIT_TIMEOUT = capsWaitTimeout(
|
|
48
|
+
process.env.NANO_CAPS_WAIT_TIMEOUT,
|
|
49
|
+
DEFAULT_CAPS_WAIT_TIMEOUT,
|
|
50
|
+
);
|
|
51
|
+
|
|
40
52
|
const now = () => new Date().toISOString();
|
|
41
53
|
|
|
42
54
|
// Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
|
|
@@ -263,6 +275,51 @@ export interface PlanTaskDep {
|
|
|
263
275
|
export const planTaskDeps = (data: DataLayer) =>
|
|
264
276
|
data.table<PlanTaskDep>("plan_task_deps", "plan_key");
|
|
265
277
|
|
|
278
|
+
/** One cross-repo CAPABILITY EDGE on a plan task (049_plan_task_needs.sql, issue #289): the
|
|
279
|
+
* consuming `task_id` must not start until the upstream capability `capability_ref` first ships as
|
|
280
|
+
* a published `package` version. Levelized from the planner's `RecordPlanTask.needs[]` by
|
|
281
|
+
* `pr.record-plan`, read back by `pr.select-wave` to gate the task before dispatch. Keyed on
|
|
282
|
+
* `plan_key` (like {@link PlanTaskDep}) so one delete clears a plan's whole need set on re-plan.
|
|
283
|
+
*
|
|
284
|
+
* `capability_ref` is the STABLE handle (`owner/repo#NNN` | `repo#NNN` | `#NNN`) — NEVER a version
|
|
285
|
+
* (the #263 core decision). `package` is the per-package-scoped provenance artifact. `verify_command`
|
|
286
|
+
* is the optional gated empirical fallback (#274 decision 5); NULL means deterministic-provenance-only. */
|
|
287
|
+
export interface PlanTaskNeed {
|
|
288
|
+
plan_key: string;
|
|
289
|
+
task_id: string;
|
|
290
|
+
capability_ref: string;
|
|
291
|
+
package: string;
|
|
292
|
+
verify_command: string | null;
|
|
293
|
+
}
|
|
294
|
+
export const planTaskNeeds = (data: DataLayer) =>
|
|
295
|
+
data.table<PlanTaskNeed>("plan_task_needs", "plan_key");
|
|
296
|
+
|
|
297
|
+
/** One host-orchestrated CAPABILITY GATE (050_capability_gates.sql, issue #289): the durable,
|
|
298
|
+
* idempotent state the `pollCapabilityGatesImpl` reconciler keeps for ONE (plan, task, capability
|
|
299
|
+
* need) while its plan-fanout fan-out is parked at the `wait-caps-resolved` barrier. The host starts
|
|
300
|
+
* the EXISTING `readiness-gate` process (#258) per need — recording its instance key on `process_key`
|
|
301
|
+
* so it starts exactly once — and, each pass, reconciles whether the capability has shipped as a
|
|
302
|
+
* published `pkg@version`; on match it stamps `resolved_artifact` and flips `status` to `resolved`.
|
|
303
|
+
* When every one of a task's needs is `resolved` the reconciler publishes `caps-resolved` (releasing
|
|
304
|
+
* the barrier with the late-bound brief). Keyed on the readiness-gate correlation key
|
|
305
|
+
* `<plan_key>:<task_id>:<capability_ref>:<package>` ({@link capabilityGateKey}) so a host restart
|
|
306
|
+
* re-derives the whole picture from the DB — never re-starting a gate nor re-publishing a settled
|
|
307
|
+
* barrier. `package` is part of the key so two needs sharing a `capability_ref` across different
|
|
308
|
+
* packages never collide on one gate row. */
|
|
309
|
+
export interface CapabilityGate {
|
|
310
|
+
gate_key: string;
|
|
311
|
+
plan_key: string;
|
|
312
|
+
task_id: string;
|
|
313
|
+
capability_ref: string;
|
|
314
|
+
package: string;
|
|
315
|
+
status: string;
|
|
316
|
+
resolved_artifact: string | null;
|
|
317
|
+
process_key: string | null;
|
|
318
|
+
created_at: string;
|
|
319
|
+
updated_at: string;
|
|
320
|
+
}
|
|
321
|
+
export const capabilityGates = (data: DataLayer) =>
|
|
322
|
+
data.table<CapabilityGate>("capability_gates", "gate_key");
|
|
266
323
|
/** One INTER-epic dependency edge in the plan-set DAG (issue #292, slice S1): the epic `plan_key`
|
|
267
324
|
* waits for the producer epic `depends_on_plan_key` to publish a capability before it may fan out.
|
|
268
325
|
*
|
|
@@ -1014,6 +1071,12 @@ export async function startPlan(
|
|
|
1014
1071
|
// `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
|
|
1015
1072
|
escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
|
|
1016
1073
|
escalationAssignee: null,
|
|
1074
|
+
// Capability-barrier bound (#289): the validated ISO-8601 duration read by the
|
|
1075
|
+
// `wait-caps-timeout` timer arm of the `wait-caps-resolved` event-based gateway. A task whose
|
|
1076
|
+
// declared cross-repo capabilities never resolve (most acutely an unresolvable capabilityRef the
|
|
1077
|
+
// host reconciler can never gate) escalates to the `feature-escalation` operator user task when
|
|
1078
|
+
// this elapses, instead of parking at the barrier forever — durable in-process liveness.
|
|
1079
|
+
capsWaitTimeout: CAPS_WAIT_TIMEOUT,
|
|
1017
1080
|
// Coordination blackboard (#51): the capability URL + the protocol brief that each
|
|
1018
1081
|
// implementer agent gets appended to its prompt (composed into `appendPrompt` in
|
|
1019
1082
|
// plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
|
package/app/service.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import { parsePr, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
10
|
+
import { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
11
11
|
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
@@ -15,6 +15,8 @@ function memTable(rows: any[], key: string) {
|
|
|
15
15
|
all: () => Promise.resolve([...rows]),
|
|
16
16
|
find: (q: any) =>
|
|
17
17
|
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
18
|
+
findOne: (q: any) =>
|
|
19
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
18
20
|
insert: (r: any) => {
|
|
19
21
|
rows.push(r);
|
|
20
22
|
return Promise.resolve(r);
|
|
@@ -704,3 +706,375 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
704
706
|
assertEquals(plan.gate_wave, 1, "gate_wave must stay armed while no OPEN subscription is confirmed");
|
|
705
707
|
});
|
|
706
708
|
});
|
|
709
|
+
|
|
710
|
+
// ── pollCapabilityGatesImpl — the host half of the cross-repo capability edge (issue #289) ──────────
|
|
711
|
+
//
|
|
712
|
+
// plan-fanout parks a task with capability `needs` at the `wait-caps-resolved` message barrier. This
|
|
713
|
+
// reconciler, on every pass, (a) starts the durable `readiness-gate` once per need, (b) does a single
|
|
714
|
+
// DETERMINISTIC provenance lookup (`probeOnce`, reused verbatim), and (c) publishes `caps-resolved`
|
|
715
|
+
// (correlated on the per-task barrier key `<planKey>:<taskId>`) with the late-bound resolved-deps brief
|
|
716
|
+
// ONLY when EVERY need has shipped as a published `pkg@version` AND the barrier subscription is open.
|
|
717
|
+
//
|
|
718
|
+
// Stubs: `/message-subscriptions/search` (toggle the barrier open per barrier key), a capture engine
|
|
719
|
+
// (`createInstance`/`publishMessage`), and a `ProbeExec` returning a canned `gh api .../releases`
|
|
720
|
+
// payload so `matchCapability` resolves the lowest capability-bearing version — all hermetic.
|
|
721
|
+
|
|
722
|
+
function capsSubscriptionFetch(openKeys: Set<string>) {
|
|
723
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
724
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
725
|
+
if (!u.endsWith("/message-subscriptions/search")) throw new Error(`unexpected fetch: ${u}`);
|
|
726
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
727
|
+
filter?: { messageName?: string; processInstanceKey?: string };
|
|
728
|
+
};
|
|
729
|
+
const key = body.filter?.processInstanceKey ?? "";
|
|
730
|
+
// The reconciler filters by processInstanceKey + messageName; we toggle by barrier correlationKey,
|
|
731
|
+
// which the search response carries back on each item. Return an open item for every requested key
|
|
732
|
+
// registered in `openKeys` (keyed by the barrier correlationKey the caller expects).
|
|
733
|
+
const items = [...openKeys]
|
|
734
|
+
.filter((k) => k.startsWith(`${key}|`))
|
|
735
|
+
.map((k) => ({
|
|
736
|
+
messageName: "caps-resolved",
|
|
737
|
+
correlationKey: k.slice(k.indexOf("|") + 1),
|
|
738
|
+
messageSubscriptionState: "CREATED",
|
|
739
|
+
}));
|
|
740
|
+
return Promise.resolve(
|
|
741
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
742
|
+
);
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// A `ProbeExec` whose `gh api .../releases` output resolves `capabilityRef` #274 to `@nanobpm/urban@0.54.0`
|
|
747
|
+
// (the LOWEST version whose body references #274) — or resolves nothing when `ready` is false.
|
|
748
|
+
function capsProbeExec(ready: boolean) {
|
|
749
|
+
const releases = ready
|
|
750
|
+
? [
|
|
751
|
+
{ tag_name: "@nanobpm/urban@0.55.0", body: "## Provenance\n- nanobpm/nano-ide#274" },
|
|
752
|
+
{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- nanobpm/nano-ide#274" },
|
|
753
|
+
{ tag_name: "@nanobpm/urban@0.53.0", body: "unrelated" },
|
|
754
|
+
]
|
|
755
|
+
: [{ tag_name: "@nanobpm/urban@0.53.0", body: "unrelated" }];
|
|
756
|
+
const calls: string[] = [];
|
|
757
|
+
return {
|
|
758
|
+
calls,
|
|
759
|
+
exec: {
|
|
760
|
+
httpGet: () => Promise.reject(new Error("no http probe expected")),
|
|
761
|
+
run: (command: string) => {
|
|
762
|
+
calls.push(command);
|
|
763
|
+
return Promise.resolve({ code: 0, stdout: JSON.stringify(releases), stderr: "" });
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
770
|
+
return {
|
|
771
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
772
|
+
} as any;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function capsEngine() {
|
|
776
|
+
const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
|
|
777
|
+
const published: { name: string; correlationKey?: string; variables?: Record<string, unknown> }[] = [];
|
|
778
|
+
let seq = 0;
|
|
779
|
+
return {
|
|
780
|
+
created,
|
|
781
|
+
published,
|
|
782
|
+
engine: {
|
|
783
|
+
createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
|
|
784
|
+
created.push(req);
|
|
785
|
+
return Promise.resolve({ processInstanceKey: `RG-${++seq}` });
|
|
786
|
+
},
|
|
787
|
+
publishMessage: (input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
|
|
788
|
+
published.push(input);
|
|
789
|
+
return Promise.resolve();
|
|
790
|
+
},
|
|
791
|
+
} as any,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
test("pollCapabilityGatesImpl: releases a task once every need ships, starting the gate + publishing the resolved brief (#289)", async () => {
|
|
796
|
+
const PLAN_KEY = "owner/repo#7";
|
|
797
|
+
const PI = "PI-289";
|
|
798
|
+
const TASK = "gap-a";
|
|
799
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
800
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
801
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
802
|
+
plan_task_needs: {
|
|
803
|
+
rows: [
|
|
804
|
+
{
|
|
805
|
+
plan_key: PLAN_KEY,
|
|
806
|
+
task_id: TASK,
|
|
807
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
808
|
+
package: "@nanobpm/urban",
|
|
809
|
+
verify_command: null,
|
|
810
|
+
},
|
|
811
|
+
],
|
|
812
|
+
key: "plan_key",
|
|
813
|
+
},
|
|
814
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
815
|
+
};
|
|
816
|
+
const data = capsDataLayer(stores);
|
|
817
|
+
const { engine, created, published } = capsEngine();
|
|
818
|
+
const { exec, calls } = capsProbeExec(true);
|
|
819
|
+
const headers = { "content-type": "application/json" };
|
|
820
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
821
|
+
const prevFetch = globalThis.fetch;
|
|
822
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
823
|
+
try {
|
|
824
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
825
|
+
} finally {
|
|
826
|
+
globalThis.fetch = prevFetch;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The durable readiness-gate was started exactly once, its instance key persisted.
|
|
830
|
+
assertEquals(created.length, 1, "readiness-gate started exactly once");
|
|
831
|
+
assertEquals(created[0]?.processDefinitionId, "readiness-gate");
|
|
832
|
+
assertEquals(stores.capability_gates.rows.length, 1);
|
|
833
|
+
assertEquals(stores.capability_gates.rows[0]?.process_key, "RG-1");
|
|
834
|
+
assertEquals(stores.capability_gates.rows[0]?.status, "resolved");
|
|
835
|
+
assertEquals(stores.capability_gates.rows[0]?.resolved_artifact, "@nanobpm/urban@0.54.0");
|
|
836
|
+
|
|
837
|
+
// The barrier was released once with the late-bound brief pinning the LOWEST capability-bearing version.
|
|
838
|
+
assertEquals(published.length, 1, "caps-resolved published once");
|
|
839
|
+
assertEquals(published[0]?.name, "caps-resolved");
|
|
840
|
+
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
841
|
+
const brief = String(published[0]?.variables?.["resolvedDepsBrief"] ?? "");
|
|
842
|
+
assertEquals(brief.includes("@nanobpm/urban@0.54.0"), true, "brief pins the resolved artifact");
|
|
843
|
+
assertEquals(brief.includes("nanobpm/nano-ide#274"), true, "brief names the capability ref");
|
|
844
|
+
assertEquals(calls.length, 1, "one deterministic provenance lookup");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("pollCapabilityGatesImpl: an unresolved need starts the gate but never releases the barrier (#289)", async () => {
|
|
848
|
+
const PLAN_KEY = "owner/repo#8";
|
|
849
|
+
const PI = "PI-290";
|
|
850
|
+
const TASK = "gap-b";
|
|
851
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
852
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
853
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
854
|
+
plan_task_needs: {
|
|
855
|
+
rows: [
|
|
856
|
+
{
|
|
857
|
+
plan_key: PLAN_KEY,
|
|
858
|
+
task_id: TASK,
|
|
859
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
860
|
+
package: "@nanobpm/urban",
|
|
861
|
+
verify_command: null,
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
key: "plan_key",
|
|
865
|
+
},
|
|
866
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
867
|
+
};
|
|
868
|
+
const data = capsDataLayer(stores);
|
|
869
|
+
const { engine, created, published } = capsEngine();
|
|
870
|
+
const { exec } = capsProbeExec(false); // capability not published yet
|
|
871
|
+
const headers = { "content-type": "application/json" };
|
|
872
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
873
|
+
const prevFetch = globalThis.fetch;
|
|
874
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
875
|
+
try {
|
|
876
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
877
|
+
} finally {
|
|
878
|
+
globalThis.fetch = prevFetch;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// The gate is still started (bounded/durable wait + operator escalation) but no release.
|
|
882
|
+
assertEquals(created.length, 1, "gate started even while unresolved");
|
|
883
|
+
assertEquals(stores.capability_gates.rows[0]?.status, "pending");
|
|
884
|
+
assertEquals(stores.capability_gates.rows[0]?.resolved_artifact, null);
|
|
885
|
+
assertEquals(published.length, 0, "barrier NOT released until the capability ships");
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("pollCapabilityGatesImpl: level-triggered — no publish and no re-probe when the barrier subscription is not open (#289)", async () => {
|
|
889
|
+
const PLAN_KEY = "owner/repo#9";
|
|
890
|
+
const PI = "PI-291";
|
|
891
|
+
const TASK = "gap-c";
|
|
892
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
893
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
894
|
+
plan_task_needs: {
|
|
895
|
+
rows: [
|
|
896
|
+
{
|
|
897
|
+
plan_key: PLAN_KEY,
|
|
898
|
+
task_id: TASK,
|
|
899
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
900
|
+
package: "@nanobpm/urban",
|
|
901
|
+
verify_command: null,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
key: "plan_key",
|
|
905
|
+
},
|
|
906
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
907
|
+
};
|
|
908
|
+
const data = capsDataLayer(stores);
|
|
909
|
+
const { engine, created, published } = capsEngine();
|
|
910
|
+
const { exec, calls } = capsProbeExec(true);
|
|
911
|
+
const headers = { "content-type": "application/json" };
|
|
912
|
+
const prevFetch = globalThis.fetch;
|
|
913
|
+
globalThis.fetch = capsSubscriptionFetch(new Set<string>()) as typeof fetch; // barrier not parked
|
|
914
|
+
try {
|
|
915
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
916
|
+
} finally {
|
|
917
|
+
globalThis.fetch = prevFetch;
|
|
918
|
+
}
|
|
919
|
+
assertEquals(published.length, 0, "no publish into a subscription that is not open");
|
|
920
|
+
assertEquals(created.length, 0, "no gate work until the task is parked at the barrier");
|
|
921
|
+
assertEquals(calls.length, 0, "no provenance probe until the task is parked at the barrier");
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test("pollCapabilityGatesImpl: idempotent — a resolved gate is reused without a re-probe or a second publish (#289)", async () => {
|
|
925
|
+
const PLAN_KEY = "owner/repo#10";
|
|
926
|
+
const PI = "PI-292";
|
|
927
|
+
const TASK = "gap-d";
|
|
928
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
929
|
+
const gateKey = `${PLAN_KEY}:${TASK}:nanobpm/nano-ide#274:@nanobpm/urban`;
|
|
930
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
931
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
932
|
+
plan_task_needs: {
|
|
933
|
+
rows: [
|
|
934
|
+
{
|
|
935
|
+
plan_key: PLAN_KEY,
|
|
936
|
+
task_id: TASK,
|
|
937
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
938
|
+
package: "@nanobpm/urban",
|
|
939
|
+
verify_command: null,
|
|
940
|
+
},
|
|
941
|
+
],
|
|
942
|
+
key: "plan_key",
|
|
943
|
+
},
|
|
944
|
+
capability_gates: {
|
|
945
|
+
rows: [
|
|
946
|
+
{
|
|
947
|
+
gate_key: gateKey,
|
|
948
|
+
plan_key: PLAN_KEY,
|
|
949
|
+
task_id: TASK,
|
|
950
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
951
|
+
package: "@nanobpm/urban",
|
|
952
|
+
status: "resolved",
|
|
953
|
+
resolved_artifact: "@nanobpm/urban@0.54.0",
|
|
954
|
+
process_key: "RG-EXISTING",
|
|
955
|
+
created_at: "t0",
|
|
956
|
+
updated_at: "t0",
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
key: "gate_key",
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
const data = capsDataLayer(stores);
|
|
963
|
+
const { engine, created, published } = capsEngine();
|
|
964
|
+
const { exec, calls } = capsProbeExec(true);
|
|
965
|
+
const headers = { "content-type": "application/json" };
|
|
966
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
967
|
+
const prevFetch = globalThis.fetch;
|
|
968
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
969
|
+
try {
|
|
970
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
971
|
+
} finally {
|
|
972
|
+
globalThis.fetch = prevFetch;
|
|
973
|
+
}
|
|
974
|
+
assertEquals(created.length, 0, "already-started gate is never re-started");
|
|
975
|
+
assertEquals(calls.length, 0, "already-resolved need is never re-probed");
|
|
976
|
+
assertEquals(published.length, 1, "the still-parked barrier is released from the pinned artifact");
|
|
977
|
+
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test("pollCapabilityGatesImpl: two needs sharing a capabilityRef across packages get distinct gate rows (#290)", async () => {
|
|
981
|
+
// Regression for the gate_key collision: `capabilityGateKey` folds `package` into the key, so a task
|
|
982
|
+
// that declares the SAME `capabilityRef` for two different packages tracks each `(capabilityRef,
|
|
983
|
+
// package)` edge on its OWN gate row and starts its OWN readiness-gate. With the old package-blind key
|
|
984
|
+
// the second need would alias the first row, only one gate would ever start, and the second package
|
|
985
|
+
// could never resolve — wedging the barrier forever.
|
|
986
|
+
const PLAN_KEY = "owner/repo#12";
|
|
987
|
+
const PI = "PI-294";
|
|
988
|
+
const TASK = "gap-f";
|
|
989
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
990
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
991
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
992
|
+
plan_task_needs: {
|
|
993
|
+
rows: [
|
|
994
|
+
{ plan_key: PLAN_KEY, task_id: TASK, capability_ref: "nanobpm/nano-ide#274", package: "@nanobpm/urban", verify_command: null },
|
|
995
|
+
{ plan_key: PLAN_KEY, task_id: TASK, capability_ref: "nanobpm/nano-ide#274", package: "@nanobpm/urban-testkit", verify_command: null },
|
|
996
|
+
],
|
|
997
|
+
key: "plan_key",
|
|
998
|
+
},
|
|
999
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
1000
|
+
};
|
|
1001
|
+
const data = capsDataLayer(stores);
|
|
1002
|
+
const { engine, created, published } = capsEngine();
|
|
1003
|
+
const { exec } = capsProbeExec(false); // neither capability published yet — both stay pending
|
|
1004
|
+
const headers = { "content-type": "application/json" };
|
|
1005
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
1006
|
+
const prevFetch = globalThis.fetch;
|
|
1007
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
1008
|
+
try {
|
|
1009
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
1010
|
+
} finally {
|
|
1011
|
+
globalThis.fetch = prevFetch;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Two distinct gate rows (one per package) — no collision, no aliasing.
|
|
1015
|
+
assertEquals(stores.capability_gates.rows.length, 2, "one gate row per (capabilityRef, package) need");
|
|
1016
|
+
const gateKeys = stores.capability_gates.rows.map((r) => r.gate_key).sort();
|
|
1017
|
+
assertEquals(gateKeys, [
|
|
1018
|
+
`${PLAN_KEY}:${TASK}:nanobpm/nano-ide#274:@nanobpm/urban`,
|
|
1019
|
+
`${PLAN_KEY}:${TASK}:nanobpm/nano-ide#274:@nanobpm/urban-testkit`,
|
|
1020
|
+
]);
|
|
1021
|
+
assertEquals(created.length, 2, "each need starts its own readiness-gate");
|
|
1022
|
+
assertEquals(published.length, 0, "barrier NOT released while either need is unresolved");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("pollCapabilityGatesImpl: scopes the barrier subscription search server-side by correlationKey (#290)", async () => {
|
|
1026
|
+
// Regression for the page-limit false negative: the capability barrier opens ONE subscription per
|
|
1027
|
+
// task, so a plan with many parked siblings can overflow a process+message-only search page and omit
|
|
1028
|
+
// THIS task's subscription — wedging its gate forever. The reconciler must therefore scope the search
|
|
1029
|
+
// by `correlationKey`. This stub emulates an engine that HONOURS the server-side `correlationKey`
|
|
1030
|
+
// filter: it returns the open item only when the request carries the matching key (as the real engine
|
|
1031
|
+
// does), so the old process+message-only filter would come back empty and never release the barrier.
|
|
1032
|
+
const PLAN_KEY = "owner/repo#11";
|
|
1033
|
+
const PI = "PI-293";
|
|
1034
|
+
const TASK = "gap-e";
|
|
1035
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
1036
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
1037
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
1038
|
+
plan_task_needs: {
|
|
1039
|
+
rows: [
|
|
1040
|
+
{
|
|
1041
|
+
plan_key: PLAN_KEY,
|
|
1042
|
+
task_id: TASK,
|
|
1043
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
1044
|
+
package: "@nanobpm/urban",
|
|
1045
|
+
verify_command: null,
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
key: "plan_key",
|
|
1049
|
+
},
|
|
1050
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
1051
|
+
};
|
|
1052
|
+
const data = capsDataLayer(stores);
|
|
1053
|
+
const { engine, published } = capsEngine();
|
|
1054
|
+
const { exec } = capsProbeExec(true);
|
|
1055
|
+
const headers = { "content-type": "application/json" };
|
|
1056
|
+
const seenFilters: Array<Record<string, unknown>> = [];
|
|
1057
|
+
const prevFetch = globalThis.fetch;
|
|
1058
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
1059
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
1060
|
+
if (!u.endsWith("/message-subscriptions/search")) throw new Error(`unexpected fetch: ${u}`);
|
|
1061
|
+
const filter = (JSON.parse(String(init?.body ?? "{}")) as { filter?: Record<string, unknown> }).filter ?? {};
|
|
1062
|
+
seenFilters.push(filter);
|
|
1063
|
+
// Engine honours the server-side correlationKey filter: only the exactly-scoped query sees the item.
|
|
1064
|
+
const items =
|
|
1065
|
+
filter.correlationKey === barrierKey
|
|
1066
|
+
? [{ messageName: "caps-resolved", correlationKey: barrierKey, messageSubscriptionState: "CREATED" }]
|
|
1067
|
+
: [];
|
|
1068
|
+
return Promise.resolve(
|
|
1069
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
1070
|
+
);
|
|
1071
|
+
}) as typeof fetch;
|
|
1072
|
+
try {
|
|
1073
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
1074
|
+
} finally {
|
|
1075
|
+
globalThis.fetch = prevFetch;
|
|
1076
|
+
}
|
|
1077
|
+
assertEquals(seenFilters[0]?.correlationKey, barrierKey, "search is scoped server-side by the barrier key");
|
|
1078
|
+
assertEquals(published.length, 1, "the scoped search still finds THIS task's subscription and releases it");
|
|
1079
|
+
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
1080
|
+
});
|