@nanobpm/nano-workforce 0.128.0 → 0.129.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/pr-title-lint.yml +5 -3
- package/.releaserc.json +36 -2
- package/AGENTS.md +10 -6
- package/CHANGELOG.md +13 -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/mergeEscalationUserTask.test.ts +21 -55
- package/app/mergeLoopBehaviour.test.ts +446 -0
- 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 +4 -3
- package/resources/processes/merge-loop.bpmn +692 -395
- package/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
- package/app/mergeCiReattempt.test.ts +0 -138
- package/app/mergeEscalationQuestion.test.ts +0 -190
- package/app/mergeRebaseArm.test.ts +0 -140
- package/app/mergeRetryArm.test.ts +0 -100
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
name: PR title lint
|
|
2
2
|
|
|
3
3
|
# Squash-merge uses the PR title as the commit subject on `main`, and
|
|
4
|
-
# semantic-release (
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# semantic-release (conventionalcommits preset) cuts a release for
|
|
5
|
+
# `feat:`/`fix:`/`perf:`/`refactor:`/`build:`/`revert:`/`docs:` (see
|
|
6
|
+
# `.releaserc.json` releaseRules). A non-conventional title therefore lands on
|
|
7
|
+
# `main` and is silently skipped by the release job — so gate the title against
|
|
8
|
+
# the Conventional Commits grammar.
|
|
7
9
|
on:
|
|
8
10
|
pull_request:
|
|
9
11
|
types: [opened, edited, synchronize, reopened]
|
package/.releaserc.json
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"branches": ["main"],
|
|
3
3
|
"plugins": [
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
[
|
|
5
|
+
"@semantic-release/commit-analyzer",
|
|
6
|
+
{
|
|
7
|
+
"preset": "conventionalcommits",
|
|
8
|
+
"releaseRules": [
|
|
9
|
+
{ "type": "feat", "release": "minor" },
|
|
10
|
+
{ "type": "fix", "release": "patch" },
|
|
11
|
+
{ "type": "perf", "release": "patch" },
|
|
12
|
+
{ "type": "refactor", "release": "patch" },
|
|
13
|
+
{ "type": "build", "release": "patch" },
|
|
14
|
+
{ "type": "revert", "release": "patch" },
|
|
15
|
+
{ "type": "docs", "release": "patch" }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
"@semantic-release/release-notes-generator",
|
|
21
|
+
{
|
|
22
|
+
"preset": "conventionalcommits",
|
|
23
|
+
"presetConfig": {
|
|
24
|
+
"types": [
|
|
25
|
+
{ "type": "feat", "section": "Features" },
|
|
26
|
+
{ "type": "fix", "section": "Bug Fixes" },
|
|
27
|
+
{ "type": "perf", "section": "Performance Improvements" },
|
|
28
|
+
{ "type": "refactor", "section": "Code Refactoring" },
|
|
29
|
+
{ "type": "build", "section": "Build System" },
|
|
30
|
+
{ "type": "revert", "section": "Reverts" },
|
|
31
|
+
{ "type": "docs", "section": "Documentation" },
|
|
32
|
+
{ "type": "test", "hidden": true },
|
|
33
|
+
{ "type": "ci", "hidden": true },
|
|
34
|
+
{ "type": "chore", "hidden": true },
|
|
35
|
+
{ "type": "style", "hidden": true }
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
],
|
|
6
40
|
"@semantic-release/changelog",
|
|
7
41
|
"@semantic-release/npm",
|
|
8
42
|
"@semantic-release/github",
|
package/AGENTS.md
CHANGED
|
@@ -353,16 +353,20 @@ agents:
|
|
|
353
353
|
- **DCO sign-off is enforced.** Every commit needs a `Signed-off-by` trailer —
|
|
354
354
|
use `git commit -s` (or `git rebase --signoff`). A missing sign-off fails the
|
|
355
355
|
DCO check.
|
|
356
|
-
- **Conventional Commits.** `feat:`, `fix:`, `
|
|
357
|
-
`
|
|
356
|
+
- **Conventional Commits.** `feat:`, `fix:`, `perf:`, `refactor:`, `build:`,
|
|
357
|
+
`revert:`, `docs:`, `chore:`, `ci:`, `test:`, `style:`, imperative mood — see the
|
|
358
|
+
next bullet for which of these trigger a release. Review-comment fix-ups are
|
|
359
|
+
`chore:`, not `fix:`.
|
|
358
360
|
- **PR titles must be Conventional too — they become the release trigger.** PRs
|
|
359
361
|
land on `main` via **squash merge**, so the **PR title is the commit subject**
|
|
360
|
-
semantic-release analyses.
|
|
361
|
-
|
|
362
|
-
`
|
|
362
|
+
semantic-release analyses. `feat:` cuts a **minor**; `fix:`, `perf:`,
|
|
363
|
+
`refactor:`, `build:`, `revert:`, and `docs:` cut a **patch** (see
|
|
364
|
+
`.releaserc.json` `releaseRules`); a `BREAKING CHANGE:` footer cuts a **major**.
|
|
365
|
+
Only `chore:`, `ci:`, `test:`, and `style:` are **no-release** — a PR titled
|
|
366
|
+
with one of those lands on `main` and is **silently skipped** by the release job
|
|
363
367
|
(no version, no changelog, no deploy). A user-facing feature **must** be titled
|
|
364
368
|
`feat:`. The `PR title lint` workflow (`.github/workflows/pr-title-lint.yml`)
|
|
365
|
-
enforces this; if a non-
|
|
369
|
+
enforces this; if a non-releasing title ever slips through, push one empty
|
|
366
370
|
releasable commit (`git commit --allow-empty -s -m "feat(scope): …"`) to release
|
|
367
371
|
the accumulated changes.
|
|
368
372
|
- **Feature work in a worktree** off `origin/main`, one branch per change; open a
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## [0.129.1](https://github.com/nanobpm/nano-workforce/compare/v0.129.0...v0.129.1) (2026-08-23)
|
|
2
|
+
|
|
3
|
+
### Code Refactoring
|
|
4
|
+
|
|
5
|
+
* **merge-loop:** sub-process topology + behavioural guards ([#466](https://github.com/nanobpm/nano-workforce/issues/466)) ([#490](https://github.com/nanobpm/nano-workforce/issues/490)) ([ac6f130](https://github.com/nanobpm/nano-workforce/commit/ac6f130d19e545c4d3da23d738c30c1cb6f2b981)), closes [Magikcraft/nano-bpm#971](https://github.com/Magikcraft/nano-bpm/issues/971)
|
|
6
|
+
|
|
7
|
+
# [0.129.0](https://github.com/nanobpm/nano-workforce/compare/v0.128.0...v0.129.0) (2026-08-23)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* 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)
|
|
13
|
+
|
|
1
14
|
# [0.128.0](https://github.com/nanobpm/nano-workforce/compare/v0.127.0...v0.128.0) (2026-08-23)
|
|
2
15
|
|
|
3
16
|
|
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
|
|
|
@@ -1,71 +1,37 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Cross-layer drift guard: the merge-loop escalation user task the model parks on must be one the
|
|
2
|
+
// canonical completer (`agentCompletion.ts`) actually accepts and validates (#256, #466).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
4
|
+
// The merge escalation converges on ONE native `wait-merge-answer` userTask (backed by the shared
|
|
5
|
+
// `pr-escalation` form) so it is answerable from the one Tasks inbox. The *behavioural* invariants —
|
|
6
|
+
// that the loop parks on that task, that answering it reconciles the escalations row and re-arms the
|
|
7
|
+
// poller — are exercised end-to-end by the WASM engine in `mergeLoopBehaviour.test.ts`. But that
|
|
8
|
+
// engine harness completes the task through the engine, NOT through nwf's application-level
|
|
9
|
+
// completer, so it cannot catch the specific silent-drift failure this guard closes: the model
|
|
10
|
+
// deploys and parks on `wait-merge-answer`, yet `agentCompletion.ts` refuses to drive it because the
|
|
11
|
+
// id fell out of `ESCALATION_TASK_ELEMENTS` (or its form contract drifted) — a task no worker will
|
|
12
|
+
// ever answer. This guard ties the model's user-task id to the completer's accepted set + form
|
|
13
|
+
// contract so the two layers cannot diverge unnoticed.
|
|
14
14
|
|
|
15
15
|
import { test } from "node:test";
|
|
16
|
-
import { assert
|
|
16
|
+
import { assert } from "#test-assert";
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
18
|
import { ESCALATION_TASK_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
const flat = bpmn.replace(/\s+/g, " ");
|
|
22
|
-
|
|
23
|
-
function hasFlow(source: string, target: string): boolean {
|
|
24
|
-
const re = new RegExp(
|
|
25
|
-
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
26
|
-
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
27
|
-
);
|
|
28
|
-
return re.test(flat);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
test("the merge escalation parks on a native wait-merge-answer userTask backed by pr-escalation.form", () => {
|
|
32
|
-
const task = flat.match(/<bpmn:userTask\b[^>]*\bid="wait-merge-answer"[\s\S]*?<\/bpmn:userTask>/);
|
|
33
|
-
assert(task, "wait-merge-answer must be a <bpmn:userTask>");
|
|
34
|
-
assertStringIncludes(task![0], 'formId="pr-escalation"', "it must render the shared pr-escalation form");
|
|
35
|
-
assertStringIncludes(task![0], "<zeebe:userTask", "it must be a native (Zeebe) user task");
|
|
36
|
-
});
|
|
20
|
+
const flat = readFileSync("resources/processes/merge-loop.bpmn", "utf8").replace(/\s+/g, " ");
|
|
37
21
|
|
|
38
|
-
test("
|
|
39
|
-
//
|
|
40
|
-
// loop's wait-answer → record-answer. Without the reconcile step the escalations row would stay
|
|
41
|
-
// `open` forever after the task completes (a phantom on /status).
|
|
42
|
-
const record = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-merge-answer"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
43
|
-
assert(record, "record-merge-answer service task must exist");
|
|
44
|
-
assertStringIncludes(record![0], 'type="pr.answer-escalation"', "it must run the shared reconcile worker");
|
|
45
|
-
assert(hasFlow("wait-merge-answer", "record-merge-answer"), "wait-merge-answer → record-merge-answer missing");
|
|
46
|
-
assert(hasFlow("record-merge-answer", "arm-merge"), "record-merge-answer → arm-merge (re-arm) missing");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("the legacy escalation-answered message pathway is gone", () => {
|
|
50
|
-
assert(!flat.includes("escalation-answered"), "the escalation-answered message must be removed");
|
|
51
|
-
assert(!flat.includes("Message_mergeEscAnswered"), "the merge escalation message declaration must be removed");
|
|
52
|
-
// The answer wait must no longer be a message catch — it is now a user task.
|
|
22
|
+
test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
|
|
23
|
+
// (1) The model actually parks on `wait-merge-answer` as a native user task...
|
|
53
24
|
assert(
|
|
54
|
-
|
|
55
|
-
"
|
|
25
|
+
/<bpmn:userTask\b[^>]*\bid="wait-merge-answer"/.test(flat),
|
|
26
|
+
"the model must park the merge escalation on a userTask id='wait-merge-answer'",
|
|
56
27
|
);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
|
|
60
|
-
// The completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that parks on
|
|
61
|
-
// `wait-merge-answer` while the code doesn't accept it would deploy but never be answerable — the
|
|
62
|
-
// exact silent-drift failure mode this guard closes.
|
|
28
|
+
// (2) ...and the completer accepts that exact id (else it deploys but is never answerable)...
|
|
63
29
|
assert(
|
|
64
30
|
ESCALATION_TASK_ELEMENTS.has("wait-merge-answer"),
|
|
65
31
|
"ESCALATION_TASK_ELEMENTS must accept wait-merge-answer",
|
|
66
32
|
);
|
|
67
|
-
//
|
|
68
|
-
//
|
|
33
|
+
// (3) ...resolving to the pr-escalation form contract (a missing answer is rejected; a present
|
|
34
|
+
// one accepted), proving the element maps to the same form the model renders.
|
|
69
35
|
assert(
|
|
70
36
|
validateEscalationVariables("wait-merge-answer", {}) !== null,
|
|
71
37
|
"wait-merge-answer must enforce the pr-escalation form contract (answer required)",
|