@nanobpm/nano-workforce 0.60.0 → 0.61.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.
@@ -1,9 +1,12 @@
1
1
  // Red/green coverage for the agent-prompt deploy guard (scripts/check-agent-prompts.ts).
2
2
  //
3
- // The guard exists because a `{{token}}` header that resolves to a missing/blank template or a
4
- // blank agent-prompt header ships an effectively prompt-less agent (the root of the empty
5
- // "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). These cases assert it
6
- // fails on each of those shapes and passes on a well-formed app.
3
+ // Since #169 each agent's base prompt is a linked *resource*: `prompts/<token>.md` is deployed as a
4
+ // generic resource (a `models` deploy glob) and each service task links it with
5
+ // `<zeebe:linkedResource resourceId="<token>.md" bindingType="latest" linkName="prompt"/>`. The
6
+ // engine silently OMITS an unresolvable link a typo'd or undeployed `resourceId` yields a blank
7
+ // base prompt at runtime (the prompt-less-agent root of the empty "(no question provided)"
8
+ // escalations, Magikcraft/nano-bpm #597/#599). These cases assert the guard fails on each broken
9
+ // shape and passes on a well-formed app.
7
10
  import { test } from "node:test";
8
11
  import { assert, assertEquals } from "#test-assert";
9
12
  import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
@@ -11,12 +14,18 @@ import { tmpdir } from "node:os";
11
14
  import { dirname, join } from "node:path";
12
15
  import { checkAgentPrompts } from "./check-agent-prompts.ts";
13
16
 
17
+ // A manifest that deploys BPMN and the prompt resources (the migrated shape: prompts are a deploy
18
+ // glob, not a `templates` substitution source).
14
19
  const MANIFEST = JSON.stringify({
15
- models: { processes: ["resources/processes/*.bpmn"], templates: ["prompts/*.md"] },
20
+ models: { processes: ["resources/processes/*.bpmn", "prompts/*.md"] },
16
21
  });
17
22
 
