@nanobpm/nano-workforce 0.105.0 → 0.106.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/ci.yml +5 -0
- package/AGENTS.md +31 -1
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/app/agentCompletion.test.ts +28 -0
- package/app/agentCompletion.ts +14 -2
- package/app/conformance.test.ts +94 -1
- package/app/conformance.ts +89 -0
- package/app/instance-tracking.test.ts +24 -0
- package/app/migration-upgrade-smoke.test.ts +55 -0
- package/app/migrationHeal.test.ts +75 -0
- package/app/migrationHeal.ts +74 -0
- package/app/pollUserTasks.test.ts +37 -0
- package/app/service.ts +40 -0
- package/app/userTasks.test.ts +20 -0
- package/app/userTasks.ts +2 -0
- package/db/migrations/054_conformance_review_tracking.sql +27 -0
- package/nano.app.json +18 -0
- package/package.json +4 -2
- package/pages/tasks.page.json +84 -0
- package/resources/forms/conformance-escalation.form +17 -0
- package/resources/processes/retro.bpmn +74 -13
- package/scripts/check-migrations.test.ts +58 -0
- package/scripts/check-migrations.ts +127 -5
- package/scripts/heal-migration-ledger.ts +49 -0
- package/test/migrations.ts +121 -0
- package/workers/conformance-ack/worker.test.ts +50 -0
- package/workers/conformance-ack/worker.ts +31 -0
- package/workers/conformance-record/worker.test.ts +47 -5
- package/workers/conformance-record/worker.ts +33 -1
|
@@ -13,11 +13,23 @@
|
|
|
13
13
|
// The rule: no two migration files may share a numeric prefix. The pre-existing historical
|
|
14
14
|
// duplicates are forward-only and already applied, so they cannot be renamed — they are
|
|
15
15
|
// grandfathered in GRANDFATHERED_DUPES. Any NEW duplicate prefix fails the build.
|
|
16
|
+
//
|
|
17
|
+
// A second, independent invariant lives here too (issue #357): once a migration has merged to
|
|
18
|
+
// `main` it is IMMUTABLE — never renamed, deleted, or edited. The runtime keys the migration ledger
|
|
19
|
+
// by FILENAME, so renaming a merged migration makes the runner re-apply it against an already-
|
|
20
|
+
// migrated DB and abort boot ("duplicate column"); editing one silently no-ops on every existing
|
|
21
|
+
// install (the ledger already has that name) while diverging fresh installs. `checkImmutability`
|
|
22
|
+
// diffs `db/migrations/` against the merge-base with `origin/main` and fails on any rename, delete,
|
|
23
|
+
// or content change to a file that existed there. It compares against the merge-base (the branch's
|
|
24
|
+
// fork point), NOT `origin/main`'s tip, so a branch that is merely behind main isn't wrongly flagged
|
|
25
|
+
// for migrations added to main after it forked.
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
16
27
|
import { readdirSync } from "node:fs";
|
|
17
28
|
import { dirname, join } from "node:path";
|
|
18
29
|
import { fileURLToPath } from "node:url";
|
|
19
30
|
|
|
20
|
-
const
|
|
31
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
|
+
const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
|
|
21
33
|
|
|
22
34
|
// Historical collisions that predate this gate. Forward-only + already applied ⇒ cannot be
|
|
23
35
|
// renumbered. New duplicates are NOT allowed here — fix them before merge.
|
|
@@ -32,10 +44,112 @@ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db",
|
|
|
32
44
|
// cannot be renumbered (a rename re-runs `CREATE TABLE`/`ALTER TABLE DROP COLUMN` on migrated DBs and
|
|
33
45
|
// fails). They create three disjoint schema objects, so their relative apply order is irrelevant.
|
|
34
46
|
// Grandfather 049; any NEW duplicate prefix still fails the build.
|
|
35
|
-
|
|
47
|
+
//
|
|
48
|
+
// 052 is the same story across two PRs: #351 landed `052_worker_durable_resume` and #355 landed
|
|
49
|
+
// `052_plan_conformance`, each the branch-local "next" prefix, colliding silently only once both were
|
|
50
|
+
// on main. Both are already applied forward-only, and — reinforced now by the immutability check
|
|
51
|
+
// below — renumbering a merged migration is itself forbidden (the rename would re-run it and abort
|
|
52
|
+
// boot, issue #357). The two create disjoint tables (`worker_durable_resume`, `plan_conformance`), so
|
|
53
|
+
// apply order is irrelevant. Grandfather 052; any NEW duplicate prefix still fails the build.
|
|
54
|
+
const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049", "052"]);
|
|
36
55
|
|
|
37
56
|
const PREFIX = /^(\d{3})_[^/]*\.sql$/;
|
|
38
57
|
|
|
58
|
+
function git(args: string[]): string {
|
|
59
|
+
return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The commit to treat as "already merged" — the branch's fork point from `origin/main` (fall back to
|
|
63
|
+
* local `main`). `null` when no baseline is resolvable (e.g. a shallow clone with no `main`), in
|
|
64
|
+
* which case the immutability check is skipped with a warning; CI checks out full history. Override
|
|
65
|
+
* with `MIGRATION_BASELINE_REF` (used by the gate's own tests). */
|
|
66
|
+
function resolveBaseline(): string | null {
|
|
67
|
+
const override = process.env.MIGRATION_BASELINE_REF;
|
|
68
|
+
const upstreams = override ? [override] : ["origin/main", "main"];
|
|
69
|
+
for (const ref of upstreams) {
|
|
70
|
+
try {
|
|
71
|
+
// An explicit override is used as-is; a branch name is resolved to its merge-base with HEAD so a
|
|
72
|
+
// branch that is merely behind main isn't blamed for migrations main gained after it forked.
|
|
73
|
+
if (override) {
|
|
74
|
+
git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
75
|
+
return ref;
|
|
76
|
+
}
|
|
77
|
+
return git(["merge-base", ref, "HEAD"]).trim();
|
|
78
|
+
} catch {
|
|
79
|
+
// try the next candidate
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Classify a `git diff --find-renames --name-status <baseline> -- db/migrations` listing into
|
|
86
|
+
* immutability violations. Renames (`R`), deletes (`D`), and content edits (`M`) of a merged
|
|
87
|
+
* migration are violations; additions (`A`) are allowed. Exported for unit coverage. */
|
|
88
|
+
export function immutabilityErrorsFromDiff(statusOutput: string): string[] {
|
|
89
|
+
const errors: string[] = [];
|
|
90
|
+
const name = (p: string): string => p.slice(p.lastIndexOf("/") + 1);
|
|
91
|
+
for (const line of statusOutput.split("\n")) {
|
|
92
|
+
if (line.trim() === "") continue;
|
|
93
|
+
const [status, ...paths] = line.split("\t");
|
|
94
|
+
if (status.startsWith("R")) {
|
|
95
|
+
errors.push(
|
|
96
|
+
` ${name(paths[0])} -> ${name(paths[1])}: a merged migration was RENAMED. The ledger keys ` +
|
|
97
|
+
`by filename, so the renamed file re-runs on every existing install and aborts boot. Keep ` +
|
|
98
|
+
`the original filename; add a NEW migration for further change.`,
|
|
99
|
+
);
|
|
100
|
+
} else if (status.startsWith("D")) {
|
|
101
|
+
errors.push(
|
|
102
|
+
` ${name(paths[0])}: a merged migration was DELETED. Forward-only migrations are immutable — ` +
|
|
103
|
+
`removing one desyncs the ledger from the schema. Leave it in place.`,
|
|
104
|
+
);
|
|
105
|
+
} else if (status.startsWith("M")) {
|
|
106
|
+
errors.push(
|
|
107
|
+
` ${name(paths[0])}: a merged migration was EDITED. The ledger already records it, so the ` +
|
|
108
|
+
`edit silently no-ops on every migrated install while diverging fresh ones. Add a NEW ` +
|
|
109
|
+
`migration instead of changing a merged one.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return errors;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Fail on any rename, delete, or content change to a migration already present at the baseline — a
|
|
117
|
+
* merged migration is immutable (issue #357). Additions are fine. Returns whether the immutability
|
|
118
|
+
* gate actually ran (false when it was skipped because no baseline/diff was available), so the
|
|
119
|
+
* caller's success message doesn't claim a guarantee the gate never checked. */
|
|
120
|
+
function checkImmutability(errors: string[]): boolean {
|
|
121
|
+
const baseline = resolveBaseline();
|
|
122
|
+
if (baseline === null) {
|
|
123
|
+
console.warn(
|
|
124
|
+
"check-migrations: WARN — no origin/main baseline resolvable; skipping the immutability check " +
|
|
125
|
+
"(CI runs it with full history via fetch-depth: 0).",
|
|
126
|
+
);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let statusOutput: string;
|
|
131
|
+
try {
|
|
132
|
+
// Diff the baseline tree against the WORKING TREE (staged + unstaged), detecting renames, scoped
|
|
133
|
+
// to db/migrations. `--find-renames` surfaces a rename as one `R` row instead of a delete+add.
|
|
134
|
+
statusOutput = git([
|
|
135
|
+
"diff",
|
|
136
|
+
"--find-renames",
|
|
137
|
+
"--name-status",
|
|
138
|
+
baseline,
|
|
139
|
+
"--",
|
|
140
|
+
"db/migrations",
|
|
141
|
+
]);
|
|
142
|
+
} catch {
|
|
143
|
+
console.warn(
|
|
144
|
+
`check-migrations: WARN — could not diff migrations against ${baseline}; skipping the immutability check.`,
|
|
145
|
+
);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
errors.push(...immutabilityErrorsFromDiff(statusOutput));
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
39
153
|
function main(): void {
|
|
40
154
|
const files = readdirSync(MIGRATIONS_DIR)
|
|
41
155
|
.filter((f) => f.endsWith(".sql"))
|
|
@@ -68,12 +182,20 @@ function main(): void {
|
|
|
68
182
|
}
|
|
69
183
|
}
|
|
70
184
|
|
|
185
|
+
const immutabilityChecked = checkImmutability(errors);
|
|
186
|
+
|
|
71
187
|
if (errors.length > 0) {
|
|
72
|
-
console.error(`check-migrations: db/migrations
|
|
188
|
+
console.error(`check-migrations: db/migrations failed its merge-safety checks:\n${errors.join("\n")}`);
|
|
73
189
|
process.exit(1);
|
|
74
190
|
}
|
|
75
191
|
|
|
76
|
-
|
|
192
|
+
const immutabilityClause = immutabilityChecked
|
|
193
|
+
? "none renamed/deleted/edited"
|
|
194
|
+
: "immutability check skipped (no baseline)";
|
|
195
|
+
console.log(
|
|
196
|
+
`check-migrations: OK (${files.length} migrations, no colliding prefixes, ${immutabilityClause}).`,
|
|
197
|
+
);
|
|
77
198
|
}
|
|
78
199
|
|
|
79
|
-
main();
|
|
200
|
+
if (import.meta.main) main();
|
|
201
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// heal-migration-ledger — one-shot recovery for an install wedged by issue #357 (a migration that
|
|
2
|
+
// was renamed after it merged, so the runtime re-runs it and boot aborts on "duplicate column").
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node --experimental-strip-types scripts/heal-migration-ledger.ts <path-to-sqlite.db>
|
|
6
|
+
//
|
|
7
|
+
// It reconciles the `_urban_migrations` ledger for every known historical rename
|
|
8
|
+
// (`RENAMED_MIGRATIONS`): where the OLD filename is recorded as applied but the NEW one is not, it
|
|
9
|
+
// records the new filename as applied. This is a pure ledger-alias — it makes NO schema change — and
|
|
10
|
+
// is safe to run repeatedly and on a healthy DB (it only acts on the broken state). After running it,
|
|
11
|
+
// restart the node; the renamed migration is skipped and boot completes.
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { healMigrationLedger } from "../app/migrationHeal.ts";
|
|
15
|
+
|
|
16
|
+
function main(): void {
|
|
17
|
+
const dbPath = process.argv[2];
|
|
18
|
+
if (!dbPath) {
|
|
19
|
+
console.error(
|
|
20
|
+
"usage: node --experimental-strip-types scripts/heal-migration-ledger.ts <path-to-sqlite.db>",
|
|
21
|
+
);
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// `DatabaseSync` silently CREATES an empty DB when the path doesn't exist, so a typo'd path would
|
|
26
|
+
// "heal" a brand-new empty file and misleadingly report "nothing to heal". Fail loudly instead.
|
|
27
|
+
if (!existsSync(dbPath)) {
|
|
28
|
+
console.error(
|
|
29
|
+
`heal-migration-ledger: ${dbPath} — no such file. Pass the path to the existing install's SQLite DB.`,
|
|
30
|
+
);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const db = new DatabaseSync(dbPath);
|
|
35
|
+
try {
|
|
36
|
+
const healed = healMigrationLedger(db);
|
|
37
|
+
if (healed.length === 0) {
|
|
38
|
+
console.log(`heal-migration-ledger: ${dbPath} — nothing to heal (ledger already consistent).`);
|
|
39
|
+
} else {
|
|
40
|
+
console.log(
|
|
41
|
+
`heal-migration-ledger: ${dbPath} — aliased ${healed.length} migration(s) as applied: ${healed.join(", ")}. Restart the node to complete boot.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (import.meta.main) main();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Test helpers for exercising the forward-only SQLite migration set the way the runtime does.
|
|
2
|
+
//
|
|
3
|
+
// `applyMigrationSet` mirrors `@nanobpm/urban`'s `applyMigrations` (ledger keyed by FILENAME, each
|
|
4
|
+
// migration wrapped in its own transaction, skip-applied / apply-new) against a `node:sqlite`
|
|
5
|
+
// `DatabaseSync`, so upgrade tests reproduce the exact boot-time behaviour — including the
|
|
6
|
+
// "duplicate column" abort a renamed migration causes — without booting the whole app.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
|
|
15
|
+
const MIGRATIONS_TABLE = "_urban_migrations";
|
|
16
|
+
|
|
17
|
+
export interface MigrationFile {
|
|
18
|
+
name: string;
|
|
19
|
+
sql: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function byName(files: MigrationFile[]): MigrationFile[] {
|
|
23
|
+
// Match the runtime's plain lexical `.sort()` on filename (apply order).
|
|
24
|
+
return [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply `files` to `db` exactly as the runtime does: create the ledger, skip any filename already
|
|
29
|
+
* recorded, and for each remaining file run its SQL + record the filename atomically in one
|
|
30
|
+
* transaction. Throws the same rolled-back error the runtime raises when a migration's SQL fails.
|
|
31
|
+
* @returns the filenames newly applied.
|
|
32
|
+
*/
|
|
33
|
+
export function applyMigrationSet(
|
|
34
|
+
db: DatabaseSync,
|
|
35
|
+
files: MigrationFile[],
|
|
36
|
+
now: () => Date = () => new Date(),
|
|
37
|
+
): string[] {
|
|
38
|
+
db.exec(
|
|
39
|
+
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
|
|
40
|
+
);
|
|
41
|
+
const applied = new Set(
|
|
42
|
+
(db.prepare(`SELECT name FROM ${MIGRATIONS_TABLE}`).all() as { name: string }[]).map(
|
|
43
|
+
(r) => r.name,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
const newlyApplied: string[] = [];
|
|
47
|
+
for (const file of byName(files)) {
|
|
48
|
+
if (applied.has(file.name)) continue;
|
|
49
|
+
db.exec("BEGIN");
|
|
50
|
+
try {
|
|
51
|
+
db.exec(file.sql);
|
|
52
|
+
db.prepare(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`).run(
|
|
53
|
+
file.name,
|
|
54
|
+
now().toISOString(),
|
|
55
|
+
);
|
|
56
|
+
db.exec("COMMIT");
|
|
57
|
+
} catch (err) {
|
|
58
|
+
db.exec("ROLLBACK");
|
|
59
|
+
throw new Error(
|
|
60
|
+
`migration "${file.name}" failed and was rolled back (no partial schema change, not recorded as applied): ${err instanceof Error ? err.message : String(err)}`,
|
|
61
|
+
{ cause: err },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
newlyApplied.push(file.name);
|
|
65
|
+
}
|
|
66
|
+
return newlyApplied;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The current worktree's migration set, read from `db/migrations/`. */
|
|
70
|
+
export function readMigrationSetFromDisk(): MigrationFile[] {
|
|
71
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
72
|
+
.filter((f) => f.endsWith(".sql"))
|
|
73
|
+
.map((name) => ({ name, sql: readFileSync(join(MIGRATIONS_DIR, name), "utf8") }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function git(args: string[]): string {
|
|
77
|
+
return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The migration set as it existed at a git ref (tag/branch/sha), read straight from the object
|
|
82
|
+
* store so it works from any worktree. Returns `null` if the ref (or git) is unavailable — e.g. a
|
|
83
|
+
* shallow clone without tags — so callers can skip rather than fail spuriously (CI fetches full
|
|
84
|
+
* history + tags, so it exercises the real path).
|
|
85
|
+
*/
|
|
86
|
+
export function readMigrationSetFromGit(ref: string): MigrationFile[] | null {
|
|
87
|
+
let listing: string;
|
|
88
|
+
try {
|
|
89
|
+
listing = git(["ls-tree", "-r", "--name-only", ref, "--", "db/migrations"]);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const paths = listing
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((l) => l.trim())
|
|
96
|
+
.filter((l) => l.endsWith(".sql"));
|
|
97
|
+
if (paths.length === 0) return null;
|
|
98
|
+
const files: MigrationFile[] = [];
|
|
99
|
+
for (const path of paths) {
|
|
100
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
101
|
+
files.push({ name, sql: git(["show", `${ref}:${path}`]) });
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The most recent release tag (`vN.N.N`) reachable from HEAD's parent — i.e. the release we would be
|
|
108
|
+
* upgrading a live install FROM. Falls back to the tag on HEAD itself, then `null` if none is
|
|
109
|
+
* reachable (shallow clone without tags).
|
|
110
|
+
*/
|
|
111
|
+
export function previousReleaseTag(): string | null {
|
|
112
|
+
for (const from of ["HEAD^", "HEAD"]) {
|
|
113
|
+
try {
|
|
114
|
+
const ref = git(["describe", "--tags", "--abbrev=0", "--match", "v*", from]).trim();
|
|
115
|
+
if (ref) return ref;
|
|
116
|
+
} catch {
|
|
117
|
+
// try the next base
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Unit coverage for pr.conformance-ack — the operator acknowledged a conformance-review escalation
|
|
2
|
+
// (issue #216). It must settle the parked `plan_conformance` row at `review_status = 'reviewed'` so
|
|
3
|
+
// the inbox scan drops it (the retro instance COMPLETES normally, so `instanceTracking.onTerminated`
|
|
4
|
+
// never fires) and fold the operator's disposition note into the audit `summary`.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
7
|
+
import { noopLog } from "../../test/log.ts";
|
|
8
|
+
import handler from "./worker.ts";
|
|
9
|
+
|
|
10
|
+
function fakeApp(rows: Record<string, unknown>[]) {
|
|
11
|
+
const stores: Record<string, Record<string, unknown>[]> = { plan_conformance: rows };
|
|
12
|
+
return {
|
|
13
|
+
data: {
|
|
14
|
+
table(name: string, key: string) {
|
|
15
|
+
const store = (stores[name] ??= []);
|
|
16
|
+
return {
|
|
17
|
+
get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
18
|
+
find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
19
|
+
insert: (row: any) => {
|
|
20
|
+
store.push(row);
|
|
21
|
+
return Promise.resolve(store.length);
|
|
22
|
+
},
|
|
23
|
+
update: (k: any, patch: any) => {
|
|
24
|
+
const row = store.find((r) => r[key] === k);
|
|
25
|
+
if (row) Object.assign(row, patch);
|
|
26
|
+
return Promise.resolve(row);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
log: noopLog(),
|
|
32
|
+
} as any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("conformance-ack: settles the review at reviewed and appends the operator note to the summary", async () => {
|
|
36
|
+
const rows = [{ plan_key: "owner/repo#7", review_status: "reviewing", summary: "slice 2 reduced" }];
|
|
37
|
+
const app = fakeApp(rows);
|
|
38
|
+
const out = await handler({ variables: { planKey: "owner/repo#7", note: "filed follow-up #9" } } as any, app);
|
|
39
|
+
assertEquals(out, {});
|
|
40
|
+
assertEquals(rows[0].review_status, "reviewed");
|
|
41
|
+
assertEquals(rows[0].summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("conformance-ack: a blank note settles the review without touching the summary", async () => {
|
|
45
|
+
const rows = [{ plan_key: "owner/repo#8", review_status: "reviewing", summary: "auth cache unverified" }];
|
|
46
|
+
const app = fakeApp(rows);
|
|
47
|
+
await handler({ variables: { planKey: "owner/repo#8", note: " " } } as any, app);
|
|
48
|
+
assertEquals(rows[0].review_status, "reviewed");
|
|
49
|
+
assertEquals(rows[0].summary, "auth cache unverified");
|
|
50
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// pr.conformance-ack — an operator acknowledged a conformance-review escalation (issue #216).
|
|
2
|
+
//
|
|
3
|
+
// When the spec-conformance audit finds the epic did NOT cleanly meet its spec, the `retro` process
|
|
4
|
+
// routes to the `conformance-escalation` operator user task (retro.bpmn), which parks the instance
|
|
5
|
+
// on the operators' inbox with `plan_conformance.review_status = 'reviewing'`. This worker fires once
|
|
6
|
+
// the operator completes that ack task: it settles the row at `reviewed` (so the inbox scan drops it)
|
|
7
|
+
// and folds the operator's optional disposition note into the audit `summary`. The instance then
|
|
8
|
+
// continues to the lessons (retro) synthesis, so the ack is NON-blocking — delivery already landed.
|
|
9
|
+
// Persistence goes through the record gateway (`app.data`), never hand-written SQL.
|
|
10
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
11
|
+
import { acknowledgeConformance } from "../../app/conformance.ts";
|
|
12
|
+
|
|
13
|
+
interface In extends Record<string, unknown> {
|
|
14
|
+
planKey: string;
|
|
15
|
+
note?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const str = (v: unknown): string | undefined =>
|
|
19
|
+
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
20
|
+
|
|
21
|
+
const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
22
|
+
const planKey = job.variables.planKey;
|
|
23
|
+
const note = str(job.variables.note);
|
|
24
|
+
|
|
25
|
+
await acknowledgeConformance(app.data, planKey, note);
|
|
26
|
+
app.log.info("conformance-ack", { planKey, note: note ?? null });
|
|
27
|
+
|
|
28
|
+
return {};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default handler;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
-
import { assertEquals } from "#test-assert";
|
|
2
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
3
3
|
import { noopLog } from "../../test/log.ts";
|
|
4
4
|
import handler from "./worker.ts";
|
|
5
5
|
|
|
@@ -36,8 +36,9 @@ function fakeApp() {
|
|
|
36
36
|
|
|
37
37
|
test("conformance-record: persists a filed conformance from hoisted result vars", async () => {
|
|
38
38
|
const { app, stores } = fakeApp();
|
|
39
|
-
await handler(
|
|
39
|
+
const out = await handler(
|
|
40
40
|
{
|
|
41
|
+
processInstanceKey: "retro-inst-5",
|
|
41
42
|
variables: {
|
|
42
43
|
planKey: "o/r#5",
|
|
43
44
|
status: "filed",
|
|
@@ -66,12 +67,17 @@ test("conformance-record: persists a filed conformance from hoisted result vars"
|
|
|
66
67
|
assertEquals(row.deviations_unraised, 1);
|
|
67
68
|
assertEquals(row.has_deviations, 1);
|
|
68
69
|
assertEquals(row.report, "the full conformance report");
|
|
70
|
+
// Tracks the retro instance and enters the inbox scan (issue #216) — a deviation escalates.
|
|
71
|
+
assertEquals(row.process_key, "retro-inst-5");
|
|
72
|
+
assertEquals(row.review_status, "reviewing");
|
|
73
|
+
// The gateway routes off the returned ground-truth flag, not the agent's hoisted var.
|
|
74
|
+
assertEquals(out, { hasDeviations: true });
|
|
69
75
|
});
|
|
70
76
|
|
|
71
77
|
test("conformance-record: derives has_deviations from ground truth even when the agent flag is absent", async () => {
|
|
72
78
|
const { app, stores } = fakeApp();
|
|
73
79
|
await handler(
|
|
74
|
-
{ variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
|
|
80
|
+
{ processInstanceKey: "retro-inst-6", variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
|
|
75
81
|
app as any,
|
|
76
82
|
);
|
|
77
83
|
// The agent didn't set hasDeviations, but a not-verified item means the epic didn't cleanly meet spec.
|
|
@@ -80,12 +86,15 @@ test("conformance-record: derives has_deviations from ground truth even when the
|
|
|
80
86
|
|
|
81
87
|
test("conformance-record: a clean epic records has_deviations = 0", async () => {
|
|
82
88
|
const { app, stores } = fakeApp();
|
|
83
|
-
await handler(
|
|
84
|
-
{ variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
|
|
89
|
+
const out = await handler(
|
|
90
|
+
{ processInstanceKey: "retro-inst-7", variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
|
|
85
91
|
app as any,
|
|
86
92
|
);
|
|
87
93
|
assertEquals(stores.plan_conformance[0].has_deviations, 0);
|
|
88
94
|
assertEquals(stores.plan_conformance[0].slices_met, 3);
|
|
95
|
+
// No deviation → settles straight to `reviewed`, never entering the inbox scan.
|
|
96
|
+
assertEquals(stores.plan_conformance[0].review_status, "reviewed");
|
|
97
|
+
assertEquals(out, { hasDeviations: false });
|
|
89
98
|
});
|
|
90
99
|
|
|
91
100
|
test("conformance-record: coerces filed without a comment URL to skipped", async () => {
|
|
@@ -139,6 +148,7 @@ test("conformance-record: coerces string-encoded numeric counts hoisted by the a
|
|
|
139
148
|
// These must be parsed, not silently coerced to 0 (which would wrongly clear the verdict).
|
|
140
149
|
await handler(
|
|
141
150
|
{
|
|
151
|
+
processInstanceKey: "retro-inst-12",
|
|
142
152
|
variables: {
|
|
143
153
|
planKey: "o/r#12",
|
|
144
154
|
status: "filed",
|
|
@@ -167,6 +177,7 @@ test("conformance-record: honours a string-encoded hasDeviations flag", async ()
|
|
|
167
177
|
// string "true" must still record a deviation — a stringified boolean can't silently be dropped.
|
|
168
178
|
await handler(
|
|
169
179
|
{
|
|
180
|
+
processInstanceKey: "retro-inst-13",
|
|
170
181
|
variables: {
|
|
171
182
|
planKey: "o/r#13",
|
|
172
183
|
status: "filed",
|
|
@@ -197,3 +208,34 @@ test("conformance-record: defaults to skipped when the agent reported nothing",
|
|
|
197
208
|
);
|
|
198
209
|
assertEquals(stores.plan_conformance[0].status, "skipped");
|
|
199
210
|
});
|
|
211
|
+
|
|
212
|
+
test("conformance-record: coerces a numeric processInstanceKey to a string (TEXT process_key never drifts)", async () => {
|
|
213
|
+
const { app, stores } = fakeApp();
|
|
214
|
+
await handler(
|
|
215
|
+
{ processInstanceKey: 220592130 as any, variables: { planKey: "o/r#11", status: "filed", commentUrl: "https://x/11#c", slicesNotVerified: 1 } } as any,
|
|
216
|
+
app as any,
|
|
217
|
+
);
|
|
218
|
+
const row = stores.plan_conformance[0];
|
|
219
|
+
assertEquals(row.process_key, "220592130");
|
|
220
|
+
assertEquals(typeof row.process_key, "string");
|
|
221
|
+
assertEquals(row.review_status, "reviewing");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("conformance-record: fails (not a silent, untrackable escalation) when there is no processKey but there are deviations", async () => {
|
|
225
|
+
const { app, stores } = fakeApp();
|
|
226
|
+
// No `processInstanceKey` + deviations: the handler would otherwise return `hasDeviations:true`
|
|
227
|
+
// (routing retro to `conformance-escalation`) while the row is `reviewed`/`process_key=null`, an
|
|
228
|
+
// ack `pollUserTasks` can never surface nor `onTerminated` clear — an invisible, wedged escalation.
|
|
229
|
+
// Fail loudly instead so the run retries/alerts rather than encoding that silent state.
|
|
230
|
+
await assertRejects(
|
|
231
|
+
() =>
|
|
232
|
+
handler(
|
|
233
|
+
{ variables: { planKey: "o/r#12", status: "filed", commentUrl: "https://x/12#c", slicesNotVerified: 1, hasDeviations: true } } as any,
|
|
234
|
+
app as any,
|
|
235
|
+
),
|
|
236
|
+
Error,
|
|
237
|
+
"no processInstanceKey",
|
|
238
|
+
);
|
|
239
|
+
// Nothing was persisted: the throw precedes the write, so no untrackable row is left behind.
|
|
240
|
+
assertEquals(stores.plan_conformance.length, 0);
|
|
241
|
+
});
|
|
@@ -73,6 +73,28 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
73
73
|
(asBool(job.variables.hasDeviations) ||
|
|
74
74
|
slicesReduced > 0 || slicesNotVerified > 0 || deviationsUnraised > 0);
|
|
75
75
|
|
|
76
|
+
// Track this retro instance on the conformance row so `pollUserTasks` can find the escalation ack
|
|
77
|
+
// task, but only mark it `reviewing` when there IS something to escalate — a clean run settles
|
|
78
|
+
// straight to `reviewed` and never enters the inbox scan (migration 054). Coerce the instance key
|
|
79
|
+
// to a string (the engine can hand back a numeric key) so `plan_conformance.process_key` (TEXT)
|
|
80
|
+
// never drifts to a number and break the string-filter reads in `pollUserTasks`/`openUserTasks` —
|
|
81
|
+
// the same `String(...)` coercion app/service.ts applies when it stamps `process_key`.
|
|
82
|
+
const processKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
|
|
83
|
+
|
|
84
|
+
// Invariant: an escalation must be trackable. If we found deviations to escalate but have no
|
|
85
|
+
// process key to key the `reviewing` row off, the `hasDeviations` return below would still route
|
|
86
|
+
// retro to the `conformance-escalation` user task — yet `pollUserTasks` can never surface that ack
|
|
87
|
+
// (it skips rows without `process_key`) nor can the `onTerminated` binding ever clear it, so the
|
|
88
|
+
// escalation wedges forever, invisible to any human. Rather than record that silent, unreachable
|
|
89
|
+
// state, fail loudly so the run retries/alerts. `job.processInstanceKey` is always present for an
|
|
90
|
+
// activated job, so this only fires on a genuine engine-contract violation.
|
|
91
|
+
if (hasDeviations && processKey == null) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`conformance-record: ${planKey} has deviations to escalate but no processInstanceKey to track ` +
|
|
94
|
+
"the escalation — refusing to route to an untrackable conformance-escalation ack task",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
await recordConformance(app.data, planKey, {
|
|
77
99
|
status,
|
|
78
100
|
commentUrl: filed ? commentUrl : null,
|
|
@@ -84,12 +106,22 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
84
106
|
hasDeviations,
|
|
85
107
|
summary,
|
|
86
108
|
report,
|
|
109
|
+
processKey,
|
|
110
|
+
// Only enter the `reviewing` inbox scan when we actually have a `processKey` to key off — a
|
|
111
|
+
// null key can never be found by `pollUserTasks` (it skips rows without `process_key`) nor
|
|
112
|
+
// cleared by the `instanceTracking` `onTerminated` binding, so a `reviewing` row with no key
|
|
113
|
+
// would wedge forever. The invariant guard above already rejected `hasDeviations` with a null
|
|
114
|
+
// key, so `reviewing` here always carries a non-null `processKey`.
|
|
115
|
+
reviewStatus: hasDeviations ? "reviewing" : "reviewed",
|
|
87
116
|
});
|
|
88
117
|
|
|
89
118
|
app.log.info(
|
|
90
119
|
`conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
|
|
91
120
|
);
|
|
92
|
-
|
|
121
|
+
// Return the ground-truth `hasDeviations` as a process variable so the `gw-deviations` gateway
|
|
122
|
+
// routes to the human ack task (retro.bpmn) — overriding the agent's hoisted flag with the value
|
|
123
|
+
// reconciled against the recorded counts above.
|
|
124
|
+
return { hasDeviations };
|
|
93
125
|
};
|
|
94
126
|
|
|
95
127
|
export default handler;
|