@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.
- package/CHANGELOG.md +14 -0
- package/README.md +14 -5
- package/SPEC.md +56 -25
- package/app/feature.test.ts +25 -1
- package/app/feature.ts +56 -2
- package/app/featureDelivery.test.ts +117 -0
- package/app/mergeRebaseArm.test.ts +2 -2
- package/app/plan.ts +6 -5
- package/app/retro.ts +1 -1
- package/app/service.ts +40 -4
- package/db/migrations/030_feature_delivery.sql +21 -0
- package/e2e/feature-run.e2e.ts +31 -6
- package/nano.app.json +6 -4
- package/package.json +1 -1
- package/pages/feature.page.json +4 -3
- package/resources/agent-guide.md +11 -3
- package/resources/forms/feature-blocked.form +17 -0
- package/resources/processes/convergence-loop.bpmn +3 -3
- package/resources/processes/feature.bpmn +90 -22
- package/resources/processes/merge-loop.bpmn +6 -6
- package/resources/processes/plan-fanout.bpmn +12 -12
- package/resources/processes/retro.bpmn +3 -3
- package/scripts/check-agent-prompts.test.ts +141 -35
- package/scripts/check-agent-prompts.ts +145 -93
- package/workers/record-blocked-ack/worker.test.ts +49 -0
- package/workers/record-blocked-ack/worker.ts +36 -0
- package/workers/record-feature/worker.ts +8 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Red/green coverage for the agent-prompt deploy guard (scripts/check-agent-prompts.ts).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// "
|
|
6
|
-
//
|
|
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"
|
|
20
|
+
models: { processes: ["resources/processes/*.bpmn", "prompts/*.md"] },
|
|
16
21
|
});
|
|
17
22
|
|
|
18
|
-
function
|
|
19
|
-
return `<zeebe:
|
|
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
|
|
34
|
-
const root =
|
|
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":
|
|
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
|
|
46
|
-
const root =
|
|
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":
|
|
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("
|
|
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
|
|
57
|
-
|
|
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":
|
|
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
|
|
68
|
-
const root =
|
|
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":
|
|
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("
|
|
99
|
+
assert(res.errors.some((e) => e.includes("empty resourceId")));
|
|
76
100
|
});
|
|
77
101
|
|
|
78
|
-
test("fails when
|
|
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 =
|
|
137
|
+
const root = fixture({
|
|
83
138
|
"nano.app.json": MANIFEST,
|
|
84
|
-
"resources/processes/loop.bpmn":
|
|
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("
|
|
144
|
+
assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("AGENT_RESULT_FILE")));
|
|
90
145
|
});
|
|
91
146
|
|
|
92
|
-
test("passes when
|
|
93
|
-
const root =
|
|
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":
|
|
96
|
-
"prompts/review-round.md":
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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 #
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
23
|
-
//
|
|
24
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
64
|
-
//
|
|
65
|
-
function
|
|
66
|
-
const
|
|
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
|
-
|
|
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
|
|
92
|
+
return links;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
]
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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}:
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
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
|
+
});
|