@nanobpm/nano-workforce 0.70.2 → 0.72.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +14 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +77 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +3 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- package/scripts/reconcile-contracts.ts +104 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// check-contracts — CI gate for the shared contract registry (issue #227, ADR 0004).
|
|
2
|
+
//
|
|
3
|
+
// The registry (`app/contracts.ts`) is the single source of truth for cross-cutting contracts. This
|
|
4
|
+
// gate enforces the invariants that make a duplicate/synonymous contract a BUILD failure rather than
|
|
5
|
+
// a silent runtime fallback (the #223 / nano-ide #234 failure mode):
|
|
6
|
+
//
|
|
7
|
+
// 1. EVERY config-family env key read anywhere in the app (`process.env.NANO_*`, `CAMUNDA_*`,
|
|
8
|
+
// `NANOBPMN_BASE_URL`, `PR_REVIEW_PORT`) MUST be declared in the ONE typed schema
|
|
9
|
+
// `ENV_CONTRACTS`. An undeclared config key is a second, unregistered source of truth — fail.
|
|
10
|
+
// The scan recognises all three read patterns the app uses: dot-access (`process.env.KEY`),
|
|
11
|
+
// string-literal bracket-access (`process.env["KEY"]`), and the `envVar("KEY")` helper
|
|
12
|
+
// (`app/version.ts`) — a config key smuggled in through any of them must still be declared.
|
|
13
|
+
// 2. NO rejected synonym (a name we deliberately retired, e.g. `NANO_PR_BASE_URL`) may appear in
|
|
14
|
+
// code. Its reappearance is the #223 phantom-fallback cascade — fail.
|
|
15
|
+
// 3. The registry must reconcile against itself: no two entries are synonyms, no rejected synonym
|
|
16
|
+
// leaked back into the registry (`reconcileRegistry`).
|
|
17
|
+
//
|
|
18
|
+
// Scope note: the env-key scan targets the config family (below), NOT every env var — infra reads
|
|
19
|
+
// like `NODE_ENV` are not app contracts. `GITHUB_TOKEN`/`CAMUNDA_TOKEN` ARE declared (as secrets) so
|
|
20
|
+
// the schema documents them, but they need no fallback default.
|
|
21
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { dirname, extname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { reconcileRegistry } from "../app/contractReconcile.ts";
|
|
25
|
+
import { ENV_CONTRACTS, rejectedEnvSynonyms } from "../app/contracts.ts";
|
|
26
|
+
|
|
27
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
|
+
|
|
29
|
+
// The directories that hold app code (mirrors the `lint` glob in package.json).
|
|
30
|
+
const CODE_DIRS = ["app", "operations", "workers", "pages", "components", "scripts", "e2e"];
|
|
31
|
+
const CODE_FILES = ["main.ts"];
|
|
32
|
+
|
|
33
|
+
// The config-family env-key prefixes/names that MUST be declared in ENV_CONTRACTS. Anything matching
|
|
34
|
+
// this and not declared is an unregistered config source of truth.
|
|
35
|
+
//
|
|
36
|
+
// A config key is read one of three ways in this app, and all three must be held to the registry
|
|
37
|
+
// invariant — otherwise a key smuggled in via `envVar("…")` or bracket-access silently bypasses the
|
|
38
|
+
// gate (the exact blind spot that let NANO_PR_WEBHOOK_SECRET / NANO_AGENTIC* go undeclared):
|
|
39
|
+
// - dot-access: process.env.KEY
|
|
40
|
+
// - string-literal bracket: process.env["KEY"] / process.env['KEY']
|
|
41
|
+
// - the envVar() helper: envVar("KEY") (app/version.ts)
|
|
42
|
+
const CONFIG_KEY_MATCHERS: readonly RegExp[] = [
|
|
43
|
+
/\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g,
|
|
44
|
+
/\bprocess\.env\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/g,
|
|
45
|
+
/\benvVar\(\s*["']([A-Z][A-Z0-9_]*)["']\s*\)/g,
|
|
46
|
+
];
|
|
47
|
+
const CONFIG_FAMILY = /^(NANO_|NANOBPMN_|CAMUNDA_|PR_REVIEW_)/;
|
|
48
|
+
// `GITHUB_TOKEN` is the one production credential read outside the config families (app/service.ts,
|
|
49
|
+
// operations/startPlanFanout.ts). It is declared in ENV_CONTRACTS; enforce that declaration here so a
|
|
50
|
+
// future removal from the registry while code still reads it trips the gate (issue #227).
|
|
51
|
+
export const EXPLICIT_CONFIG_KEYS = new Set(["PR_REVIEW_PORT", "GITHUB_TOKEN"]);
|
|
52
|
+
|
|
53
|
+
/** Every env-key name read in `src` via any supported pattern (dot-access, string-literal
|
|
54
|
+
* bracket-access, or the `envVar("KEY")` helper). Order-preserving, with duplicates, so a caller can
|
|
55
|
+
* report each read site's key against the registry. */
|
|
56
|
+
export function envKeyReads(src: string): string[] {
|
|
57
|
+
const keys: string[] = [];
|
|
58
|
+
for (const matcher of CONFIG_KEY_MATCHERS) {
|
|
59
|
+
for (const m of src.matchAll(matcher)) keys.push(m[1]);
|
|
60
|
+
}
|
|
61
|
+
return keys;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The registry module declares the keys and (in comments/strings) names the rejected synonyms, this
|
|
65
|
+
// checker's own doc comment shows the read patterns it scans for, and this checker's test embeds
|
|
66
|
+
// literal `process.env[…]` / `envVar(…)` fixtures — all would self-trip the scan, so they are exempt.
|
|
67
|
+
const EXEMPT_FILES = new Set([
|
|
68
|
+
"app/contracts.ts",
|
|
69
|
+
"scripts/check-contracts.ts",
|
|
70
|
+
"scripts/check-contracts.test.ts",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function walk(dir: string, out: string[]): void {
|
|
74
|
+
for (const entry of readdirSync(dir)) {
|
|
75
|
+
const full = join(dir, entry);
|
|
76
|
+
if (statSync(full).isDirectory()) {
|
|
77
|
+
if (entry === "node_modules" || entry === "nano-generated" || entry.startsWith(".")) continue;
|
|
78
|
+
walk(full, out);
|
|
79
|
+
} else if ([".ts", ".mts", ".cts"].includes(extname(full))) {
|
|
80
|
+
out.push(full);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Path of `file` relative to ROOT, normalised to POSIX separators so it matches the POSIX-style
|
|
86
|
+
* `EXEMPT_FILES` entries on every platform (Windows `join` yields backslashes — issue #229 review). */
|
|
87
|
+
export function toPosixRel(file: string, root: string = ROOT): string {
|
|
88
|
+
return file.slice(root.length + 1).replaceAll("\\", "/");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectFiles(): string[] {
|
|
92
|
+
const files: string[] = [];
|
|
93
|
+
for (const d of CODE_DIRS) {
|
|
94
|
+
const full = join(ROOT, d);
|
|
95
|
+
try {
|
|
96
|
+
if (statSync(full).isDirectory()) walk(full, files);
|
|
97
|
+
} catch {
|
|
98
|
+
/* dir may not exist in every checkout */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const f of CODE_FILES) {
|
|
102
|
+
const full = join(ROOT, f);
|
|
103
|
+
try {
|
|
104
|
+
if (statSync(full).isFile()) files.push(full);
|
|
105
|
+
} catch {
|
|
106
|
+
/* optional */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function main(): void {
|
|
113
|
+
const declared = new Set(Object.keys(ENV_CONTRACTS));
|
|
114
|
+
const rejected = rejectedEnvSynonyms();
|
|
115
|
+
const errors: string[] = [];
|
|
116
|
+
|
|
117
|
+
for (const file of collectFiles()) {
|
|
118
|
+
const rel = toPosixRel(file);
|
|
119
|
+
if (EXEMPT_FILES.has(rel)) continue;
|
|
120
|
+
const src = readFileSync(file, "utf8");
|
|
121
|
+
for (const key of envKeyReads(src)) {
|
|
122
|
+
if (rejected.has(key)) {
|
|
123
|
+
errors.push(
|
|
124
|
+
` ${rel}: reads retired synonym '${key}' — reuse the canonical key '${rejected.get(key)}'. ` +
|
|
125
|
+
`A retired synonym must never come back as a silent fallback (issue #223).`,
|
|
126
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!CONFIG_FAMILY.test(key) && !EXPLICIT_CONFIG_KEYS.has(key)) continue;
|
|
130
|
+
if (!declared.has(key)) {
|
|
131
|
+
errors.push(
|
|
132
|
+
` ${rel}: reads config env key '${key}' that is NOT declared in ENV_CONTRACTS ` +
|
|
133
|
+
`(app/contracts.ts). Declare it in the ONE typed schema so it can't become a synonym.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const finding of reconcileRegistry()) {
|
|
140
|
+
errors.push(` registry: [${finding.kind}] ${finding.detail}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (errors.length > 0) {
|
|
144
|
+
console.error(`check-contracts: contract-registry violations:\n${errors.join("\n")}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(`check-contracts: OK (${declared.size} declared env keys, registry reconciles clean).`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (import.meta.main) main();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Coverage for the advisory-only reconcile pass's URL decoding (scripts/reconcile-contracts.ts, PR
|
|
2
|
+
// #229). The pass is designed to NEVER fail the build. `fileUrlToPath` decodes NANO_APP_DB_URL, and a
|
|
3
|
+
// literal `%` (or any malformed %-escape) makes `decodeURIComponent` throw — which would crash an
|
|
4
|
+
// "advisory, never gate CI" script. Decoding is now best-effort, so a malformed URL degrades to the
|
|
5
|
+
// raw path instead of throwing.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { fileUrlToPath } from "./reconcile-contracts.ts";
|
|
9
|
+
|
|
10
|
+
test("fileUrlToPath: decodes a well-formed percent-escape", () => {
|
|
11
|
+
assertEquals(fileUrlToPath("file:./my%20app.db"), "./my app.db");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("fileUrlToPath: a malformed percent-escape does not throw (best-effort)", () => {
|
|
15
|
+
// A bare `%` is not a valid escape; decodeURIComponent would throw. The pass must not crash — a
|
|
16
|
+
// successful call that returns the raw path proves the decode is best-effort.
|
|
17
|
+
assertEquals(fileUrlToPath("file:./100%done.db"), "./100%done.db");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("fileUrlToPath: a non-file URL yields undefined", () => {
|
|
21
|
+
assertEquals(fileUrlToPath("postgres://localhost/db"), undefined);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("fileUrlToPath: a malformed file:// URL yields undefined, never throws (PR #229)", () => {
|
|
25
|
+
// `new URL("file://…")` throws on a malformed authority/host. The advisory pass must degrade to
|
|
26
|
+
// undefined (best-effort: file simply not found) rather than crash a "never gate CI" script.
|
|
27
|
+
assertEquals(fileUrlToPath("file://%zz/bad host/db.sqlite"), undefined);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("fileUrlToPath: strips a query/hash suffix on an opaque file: URL (PR #229)", () => {
|
|
31
|
+
// A valid SQLite-style URL carries connection params (`?mode=ro`). The opaque `file:` branch must
|
|
32
|
+
// drop the `?…`/`#…` suffix so the path resolves to a real on-disk file, else reconciliation is
|
|
33
|
+
// silently disabled by a path that never exists.
|
|
34
|
+
assertEquals(fileUrlToPath("file:./app.db?mode=ro"), "./app.db");
|
|
35
|
+
assertEquals(fileUrlToPath("file:./app.db#frag"), "./app.db");
|
|
36
|
+
assertEquals(fileUrlToPath("file:./app.db?mode=ro&cache=shared#x"), "./app.db");
|
|
37
|
+
});
|
|
38
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// npm run reconcile:contracts — the contract reconciliation pass runner (issue #227, ADR 0004).
|
|
2
|
+
//
|
|
3
|
+
// A sibling to the L2 retro: read the whole blackboard (`agentic_blackboard`) plus the durable
|
|
4
|
+
// contract registry (`app/contracts.ts`) and report synonyms / contradictions / mock-vs-real skew.
|
|
5
|
+
// Advisory by design — it PRINTS a report (an escalation / merge candidate) and exits 0, so it can
|
|
6
|
+
// run periodically without gating CI. The registry-only, mechanically-enforceable half is the hard
|
|
7
|
+
// gate `scripts/check-contracts.ts`; this pass adds the blackboard-vs-registry view.
|
|
8
|
+
//
|
|
9
|
+
// It reads the app sqlite datasource (NANO_APP_DB_URL, default `file:./app.db`) directly through
|
|
10
|
+
// node:sqlite. If the datasource or the blackboard table is absent, it still runs the static
|
|
11
|
+
// registry reconciliation.
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { DatabaseSync } from "node:sqlite";
|
|
15
|
+
import { BLACKBOARD_TABLE } from "@nanobpm/agentic/blackboard";
|
|
16
|
+
import {
|
|
17
|
+
type ContractSignal,
|
|
18
|
+
formatReconciliationReport,
|
|
19
|
+
reconcileContracts,
|
|
20
|
+
} from "../app/contractReconcile.ts";
|
|
21
|
+
import type { ContractCategory } from "../app/contracts.ts";
|
|
22
|
+
|
|
23
|
+
function safeDecodeURIComponent(s: string): string {
|
|
24
|
+
try {
|
|
25
|
+
return decodeURIComponent(s);
|
|
26
|
+
} catch {
|
|
27
|
+
// Advisory-only pass: a malformed %-escape in NANO_APP_DB_URL must never fail the build. Fall
|
|
28
|
+
// back to the raw segment so reconciliation still runs (worst case: the file simply isn't found).
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fileUrlToPath(u: string): string | undefined {
|
|
34
|
+
if (!u.startsWith("file:")) return undefined;
|
|
35
|
+
if (u.startsWith("file://")) {
|
|
36
|
+
let pathname: string;
|
|
37
|
+
try {
|
|
38
|
+
pathname = new URL(u).pathname;
|
|
39
|
+
} catch {
|
|
40
|
+
// Advisory-only pass: a malformed file:// URL must never fail the build. Give up on this
|
|
41
|
+
// input (worst case the file simply isn't found) rather than throwing out of reconciliation.
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const p = safeDecodeURIComponent(pathname);
|
|
45
|
+
return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
|
|
46
|
+
}
|
|
47
|
+
// Opaque `file:` form (no authority) — the `new URL().pathname` branch above already drops any
|
|
48
|
+
// query/hash, but here we hold the raw remainder, so strip a `?…`/`#…` suffix ourselves. A valid
|
|
49
|
+
// SQLite-style URL like `file:./app.db?mode=ro` must resolve to `./app.db`, not `./app.db?mode=ro`
|
|
50
|
+
// (which never exists on disk and would silently disable blackboard reconciliation).
|
|
51
|
+
const raw = u.slice("file:".length).replace(/[?#].*$/, "");
|
|
52
|
+
const p = safeDecodeURIComponent(raw);
|
|
53
|
+
return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseRef(dedupeKey: string | null): { category?: ContractCategory; name?: string } {
|
|
57
|
+
if (!dedupeKey) return {};
|
|
58
|
+
const idx = dedupeKey.indexOf(":");
|
|
59
|
+
if (idx <= 0) return {};
|
|
60
|
+
const category = dedupeKey.slice(0, idx).trim();
|
|
61
|
+
const name = dedupeKey.slice(idx + 1).trim();
|
|
62
|
+
if (!name) return {};
|
|
63
|
+
if (category === "env" || category === "wire" || category === "type" || category === "capability-url") {
|
|
64
|
+
return { category, name };
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadSignals(): ContractSignal[] {
|
|
70
|
+
const url = process.env.NANO_APP_DB_URL ?? "file:./app.db";
|
|
71
|
+
const path = fileUrlToPath(url);
|
|
72
|
+
if (!path || !existsSync(path)) return [];
|
|
73
|
+
let db: DatabaseSync;
|
|
74
|
+
try {
|
|
75
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
76
|
+
} catch {
|
|
77
|
+
// Advisory-only pass: opening the db can still fail (permission, invalid/corrupt file) even
|
|
78
|
+
// though the path exists. Degrade to no signals — reconcile the registry alone — rather than
|
|
79
|
+
// crash a script that must never gate CI.
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
// biome-ignore lint/plugin: external node:sqlite row shape at the DB boundary — the column list is fixed by the SELECT above.
|
|
84
|
+
const rows = db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT author_task, body, dedupe_key FROM ${BLACKBOARD_TABLE} WHERE kind = 'contract' ORDER BY id ASC`,
|
|
87
|
+
)
|
|
88
|
+
.all() as { author_task: string; body: string; dedupe_key: string | null }[];
|
|
89
|
+
return rows.map((r) => ({ authorTask: r.author_task, body: r.body, ...parseRef(r.dedupe_key) }));
|
|
90
|
+
} catch {
|
|
91
|
+
// No blackboard table (bare/unmigrated db) — reconcile the registry alone.
|
|
92
|
+
return [];
|
|
93
|
+
} finally {
|
|
94
|
+
db.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function main(): void {
|
|
99
|
+
const report = reconcileContracts(loadSignals());
|
|
100
|
+
console.log(formatReconciliationReport(report));
|
|
101
|
+
// Advisory: never fail the build. The hard, registry-only gate is `npm run check:contracts`.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (import.meta.main) main();
|