@nanobpm/nano-workforce 0.26.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.
Files changed (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
@@ -0,0 +1,143 @@
1
+ // check-agent-prompts — deploy-safety gate for the model-authored `{{template}}` agent prompts.
2
+ //
3
+ // Since #31 (v0.11.0) each agent's prompt is authored in the BPMN as a deploy-time template
4
+ // header, e.g. `<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review-round}}" />`.
5
+ // `@nanobpm/urban` substitutes `{{token}}` with `prompts/<token>.md` (nano.app.json
6
+ // `models.templates`) at deploy time; the harness (`c8ctl nano work`) does NO substitution and
7
+ // relays whatever header ships. So a token that resolves to a missing/blank template — or a blank
8
+ // agent-prompt header — runs the agent effectively prompt-less. For `senior:pr-review` that makes
9
+ // it improvise as a "reviewer" and escalate with no question (Magikcraft/nano-bpm #597/#599).
10
+ //
11
+ // urban's deploy only *warns* on an unresolved placeholder and ships the resource with the raw
12
+ // token in place — and this project does not tolerate warnings. This guard runs urban's OWN
13
+ // substitution (`applyTemplates`, the single source of truth for token scanning/escaping) exactly
14
+ // as deploy does and turns any surviving placeholder into a hard failure, plus flags a blank
15
+ // template or a blank agent-prompt header (which substitute to an empty prompt without being
16
+ // "unresolved"). Importing from `@nanobpm/urban/runtime` also asserts the installed urban is new
17
+ // enough to substitute at all (the capability was added in the 0.22 / nano-ide #106 release).
18
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
19
+ import { basename, join } from "node:path";
20
+ import { applyTemplates } from "@nanobpm/urban/runtime";
21
+
22
+ // The reserved header carrying an agent's base prompt. A blank value here means the agent gets no
23
+ // instructions — the exact failure mode we guard against.
24
+ const AGENT_PROMPT_HEADER = "io.nanobpm.agentTask.task.prompt";
25
+
26
+ interface AppManifest {
27
+ models?: { processes?: string[]; decisions?: string[]; forms?: string[]; templates?: string[] };
28
+ }
29
+
30
+ // Mirror urban deploy's `contentTypeFor`: only the escapable model types are substituted.
31
+ function contentTypeFor(path: string): string {
32
+ if (path.endsWith(".bpmn") || path.endsWith(".dmn")) return "text/xml";
33
+ if (path.endsWith(".form")) return "application/json";
34
+ return "application/octet-stream";
35
+ }
36
+
37
+ // Minimal `dir/*.ext` glob — the only shape nano.app.json uses. Unknown patterns throw loudly
38
+ // rather than silently matching nothing.
39
+ function expandGlob(root: string, pattern: string): string[] {
40
+ const m = /^(.*)\/\*\.([A-Za-z0-9]+)$/.exec(pattern);
41
+ if (!m) throw new Error(`check-agent-prompts: unsupported glob pattern "${pattern}"`);
42
+ const [, dir, ext] = m;
43
+ const abs = join(root, dir);
44
+ if (!existsSync(abs)) return [];
45
+ return readdirSync(abs)
46
+ .filter((f) => f.endsWith(`.${ext}`))
47
+ .sort()
48
+ .map((f) => join(dir, f));
49
+ }
50
+
51
+ // The `name -> content` template map urban substitutes from (array source: name = file stem).
52
+ function templateMap(root: string, patterns: string[]): Record<string, string> {
53
+ const map: Record<string, string> = {};
54
+ for (const pattern of patterns) {
55
+ for (const rel of expandGlob(root, pattern)) {
56
+ const stem = basename(rel).replace(/\.[^.]+$/, "");
57
+ map[stem] = readFileSync(join(root, rel), "utf8");
58
+ }
59
+ }
60
+ return map;
61
+ }
62
+
63
+ // Blank reserved agent-prompt headers in a BPMN source — the one blank case urban's `unresolved`
64
+ // signal can't see (an empty value carries no `{{token}}` to be unresolved).
65
+ function hasBlankAgentPromptHeader(bpmn: string): boolean {
66
+ const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
67
+ let m: RegExpExecArray | null;
68
+ while ((m = re.exec(bpmn)) !== null) {
69
+ if (m[1] === AGENT_PROMPT_HEADER && m[2].trim() === "") return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ export interface CheckResult {
75
+ ok: boolean;
76
+ errors: string[];
77
+ /** template names successfully substituted into a model — surfaced for the CLI summary line. */
78
+ resolved: string[];
79
+ }
80
+
81
+ export function checkAgentPrompts(root: string): CheckResult {
82
+ const errors: string[] = [];
83
+ const resolved = new Set<string>();
84
+
85
+ const manifestPath = join(root, "nano.app.json");
86
+ if (!existsSync(manifestPath)) {
87
+ return { ok: false, errors: [`nano.app.json not found under ${root}`], resolved: [] };
88
+ }
89
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
90
+ const models = manifest.models ?? {};
91
+ const templates = templateMap(root, models.templates ?? []);
92
+
93
+ // A declared-but-blank template substitutes to an empty prompt without being "unresolved" —
94
+ // catch it up front (urban would silently produce a blank prompt).
95
+ for (const [name, body] of Object.entries(templates)) {
96
+ if (body.trim() === "") errors.push(`template {{${name}}} is empty — it would substitute to a blank prompt`);
97
+ }
98
+
99
+ const modelFiles = [
100
+ ...(models.processes ?? []),
101
+ ...(models.decisions ?? []),
102
+ ...(models.forms ?? []),
103
+ ].flatMap((p) => expandGlob(root, p));
104
+ if (modelFiles.length === 0) {
105
+ errors.push(`no model files matched ${JSON.stringify(models.processes ?? [])}`);
106
+ }
107
+
108
+ for (const rel of modelFiles) {
109
+ const contentType = contentTypeFor(rel);
110
+ if (contentType === "application/octet-stream") continue; // urban does not substitute these
111
+ const content = readFileSync(join(root, rel), "utf8");
112
+
113
+ // Run urban's canonical substitution — the same call deploy makes — and fail on any token it
114
+ // leaves unresolved (deploy only warns, which we don't tolerate).
115
+ const applied = applyTemplates(content, contentType, templates);
116
+ for (const name of applied.unresolved) {
117
+ errors.push(
118
+ `${rel}: unresolved template {{${name}}} — no such template is declared in models.templates`,
119
+ );
120
+ }
121
+ for (const name of Object.keys(templates)) {
122
+ if (content.includes(`{{${name}}}`)) resolved.add(name);
123
+ }
124
+
125
+ if (hasBlankAgentPromptHeader(content)) {
126
+ errors.push(`${rel}: a reserved "${AGENT_PROMPT_HEADER}" header is empty (agent would run prompt-less)`);
127
+ }
128
+ }
129
+
130
+ return { ok: errors.length === 0, errors, resolved: [...resolved].sort() };
131
+ }
132
+
133
+ // CLI: check the repo rooted at cwd. Exit non-zero (fail CI) on any problem.
134
+ if (import.meta.main) {
135
+ const root = process.cwd();
136
+ const { ok, errors, resolved } = checkAgentPrompts(root);
137
+ if (!ok) {
138
+ console.error(`✖ agent prompt check failed (${errors.length} problem(s)):`);
139
+ for (const e of errors) console.error(` - ${e}`);
140
+ process.exit(1);
141
+ }
142
+ console.log(`✔ agent prompt templates resolve (${resolved.length}: ${resolved.join(", ")})`);
143
+ }
@@ -0,0 +1,99 @@
1
+ // npm run layout <file.bpmn ...> (or `deno task layout <file.bpmn ...>`) — (re)generate the
2
+ // bpmndi:BPMNDiagram for one or more BPMN models using the urban toolkit's `layoutBpmn`
3
+ // (bpmn-auto-layout). The semantic model stays authoritative: author the process elements
4
+ // (tasks, gateways, flows, zeebe extensions) and run this to derive an auto-laid-out diagram,
5
+ // rather than hand-editing DI. Works on DI-less or already-laid-out input; only the diagram is
6
+ // (re)written — the semantic model round-trips 1:1. Re-run whenever the flow changes.
7
+ //
8
+ // `--check` (npm run layout:check) regenerates the DI in memory and fails with a non-zero exit
9
+ // if any committed diagram is stale, WITHOUT rewriting files — the CI freshness gate that stops
10
+ // a BPMN flow change from merging with an un-regenerated diagram.
11
+ import { layoutBpmn } from "@nanobpm/urban";
12
+
13
+ // Host-agnostic file I/O: Deno inside a compiled binary, else node:fs under Node — mirrors
14
+ // app/plan.ts's readAsset seam so this runs the same under `npm run` and `deno task`.
15
+ const g = globalThis as {
16
+ Deno?: {
17
+ args: string[];
18
+ readDir(p: string): AsyncIterable<{ name: string; isFile: boolean }>;
19
+ readTextFile(p: string): Promise<string>;
20
+ writeTextFile(p: string, s: string): Promise<void>;
21
+ };
22
+ };
23
+
24
+ async function readText(path: string): Promise<string> {
25
+ return g.Deno?.readTextFile
26
+ ? await g.Deno.readTextFile(path)
27
+ : await (await import("node:fs/promises")).readFile(path, "utf8");
28
+ }
29
+ async function writeText(path: string, text: string): Promise<void> {
30
+ if (g.Deno?.writeTextFile) return await g.Deno.writeTextFile(path, text);
31
+ await (await import("node:fs/promises")).writeFile(path, text, "utf8");
32
+ }
33
+ async function defaultProcessFiles(): Promise<string[]> {
34
+ const dir = "resources/processes";
35
+ if (g.Deno?.readDir) {
36
+ const files: string[] = [];
37
+ for await (const e of g.Deno.readDir(dir)) if (e.isFile && e.name.endsWith(".bpmn")) files.push(`${dir}/${e.name}`);
38
+ return files.sort();
39
+ }
40
+ const fs = await import("node:fs/promises");
41
+ return (await fs.readdir(dir, { withFileTypes: true }))
42
+ .filter((e) => e.isFile() && e.name.endsWith(".bpmn"))
43
+ .map((e) => `${dir}/${e.name}`)
44
+ .sort();
45
+ }
46
+
47
+ // Count the shapes/edges the layout produced, so the run reports what it drew (matches the
48
+ // "N shapes + M edges" accounting used when merge-loop's DI was first generated, #18).
49
+ const countDi = (xml: string) => ({
50
+ shapes: (xml.match(/<bpmndi:BPMNShape\b/g) ?? []).length,
51
+ edges: (xml.match(/<bpmndi:BPMNEdge\b/g) ?? []).length,
52
+ });
53
+
54
+ function exit(code: number): never {
55
+ if (g.Deno) return (globalThis as { Deno?: { exit(c: number): never } }).Deno!.exit(code);
56
+ process.exit(code);
57
+ }
58
+
59
+ async function main() {
60
+ const argv = g.Deno?.args ?? process.argv.slice(2);
61
+ // `--check` mode: regenerate the DI in memory and fail (non-zero) if it differs from what's
62
+ // committed, WITHOUT rewriting any file. This is the CI freshness gate — it catches a BPMN
63
+ // flow change whose author forgot to re-run `npm run layout`, so a stale diagram can't merge.
64
+ const check = argv.includes("--check");
65
+ const files = (() => {
66
+ const named = argv.filter((a) => a !== "--check");
67
+ return named.length ? named : undefined;
68
+ })() ?? await defaultProcessFiles();
69
+ if (files.length === 0) {
70
+ console.error("usage: layout-bpmn [--check] [file.bpmn ...] (default: resources/processes/*.bpmn)");
71
+ exit(2);
72
+ }
73
+ const stale: string[] = [];
74
+ for (const file of files) {
75
+ const current = await readText(file);
76
+ const laid = await layoutBpmn(current);
77
+ const { shapes, edges } = countDi(laid);
78
+ if (check) {
79
+ if (laid !== current) {
80
+ stale.push(file);
81
+ console.error(`[layout:check] ${file}: DI is STALE — re-run \`npm run layout ${file}\` and commit`);
82
+ } else {
83
+ console.log(`[layout:check] ${file}: DI fresh (${shapes} shapes + ${edges} edges)`);
84
+ }
85
+ continue;
86
+ }
87
+ await writeText(file, laid);
88
+ console.log(`[layout] ${file}: ${shapes} shapes + ${edges} edges`);
89
+ }
90
+ if (check && stale.length) {
91
+ console.error(
92
+ `\n${stale.length} BPMN model(s) have stale diagram interchange. The semantic model is ` +
93
+ `authoritative; regenerate the DI with \`npm run layout\` and commit the result.`,
94
+ );
95
+ exit(1);
96
+ }
97
+ }
98
+
99
+ await main();
@@ -0,0 +1,57 @@
1
+ // npm run purge (or `deno task purge`) — wipe the app's sqlite datasource so `npm start`
2
+ // comes up against a fresh schema (the runtime re-applies db/migrations on boot). Deletes the
3
+ // sqlite file and its WAL/SHM sidecars for the `app` source declared in nano.app.json.
4
+ import { rmSync } from "node:fs";
5
+
6
+ const url = process.env.NANO_APP_DB_URL ?? "file:./app.db";
7
+
8
+ /** Percent-decode a path, turning `decodeURIComponent`'s opaque `URIError`
9
+ * (malformed escape, or a literal `%` in a filename) into a clear message. */
10
+ function decodePath(p: string, url: string): string {
11
+ try {
12
+ return decodeURIComponent(p);
13
+ } catch (e) {
14
+ throw new Error(`purge could not decode the path in datasource URL: ${url}`, {
15
+ cause: e,
16
+ });
17
+ }
18
+ }
19
+
20
+ /** Resolve a `file:` datasource URL to a filesystem path. Handles both the opaque
21
+ * form (`file:./app.db`, `file:/abs/app.db`) and the authority form
22
+ * (`file:///abs/app.db`), and refuses non-`file:` schemes. */
23
+ function fileUrlToPath(u: string): string {
24
+ if (!u.startsWith("file:")) {
25
+ throw new Error(`purge only supports file: datasource URLs, got: ${u}`);
26
+ }
27
+ // Authority form (`file://host/path` or `file:///path`) parses cleanly as a URL.
28
+ if (u.startsWith("file://")) {
29
+ const parsed = new URL(u);
30
+ if (parsed.hostname && parsed.hostname !== "localhost") {
31
+ throw new Error(
32
+ `purge does not support remote file hosts, got host "${parsed.hostname}" in: ${u}`,
33
+ );
34
+ }
35
+ const p = decodePath(parsed.pathname, u);
36
+ // Windows drive fixup: `/C:/x` -> `C:/x`.
37
+ return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
38
+ }
39
+ // Opaque form: everything after the scheme is the (possibly relative) path.
40
+ // Decode percent-escapes too (mirroring the authority branch above), so an
41
+ // encoded path like `file:./my%20app.db` resolves to `./my app.db`.
42
+ const p = decodePath(u.slice("file:".length), u);
43
+ // Windows single-slash absolute form (`file:/C:/x`): strip the leading slash.
44
+ return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
45
+ }
46
+
47
+ const path = fileUrlToPath(url);
48
+
49
+ for (const suffix of ["", "-wal", "-shm"]) {
50
+ try {
51
+ rmSync(path + suffix);
52
+ console.log(`removed ${path}${suffix}`);
53
+ } catch (err) {
54
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
55
+ }
56
+ }
57
+ console.log("app db purged");
@@ -0,0 +1,334 @@
1
+ // npm run upgrade (or `deno task upgrade`) — refresh THIS app's source from a newer
2
+ // published pack of @nanobpm/nano-workforce, WITHOUT touching your data.
3
+ //
4
+ // Why this exists: a Console project stamped from the example pack is a one-time
5
+ // COPY (a snapshot/fork) — there is no built-in "update from template". This
6
+ // script performs the Option-A "keep the database" upgrade: it fetches a newer
7
+ // pack (via `npm pack`), then overlays its files onto the current directory while
8
+ // PRESERVING the sqlite datasource (`app.db` + WAL/SHM sidecars), generated SDK
9
+ // (`nano-generated/`), and local VCS/deps (`.git/`, `node_modules/`). Because the
10
+ // app's migrations in `db/migrations` are additive and re-applied on boot, your
11
+ // existing data survives and any new migrations top it up on the next `npm start`.
12
+ //
13
+ // SAFETY: dry-run by default. It prints the plan (files it would create/overwrite,
14
+ // and anything preserved or newly-orphaned) and writes NOTHING until you pass
15
+ // `--apply`. It never deletes files. If you modified the app, review the plan —
16
+ // an overlay overwrites your edits to any file the new version also ships.
17
+ //
18
+ // Usage:
19
+ // npm run upgrade # dry-run against @latest
20
+ // npm run upgrade -- --apply # perform the @latest overlay
21
+ // npm run upgrade -- --version 0.7.0 --apply
22
+ // npm run upgrade -- --from ./pkg --apply # overlay a local dir/tarball (offline)
23
+ //
24
+ // Flags:
25
+ // --apply write changes (default: dry-run preview)
26
+ // --version <v> npm dist-tag or version to fetch (default: "latest")
27
+ // --package <name> package to fetch (default: "@nanobpm/nano-workforce")
28
+ // --from <path> use a local extracted dir OR a .tgz tarball as the source
29
+ // (skips `npm pack`; --version/--package are ignored)
30
+ // --force overlay even if the cwd doesn't look like this app
31
+ // -h, --help show this help
32
+ import {
33
+ cpSync,
34
+ existsSync,
35
+ mkdirSync,
36
+ mkdtempSync,
37
+ readdirSync,
38
+ readFileSync,
39
+ rmSync,
40
+ statSync,
41
+ } from "node:fs";
42
+ import { execFileSync } from "node:child_process";
43
+ import { tmpdir } from "node:os";
44
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
45
+
46
+ interface Args {
47
+ apply: boolean;
48
+ version: string;
49
+ pkg: string;
50
+ from: string | null;
51
+ force: boolean;
52
+ }
53
+
54
+ function parseArgs(argv: string[]): Args {
55
+ const a: Args = {
56
+ apply: false,
57
+ version: "latest",
58
+ pkg: "@nanobpm/nano-workforce",
59
+ from: null,
60
+ force: false,
61
+ };
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ switch (arg) {
65
+ case "--apply":
66
+ a.apply = true;
67
+ break;
68
+ case "--force":
69
+ a.force = true;
70
+ break;
71
+ case "--version":
72
+ a.version = req(argv, ++i, arg);
73
+ break;
74
+ case "--package":
75
+ a.pkg = req(argv, ++i, arg);
76
+ break;
77
+ case "--from":
78
+ a.from = req(argv, ++i, arg);
79
+ break;
80
+ case "-h":
81
+ case "--help":
82
+ printHelp();
83
+ process.exit(0);
84
+ break;
85
+ default:
86
+ throw new Error(`unknown argument: ${arg} (try --help)`);
87
+ }
88
+ }
89
+ return a;
90
+ }
91
+
92
+ function req(argv: string[], i: number, flag: string): string {
93
+ const v = argv[i];
94
+ if (v === undefined || v.startsWith("--")) {
95
+ throw new Error(`${flag} requires a value`);
96
+ }
97
+ return v;
98
+ }
99
+
100
+ function printHelp(): void {
101
+ console.log(
102
+ readFileSync(new URL(import.meta.url), "utf8")
103
+ .split("\n")
104
+ .filter((l) => l.startsWith("// "))
105
+ .map((l) => l.slice(3))
106
+ .join("\n"),
107
+ );
108
+ }
109
+
110
+ /** Resolve a `file:` datasource URL to a filesystem path (mirrors purge-db.ts so
111
+ * the DB we preserve is exactly the one the runtime opens). Non-`file:` schemes
112
+ * (e.g. libsql/turso) have no local file to protect, so they return null. */
113
+ function datasourcePath(url: string): string | null {
114
+ if (!url.startsWith("file:")) return null;
115
+ const decode = (p: string): string => {
116
+ try {
117
+ return decodeURIComponent(p);
118
+ } catch (e) {
119
+ throw new Error(`could not decode datasource path in URL: ${url}`, { cause: e });
120
+ }
121
+ };
122
+ if (url.startsWith("file://")) {
123
+ const parsed = new URL(url);
124
+ if (parsed.hostname && parsed.hostname !== "localhost") {
125
+ throw new Error(`remote file host unsupported in datasource URL: ${url}`);
126
+ }
127
+ const p = decode(parsed.pathname);
128
+ return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
129
+ }
130
+ const p = decode(url.slice("file:".length));
131
+ return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
132
+ }
133
+
134
+ /** Recursively list files (not directories) under `root`, as paths relative to it.
135
+ * `skipTop` names top-level directories to skip entirely (e.g. node_modules, .git)
136
+ * so we never walk large machine-local trees. */
137
+ function listFiles(root: string, skipTop: Set<string> = new Set()): string[] {
138
+ const out: string[] = [];
139
+ const walk = (dir: string): void => {
140
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
141
+ if (dir === root && entry.isDirectory() && skipTop.has(entry.name)) continue;
142
+ const abs = join(dir, entry.name);
143
+ if (entry.isDirectory()) walk(abs);
144
+ else if (entry.isFile()) out.push(relative(root, abs));
145
+ }
146
+ };
147
+ walk(root);
148
+ return out;
149
+ }
150
+
151
+ /** True when `rel` is inside one of the preserved top-level directories, or is a
152
+ * preserved DB file/sidecar. */
153
+ function isPreserved(rel: string, preservedDirs: Set<string>, preservedFiles: Set<string>): boolean {
154
+ if (preservedFiles.has(rel)) return true;
155
+ const top = rel.split(sep)[0];
156
+ return preservedDirs.has(top);
157
+ }
158
+
159
+ /** Fetch the package with `npm pack` into a temp dir and extract it, returning the
160
+ * path to the extracted `package/` directory. */
161
+ function fetchPack(pkg: string, version: string, tmp: string): string {
162
+ const spec = `${pkg}@${version}`;
163
+ console.log(`• fetching ${spec} via npm pack …`);
164
+ const out = execFileSync("npm", ["pack", spec, "--pack-destination", tmp, "--silent"], {
165
+ encoding: "utf8",
166
+ }).trim();
167
+ // `npm pack --silent` prints the tarball filename (last line, to be safe).
168
+ const tgz = out.split("\n").filter(Boolean).pop();
169
+ if (!tgz) throw new Error(`npm pack produced no tarball for ${spec}`);
170
+ return extractTarball(join(tmp, tgz), tmp);
171
+ }
172
+
173
+ /** Extract a .tgz into `tmp` and return the `package/` dir npm tarballs wrap. */
174
+ function extractTarball(tgz: string, tmp: string): string {
175
+ if (!existsSync(tgz)) throw new Error(`tarball not found: ${tgz}`);
176
+ // Validate the archive listing BEFORE extracting: an untrusted `.tgz` may carry
177
+ // absolute paths or `../` segments that would let `tar` write outside `tmp`.
178
+ // Fail fast on any unsafe entry instead of trusting `tar` to sandbox itself.
179
+ const listing = runTar(["-tzf", tgz], tgz);
180
+ for (const raw of listing.split("\n")) {
181
+ const entry = raw.trim();
182
+ if (!entry) continue;
183
+ if (isAbsolute(entry) || /^[A-Za-z]:[\\/]/.test(entry)) {
184
+ throw new Error(`refusing to extract tarball with absolute path entry: ${entry} (${tgz})`);
185
+ }
186
+ const parts = entry.split(/[\\/]/);
187
+ if (parts.includes("..")) {
188
+ throw new Error(`refusing to extract tarball with '..' path segment: ${entry} (${tgz})`);
189
+ }
190
+ }
191
+ runTar(["-xzf", tgz, "-C", tmp], tgz);
192
+ const pkgDir = join(tmp, "package");
193
+ if (!existsSync(pkgDir)) throw new Error(`extracted tarball has no package/ dir: ${tgz}`);
194
+ return pkgDir;
195
+ }
196
+
197
+ /** Run `tar` with a clearer error when it is missing or fails. */
198
+ function runTar(tarArgs: string[], tgz: string): string {
199
+ try {
200
+ return execFileSync("tar", tarArgs, { encoding: "utf8" });
201
+ } catch (e) {
202
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") {
203
+ throw new Error(`'tar' not found on PATH — install it to extract ${tgz}`, { cause: e });
204
+ }
205
+ throw new Error(`tar failed on ${tgz}: ${(e as Error).message}`, { cause: e });
206
+ }
207
+ }
208
+
209
+ /** Resolve the source dir from --from (a dir or a .tgz) or from npm. */
210
+ function resolveSource(args: Args, tmp: string): string {
211
+ if (args.from) {
212
+ const p = resolve(args.from);
213
+ if (!existsSync(p)) throw new Error(`--from path does not exist: ${p}`);
214
+ if (statSync(p).isDirectory()) {
215
+ // Accept either the app dir itself or an npm-style `package/` wrapper.
216
+ const wrapped = join(p, "package");
217
+ return existsSync(join(wrapped, "nano.app.json")) ? wrapped : p;
218
+ }
219
+ return extractTarball(p, tmp);
220
+ }
221
+ return fetchPack(args.pkg, args.version, tmp);
222
+ }
223
+
224
+ function packageVersion(dir: string): string {
225
+ try {
226
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version ?? "?";
227
+ } catch {
228
+ return "?";
229
+ }
230
+ }
231
+
232
+ function main(): void {
233
+ const args = parseArgs(process.argv.slice(2));
234
+ const cwd = process.cwd();
235
+
236
+ if (!existsSync(join(cwd, "nano.app.json")) && !args.force) {
237
+ throw new Error(
238
+ "current directory has no nano.app.json — run this from the app/project root " +
239
+ "(or pass --force if you're sure)",
240
+ );
241
+ }
242
+
243
+ // Preserve the live datasource (+ WAL/SHM sidecars) and machine-local dirs.
244
+ const dbUrl = process.env.NANO_APP_DB_URL ?? "file:./app.db";
245
+ const dbPath = datasourcePath(dbUrl);
246
+ const preservedFiles = new Set<string>();
247
+ if (dbPath) {
248
+ for (const suffix of ["", "-wal", "-shm"]) {
249
+ const rel = relative(cwd, resolve(cwd, dbPath + suffix));
250
+ // Only protect DB files that live inside the project: a relative path with
251
+ // no leading `..` and not absolute. On Windows `path.relative()` returns an
252
+ // absolute path (e.g. `C:\…`) when cwd and the DB are on different drives —
253
+ // that is out-of-tree, so an overlay never touches it.
254
+ if (!rel.startsWith("..") && !isAbsolute(rel)) preservedFiles.add(rel);
255
+ }
256
+ }
257
+ const preservedDirs = new Set(["nano-generated", ".nano", ".git", "node_modules"]);
258
+
259
+ const tmp = mkdtempSync(join(tmpdir(), "upr-upgrade-"));
260
+ try {
261
+ const src = resolveSource(args, tmp);
262
+ const srcVersion = packageVersion(src);
263
+ const curVersion = packageVersion(cwd);
264
+ // Describe the source accurately: with --from we overlay a local path and never
265
+ // consult npm, so naming args.pkg (the npm default) would be misleading.
266
+ const srcLabel = args.from ? `${args.from} (local)` : args.pkg;
267
+ console.log(`• source ${srcLabel} @ ${srcVersion} → current @ ${curVersion}\n`);
268
+
269
+ const srcFiles = listFiles(src);
270
+ const created: string[] = [];
271
+ const overwritten: string[] = [];
272
+ const skipped: string[] = [];
273
+ for (const rel of srcFiles) {
274
+ if (isPreserved(rel, preservedDirs, preservedFiles)) {
275
+ skipped.push(rel);
276
+ continue;
277
+ }
278
+ (existsSync(join(cwd, rel)) ? overwritten : created).push(rel);
279
+ }
280
+
281
+ // Files present locally but not shipped by the new version (informational —
282
+ // never deleted; may be your data, or something the new version dropped).
283
+ const srcSet = new Set(srcFiles);
284
+ const orphans = listFiles(cwd, preservedDirs)
285
+ .filter((rel) => !isPreserved(rel, preservedDirs, preservedFiles))
286
+ .filter((rel) => !srcSet.has(rel));
287
+
288
+ report("create", created);
289
+ report("overwrite", overwritten);
290
+ if (skipped.length) console.log(` preserved (not overlaid): ${skipped.length} file(s)`);
291
+ if (preservedFiles.size) {
292
+ console.log(` keeping database: ${[...preservedFiles].join(", ")}`);
293
+ }
294
+ report("orphan (kept, review)", orphans);
295
+
296
+ if (!args.apply) {
297
+ console.log(
298
+ `\nDRY RUN — nothing written. Re-run with --apply to overlay ` +
299
+ `${created.length + overwritten.length} file(s).`,
300
+ );
301
+ return;
302
+ }
303
+
304
+ for (const rel of [...created, ...overwritten]) {
305
+ const dest = join(cwd, rel);
306
+ mkdirSync(dirname(dest), { recursive: true });
307
+ cpSync(join(src, rel), dest, { recursive: false });
308
+ }
309
+ // Guard against a script that overwrote itself mid-run on some platforms: the
310
+ // copy is byte-for-byte, so this is just a friendly confirmation.
311
+ console.log(
312
+ `\n✔ overlaid ${created.length + overwritten.length} file(s) → now @ ${srcVersion}.\n` +
313
+ `Next: reinstall deps if package.json changed, then restart the app —\n` +
314
+ `the runtime re-applies db/migrations on boot, preserving your data.`,
315
+ );
316
+ } finally {
317
+ rmSync(tmp, { recursive: true, force: true });
318
+ }
319
+ }
320
+
321
+ function report(label: string, files: string[]): void {
322
+ if (!files.length) return;
323
+ console.log(` ${label}: ${files.length} file(s)`);
324
+ const show = files.slice(0, 20);
325
+ for (const f of show) console.log(` ${f}`);
326
+ if (files.length > show.length) console.log(` … and ${files.length - show.length} more`);
327
+ }
328
+
329
+ try {
330
+ main();
331
+ } catch (err) {
332
+ console.error(`upgrade failed: ${(err as Error).message}`);
333
+ process.exit(1);
334
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "allowImportingTsExtensions": true,
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "lib": [
11
+ "esnext",
12
+ "dom"
13
+ ],
14
+ "paths": {
15
+ "@nanobpm/worker": [
16
+ "./nano-generated/workers.ts"
17
+ ],
18
+ "@nanobpm/messages": [
19
+ "./nano-generated/messages.ts"
20
+ ],
21
+ "@nanobpm/meta": [
22
+ "./nano-generated/meta.ts"
23
+ ],
24
+ "@nanobpm/llm": [
25
+ "./nano-generated/llm-worker.ts"
26
+ ],
27
+ "@nanobpm/data": [
28
+ "./nano-generated/data-sdk.ts"
29
+ ],
30
+ "@nanobpm/domain": [
31
+ "./nano-generated/domain.ts"
32
+ ],
33
+ "@lib/*": [
34
+ "./lib/*"
35
+ ]
36
+ }
37
+ },
38
+ "include": [
39
+ "main.ts",
40
+ "workers/**/*.ts",
41
+ "lib/**/*.ts",
42
+ "src/**/*.ts",
43
+ "scripts/**/*.ts",
44
+ "actions/**/*.ts"
45
+ ],
46
+ "exclude": [
47
+ "node_modules",
48
+ "dist",
49
+ "**/*.test.ts"
50
+ ]
51
+ }