@nanobpm/nano-workforce 0.128.0 → 0.129.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/abandon.test.ts +16 -2
- package/app/abandon.ts +39 -17
- package/app/conformance.test.ts +2 -1
- package/app/conformance.ts +9 -3
- package/app/featureDelivery.test.ts +2 -1
- package/app/instanceTracking.ts +97 -0
- package/app/lineage.test.ts +2 -1
- package/app/lineage.ts +15 -2
- package/app/promotionPoll.test.ts +2 -1
- package/app/retro.test.ts +2 -1
- package/app/retro.ts +9 -2
- package/app/service.test.ts +15 -14
- package/app/service.ts +17 -24
- package/e2e/convergence-loop.e2e.ts +41 -8
- package/operations/acknowledgeEpic.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/getLineage.test.ts +2 -1
- package/package.json +3 -3
- package/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.129.0](https://github.com/nanobpm/nano-workforce/compare/v0.128.0...v0.129.0) (2026-08-23)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* adopt ADR-0065 derived instanceTracking read-models (urban 0.81.0) ([#489](https://github.com/nanobpm/nano-workforce/issues/489)) ([ce25501](https://github.com/nanobpm/nano-workforce/commit/ce255013b383eb4d593526c5f34e74aaab535d25)), closes [#318](https://github.com/nanobpm/nano-workforce/issues/318) [nano-workforce#422](https://github.com/nano-workforce/issues/422) [#76](https://github.com/nanobpm/nano-workforce/issues/76)
|
|
7
|
+
|
|
1
8
|
# [0.128.0](https://github.com/nanobpm/nano-workforce/compare/v0.127.0...v0.128.0) (2026-08-23)
|
|
2
9
|
|
|
3
10
|
|
package/app/abandon.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Tests for the cooperative abandon-check helpers (issue #76).
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { assertEquals, assertNotEquals } from "#test-assert";
|
|
3
|
+
import { assertEquals, assertNotEquals, assertRejects } from "#test-assert";
|
|
4
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
5
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
5
6
|
import {
|
|
6
7
|
abandonStatusForToken,
|
|
7
8
|
abandonTokenFromUrl,
|
|
@@ -26,7 +27,7 @@ function memData(): DataLayer {
|
|
|
26
27
|
},
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
|
-
return { table: (n: string) => tbl(n) } as any as DataLayer;
|
|
30
|
+
return { table: withTrackingViews((n: string) => tbl(n)) } as any as DataLayer;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
async function seedPr(data: DataLayer, pr_key: string, abandon_token: string, status: string) {
|
|
@@ -106,3 +107,16 @@ test("abandonStatusForToken derives abandoned from the row status", async () =>
|
|
|
106
107
|
});
|
|
107
108
|
assertEquals(await abandonStatusForToken(data, "nope"), undefined);
|
|
108
109
|
});
|
|
110
|
+
|
|
111
|
+
test("abandonStatusForToken fails CLOSED on a non-string derived status", async () => {
|
|
112
|
+
// A malformed/missing derived_status must never be reported as `abandoned:false`
|
|
113
|
+
// (that would let a cancelled run proceed with irreversible side effects). It throws
|
|
114
|
+
// instead, which the operation dispatcher maps to a 500 so the agent's `curl -f` aborts.
|
|
115
|
+
const data = memData();
|
|
116
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
117
|
+
pr_key: "o/r#3",
|
|
118
|
+
abandon_token: "weird",
|
|
119
|
+
status: 123,
|
|
120
|
+
});
|
|
121
|
+
await assertRejects(() => abandonStatusForToken(data, "weird"), Error, "is not a string");
|
|
122
|
+
});
|
package/app/abandon.ts
CHANGED
|
@@ -10,23 +10,28 @@
|
|
|
10
10
|
// Design invariants (mirroring the blackboard, app/blackboard.ts):
|
|
11
11
|
// - CAPABILITY URL. The per-PR token IS the credential; the agent curls the exact URL it was
|
|
12
12
|
// handed in its prompt. An unknown token is a 404 (never leaks which PRs exist).
|
|
13
|
-
// - DERIVED, not a separate marker. `abandoned` is read
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// - DERIVED, not a separate marker. `abandoned` is read off the PR's ADR-0065 derived tracking
|
|
14
|
+
// VIEW (`pull_requests__tracking.derived_status`). Since urban 0.81.0 the `instanceTracking`
|
|
15
|
+
// reconciler no longer WRITES 'abandoned' onto the base row on cancel; it feeds urban's instance
|
|
16
|
+
// projection and the `onTerminated.set` edge is recomputed on every read as `derived_status`. So
|
|
17
|
+
// an out-of-band cancel leaves the base `status` at its transient (e.g. `converging`) but the
|
|
18
|
+
// derived view reports 'abandoned' immediately — reading the view keeps the abort-check correct
|
|
19
|
+
// with no new state to sync. (`abandonClosedPr`, #352, still writes 'abandoned' onto the base
|
|
20
|
+
// row directly; the view passes that worker-written terminal through unchanged.)
|
|
17
21
|
// - ADVISORY. Like the blackboard, this never hard-locks; it narrows an unavoidable
|
|
18
22
|
// check-then-push (TOCTOU) window to near-zero. Job fencing in the harness (issue #76 layer 2)
|
|
19
23
|
// is what makes it airtight.
|
|
20
24
|
import type { DataLayer } from "@nanobpm/urban";
|
|
21
25
|
import { publicBaseUrl } from "./blackboard.ts";
|
|
26
|
+
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
22
27
|
|
|
23
|
-
/** The one
|
|
24
|
-
* further. Two disjoint producers
|
|
28
|
+
/** The one derived-status value meaning a PR is terminally abandoned — the run must not be worked on
|
|
29
|
+
* further. Two disjoint producers surface a row here, and both are non-completion terminals that must
|
|
25
30
|
* stop a servicing agent:
|
|
26
|
-
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive
|
|
27
|
-
* `instanceTracking` `onTerminated.set`
|
|
31
|
+
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive terminates
|
|
32
|
+
* the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read), and
|
|
28
33
|
* 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
|
|
29
|
-
* merging** (#352) — for both `pull_requests` and its `plan_tasks
|
|
34
|
+
* merging** (#352) — for both `pull_requests` and its `plan_tasks` — by writing the base row.
|
|
30
35
|
* Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
|
|
31
36
|
* case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
|
|
32
37
|
* true` is correct. */
|
|
@@ -69,32 +74,49 @@ export function abandonTokenFromUrl(url: string | null | undefined): string | un
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
/** Resolve an abandon token back to its PR key, or undefined when the token is unknown.
|
|
77
|
+
/** Resolve an abandon token back to its PR key, or undefined when the token is unknown. Reads the
|
|
78
|
+
* derived tracking VIEW (a strict superset of the base row) so this stays valid post-ADR-0065. */
|
|
73
79
|
export async function prKeyForAbandonToken(
|
|
74
80
|
data: DataLayer,
|
|
75
81
|
token: string,
|
|
76
82
|
): Promise<string | undefined> {
|
|
77
83
|
if (!token) return undefined;
|
|
78
84
|
const row = await data
|
|
79
|
-
.table<{ pr_key: string; abandon_token: string | null }>(
|
|
85
|
+
.table<{ pr_key: string; abandon_token: string | null }>(
|
|
86
|
+
trackingTargetFor("pull_requests").view,
|
|
87
|
+
"pr_key",
|
|
88
|
+
)
|
|
80
89
|
.findOne({ abandon_token: token });
|
|
81
90
|
return row?.pr_key;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
/** The abandon status of a PR, or undefined when the token is unknown.
|
|
93
|
+
/** The abandon status of a PR, or undefined when the token is unknown. Reads the ADR-0065 derived
|
|
94
|
+
* tracking VIEW's `derived_status`, so an out-of-band-cancelled run (whose base row is still
|
|
95
|
+
* `converging`) is correctly reported `abandoned: true` the instant the instance terminates. */
|
|
85
96
|
export async function abandonStatusForToken(
|
|
86
97
|
data: DataLayer,
|
|
87
98
|
token: string,
|
|
88
99
|
): Promise<{ prKey: string; status: string; abandoned: boolean } | undefined> {
|
|
89
100
|
if (!token) return undefined;
|
|
101
|
+
const target = trackingTargetFor("pull_requests");
|
|
90
102
|
const row = await data
|
|
91
|
-
.table<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
103
|
+
.table<
|
|
104
|
+
{ pr_key: string; abandon_token: string | null } & Record<string, unknown>
|
|
105
|
+
>(target.view, "pr_key")
|
|
95
106
|
.findOne({ abandon_token: token });
|
|
96
107
|
if (!row) return undefined;
|
|
97
|
-
|
|
108
|
+
const rawStatus = row[target.statusColumn];
|
|
109
|
+
if (typeof rawStatus !== "string") {
|
|
110
|
+
// Fail CLOSED: a missing/non-string derived_status must never be reported as
|
|
111
|
+
// `abandoned:false`. The abort brief tells agents to proceed on a 200 with
|
|
112
|
+
// `abandoned:false`, so surfacing this as a thrown error (→ 500, which trips the
|
|
113
|
+
// agent's `curl -f` and aborts) is the safe direction for a cancelled run.
|
|
114
|
+
throw new Error(
|
|
115
|
+
`abandonStatusForToken: ${target.view}.${target.statusColumn} is not a string`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const status = rawStatus;
|
|
119
|
+
return { prKey: row.pr_key, status, abandoned: isAbandoned(status) };
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
/** The instruction block appended (verbatim, via `appendPrompt`) to each side-effecting agent's
|
package/app/conformance.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
4
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
5
5
|
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
6
7
|
import { appendEntry } from "./blackboard.ts";
|
|
7
8
|
import {
|
|
8
9
|
acknowledgeConformance,
|
|
@@ -45,7 +46,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
45
46
|
},
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
49
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
|
|
49
50
|
return { data, stores };
|
|
50
51
|
}
|
|
51
52
|
|
package/app/conformance.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { DataLayer } from "@nanobpm/urban";
|
|
18
18
|
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
19
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
20
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
20
21
|
import { planTasks } from "./plan.ts";
|
|
21
22
|
|
|
22
23
|
const now = () => new Date().toISOString();
|
|
@@ -45,15 +46,20 @@ interface PlanRow extends Record<string, unknown> {
|
|
|
45
46
|
|
|
46
47
|
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
47
48
|
const prsTbl = (data: DataLayer) =>
|
|
48
|
-
|
|
49
|
+
derivedTrackingTable<{ pr_key: string; derived_status: string }>(
|
|
50
|
+
data,
|
|
51
|
+
"pull_requests",
|
|
52
|
+
"pr_key",
|
|
53
|
+
);
|
|
49
54
|
|
|
50
55
|
/** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
|
|
51
56
|
* predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
|
|
52
|
-
* the full digest and the cheap trigger check can't disagree about what counts as landed.
|
|
57
|
+
* the full digest and the cheap trigger check can't disagree about what counts as landed. Reads the
|
|
58
|
+
* ADR-0065 derived edge so an out-of-band-terminated PR is correctly excluded from "landed". */
|
|
53
59
|
async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
|
|
54
60
|
if (!prKey) return false;
|
|
55
61
|
const pr = await prsTbl(data).get(prKey);
|
|
56
|
-
return !!pr && LANDED_PR_STATUSES.has(pr.
|
|
62
|
+
return !!pr && LANDED_PR_STATUSES.has(pr.derived_status);
|
|
57
63
|
}
|
|
58
64
|
const conformanceTbl = (data: DataLayer) =>
|
|
59
65
|
data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assertEquals } from "#test-assert";
|
|
8
8
|
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import { deriveFeatureDelivery } from "./feature.ts";
|
|
10
11
|
import { pollFeatureDelivery } from "./service.ts";
|
|
11
12
|
|
|
@@ -34,7 +35,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
34
35
|
},
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
38
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
38
39
|
return { data, stores };
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// nano-workforce — the app's single accessor for the `instanceTracking` derived read models
|
|
2
|
+
// (ADR 0065, the writer→source inversion adopted with `@nanobpm/urban@0.81.0`).
|
|
3
|
+
//
|
|
4
|
+
// Since ADR 0065 the `instanceTracking` reconciler is a SOURCE, not a writer: on each poll it feeds
|
|
5
|
+
// engine truth into urban's canonical projections (`urban_instance_state`, `urban_open_user_tasks`)
|
|
6
|
+
// and NO LONGER writes the terminal (`onTerminated.set`) / wait-on-human (`onWaitingHuman.set`)
|
|
7
|
+
// edges onto the app's base row. Those edges are now DERIVED — recomputed on every read — by an
|
|
8
|
+
// auto-provisioned managed VIEW `<table>__tracking` whose `derived_status` column is
|
|
9
|
+
// `CASE WHEN terminated THEN <onTerminated value> WHEN waiting-human THEN <onWaitingHuman value>
|
|
10
|
+
// ELSE base.<statusField> END`. So the base `statusField` keeps only the worker-owned transient
|
|
11
|
+
// status, and any reader that used to rely on the reconciler having written the terminal status
|
|
12
|
+
// onto the base row must read `derived_status` off the VIEW instead.
|
|
13
|
+
//
|
|
14
|
+
// This module is the ONE place that:
|
|
15
|
+
// - parses the `instanceTracking` bindings from `nano.app.json` (the single source of truth), and
|
|
16
|
+
// - resolves each binding's derived VIEW name + `derived_status` column via urban's OWN target
|
|
17
|
+
// resolver (`instanceTrackingReadModelTarget`), so the app can never drift from the framework's
|
|
18
|
+
// view naming.
|
|
19
|
+
//
|
|
20
|
+
// Writers are unchanged: a service-task worker that owns a terminal outcome (`converged`, `merged`,
|
|
21
|
+
// `done`, …) still writes it to the base `data.table(<table>)`. Only readers that classify on the
|
|
22
|
+
// RECONCILER-derived edge (terminated → abandoned/failed/reviewed, or waiting-human →
|
|
23
|
+
// awaiting_operator) route through the derived VIEW here.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import {
|
|
27
|
+
type AppManifest,
|
|
28
|
+
type DataLayer,
|
|
29
|
+
type InstanceTracking,
|
|
30
|
+
instanceTrackingReadModelTarget,
|
|
31
|
+
type Table,
|
|
32
|
+
} from "@nanobpm/urban";
|
|
33
|
+
|
|
34
|
+
/** The app manifest, parsed exactly ONCE at module load, typed by urban's own `AppManifest` so the
|
|
35
|
+
* binding shape can never drift from the framework's schema. */
|
|
36
|
+
const APP_MANIFEST: AppManifest = JSON.parse(
|
|
37
|
+
readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** The app manifest's `instanceTracking` bindings — the single source of truth for the derived
|
|
41
|
+
* read-model registry. */
|
|
42
|
+
const INSTANCE_TRACKING_BINDINGS: readonly InstanceTracking[] = APP_MANIFEST.instanceTracking ?? [];
|
|
43
|
+
|
|
44
|
+
/** The single `instanceTracking` binding for a base table, or throw if the manifest has none. */
|
|
45
|
+
export function trackingBindingFor(table: string): InstanceTracking {
|
|
46
|
+
const binding = INSTANCE_TRACKING_BINDINGS.find((b) => b.table === table);
|
|
47
|
+
if (!binding) {
|
|
48
|
+
throw new Error(`nano.app.json: no instanceTracking binding for table "${table}"`);
|
|
49
|
+
}
|
|
50
|
+
return binding;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A tracked table's parked-and-active statuses, from the single source of truth
|
|
54
|
+
* (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
|
|
55
|
+
* from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
|
|
56
|
+
export function activeStatusesFor(table: string): readonly string[] {
|
|
57
|
+
const binding = trackingBindingFor(table);
|
|
58
|
+
if (!binding.activeStatuses?.length) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return binding.activeStatuses;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The managed derived read-model VIEW name + effective-status column for a base table, resolved by
|
|
67
|
+
* urban's OWN target resolver so the app never drifts from the framework's `<table>__tracking` /
|
|
68
|
+
* `derived_status` naming (ADR 0065). */
|
|
69
|
+
export function trackingTargetFor(table: string): { view: string; statusColumn: string } {
|
|
70
|
+
return instanceTrackingReadModelTarget(trackingBindingFor(table));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The base table a derived tracking VIEW projects, or undefined when `view` is not a tracking view.
|
|
74
|
+
* The inverse of {@link trackingTargetFor}, resolved off the same binding registry so it can't drift
|
|
75
|
+
* from the framework's view naming. */
|
|
76
|
+
export function baseTableForTrackingView(view: string): string | undefined {
|
|
77
|
+
return INSTANCE_TRACKING_BINDINGS.find((b) => trackingTargetFor(b.table).view === view)?.table;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The base `statusField` a binding's derived edge falls through to when no terminal/wait edge
|
|
81
|
+
* applies (the VIEW's `ELSE base.<statusField>` branch). Defaults to `"status"`, mirroring urban. */
|
|
82
|
+
export function baseStatusFieldFor(table: string): string {
|
|
83
|
+
return trackingBindingFor(table).statusField ?? "status";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A read-only typed gateway over a tracked table's derived VIEW (`<table>__tracking`). The VIEW
|
|
87
|
+
* re-exports `base.*` plus the derived `derived_status` column, so a row carries BOTH the base
|
|
88
|
+
* transient `<statusField>` and the effective (ADR-0065-derived) `derived_status`. Read
|
|
89
|
+
* `derived_status` to classify on the terminal / wait-on-human edge; urban forbids writing a VIEW,
|
|
90
|
+
* so use `data.table(<table>)` for writes. `T` should include `derived_status: string`. */
|
|
91
|
+
export function derivedTrackingTable<T extends object>(
|
|
92
|
+
data: DataLayer,
|
|
93
|
+
table: string,
|
|
94
|
+
pk: string,
|
|
95
|
+
): Table<T> {
|
|
96
|
+
return data.table<T>(trackingTargetFor(table).view, pk);
|
|
97
|
+
}
|
package/app/lineage.test.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assert, assertEquals } from "#test-assert";
|
|
8
8
|
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import {
|
|
10
11
|
deriveLineage,
|
|
11
12
|
type LineagePr,
|
|
@@ -176,7 +177,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
176
177
|
},
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
180
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
180
181
|
return { data, stores };
|
|
181
182
|
}
|
|
182
183
|
|
package/app/lineage.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import type { DataLayer } from "@nanobpm/urban";
|
|
22
22
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
23
23
|
import { type FeatureRun, featureRuns } from "./feature.ts";
|
|
24
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
24
25
|
import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
|
|
25
26
|
|
|
26
27
|
const now = () => new Date().toISOString();
|
|
@@ -307,9 +308,19 @@ interface PrRow {
|
|
|
307
308
|
// Epic-phase projection this module maintains (issue #304, migration 043): the parent epic's phase
|
|
308
309
|
// label for an epic slice PR, NULL otherwise. Read here only to keep the write idempotent.
|
|
309
310
|
epic_phase_label: string | null;
|
|
311
|
+
// The ADR-0065 derived tracking edge (`pull_requests__tracking.derived_status`). Present ONLY on
|
|
312
|
+
// rows read through the derived VIEW (`prRowsRead`); undefined on base-table reads/writes. The
|
|
313
|
+
// frontier stage is derived from THIS, not the base transient `status`, so an out-of-band-
|
|
314
|
+
// terminated slice reads `abandoned` rather than a stale `converging`.
|
|
315
|
+
derived_status?: string;
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
|
|
319
|
+
/** Read-only accessor over the PR derived tracking VIEW (`pull_requests__tracking`). The lineage
|
|
320
|
+
* frontier classifies on the reconciler-derived edge, so `collectThreads` reads through this and
|
|
321
|
+
* `toLineagePr` folds `derived_status` onto `LineagePr.status`. Writes stay on `prRows`. */
|
|
322
|
+
const prRowsRead = (data: DataLayer) =>
|
|
323
|
+
derivedTrackingTable<PrRow & { derived_status: string }>(data, "pull_requests", "pr_key");
|
|
313
324
|
|
|
314
325
|
/** The `lineage_thread_view` VIEW row (migration 064) — the read shape the Lineage page binds. The
|
|
315
326
|
* view PASSES THROUGH the procedural frontier columns from `lineage_threads` and DERIVES the
|
|
@@ -382,7 +393,9 @@ function toLineagePr(row: PrRow): LineagePr {
|
|
|
382
393
|
prKey: row.pr_key,
|
|
383
394
|
title: row.title,
|
|
384
395
|
url: row.url,
|
|
385
|
-
|
|
396
|
+
// Classify the frontier on the ADR-0065 derived edge when the row came through the tracking VIEW
|
|
397
|
+
// (`prRowsRead`); fall back to the base transient for any base-table row.
|
|
398
|
+
status: row.derived_status ?? row.status,
|
|
386
399
|
round: row.current_round,
|
|
387
400
|
processKey: row.process_key,
|
|
388
401
|
outcome: row.outcome,
|
|
@@ -394,7 +407,7 @@ function toLineagePr(row: PrRow): LineagePr {
|
|
|
394
407
|
async function collectThreads(
|
|
395
408
|
data: DataLayer,
|
|
396
409
|
): Promise<{ threads: Map<string, LineageThread>; allPrs: PrRow[] }> {
|
|
397
|
-
const allPrs = await
|
|
410
|
+
const allPrs = await prRowsRead(data).all();
|
|
398
411
|
const prByKey = new Map<string, PrRow>();
|
|
399
412
|
for (const pr of allPrs) prByKey.set(pr.pr_key, pr);
|
|
400
413
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// recording engine: open exactly one PR, never a duplicate on re-run, never for a converging epic,
|
|
7
7
|
// and never for a `main`-based epic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import { assert, assertEquals } from "#test-assert";
|
|
10
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
12
|
import { resetDefaultBranchCache } from "./github.ts";
|
|
@@ -41,7 +42,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
45
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
45
46
|
return { data, stores };
|
|
46
47
|
}
|
|
47
48
|
|
package/app/retro.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
4
4
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
5
5
|
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
6
7
|
import { appendEntry } from "./blackboard.ts";
|
|
7
8
|
import { recordTaskDelta } from "./taskDelta.ts";
|
|
8
9
|
import {
|
|
@@ -47,7 +48,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
47
48
|
},
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
51
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
|
|
51
52
|
return { data, stores };
|
|
52
53
|
}
|
|
53
54
|
|
package/app/retro.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
|
|
|
17
17
|
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
18
|
import { hasDeliveredImplementationForPlan } from "./conformance.ts";
|
|
19
19
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
20
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
20
21
|
import { planReviews, planTasks } from "./plan.ts";
|
|
21
22
|
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
22
23
|
|
|
@@ -55,7 +56,11 @@ interface PlanRow extends Record<string, unknown> {
|
|
|
55
56
|
|
|
56
57
|
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
57
58
|
const prsTbl = (data: DataLayer) =>
|
|
58
|
-
|
|
59
|
+
derivedTrackingTable<{ pr_key: string; derived_status: string }>(
|
|
60
|
+
data,
|
|
61
|
+
"pull_requests",
|
|
62
|
+
"pr_key",
|
|
63
|
+
);
|
|
59
64
|
const retroStartsTbl = (data: DataLayer) =>
|
|
60
65
|
data.table<{ plan_key: string; started_at: string }>("plan_retro_starts", "plan_key");
|
|
61
66
|
|
|
@@ -77,8 +82,10 @@ export async function isPlanComplete(data: DataLayer, planKey: string): Promise<
|
|
|
77
82
|
if (SETTLED_TASKLESS.has(t.status)) continue;
|
|
78
83
|
// Any task that is meant to yield a PR must have a terminal PR to be settled.
|
|
79
84
|
if (!t.pr_key) return false; // pending/escalated/etc. with no PR yet → still in flight
|
|
85
|
+
// Any task that is meant to yield a PR must have a terminal PR to be settled. Read the ADR-0065
|
|
86
|
+
// derived edge so an out-of-band-terminated (`abandoned`) PR is recognised as terminal here.
|
|
80
87
|
const pr = await prsTbl(data).get(t.pr_key);
|
|
81
|
-
if (!pr || !TERMINAL_PR_STATUSES.has(pr.
|
|
88
|
+
if (!pr || !TERMINAL_PR_STATUSES.has(pr.derived_status)) return false;
|
|
82
89
|
}
|
|
83
90
|
return true;
|
|
84
91
|
}
|
package/app/service.test.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
10
10
|
import { memDataFor } from "../test/worldDb.ts";
|
|
11
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
11
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
12
13
|
import { WorldStore } from "./world/index.ts";
|
|
13
14
|
import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
@@ -71,7 +72,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
71
72
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
72
73
|
};
|
|
73
74
|
const data = {
|
|
74
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
75
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
75
76
|
} as any;
|
|
76
77
|
const engine = {
|
|
77
78
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
@@ -137,7 +138,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
|
|
|
137
138
|
pull_requests: { rows: [row], key: "pr_key" },
|
|
138
139
|
};
|
|
139
140
|
const data = {
|
|
140
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
141
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
141
142
|
} as any;
|
|
142
143
|
const headers = { "content-type": "application/json" };
|
|
143
144
|
|
|
@@ -190,7 +191,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
|
|
|
190
191
|
pull_requests: { rows: [noKey, terminal], key: "pr_key" },
|
|
191
192
|
};
|
|
192
193
|
const data = {
|
|
193
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
194
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
194
195
|
} as any;
|
|
195
196
|
const headers = { "content-type": "application/json" };
|
|
196
197
|
|
|
@@ -223,7 +224,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
223
224
|
pull_requests: { rows: [row], key: "pr_key" },
|
|
224
225
|
};
|
|
225
226
|
const data = {
|
|
226
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
227
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
227
228
|
} as any;
|
|
228
229
|
const headers = { "content-type": "application/json" };
|
|
229
230
|
|
|
@@ -262,7 +263,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
262
263
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
263
264
|
};
|
|
264
265
|
const data = {
|
|
265
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
266
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
266
267
|
} as any;
|
|
267
268
|
const engine = {
|
|
268
269
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
@@ -296,7 +297,7 @@ function captureConvergeOnly() {
|
|
|
296
297
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
297
298
|
};
|
|
298
299
|
const data = {
|
|
299
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
300
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
300
301
|
} as any;
|
|
301
302
|
let captured: unknown;
|
|
302
303
|
const engine = {
|
|
@@ -347,7 +348,7 @@ function captureRoot() {
|
|
|
347
348
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
348
349
|
};
|
|
349
350
|
const data = {
|
|
350
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
351
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
351
352
|
} as any;
|
|
352
353
|
let captured: unknown;
|
|
353
354
|
const engine = {
|
|
@@ -629,7 +630,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
|
|
|
629
630
|
},
|
|
630
631
|
};
|
|
631
632
|
const data = {
|
|
632
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
633
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
633
634
|
} as any;
|
|
634
635
|
|
|
635
636
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -718,7 +719,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
718
719
|
},
|
|
719
720
|
};
|
|
720
721
|
const data = {
|
|
721
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
722
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
722
723
|
} as any;
|
|
723
724
|
|
|
724
725
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -822,7 +823,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
|
|
|
822
823
|
merges: { rows: [], key: "id" },
|
|
823
824
|
};
|
|
824
825
|
const data = {
|
|
825
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
826
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
826
827
|
} as any;
|
|
827
828
|
|
|
828
829
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -878,7 +879,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
|
|
|
878
879
|
merges: { rows: [], key: "id" },
|
|
879
880
|
};
|
|
880
881
|
const data = {
|
|
881
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
882
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
882
883
|
} as any;
|
|
883
884
|
|
|
884
885
|
await abandonClosedPr(data, "owner/repo#70", "closed without merging");
|
|
@@ -906,7 +907,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
|
|
|
906
907
|
merges: { rows: [], key: "id" },
|
|
907
908
|
};
|
|
908
909
|
const data = {
|
|
909
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
910
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
910
911
|
} as any;
|
|
911
912
|
|
|
912
913
|
await abandonClosedPr(data, "owner/repo#71", "closed without merging");
|
|
@@ -932,7 +933,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
|
|
|
932
933
|
merges: { rows: [], key: "id" },
|
|
933
934
|
};
|
|
934
935
|
const data = {
|
|
935
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
936
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
936
937
|
} as any;
|
|
937
938
|
|
|
938
939
|
const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
|
|
@@ -1004,7 +1005,7 @@ function capsProbeExec(ready: boolean) {
|
|
|
1004
1005
|
|
|
1005
1006
|
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
1006
1007
|
return {
|
|
1007
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
1008
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1008
1009
|
} as any;
|
|
1009
1010
|
}
|
|
1010
1011
|
|
package/app/service.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
|
|
9
9
|
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
10
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
11
|
import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
12
|
import { escalationFormId } from "./agentCompletion.ts";
|
|
@@ -51,6 +50,7 @@ import {
|
|
|
51
50
|
type PrState,
|
|
52
51
|
requestCopilotReview,
|
|
53
52
|
} from "./github.ts";
|
|
53
|
+
import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
|
|
54
54
|
import { pollLineage } from "./lineage.ts";
|
|
55
55
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
56
56
|
import {
|
|
@@ -253,6 +253,16 @@ interface Escalation {
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
const prs = (data: DataLayer) => data.table<PullRequest>("pull_requests", "pr_key");
|
|
256
|
+
/** A PR row as seen through its derived tracking VIEW (`pull_requests__tracking`): the base columns
|
|
257
|
+
* plus urban's ADR-0065 `derived_status`, which folds the reconciler's terminal edge
|
|
258
|
+
* (out-of-band terminate → `abandoned`) over the worker-owned transient. */
|
|
259
|
+
type TrackedPullRequest = PullRequest & { derived_status: string };
|
|
260
|
+
/** Read-only accessor over the PR derived tracking VIEW. Use this — and read `derived_status`, not
|
|
261
|
+
* `status` — for any terminal-edge classification (delivery/promotion/feature reconciliation), so an
|
|
262
|
+
* out-of-band-terminated PR (whose base row is still `converging`) is correctly seen as `abandoned`.
|
|
263
|
+
* Worker-written terminals (`merged`/`converged`) pass through unchanged. Writes stay on `prs`. */
|
|
264
|
+
const prsTracking = (data: DataLayer) =>
|
|
265
|
+
derivedTrackingTable<TrackedPullRequest>(data, "pull_requests", "pr_key");
|
|
256
266
|
const escs = (data: DataLayer) => data.table<Escalation>("escalations", "id");
|
|
257
267
|
const deps = (data: DataLayer) => data.table<PrDependency>("pr_dependencies", "pr_key");
|
|
258
268
|
|
|
@@ -1948,7 +1958,8 @@ export async function derivePlanDelivery(
|
|
|
1948
1958
|
let status = statusByPrKey?.get(t.pr_key);
|
|
1949
1959
|
if (status === undefined && !statusByPrKey) {
|
|
1950
1960
|
// On-demand caller: fetch just this slice's PR row rather than loading the whole table.
|
|
1951
|
-
|
|
1961
|
+
// Read the ADR-0065 derived edge so an out-of-band-terminated slice reads `abandoned`.
|
|
1962
|
+
status = (await prsTracking(data).get(t.pr_key))?.derived_status;
|
|
1952
1963
|
}
|
|
1953
1964
|
prStatuses.push(status ?? MISSING_PR_STATUS);
|
|
1954
1965
|
}
|
|
@@ -2028,8 +2039,9 @@ export async function pollWaitGate(data: DataLayer) {
|
|
|
2028
2039
|
* default branch, so there is nothing to promote. Best-effort + per-plan isolated. */
|
|
2029
2040
|
export async function pollPromotion(data: DataLayer, engine: EngineClient, token: string) {
|
|
2030
2041
|
// Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
|
|
2042
|
+
// Read the ADR-0065 derived edge (`derived_status`) so a terminated slice reads `abandoned`.
|
|
2031
2043
|
const statusByPrKey = new Map<string, string>();
|
|
2032
|
-
for (const pr of await
|
|
2044
|
+
for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
|
|
2033
2045
|
for (const plan of await plans(data).all()) {
|
|
2034
2046
|
const base = plan.base_branch;
|
|
2035
2047
|
// A non-`epic/*` base is never promotable — short-circuit before the per-plan delivery join.
|
|
@@ -2107,8 +2119,9 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
|
|
|
2107
2119
|
* Never touches a run that isn't `converging` — additive/derived only, idempotent, best-effort. */
|
|
2108
2120
|
export async function pollFeatureDelivery(data: DataLayer) {
|
|
2109
2121
|
// Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
|
|
2122
|
+
// Read the ADR-0065 derived edge (`derived_status`) so a terminated run reads `abandoned`.
|
|
2110
2123
|
const statusByPrKey = new Map<string, string>();
|
|
2111
|
-
for (const pr of await
|
|
2124
|
+
for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
|
|
2112
2125
|
// Only `converging` runs are ever reconciled — query them via the `feature_runs(status)` index
|
|
2113
2126
|
// (db/migrations/028) instead of scanning all history, so this pass stays O(in-flight), not
|
|
2114
2127
|
// O(total runs), as the table grows.
|
|
@@ -2130,26 +2143,6 @@ export async function pollFeatureDelivery(data: DataLayer) {
|
|
|
2130
2143
|
}
|
|
2131
2144
|
}
|
|
2132
2145
|
|
|
2133
|
-
/** The app manifest, read and parsed exactly ONCE at module load. `activeStatusesFor` is invoked
|
|
2134
|
-
* three times during module initialization (the PR/plan/feature constants below); parsing here keeps
|
|
2135
|
-
* that to a single synchronous `readFileSync` + `JSON.parse` instead of one per lookup. */
|
|
2136
|
-
const APP_MANIFEST: { instanceTracking?: { table: string; activeStatuses?: string[] }[] } = JSON.parse(
|
|
2137
|
-
readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
|
|
2138
|
-
);
|
|
2139
|
-
|
|
2140
|
-
/** Read a tracked table's parked-and-active statuses from the single source of truth
|
|
2141
|
-
* (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
|
|
2142
|
-
* from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
|
|
2143
|
-
function activeStatusesFor(table: string): readonly string[] {
|
|
2144
|
-
const binding = APP_MANIFEST.instanceTracking?.find((b) => b.table === table);
|
|
2145
|
-
if (!binding?.activeStatuses?.length) {
|
|
2146
|
-
throw new Error(
|
|
2147
|
-
`nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
|
|
2148
|
-
);
|
|
2149
|
-
}
|
|
2150
|
-
return binding.activeStatuses;
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
2146
|
/** The `pull_requests` statuses a PR instance can be parked-and-active on, DERIVED from the single
|
|
2154
2147
|
* source of truth (`instanceTracking.pull_requests.activeStatuses` in nano.app.json) so the app-side
|
|
2155
2148
|
* scan can never drift from the reconciler's notion of "in-flight". `pollUserTasks` scans only these
|
|
@@ -22,6 +22,7 @@ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { after, before, describe, test } from "node:test";
|
|
24
24
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
25
|
+
import { trackingTargetFor } from "../app/instanceTracking.ts";
|
|
25
26
|
|
|
26
27
|
// The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + openapi.yaml
|
|
27
28
|
// + db/migrations + resources/processes live.
|
|
@@ -160,16 +161,20 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
|
|
|
160
161
|
|
|
161
162
|
// The operation registered the PR aggregate (instanceTracking table) and started a real engine
|
|
162
163
|
// instance — synchronously, before any worker ran (we never settled).
|
|
163
|
-
const prs = app.db.table<{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
const prs = app.db.table<{
|
|
165
|
+
pr_key: string;
|
|
166
|
+
status: string;
|
|
167
|
+
process_key: string | null;
|
|
168
|
+
abandon_token: string | null;
|
|
169
|
+
}>("pull_requests", "pr_key");
|
|
167
170
|
const row = await prs.findOne({ pr_key: prKey });
|
|
168
171
|
assert.ok(row, "a pull_requests row was registered");
|
|
169
172
|
assert.equal(row?.status, "converging", "the PR is tracked as actively converging");
|
|
170
173
|
assert.ok(row?.process_key, "the row carries the engine process-instance key");
|
|
174
|
+
assert.ok(row?.abandon_token, "the row carries a #76 abandon-check capability token");
|
|
171
175
|
|
|
172
176
|
const processInstanceKey = row!.process_key!;
|
|
177
|
+
const abandonToken = row!.abandon_token!;
|
|
173
178
|
const before = await app.engine.searchProcessInstances({
|
|
174
179
|
processInstanceKeys: [processInstanceKey],
|
|
175
180
|
});
|
|
@@ -182,11 +187,39 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
|
|
|
182
187
|
assert.equal(stillActive?.status, "converging", "row not yet reconciled before any poll fires");
|
|
183
188
|
|
|
184
189
|
// Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
|
|
185
|
-
// the reconciler observes TERMINATED and
|
|
186
|
-
// `abandoned
|
|
190
|
+
// the reconciler observes TERMINATED and feeds urban's instance projection. Under ADR-0065
|
|
191
|
+
// (urban 0.81.0, the writer→source inversion) it NO LONGER writes `abandoned` onto the base row;
|
|
192
|
+
// the terminal edge is DERIVED on read via the managed `pull_requests__tracking` VIEW.
|
|
187
193
|
await app.advanceTime(PR_POLL_MS + 1000);
|
|
188
|
-
|
|
189
|
-
|
|
194
|
+
|
|
195
|
+
// (1) The base row keeps only the worker-owned transient — it stays `converging`, NOT rewritten.
|
|
196
|
+
const baseRow = await prs.findOne({ pr_key: prKey });
|
|
197
|
+
assert.equal(
|
|
198
|
+
baseRow?.status,
|
|
199
|
+
"converging",
|
|
200
|
+
"ADR-0065: the reconciler no longer writes the terminal edge onto the base row",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// (2) The derived tracking VIEW reports the terminal edge (`abandoned`) via `derived_status`. Its
|
|
204
|
+
// name/column are resolved by the app's SSOT helper, which defers to urban's own target resolver.
|
|
205
|
+
const target = trackingTargetFor("pull_requests");
|
|
206
|
+
const view = app.db.table<{ pr_key: string } & Record<string, unknown>>(target.view, "pr_key");
|
|
207
|
+
const derived = await view.findOne({ pr_key: prKey });
|
|
208
|
+
assert.equal(
|
|
209
|
+
derived?.[target.statusColumn],
|
|
210
|
+
"abandoned",
|
|
211
|
+
"the derived read-model reports the terminated PR as abandoned",
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// (3) End-to-end, the #76 cooperative abandon-check endpoint (which a servicing agent curls
|
|
215
|
+
// before any irreversible action) now reports `abandoned: true` — the whole point of the edge.
|
|
216
|
+
const abandonCheck = await api.call<{ prKey: string; status: string; abandoned: boolean }>(
|
|
217
|
+
"checkAbandon",
|
|
218
|
+
{ query: { token: abandonToken } },
|
|
219
|
+
);
|
|
220
|
+
assert.equal(abandonCheck.status, 200, "the abandon check resolves the known token");
|
|
221
|
+
assert.equal(abandonCheck.body.abandoned, true, "a servicing agent is told to abort the run");
|
|
222
|
+
assert.equal(abandonCheck.body.status, "abandoned", "the reported status is the derived edge");
|
|
190
223
|
});
|
|
191
224
|
|
|
192
225
|
test("coverage gate: every operation the pilot claims to own was exercised", () => {
|
|
@@ -18,6 +18,7 @@ import { assertEquals } from "#test-assert";
|
|
|
18
18
|
import type { AppApi } from "@nanobpm/urban";
|
|
19
19
|
import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
|
|
20
20
|
import { noopLog } from "../test/log.ts";
|
|
21
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
21
22
|
import handler from "./acknowledgeEpic.ts";
|
|
22
23
|
|
|
23
24
|
// An in-memory data layer wired through the `plans` gateway (now a plain record table). `extra` seeds
|
|
@@ -52,7 +53,7 @@ function memApp(
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
const app = {
|
|
55
|
-
data: { table: (n: string, pk?: string) => tbl(n, pk) },
|
|
56
|
+
data: { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) },
|
|
56
57
|
log: noopLog(),
|
|
57
58
|
} as any as AppApi;
|
|
58
59
|
return { app, rows: stores.plans };
|
|
@@ -3,6 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
import { assertEquals } from "#test-assert";
|
|
4
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
5
5
|
import { noopLog } from "../test/log.ts";
|
|
6
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
6
7
|
import handler from "./checkAbandon.ts";
|
|
7
8
|
|
|
8
9
|
function memApp(): { app: AppApi } {
|
|
@@ -19,7 +20,7 @@ function memApp(): { app: AppApi } {
|
|
|
19
20
|
},
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
const app = { data: { table: (n: string) => tbl(n) }, log: noopLog() } as any as AppApi;
|
|
23
|
+
const app = { data: { table: withTrackingViews((n: string) => tbl(n)) }, log: noopLog() } as any as AppApi;
|
|
23
24
|
return { app };
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -6,6 +6,7 @@ import { test } from "node:test";
|
|
|
6
6
|
import { assert, assertEquals } from "#test-assert";
|
|
7
7
|
import type { AppApi } from "@nanobpm/urban";
|
|
8
8
|
import { noopLog } from "../test/log.ts";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import handler from "./getLineage.ts";
|
|
10
11
|
|
|
11
12
|
function memApp(stores: Record<string, any[]>): AppApi {
|
|
@@ -20,7 +21,7 @@ function memApp(stores: Record<string, any[]>): AppApi {
|
|
|
20
21
|
},
|
|
21
22
|
};
|
|
22
23
|
};
|
|
23
|
-
return { data: { table }, log: noopLog() } as any as AppApi;
|
|
24
|
+
return { data: { table: withTrackingViews(table) }, log: noopLog() } as any as AppApi;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.129.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.4.0",
|
|
62
|
-
"@nanobpm/urban": "^0.
|
|
62
|
+
"@nanobpm/urban": "^0.81.0",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@biomejs/biome": "^2.4.11",
|
|
67
|
-
"@nanobpm/urban-testkit": "^0.
|
|
67
|
+
"@nanobpm/urban-testkit": "^0.13.1",
|
|
68
68
|
"@nanobpm/workflow": "^0.14.0",
|
|
69
69
|
"@semantic-release/changelog": "^6.0.3",
|
|
70
70
|
"@semantic-release/git": "^10.0.1",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Test-only helper: make a hand-rolled fake `DataLayer.table(name)` also serve the ADR-0065 derived
|
|
2
|
+
// tracking VIEWs (`<table>__tracking`) that the real `@nanobpm/urban` runtime auto-provisions.
|
|
3
|
+
//
|
|
4
|
+
// The app's terminal-edge readers (app/abandon.ts, app/lineage.ts, app/conformance.ts, app/retro.ts,
|
|
5
|
+
// app/service.ts) route through `<table>__tracking` and read `derived_status`. The in-memory fakes in
|
|
6
|
+
// the unit tests only model base tables keyed by name, so a read of `pull_requests__tracking` would
|
|
7
|
+
// hit an empty store. Wrapping a fake's `table` resolver with `withTrackingViews` transparently
|
|
8
|
+
// aliases any tracking VIEW onto its base store and projects the derived column.
|
|
9
|
+
//
|
|
10
|
+
// Fidelity: a fake has no engine instance-state, so it can only compute the VIEW's `ELSE
|
|
11
|
+
// base.<statusField>` fall-through branch — which is exactly how these tests model an already-settled
|
|
12
|
+
// PR (they seed the terminal directly onto the base row). So `derived_status := base.<statusField>`
|
|
13
|
+
// reproduces the real view's pass-through semantics for the states unit tests exercise, with zero
|
|
14
|
+
// duplicated view-naming logic (it defers to the app's SSOT resolvers in app/instanceTracking.ts).
|
|
15
|
+
import { baseStatusFieldFor, baseTableForTrackingView, trackingTargetFor } from "../app/instanceTracking.ts";
|
|
16
|
+
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only fake `table` resolver over dynamic row shapes.
|
|
18
|
+
type TableFn = (name: string, pk?: string) => any;
|
|
19
|
+
|
|
20
|
+
/** Decorate a fake `table(name, pk)` resolver so it also serves every `<table>__tracking` derived
|
|
21
|
+
* VIEW off the corresponding base store, projecting `derived_status := base.<statusField>` onto each
|
|
22
|
+
* row the read methods (`findOne`/`get`/`find`/`all`) return. A non-tracking name is passed through
|
|
23
|
+
* untouched, and the VIEW is read-only (writes are never wrapped). */
|
|
24
|
+
export function withTrackingViews<F extends TableFn>(base: F): F {
|
|
25
|
+
return ((name: string, pk?: string) => {
|
|
26
|
+
const baseName = baseTableForTrackingView(name);
|
|
27
|
+
if (!baseName) return base(name, pk);
|
|
28
|
+
const inner = base(baseName, pk);
|
|
29
|
+
const derivedColumn = trackingTargetFor(baseName).statusColumn;
|
|
30
|
+
const statusField = baseStatusFieldFor(baseName);
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
|
|
32
|
+
const project = (row: any) =>
|
|
33
|
+
row == null ? row : { ...row, [derivedColumn]: row[statusField] };
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
|
|
35
|
+
return new Proxy(inner, {
|
|
36
|
+
get(target: any, prop: string) {
|
|
37
|
+
const value = target[prop];
|
|
38
|
+
if (typeof value !== "function") return value;
|
|
39
|
+
const bound = value.bind(target);
|
|
40
|
+
if (prop === "findOne" || prop === "get") {
|
|
41
|
+
return async (...args: unknown[]) => project(await bound(...args));
|
|
42
|
+
}
|
|
43
|
+
if (prop === "find" || prop === "all") {
|
|
44
|
+
return async (...args: unknown[]) => (await bound(...args)).map(project);
|
|
45
|
+
}
|
|
46
|
+
return bound;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}) as F;
|
|
50
|
+
}
|
package/test/worldDb.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
|
|
7
7
|
import { afterEach } from "node:test";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import type { DataLayer } from "@nanobpm/urban";
|
|
10
|
+
import { withTrackingViews } from "./trackingViews.ts";
|
|
10
11
|
|
|
11
12
|
const openDbs = new Set<DatabaseSync>();
|
|
12
13
|
afterEach(() => {
|
|
@@ -103,7 +104,7 @@ export function memDataFor(migrationFiles: readonly string[]): { data: DataLayer
|
|
|
103
104
|
db.exec(sql);
|
|
104
105
|
}
|
|
105
106
|
const data = {
|
|
106
|
-
table: (name: string, pk = "id") => gateway(db, name, pk),
|
|
107
|
+
table: withTrackingViews((name: string, pk = "id") => gateway(db, name, pk)),
|
|
107
108
|
open: () => openDataSource(db),
|
|
108
109
|
} as unknown as DataLayer;
|
|
109
110
|
return { data, db };
|
|
@@ -3,6 +3,7 @@ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
|
3
3
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
4
|
import { noopLog } from "../../test/log.ts";
|
|
5
5
|
import { memBlackboardSource } from "../../test/blackboardDb.ts";
|
|
6
|
+
import { withTrackingViews } from "../../test/trackingViews.ts";
|
|
6
7
|
import { appendEntry } from "../../app/blackboard.ts";
|
|
7
8
|
import handler from "./worker.ts";
|
|
8
9
|
|
|
@@ -30,7 +31,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
30
31
|
async update() {},
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
34
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
|
|
34
35
|
return { data, stores };
|
|
35
36
|
}
|
|
36
37
|
|