18
- function header(value: string): string {
19
- return `<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="${value}" />`;
23
+ function link(resourceId: string, bindingType = "latest"): string {
24
+ return `<zeebe:linkedResource resourceId="${resourceId}" bindingType="${bindingType}" linkName="prompt" />`;
25
+ }
26
+
27
+ function serviceTask(inner: string): string {
28
+ return `<bpmn:serviceTask id="t"><bpmn:extensionElements>${inner}</bpmn:extensionElements></bpmn:serviceTask>`;
20
29
  }
21
30
 
22
31
  // Build a throwaway app tree and return its root. Each entry maps a repo-relative path to content.
@@ -30,10 +39,10 @@ function fixture(files: Record<string, string>): string {
30
39
  return root;
31
40
  }
32
41
 
33
- test("passes when every {{token}} resolves to a non-blank template that emits a result", async () => {
34
- const root = await fixture({
42
+ test("passes when every prompt link resolves to a deployed, non-blank, result-emitting resource", () => {
43
+ const root = fixture({
35
44
  "nano.app.json": MANIFEST,
36
- "resources/processes/loop.bpmn": header("{{review-round}}"),
45
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
37
46
  "prompts/review-round.md": "# Round\nDo the thing, then write your result to `$AGENT_RESULT_FILE`.",
38
47
  });
39
48
  const res = checkAgentPrompts(root);
@@ -42,72 +51,169 @@ test("passes when every {{token}} resolves to a non-blank template that emits a
42
51
  assertEquals(res.resolved, ["review-round"]);
43
52
  });
44
53
 
45
- test("fails when a header references an undeclared template", async () => {
46
- const root = await fixture({
54
+ test("fails when a prompt link references a resourceId with no deployed file", () => {
55
+ const root = fixture({
47
56
  "nano.app.json": MANIFEST,
48
- "resources/processes/loop.bpmn": header("{{does-not-exist}}"),
49
- "prompts/review-round.md": "# Round",
57
+ "resources/processes/loop.bpmn": serviceTask(link("does-not-exist.md")),
58
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
50
59
  });
51
60
  const res = checkAgentPrompts(root);
52
61
  assert(!res.ok);
53
- assert(res.errors.some((e) => e.includes("{{does-not-exist}}") && e.includes("no such template")));
62
+ assert(res.errors.some((e) => e.includes("does-not-exist.md") && e.includes("no deployed resource")));
54
63
  });
55
64
 
56
- test("fails when the referenced template file is blank (would substitute to nothing)", async () => {
57
- const root = await fixture({
65
+ test("fails when the prompt exists on disk but is not wired into a deploy glob", () => {
66
+ // The classic migration mistake: the prompt is left in the retired `models.templates` (which is
67
+ // substituted, never deployed) instead of a deploy glob, so the engine never receives it.
68
+ const root = fixture({
69
+ "nano.app.json": JSON.stringify({
70
+ models: { processes: ["resources/processes/*.bpmn"], templates: ["prompts/*.md"] },
71
+ }),
72
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
73
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
74
+ });
75
+ const res = checkAgentPrompts(root);
76
+ assert(!res.ok);
77
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("no deployed resource")));
78
+ });
79
+
80
+ test("fails when the linked prompt resource is blank (would run prompt-less)", () => {
81
+ const root = fixture({
58
82
  "nano.app.json": MANIFEST,
59
- "resources/processes/loop.bpmn": header("{{review-round}}"),
83
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
60
84
  "prompts/review-round.md": " \n \n",
61
85
  });
62
86
  const res = checkAgentPrompts(root);
63
87
  assert(!res.ok);
64
- assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round")));
88
+ assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round.md")));
65
89
  });
66
90
 
67
- test("fails when a reserved agent-prompt header is blank", async () => {
68
- const root = await fixture({
91
+ test("fails when a linkName=prompt link has an empty resourceId", () => {
92
+ const root = fixture({
69
93
  "nano.app.json": MANIFEST,
70
- "resources/processes/loop.bpmn": header(""),
71
- "prompts/review-round.md": "# Round",
94
+ "resources/processes/loop.bpmn": serviceTask(link("")),
95
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
72
96
  });
73
97
  const res = checkAgentPrompts(root);
74
98
  assert(!res.ok);
75
- assert(res.errors.some((e) => e.includes("is empty")));
99
+ assert(res.errors.some((e) => e.includes("empty resourceId")));
76
100
  });
77
101
 
78
- test("fails when an agent-prompt template omits the machine-readable result mechanism", async () => {
102
+ test("fails when the retired baked prompt header is still present", () => {
103
+ // The deploy no longer substitutes {{token}} templates, so a surviving baked header ships a
104
+ // literal placeholder as the prompt.
105
+ const root = fixture({
106
+ "nano.app.json": MANIFEST,
107
+ "resources/processes/loop.bpmn": serviceTask(
108
+ '<zeebe:taskHeaders><zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review-round}}" /></zeebe:taskHeaders>',
109
+ ),
110
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
111
+ });
112
+ const res = checkAgentPrompts(root);
113
+ assert(!res.ok);
114
+ assert(res.errors.some((e) => e.includes("retired") && e.includes("linkedResource")));
115
+ });
116
+
117
+ test("fails when the retired baked prompt header survives with reordered attributes", () => {
118
+ // XML attribute order is not significant: a retired header with `value` before `key` must still be
119
+ // caught. The guard used to anchor `key` immediately after `<zeebe:header`, so a reordered header
120
+ // would slip through and ship a literal placeholder as the prompt.
121
+ const root = fixture({
122
+ "nano.app.json": MANIFEST,
123
+ "resources/processes/loop.bpmn": serviceTask(
124
+ '<zeebe:taskHeaders><zeebe:header value="{{review-round}}" key="io.nanobpm.agentTask.task.prompt" /></zeebe:taskHeaders>',
125
+ ),
126
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
127
+ });
128
+ const res = checkAgentPrompts(root);
129
+ assert(!res.ok);
130
+ assert(res.errors.some((e) => e.includes("retired") && e.includes("linkedResource")));
131
+ });
132
+
133
+ test("fails when a linked prompt resource omits the machine-readable result mechanism", () => {
79
134
  // A prompt wired as an agent's base prompt must tell it to write $AGENT_RESULT_FILE (or use the
80
135
  // ::nano:result:: fallback). Without it the agent finishes with prose only, `status` comes back
81
136
  // blank, and the status gateway escalates/stalls — the fix-ci/rebase gap behind #746's stuck merge.
82
- const root = await fixture({
137
+ const root = fixture({
83
138
  "nano.app.json": MANIFEST,
84
- "resources/processes/loop.bpmn": header("{{review-round}}"),
139
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
85
140
  "prompts/review-round.md": "# Round\nReturn status: converged. (but never says how to emit it)",
86
141
  });
87
142
  const res = checkAgentPrompts(root);
88
143
  assert(!res.ok);
89
- assert(res.errors.some((e) => e.includes("{{review-round}}") && e.includes("AGENT_RESULT_FILE")));
144
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("AGENT_RESULT_FILE")));
90
145
  });
91
146
 
92
- test("passes when an agent-prompt template emits via the ::nano:result:: fallback", async () => {
93
- const root = await fixture({
147
+ test("passes when a linked prompt resource emits via the ::nano:result:: fallback", () => {
148
+ const root = fixture({
94
149
  "nano.app.json": MANIFEST,
95
- "resources/processes/loop.bpmn": header("{{review-round}}"),
96
- "prompts/review-round.md": "# Round\nEmit `::nano:result:: {\"status\":\"converged\"}` at the end.",
150
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
151
+ "prompts/review-round.md": '# Round\nEmit `::nano:result:: {"status":"converged"}` at the end.',
97
152
  });
98
153
  const res = checkAgentPrompts(root);
99
154
  assertEquals(res.errors, []);
100
155
  assert(res.ok);
101
156
  });
102
157
 
103
- test("checks the real repo: all committed agent prompts resolve", () => {
158
+ test("fails when a prompt link uses a bindingType other than latest", () => {
159
+ const root = fixture({
160
+ "nano.app.json": MANIFEST,
161
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md", "deployment")),
162
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
163
+ });
164
+ const res = checkAgentPrompts(root);
165
+ assert(!res.ok);
166
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes('bindingType="deployment"')));
167
+ });
168
+
169
+ test("fails when a prompt link omits bindingType entirely", () => {
170
+ const root = fixture({
171
+ "nano.app.json": MANIFEST,
172
+ "resources/processes/loop.bpmn": serviceTask(
173
+ '<zeebe:linkedResource resourceId="review-round.md" linkName="prompt" />',
174
+ ),
175
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
176
+ });
177
+ const res = checkAgentPrompts(root);
178
+ assert(!res.ok);
179
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("(absent)")));
180
+ });
181
+
182
+ test("fails when no prompt link is wired at all", () => {
183
+ const root = fixture({
184
+ "nano.app.json": MANIFEST,
185
+ "resources/processes/loop.bpmn": serviceTask('<zeebe:taskDefinition type="pr.noop" />'),
186
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
187
+ });
188
+ const res = checkAgentPrompts(root);
189
+ assert(!res.ok);
190
+ assert(res.errors.some((e) => e.includes("unwired")));
191
+ });
192
+
193
+ test("fails when two deploy globs match files sharing a basename (ambiguous resource name)", () => {
194
+ // Two deployed files with the same basename would silently overwrite in the resourceId lookup, so
195
+ // a linkName="prompt" resourceId could resolve to the wrong file. The guard must fail fast.
196
+ const root = fixture({
197
+ "nano.app.json": JSON.stringify({
198
+ models: { processes: ["resources/processes/*.bpmn", "prompts/*.md", "extra/*.md"] },
199
+ }),
200
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
201
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
202
+ "extra/review-round.md": "# Duplicate\nWrite `$AGENT_RESULT_FILE`.",
203
+ });
204
+ const res = checkAgentPrompts(root);
205
+ assert(!res.ok);
206
+ assert(res.errors.some((e) => e.includes("duplicate deployed resource name") && e.includes("review-round.md")));
207
+ });
208
+
209
+ test("checks the real repo: all committed agent prompts link to deployed resources", () => {
104
210
  // The guard must be green against the actual app it protects — this is the case CI relies on.
105
211
  const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
106
212
  const res = checkAgentPrompts(repoRoot);
107
213
  assertEquals(res.errors, []);
108
214
  assert(res.ok);
109
- // Every senior:* agent prompt header in the three processes must have resolved.
215
+ // Every senior:* agent prompt across the processes must resolve to a deployed prompt resource.
110
216
  for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge", "rebase", "retro"]) {
111
- assert(res.resolved.includes(t), `expected template ${t} to resolve`);
217
+ assert(res.resolved.includes(t), `expected prompt ${t} to resolve`);
112
218
  }
113
219
  });
@@ -1,33 +1,53 @@
1
- // check-agent-prompts — deploy-safety gate for the model-authored `{{template}}` agent prompts.
1
+ // check-agent-prompts — deploy-safety gate for the agent prompts, now authored as *linked
2
+ // resources* (issue #169) rather than baked `{{token}}` templates.
2
3
  //
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).
4
+ // Since #169 each agent's base prompt is a generic resource: `prompts/<token>.md` is deployed as
5
+ // an `application/octet-stream` resource (via a `models` deploy glob — see nano.app.json) and each
6
+ // agent service task links it at job-activation time:
10
7
  //
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).
8
+ // <zeebe:linkedResources>
9
+ // <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
10
+ // </zeebe:linkedResources>
11
+ //
12
+ // The engine resolves the LATEST deployed key for that `resourceId` when the job activates and hands
13
+ // the content to the harness in the `linkedResources` activation header. Crucially, the engine
14
+ // *silently omits* an unresolvable link (a typo'd or undeployed `resourceId`) from the header no
15
+ // incident — so a mistake yields a blank base prompt at runtime, exactly the prompt-less-agent
16
+ // failure that produced the empty "(no question provided)" escalations (Magikcraft/nano-bpm
17
+ // #597/#599). This guard turns that silent runtime failure into a hard build failure:
18
+ //
19
+ // 1. Every `linkName="prompt"` link's `resourceId` MUST match a prompt file that the app actually
20
+ // deploys (a file matched by a `models` deploy glob). This catches both a typo'd `resourceId`
21
+ // and a prompt that exists on disk but is not wired into a deploy glob (so never reaches the
22
+ // engine — the link would resolve to nothing).
23
+ // 2. Each linked prompt file must be non-blank and must teach the agent to emit a machine-readable
24
+ // result (`$AGENT_RESULT_FILE`, or the `::nano:result::` stdout fallback) — a prose-only agent
25
+ // leaves `status` blank and the status gateway escalates/stalls (the fix-ci/rebase gap behind
26
+ // Magikcraft/nano-bpm#746's stuck merge).
27
+ // 3. No service task may still carry the retired baked `io.nanobpm.agentTask.task.prompt` header:
28
+ // the deploy no longer substitutes `{{token}}` templates, so such a header would ship a literal
29
+ // `{{token}}` (or stale frozen text) as the prompt.
18
30
  import { existsSync, readdirSync, readFileSync } from "node:fs";
19
31
  import { basename, join } from "node:path";
20
- import { applyTemplates } from "@nanobpm/urban/runtime";
21
32
 
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";
33
+ // The retired header that used to carry an agent's baked base prompt. Its continued presence is a
34
+ // migration regression (the deploy no longer substitutes templates), so we flag it.
35
+ const RETIRED_PROMPT_HEADER = "io.nanobpm.agentTask.task.prompt";
36
+
37
+ // The `linkName` that designates a linked resource as an agent's base prompt. Other link names (if
38
+ // any are ever added) are not agent prompts and are ignored by this guard.
39
+ const PROMPT_LINK_NAME = "prompt";
40
+
41
+ // A prompt link MUST bind `latest` — this whole migration (#169) is about live mid-epic prompt
42
+ // updates, which only work when the engine resolves the latest deployed key at activation. A
43
+ // missing or different `bindingType` would silently pin/omit the prompt, so the guard fails it.
44
+ const PROMPT_BINDING_TYPE = "latest";
25
45
 
26
46
  interface AppManifest {
27
47
  models?: { processes?: string[]; decisions?: string[]; forms?: string[]; templates?: string[] };
28
48
  }
29
49
 
30
- // Mirror urban deploy's `contentTypeFor`: only the escapable model types are substituted.
50
+ // Only the escapable model types are XML we scan for `<zeebe:linkedResource>` links.
31
51
  function contentTypeFor(path: string): string {
32
52
  if (path.endsWith(".bpmn") || path.endsWith(".dmn")) return "text/xml";
33
53
  if (path.endsWith(".form")) return "application/json";
@@ -48,53 +68,47 @@ function expandGlob(root: string, pattern: string): string[] {
48
68
  .map((f) => join(dir, f));
49
69
  }
50
70
 
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;
71
+ interface PromptLink {
72
+ resourceId: string;
73
+ bindingType: string | null;
61
74
  }
62
75
 
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;
76
+ // Extract the `<zeebe:linkedResource linkName="prompt" …>` links from a BPMN source. Each agent
77
+ // service task carries exactly one; a process may host several tasks.
78
+ function promptLinks(bpmn: string): PromptLink[] {
79
+ const links: PromptLink[] = [];
80
+ const re = /<zeebe:linkedResource\b([^>]*?)\/?>/g;
67
81
  let m = re.exec(bpmn);
68
82
  while (m !== null) {
69
- if (m[1] === AGENT_PROMPT_HEADER && m[2].trim() === "") return true;
83
+ const attrs = m[1];
84
+ const linkName = /\blinkName="([^"]*)"/.exec(attrs)?.[1];
85
+ if (linkName === PROMPT_LINK_NAME) {
86
+ const resourceId = /\bresourceId="([^"]*)"/.exec(attrs)?.[1] ?? "";
87
+ const bindingType = /\bbindingType="([^"]*)"/.exec(attrs)?.[1] ?? null;
88
+ links.push({ resourceId, bindingType });
89
+ }
70
90
  m = re.exec(bpmn);
71
91
  }
72
- return false;
92
+ return links;
73
93
  }
74
94
 
75
- // The template tokens a model wires as an agent's base prompt, e.g. the `fix-ci` in
76
- // `value="{{fix-ci}}"` on an `io.nanobpm.agentTask.task.prompt` header. These templates *drive an
77
- // agent*, so each must teach it to emit a machine-readable result (see agentPromptEmitsResult).
78
- function agentPromptTokens(bpmn: string): string[] {
79
- const tokens: string[] = [];
80
- const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
81
- let m = re.exec(bpmn);
82
- while (m !== null) {
83
- if (m[1] === AGENT_PROMPT_HEADER) {
84
- const tok = /^\{\{\s*([^}]+?)\s*\}\}$/.exec(m[2].trim());
85
- if (tok) tokens.push(tok[1]);
86
- }
87
- m = re.exec(bpmn);
88
- }
89
- return tokens;
95
+ // Escape a literal string for safe embedding in a RegExp the header key contains dots that would
96
+ // otherwise act as wildcards and match unintended header keys.
97
+ function escapeRegExp(literal: string): string {
98
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ }
100
+
101
+ // Any surviving retired baked-prompt header — a migration regression. XML attribute order is not
102
+ // significant, so match the `key` attribute anywhere within the opening `<zeebe:header …>` tag (not
103
+ // only immediately after the element name) — otherwise a reordered header would bypass the guard.
104
+ function hasRetiredPromptHeader(bpmn: string): boolean {
105
+ return new RegExp(`<zeebe:header\\s[^>]*\\bkey="${escapeRegExp(RETIRED_PROMPT_HEADER)}"`).test(bpmn);
90
106
  }
91
107
 
92
108
  // A prompt that drives an agent must tell it how to return a machine-readable result — the
93
109
  // `$AGENT_RESULT_FILE` write (or the `::nano:result::` stdout fallback). Without it the agent can
94
110
  // finish with prose only, its `status` variable comes back empty, the status gateway falls through
95
- // to its default escalation arm, and the run parks a human escalation / stalls the merge (the
96
- // fix-ci/rebase gap behind Magikcraft/nano-bpm#746's stuck merge). Prose is never parsed, so this
97
- // instruction is load-bearing, not documentation.
111
+ // to its default escalation arm, and the run parks a human escalation / stalls the merge.
98
112
  function agentPromptEmitsResult(body: string): boolean {
99
113
  return body.includes("AGENT_RESULT_FILE") || body.includes("::nano:result::");
100
114
  }
@@ -102,14 +116,13 @@ function agentPromptEmitsResult(body: string): boolean {
102
116
  export interface CheckResult {
103
117
  ok: boolean;
104
118
  errors: string[];
105
- /** template names successfully substituted into a model — surfaced for the CLI summary line. */
119
+ /** prompt resource ids (file stems) successfully linked — surfaced for the CLI summary line. */
106
120
  resolved: string[];
107
121
  }
108
122
 
109
123
  export function checkAgentPrompts(root: string): CheckResult {
110
124
  const errors: string[] = [];
111
125
  const resolved = new Set<string>();
112
- const agentTokens = new Set<string>();
113
126
 
114
127
  const manifestPath = join(root, "nano.app.json");
115
128
  if (!existsSync(manifestPath)) {
@@ -118,57 +131,96 @@ export function checkAgentPrompts(root: string): CheckResult {
118
131
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
119
132
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
120
133
  const models = manifest.models ?? {};
121
- const templates = templateMap(root, models.templates ?? []);
122
-
123
- // A declared-but-blank template substitutes to an empty prompt without being "unresolved" —
124
- // catch it up front (urban would silently produce a blank prompt).
125
- for (const [name, body] of Object.entries(templates)) {
126
- if (body.trim() === "") errors.push(`template {{${name}}} is empty — it would substitute to a blank prompt`);
127
- }
128
134
 
129
- const modelFiles = [
135
+ // The resources the app actually DEPLOYS: every file matched by a deploy glob (processes,
136
+ // decisions, forms). Its deployed resource name is the file's basename — the same string a
137
+ // `linkedResource resourceId` must reference. Building this from the deploy globs (not from the
138
+ // prompts/ directory) is what catches a prompt that exists on disk but is wired only into a
139
+ // non-deploying key (e.g. the retired `models.templates`), so it never reaches the engine.
140
+ const deployGlobs = [
130
141
  ...(models.processes ?? []),
131
142
  ...(models.decisions ?? []),
132
143
  ...(models.forms ?? []),
133
- ].flatMap((p) => expandGlob(root, p));
134
- if (modelFiles.length === 0) {
135
- errors.push(`no model files matched ${JSON.stringify(models.processes ?? [])}`);
144
+ ];
145
+ // Keyed by basename (the deployed resource name a `resourceId` references). Two deploy globs
146
+ // matching files with the same basename would silently overwrite here, so a `resourceId` lookup
147
+ // could resolve to the wrong file (or mask a misconfiguration). Fail fast on the collision so the
148
+ // lookup stays unambiguous.
149
+ const deployedFiles = new Map<string, string>();
150
+ for (const rel of deployGlobs.flatMap((p) => expandGlob(root, p))) {
151
+ const name = basename(rel);
152
+ const prior = deployedFiles.get(name);
153
+ if (prior != null && prior !== rel) {
154
+ errors.push(
155
+ `duplicate deployed resource name "${name}": both "${prior}" and "${rel}" deploy under the ` +
156
+ `same basename, so a linkName="prompt" resourceId="${name}" would resolve ambiguously — ` +
157
+ `rename one so deployed resource names stay unique`,
158
+ );
159
+ continue;
160
+ }
161
+ deployedFiles.set(name, rel);
136
162
  }
137
163
 
138
- for (const rel of modelFiles) {
139
- const contentType = contentTypeFor(rel);
140
- if (contentType === "application/octet-stream") continue; // urban does not substitute these
164
+ // The model files whose XML we scan for `<zeebe:linkedResource>` links.
165
+ const xmlModelFiles = deployGlobs
166
+ .flatMap((p) => expandGlob(root, p))
167
+ .filter((rel) => contentTypeFor(rel) === "text/xml");
168
+ if (xmlModelFiles.length === 0) {
169
+ errors.push(`no BPMN/DMN model files matched ${JSON.stringify(deployGlobs)}`);
170
+ }
171
+
172
+ let linkCount = 0;
173
+ for (const rel of xmlModelFiles) {
141
174
  const content = readFileSync(join(root, rel), "utf8");
142
175
 
143
- // Run urban's canonical substitution — the same call deploy makes — and fail on any token it
144
- // leaves unresolved (deploy only warns, which we don't tolerate).
145
- const applied = applyTemplates(content, contentType, templates);
146
- for (const name of applied.unresolved) {
176
+ if (hasRetiredPromptHeader(content)) {
147
177
  errors.push(
148
- `${rel}: unresolved template {{${name}}}no such template is declared in models.templates`,
178
+ `${rel}: a retired "${RETIRED_PROMPT_HEADER}" header is still present migrate it to a ` +
179
+ `<zeebe:linkedResource … linkName="prompt"/> (the deploy no longer substitutes {{token}} templates)`,
149
180
  );
150
181
  }
151
- for (const name of Object.keys(templates)) {
152
- if (content.includes(`{{${name}}}`)) resolved.add(name);
153
- }
154
182
 
155
- if (hasBlankAgentPromptHeader(content)) {
156
- errors.push(`${rel}: a reserved "${AGENT_PROMPT_HEADER}" header is empty (agent would run prompt-less)`);
183
+ for (const link of promptLinks(content)) {
184
+ linkCount++;
185
+ if (link.resourceId.trim() === "") {
186
+ errors.push(`${rel}: a linkName="prompt" linkedResource has an empty resourceId`);
187
+ continue;
188
+ }
189
+ if (link.bindingType !== PROMPT_BINDING_TYPE) {
190
+ errors.push(
191
+ `${rel}: linkName="prompt" resourceId="${link.resourceId}" has ` +
192
+ `bindingType=${link.bindingType == null ? "(absent)" : `"${link.bindingType}"`} — it must be ` +
193
+ `bindingType="${PROMPT_BINDING_TYPE}" so the engine resolves the latest deployed prompt at ` +
194
+ `activation (mid-epic prompt updates rely on it); any other value silently alters runtime ` +
195
+ `prompt resolution`,
196
+ );
197
+ }
198
+ const deployedRel = deployedFiles.get(link.resourceId);
199
+ if (deployedRel == null) {
200
+ errors.push(
201
+ `${rel}: linkName="prompt" resourceId="${link.resourceId}" has no deployed resource — ` +
202
+ `no file matched by a models deploy glob has that name, so the engine would omit the ` +
203
+ `link and the agent would run prompt-less`,
204
+ );
205
+ continue;
206
+ }
207
+ const body = readFileSync(join(root, deployedRel), "utf8");
208
+ const stem = basename(deployedRel).replace(/\.[^.]+$/, "");
209
+ if (body.trim() === "") {
210
+ errors.push(`prompt resource "${link.resourceId}" is empty — the agent would run prompt-less`);
211
+ } else if (!agentPromptEmitsResult(body)) {
212
+ errors.push(
213
+ `prompt resource "${link.resourceId}" drives an agent but never tells it to write ` +
214
+ `$AGENT_RESULT_FILE (or the ::nano:result:: fallback) — the agent can finish with prose ` +
215
+ `only, leaving its status blank so the process escalates/stalls`,
216
+ );
217
+ }
218
+ resolved.add(stem);
157
219
  }
158
- for (const tok of agentPromptTokens(content)) agentTokens.add(tok);
159
220
  }
160
221
 
161
- // Every template wired as an agent's base prompt must teach the agent to emit a machine-readable
162
- // result; a prose-only agent leaves `status` blank and the process escalates/stalls.
163
- for (const tok of [...agentTokens].sort()) {
164
- const body = templates[tok];
165
- if (body != null && body.trim() !== "" && !agentPromptEmitsResult(body)) {
166
- errors.push(
167
- `template {{${tok}}} drives an agent but never tells it to write $AGENT_RESULT_FILE ` +
168
- `(or the ::nano:result:: fallback) — the agent can finish with prose only, leaving its ` +
169
- `status blank so the process escalates/stalls`,
170
- );
171
- }
222
+ if (linkCount === 0 && errors.length === 0) {
223
+ errors.push("no linkName=\"prompt\" linkedResource found in any model agent prompts are unwired");
172
224
  }
173
225
 
174
226
  return { ok: errors.length === 0, errors, resolved: [...resolved].sort() };
@@ -183,5 +235,5 @@ if (import.meta.main) {
183
235
  for (const e of errors) console.error(` - ${e}`);
184
236
  process.exit(1);
185
237
  }
186
- console.log(`✔ agent prompt templates resolve (${resolved.length}: ${resolved.join(", ")})`);
238
+ console.log(`✔ agent prompts link to deployed resources (${resolved.length}: ${resolved.join(", ")})`);
187
239
  }
@@ -0,0 +1,49 @@
1
+ // Unit coverage for pr.record-blocked-ack — the operator acknowledged a blocked feature run.
2
+ // It must settle the parked (non-terminal `awaiting_operator`) row at terminal `blocked` and record
3
+ // the operator's disposition note into `delivery_label` so the same issue can be re-dispatched.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { noopLog } from "../../test/log.ts";
7
+ import handler from "./worker.ts";
8
+
9
+ function fakeApp(rows: Record<string, unknown>[]) {
10
+ const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
11
+ return {
12
+ data: {
13
+ table(name: string, key: string) {
14
+ const store = (stores[name] ??= []);
15
+ return {
16
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
17
+ find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
18
+ insert: (row: any) => {
19
+ store.push(row);
20
+ return Promise.resolve(store.length);
21
+ },
22
+ update: (k: any, patch: any) => {
23
+ const row = store.find((r) => r[key] === k);
24
+ if (row) Object.assign(row, patch);
25
+ return Promise.resolve(row);
26
+ },
27
+ };
28
+ },
29
+ },
30
+ log: noopLog(),
31
+ } as any;
32
+ }
33
+
34
+ test("record-blocked-ack: settles the parked run at terminal blocked and records the operator note", async () => {
35
+ const rows = [{ feature_key: "owner/repo#7", status: "awaiting_operator", delivery_label: null }];
36
+ const app = fakeApp(rows);
37
+ const out = await handler({ variables: { featureKey: "owner/repo#7", note: "reassigned to a human" } } as any, app);
38
+ assertEquals(out, {});
39
+ assertEquals(rows[0].status, "blocked");
40
+ assertEquals(rows[0].delivery_label, "operator: reassigned to a human");
41
+ });
42
+
43
+ test("record-blocked-ack: a blank note falls back to an 'acknowledged' label", async () => {
44
+ const rows = [{ feature_key: "owner/repo#8", status: "awaiting_operator", delivery_label: null }];
45
+ const app = fakeApp(rows);
46
+ await handler({ variables: { featureKey: "owner/repo#8", note: " " } } as any, app);
47
+ assertEquals(rows[0].status, "blocked");
48
+ assertEquals(rows[0].delivery_label, "acknowledged");
49
+ });