@nanobpm/nano-workforce 0.191.0 → 0.191.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/mirror-install-dispatch.yml +6 -6
- package/CHANGELOG.md +6 -0
- package/app/deliveryGraph.ts +1 -1
- package/app/deliveryGraphRun.test.ts +25 -2
- package/app/deliveryGraphRun.ts +18 -6
- package/app/deliveryHuman.test.ts +79 -2
- package/app/deliveryHuman.ts +92 -0
- package/app/mergeProtocol.ts +1 -1
- package/app/pollUserTasks.test.ts +39 -0
- package/app/service.ts +8 -3
- package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
- package/docs/adr/0003-epic-base-branch-admission.md +1 -1
- package/docs/adr/0004-shared-contract-coordination.md +1 -1
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/adr/0006-delivery-units-one-representation.md +1 -1
- package/package.json +1 -1
- package/workers/merge/worker.test.ts +2 -2
- package/workers/merge/worker.ts +1 -1
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
#
|
|
3
3
|
# https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
|
|
4
4
|
# install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
|
|
5
|
-
# built in a DIFFERENT repo (
|
|
5
|
+
# built in a DIFFERENT repo (nanobpm/nano-bpm) whose Pages workflow re-fetches
|
|
6
6
|
# install.sh on every deploy — but that deploy only triggers on changes to files
|
|
7
7
|
# in that repo, so an edit here would otherwise not reach the published mirror
|
|
8
8
|
# until the nightly backstop cron. This job pokes that repo's Pages deploy via a
|
|
9
9
|
# `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
|
|
10
10
|
# within minutes of merging an install.sh change.
|
|
11
11
|
#
|
|
12
|
-
# Requires a token that can write to
|
|
12
|
+
# Requires a token that can write to nanobpm/nano-bpm — the default
|
|
13
13
|
# GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
|
|
14
|
-
# App token) with `Contents: read and write` on
|
|
14
|
+
# App token) with `Contents: read and write` on nanobpm/nano-bpm as the repo
|
|
15
15
|
# secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
|
|
16
16
|
# cron on the site keeps the mirror eventually-consistent).
|
|
17
17
|
name: Refresh nanobpm.io install mirror
|
|
@@ -45,10 +45,10 @@ jobs:
|
|
|
45
45
|
-H 'Accept: application/vnd.github+json' \
|
|
46
46
|
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
|
|
47
47
|
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
|
48
|
-
https://api.github.com/repos/
|
|
48
|
+
https://api.github.com/repos/nanobpm/nano-bpm/dispatches \
|
|
49
49
|
-d '{"event_type":"install-sh-updated"}')
|
|
50
50
|
if [ "$code" != "204" ]; then
|
|
51
|
-
echo "::error::repository_dispatch to
|
|
51
|
+
echo "::error::repository_dispatch to nanobpm/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
|
|
52
52
|
exit 1
|
|
53
53
|
fi
|
|
54
|
-
echo "dispatched install-sh-updated to
|
|
54
|
+
echo "dispatched install-sh-updated to nanobpm/nano-bpm (HTTP 204)"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.191.1](https://github.com/nanobpm/nano-workforce/compare/v0.191.0...v0.191.1) (2026-09-26)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **tasks:** show full delivery human-step context and lift an embedded link ([#814](https://github.com/nanobpm/nano-workforce/issues/814)) ([cca3374](https://github.com/nanobpm/nano-workforce/commit/cca33740607f80816504dd2b5c60b785cb1440c6)), closes [#813](https://github.com/nanobpm/nano-workforce/issues/813) [#fragment](https://github.com/nanobpm/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#813](https://github.com/nanobpm/nano-workforce/issues/813)
|
|
6
|
+
|
|
1
7
|
## [0.191.0](https://github.com/nanobpm/nano-workforce/compare/v0.190.0...v0.191.0) (2026-09-23)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -487,7 +487,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
487
487
|
}
|
|
488
488
|
// A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
|
|
489
489
|
// end on the not-ready-at-boundary path, but the engine treats terminate-end events as
|
|
490
|
-
// parsed-not-executed (
|
|
490
|
+
// parsed-not-executed (nanobpm/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
|
|
491
491
|
// end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
|
|
492
492
|
// until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
|
|
493
493
|
// (default) and `continue` ARE honored.
|
|
@@ -201,7 +201,7 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
|
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
// ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
|
|
204
|
-
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction
|
|
204
|
+
test("buildHumanLabels: maps each human node's compiled user-task element id → its FULL instruction", async () => {
|
|
205
205
|
const graph = {
|
|
206
206
|
nodes: [
|
|
207
207
|
{ id: "open-b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -216,7 +216,9 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
|
|
|
216
216
|
const labels = buildHumanLabels(compiled);
|
|
217
217
|
const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
|
|
218
218
|
const ackEl = compiled.resolved.nodes.find((n) => n.id === "ack")?.element ?? "";
|
|
219
|
-
|
|
219
|
+
// The FULL prompt is stored (issue #813) — not a clamped first line — so the Tasks "Decision
|
|
220
|
+
// context" can show the whole instruction. The phase pill clamps this at display (deriveDeliveryPhase).
|
|
221
|
+
assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish\nsecond line");
|
|
220
222
|
assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
|
|
221
223
|
});
|
|
222
224
|
|
|
@@ -264,6 +266,16 @@ test("deriveDeliveryPhase: ACTIVE with an open human task → parked on that nod
|
|
|
264
266
|
assertEquals(p.phase_node_id, el);
|
|
265
267
|
});
|
|
266
268
|
|
|
269
|
+
test("deriveDeliveryPhase: clamps a long/multi-line stored instruction to a first-line phase pill (#813)", () => {
|
|
270
|
+
const el = humanTaskElementId("n2");
|
|
271
|
+
const full = `Review the console dependency-adopt PR opened by adopt-console (bumps the ranges).\nSecond line.`;
|
|
272
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], { [el]: full });
|
|
273
|
+
// Full instruction is stored for the Decision context, but the phase pill shows only the clamped
|
|
274
|
+
// first line (77 chars + …), never the second line.
|
|
275
|
+
assertEquals(p.phase, "Parked on human node: Review the console dependency-adopt PR opened by adopt-console (bumps the ran…");
|
|
276
|
+
assert(!p.phase.includes("Second line"));
|
|
277
|
+
});
|
|
278
|
+
|
|
267
279
|
test("deriveDeliveryPhase: a parked node with no stored label falls back to the element id", () => {
|
|
268
280
|
const el = humanTaskElementId("n5");
|
|
269
281
|
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], {});
|
|
@@ -271,6 +283,17 @@ test("deriveDeliveryPhase: a parked node with no stored label falls back to the
|
|
|
271
283
|
assertEquals(p.phase_node_id, el);
|
|
272
284
|
});
|
|
273
285
|
|
|
286
|
+
test("deliveryHuman: a parked bounded-timeout twin (`…__esc`) resolves the base node's stored label in the phase pill (#813)", () => {
|
|
287
|
+
// The twin's exact `…__esc` id is never stamped into `human_labels` (only the base id is), so the
|
|
288
|
+
// phase-pill lookup must strip `__esc` — the same fallback the Tasks Decision-context helpers use —
|
|
289
|
+
// else a parked timeout twin shows the raw element id instead of the instruction's first line.
|
|
290
|
+
const base = humanTaskElementId("n2");
|
|
291
|
+
const twin = `${base}__esc`;
|
|
292
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: twin }], { [base]: "manual OTP publish" });
|
|
293
|
+
assertEquals(p.phase, "Parked on human node: manual OTP publish");
|
|
294
|
+
assertEquals(p.phase_node_id, twin);
|
|
295
|
+
});
|
|
296
|
+
|
|
274
297
|
test("deriveDeliveryPhase: ACTIVE with only a non-human open task (or none) → a bare Running", () => {
|
|
275
298
|
assertEquals(deriveDeliveryPhase("ACTIVE", [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
|
|
276
299
|
assertEquals(deriveDeliveryPhase("ACTIVE", [{ elementId: "some-service-task" }], {}), {
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -206,9 +206,12 @@ function firstLine(text: string | undefined | null): string {
|
|
|
206
206
|
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/** Map each human node's compiled user-task element id →
|
|
210
|
-
*
|
|
211
|
-
*
|
|
209
|
+
/** Map each human node's compiled user-task element id → its FULL instruction, from the S1 compile
|
|
210
|
+
* result. Stamped on the run row at dispatch so the poller can (a) render the parked-node phase label
|
|
211
|
+
* — clamped at display by {@link deriveDeliveryPhase} — and (b) surface the WHOLE instruction as the
|
|
212
|
+
* Tasks-inbox "Decision context" (issue #813). Storing the full prompt (not a clamped first line) is
|
|
213
|
+
* what lets the operator read the entire instruction, and lets a URL embedded in the prompt reach the
|
|
214
|
+
* task's clickable link. Falls back to the node id when a human node declares no prompt. */
|
|
212
215
|
export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
|
|
213
216
|
const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
|
|
214
217
|
const labels: Record<string, string> = {};
|
|
@@ -220,8 +223,11 @@ export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<s
|
|
|
220
223
|
// task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
|
|
221
224
|
// must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
|
|
222
225
|
// path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
// The FULL redacted prompt is stored (not a clamped first line) so the Tasks "Decision context" can
|
|
227
|
+
// show the whole instruction and surface an embedded link; the phase pill clamps it at display via
|
|
228
|
+
// `firstLine` in `deriveDeliveryPhase` below (issue #813).
|
|
229
|
+
const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt).trim() : "";
|
|
230
|
+
labels[humanTaskElementId(element)] = safePrompt || stop.nodeId;
|
|
225
231
|
}
|
|
226
232
|
return labels;
|
|
227
233
|
}
|
|
@@ -267,7 +273,13 @@ export function deriveDeliveryPhase(
|
|
|
267
273
|
.filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
|
|
268
274
|
.sort()[0];
|
|
269
275
|
if (parkedOn !== undefined) {
|
|
270
|
-
|
|
276
|
+
// Clamp the compact phase label to the instruction's first line (the full prompt is stored in
|
|
277
|
+
// `human_labels` for the Tasks "Decision context" — issue #813 — but the phase pill wants a short
|
|
278
|
+
// one-liner). Use the SAME exact-then-`__esc`-stripped lookup as `deliveryHumanContext*` so a
|
|
279
|
+
// parked bounded-timeout twin (`…__esc`, whose exact id is never stamped) resolves to its base
|
|
280
|
+
// node's label instead of showing the raw element id. A missing label falls back to the raw id.
|
|
281
|
+
const stored = humanLabels[parkedOn] ?? humanLabels[parkedOn.replace(/__esc$/, "")];
|
|
282
|
+
const label = firstLine(stored) || parkedOn;
|
|
271
283
|
return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
|
|
272
284
|
}
|
|
273
285
|
return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
bindHumanEmits,
|
|
12
12
|
DELIVERY_HUMAN_ELEMENT,
|
|
13
13
|
deliveryHumanContextQuestion,
|
|
14
|
+
deliveryHumanContextUrl,
|
|
14
15
|
deriveHumanCategory,
|
|
16
|
+
firstHttpUrl,
|
|
15
17
|
GENERIC_HUMAN_FORM,
|
|
16
18
|
HUMAN_ACK_FORM,
|
|
17
19
|
HUMAN_PUBLISH_FORM,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
renderHumanEmitBrief,
|
|
22
24
|
resolveHumanForm,
|
|
23
25
|
} from "./deliveryHuman.ts";
|
|
26
|
+
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
24
27
|
import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
|
|
25
28
|
import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
|
|
26
29
|
|
|
@@ -322,8 +325,82 @@ test("deliveryHumanContextQuestion: derives the node instruction from human_labe
|
|
|
322
325
|
);
|
|
323
326
|
});
|
|
324
327
|
|
|
325
|
-
test("
|
|
326
|
-
|
|
328
|
+
test("firstHttpUrl: extracts the first http(s) URL and trims trailing prose punctuation (#813)", () => {
|
|
329
|
+
assertEquals(firstHttpUrl("see https://github.com/o/r/pull/5 for details"), "https://github.com/o/r/pull/5");
|
|
330
|
+
// Trailing sentence punctuation / closing bracket is stripped so the link resolves.
|
|
331
|
+
assertEquals(firstHttpUrl("Review the PR (https://github.com/o/r/pull/5)."), "https://github.com/o/r/pull/5");
|
|
332
|
+
assertEquals(firstHttpUrl("http://example.test/a,"), "http://example.test/a");
|
|
333
|
+
// A balanced closing bracket is part of the URL and preserved (not truncated into a broken link).
|
|
334
|
+
assertEquals(
|
|
335
|
+
firstHttpUrl("see https://en.wikipedia.org/wiki/Nano_(technology) here"),
|
|
336
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
337
|
+
);
|
|
338
|
+
// A balanced bracket followed by prose punctuation: strip only the punctuation, keep the pair.
|
|
339
|
+
assertEquals(
|
|
340
|
+
firstHttpUrl("ref https://en.wikipedia.org/wiki/Nano_(technology)."),
|
|
341
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
342
|
+
);
|
|
343
|
+
// An UNBALANCED wrapping bracket is prose and still stripped.
|
|
344
|
+
assertEquals(firstHttpUrl("(https://github.com/o/r/pull/9)"), "https://github.com/o/r/pull/9");
|
|
345
|
+
// Multiple/nested UNBALANCED trailing closers + prose punctuation are all peeled off in one linear
|
|
346
|
+
// pass (guards the O(n) trim refactor — #813 review), while an inner balanced pair is preserved.
|
|
347
|
+
assertEquals(
|
|
348
|
+
firstHttpUrl("(see [https://en.wikipedia.org/wiki/Nano_(technology)])."),
|
|
349
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
350
|
+
);
|
|
351
|
+
assertEquals(firstHttpUrl("look: {[(https://example.test/a)]}"), "https://example.test/a");
|
|
352
|
+
// An apostrophe is a valid URL sub-delimiter and preserved WITHIN the URL (not truncated at it)…
|
|
353
|
+
assertEquals(
|
|
354
|
+
firstHttpUrl("see https://en.wikipedia.org/wiki/It's_a_Wonderful_Life here"),
|
|
355
|
+
"https://en.wikipedia.org/wiki/It's_a_Wonderful_Life",
|
|
356
|
+
);
|
|
357
|
+
// …while a TRAILING apostrophe (a prose quote closer) is still trimmed.
|
|
358
|
+
assertEquals(firstHttpUrl("the link 'https://example.test/a' works"), "https://example.test/a");
|
|
359
|
+
// Scheme match is case-insensitive (RFC 3986 schemes are case-insensitive).
|
|
360
|
+
assertEquals(firstHttpUrl("see HTTPS://github.com/o/r/pull/5 now"), "HTTPS://github.com/o/r/pull/5");
|
|
361
|
+
assertEquals(firstHttpUrl("Http://example.test/b"), "Http://example.test/b");
|
|
362
|
+
// First wins when several are present.
|
|
363
|
+
assertEquals(firstHttpUrl("a https://one.test b https://two.test"), "https://one.test");
|
|
364
|
+
// No URL / non-string → null (preserves today's linkless behaviour).
|
|
365
|
+
assertEquals(firstHttpUrl("merge the PR opened by adopt-console"), null);
|
|
366
|
+
assertEquals(firstHttpUrl(undefined), null);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("deliveryHumanContextUrl: lifts a prompt-embedded URL onto the task link (base + __esc twin), else null (#813)", () => {
|
|
370
|
+
const labels = {
|
|
371
|
+
"delivery-human-task__n7": "Review the adopt PR at https://github.com/nanobpm/nano-ide/pull/42 and merge it.",
|
|
372
|
+
"delivery-human-task__n8": "Confirm CI is green, then merge — no link here.",
|
|
373
|
+
};
|
|
374
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7"), "https://github.com/nanobpm/nano-ide/pull/42");
|
|
375
|
+
// The bounded-timeout twin resolves the same node instruction.
|
|
376
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7__esc"), "https://github.com/nanobpm/nano-ide/pull/42");
|
|
377
|
+
// A node whose instruction names no URL yields null (the linkless default).
|
|
378
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n8"), null);
|
|
379
|
+
assertEquals(deliveryHumanContextUrl(undefined, "delivery-human-task__n1"), null);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("deliveryHumanContextUrl: declines to link a redaction-altered URL, links a credential-free one (#814)", () => {
|
|
383
|
+
// The stored `human_labels` are display-REDACTED via the SAME `redactFreeText` `buildHumanLabels` uses,
|
|
384
|
+
// so build the labels through it to mirror production exactly.
|
|
385
|
+
const withQuery = redactFreeText("Review the checks tab at https://example.test/o/r/pull/5?tab=checks now");
|
|
386
|
+
const withCred = redactFreeText("Open https://user:pass@example.test/o/r/pull/5 to review");
|
|
387
|
+
const withFragment = redactFreeText("Jump to https://example.test/o/r/pull/5#files section");
|
|
388
|
+
const clean = redactFreeText("Review the PR at https://example.test/o/r/pull/5 and merge it");
|
|
389
|
+
const labels = {
|
|
390
|
+
"delivery-human-task__q": withQuery,
|
|
391
|
+
"delivery-human-task__c": withCred,
|
|
392
|
+
"delivery-human-task__f": withFragment,
|
|
393
|
+
"delivery-human-task__ok": clean,
|
|
394
|
+
};
|
|
395
|
+
// A redacted query/userinfo/fragment would publish a DIFFERENT, broken link → decline (null).
|
|
396
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__q"), null);
|
|
397
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__c"), null);
|
|
398
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__f"), null);
|
|
399
|
+
// A credential-free URL is untouched by redaction and links faithfully.
|
|
400
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__ok"), "https://example.test/o/r/pull/5");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => { // A parked human step must never render a blank Decision context — an untracked/absent label still
|
|
327
404
|
// yields actionable guidance rather than null (which would leave the panel empty, issue #772).
|
|
328
405
|
assertEquals(
|
|
329
406
|
deliveryHumanContextQuestion({}, "delivery-human-task__n1"),
|
package/app/deliveryHuman.ts
CHANGED
|
@@ -83,6 +83,98 @@ export function deliveryHumanContextQuestion(
|
|
|
83
83
|
return label || "A scheduled delivery-graph step is waiting to be completed.";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** The clickable link for a parked delivery-graph `human` node's Tasks-inbox row (issue #813): the
|
|
87
|
+
* first `http(s)` URL embedded in the node's stored instruction, or `null` when it names none. The
|
|
88
|
+
* Tasks page renders the "Decision context" as PLAIN, non-clickable text (a `detail.fields` entry),
|
|
89
|
+
* and links only through the http(s)-gated `subject_url` `linkField` — so an author who wants the
|
|
90
|
+
* operator to have a clickable link (e.g. the PR to review) writes the URL into the node prompt and
|
|
91
|
+
* this lifts it onto `subject_url`. Uses the SAME exact-then-`__esc`-stripped lookup as the context
|
|
92
|
+
* question so the twin escalation task resolves to its real node's instruction. Returns `null` (not a
|
|
93
|
+
* fabricated link) when no URL is present, preserving today's linkless behaviour. */
|
|
94
|
+
export function deliveryHumanContextUrl(
|
|
95
|
+
humanLabels: Record<string, string> | undefined,
|
|
96
|
+
elementId: string,
|
|
97
|
+
): string | null {
|
|
98
|
+
const labels = humanLabels ?? {};
|
|
99
|
+
const base = elementId.replace(/__esc$/, "");
|
|
100
|
+
const instruction = labels[elementId] ?? labels[base] ?? "";
|
|
101
|
+
const url = firstHttpUrl(instruction);
|
|
102
|
+
// The stored `human_labels` are display-REDACTED (`redactFreeText` in `buildHumanLabels`): a URL that
|
|
103
|
+
// carried a credential — `//user:pass@` userinfo, or a `?query`/`#fragment` (which can hide a signed/
|
|
104
|
+
// authenticating token) — has that component collapsed to the `***` sentinel (`//***@`, `?***`, `#***`).
|
|
105
|
+
// Lifting such an altered URL onto `subject_url` would publish a DIFFERENT, broken link (a truncated
|
|
106
|
+
// query changes the referenced resource; a signed URL becomes unusable). We cannot recover the real URL
|
|
107
|
+
// here — the raw prompt is deliberately NOT stored (issue #778) — so decline to link rather than silently
|
|
108
|
+
// publishing a redaction-mangled one. A URL with no credential component is untouched by redaction and
|
|
109
|
+
// links faithfully (issue #813 review). Callers wanting a clickable link should embed a credential-free
|
|
110
|
+
// URL in the prompt.
|
|
111
|
+
return url !== null && urlBearsRedactionMarker(url) ? null : url;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The `***` sentinel {@link redactFreeText}/`redactString` leaves where it collapsed a URL's credential
|
|
115
|
+
* span — `//***@` (userinfo), `?***` (query), `#***` (fragment). Its presence in an extracted URL means
|
|
116
|
+
* redaction ALTERED the link, so it must not be published as a `subject_url` (issue #813 review). */
|
|
117
|
+
const URL_REDACTION_MARKER = /\/\/\*\*\*@|[?#]\*\*\*/;
|
|
118
|
+
|
|
119
|
+
function urlBearsRedactionMarker(url: string): boolean {
|
|
120
|
+
return URL_REDACTION_MARKER.test(url);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const URL_TRAILING_CLOSERS: Readonly<Record<string, string>> = { ")": "(", "]": "[", "}": "{" };
|
|
124
|
+
|
|
125
|
+
const URL_TRAILING_PUNCTUATION = ".,;:!?'\"";
|
|
126
|
+
|
|
127
|
+
/** Trim trailing prose punctuation a URL commonly picks up when embedded in a sentence (`.,;:!?'"` and
|
|
128
|
+
* a wrapping `)`/`]`/`}`), WITHOUT corrupting a URL that legitimately ends in a closing bracket. A
|
|
129
|
+
* closing bracket is only stripped when it is UNBALANCED within the URL (a prose wrapper, e.g. the `)`
|
|
130
|
+
* in "see (https://x/y)"); a balanced pair — e.g. Wikipedia `…/Foo_(disambiguation)` — is part of the
|
|
131
|
+
* URL and preserved, so the persisted link isn't truncated into a broken one. Bracket balance is
|
|
132
|
+
* tracked in running per-pair counts (seeded by one linear scan, then decremented as trailing closers
|
|
133
|
+
* are stripped) so the whole trim is a single O(n) pass — NOT the prior re-`split()`-the-prefix-each-
|
|
134
|
+
* step quadratic (issue #813 review). */
|
|
135
|
+
function trimUrlTrailingPunctuation(url: string): string {
|
|
136
|
+
const opens: Record<string, number> = { "(": 0, "[": 0, "{": 0 };
|
|
137
|
+
const closes: Record<string, number> = { ")": 0, "]": 0, "}": 0 };
|
|
138
|
+
for (const ch of url) {
|
|
139
|
+
if (ch in opens) opens[ch] += 1;
|
|
140
|
+
else if (ch in closes) closes[ch] += 1;
|
|
141
|
+
}
|
|
142
|
+
let end = url.length;
|
|
143
|
+
while (end > 0) {
|
|
144
|
+
const ch = url[end - 1];
|
|
145
|
+
const opener = URL_TRAILING_CLOSERS[ch];
|
|
146
|
+
if (opener !== undefined) {
|
|
147
|
+
// `opens`/`closes` reflect url[0:end]; only trailing closers are ever removed below, so they stay
|
|
148
|
+
// accurate. Balanced (opener count ≥ closer count) → the bracket belongs to the URL; stop.
|
|
149
|
+
if (opens[opener] >= closes[ch]) break;
|
|
150
|
+
closes[ch] -= 1;
|
|
151
|
+
end -= 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (URL_TRAILING_PUNCTUATION.includes(ch)) {
|
|
155
|
+
end -= 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
return url.slice(0, end);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Extract the first `http(s)` URL from free text, trimming trailing sentence punctuation/brackets a
|
|
164
|
+
* prose author commonly appends (`).,;` etc.) so the link resolves — but preserving a bracket that is
|
|
165
|
+
* BALANCED within the URL (e.g. Wikipedia `…_(technology)`), so a valid URL is never truncated into a
|
|
166
|
+
* broken task link. An apostrophe is a valid URL sub-delimiter (RFC 3986 §2.2), so it is allowed WITHIN
|
|
167
|
+
* the URL and only stripped when trailing (a prose quote closer) by `trimUrlTrailingPunctuation` — the
|
|
168
|
+
* same way a wrapping `)` is handled — so a link such as `…/It's_a_Wonderful_Life` is not truncated at
|
|
169
|
+
* the apostrophe (issue #813 review). The scheme match is case-insensitive (RFC 3986 schemes are
|
|
170
|
+
* case-insensitive). Returns `null` when none. */
|
|
171
|
+
export function firstHttpUrl(text: string | undefined | null): string | null {
|
|
172
|
+
if (typeof text !== "string") return null;
|
|
173
|
+
const match = text.match(/https?:\/\/[^\s<>"`]+/i);
|
|
174
|
+
if (!match) return null;
|
|
175
|
+
return trimUrlTrailingPunctuation(match[0]) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
86
178
|
/** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
|
|
87
179
|
* declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
|
|
88
180
|
export const GENERIC_HUMAN_FORM = "delivery-human-generic";
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Merlin's merge stage was repo-blind: it ran a plain `gh pr merge` and, on refusal, escalated to
|
|
4
4
|
// a human. That breaks on any repo with a frugal-CI + on-demand-queue posture — e.g.
|
|
5
|
-
// `
|
|
5
|
+
// `nanobpm/nano-bpm`, where auto-merge is OFF, CI runs only on `opened` (review-fix pushes do
|
|
6
6
|
// NOT re-run it), and the documented way to land is: produce a fresh head run (`gh pr ready` /
|
|
7
7
|
// close+reopen) → wait for it to go green → `@mergifyio queue`. See that repo's
|
|
8
8
|
// `AGENTS.md → ## Merging PRs`.
|
|
@@ -695,6 +695,45 @@ test("pollUserTasks: a parked delivery-human node carries its instruction as `qu
|
|
|
695
695
|
assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
|
|
696
696
|
});
|
|
697
697
|
|
|
698
|
+
test("pollUserTasks: a delivery-human node lifts an embedded prompt URL onto subject_url (issue #813)", async () => {
|
|
699
|
+
// A delivery run carries no other subject URL, so the ONLY clickable link a delivery human task can
|
|
700
|
+
// offer is a URL the author embedded in the node instruction — `deliveryHumanContextUrl` lifts it
|
|
701
|
+
// onto `subject_url`. Assert the wiring end-to-end (pure-helper tests alone would miss a projection
|
|
702
|
+
// regression): the base node AND its bounded-timeout `…__esc` twin both resolve the same stored
|
|
703
|
+
// label to the same link, while a URL-less instruction leaves `subject_url` null.
|
|
704
|
+
const { data, stores } = memData({
|
|
705
|
+
delivery_graph_runs: [
|
|
706
|
+
{
|
|
707
|
+
run_key: "delivery-graph-403eb22e",
|
|
708
|
+
process_key: "dg-1",
|
|
709
|
+
status: "running",
|
|
710
|
+
title: "release runbook",
|
|
711
|
+
human_labels: JSON.stringify({
|
|
712
|
+
"delivery-human-task__n7": "Review the release PR at https://github.com/nanobpm/nano-workforce/pull/814 before publishing.",
|
|
713
|
+
"delivery-human-task__n8": "Run the manual OTP publish for @nanobpm/urban",
|
|
714
|
+
}),
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
});
|
|
718
|
+
const restore = stubUserTaskSearch([
|
|
719
|
+
{ userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
|
|
720
|
+
// the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base link
|
|
721
|
+
{ userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
|
|
722
|
+
// a URL-less instruction leaves the link null (no fabricated link)
|
|
723
|
+
{ userTaskKey: "20413", elementId: "delivery-human-task__n8", processInstanceKey: "dg-1", state: "CREATED" },
|
|
724
|
+
]);
|
|
725
|
+
try {
|
|
726
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
727
|
+
} finally {
|
|
728
|
+
restore();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
732
|
+
assertEquals(byKey["20411"].subject_url, "https://github.com/nanobpm/nano-workforce/pull/814");
|
|
733
|
+
assertEquals(byKey["20412"].subject_url, "https://github.com/nanobpm/nano-workforce/pull/814");
|
|
734
|
+
assertEquals(byKey["20413"].subject_url, null);
|
|
735
|
+
});
|
|
736
|
+
|
|
698
737
|
test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
|
|
699
738
|
// An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
|
|
700
739
|
// node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
|
package/app/service.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
|
32
32
|
import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
|
|
33
33
|
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
34
34
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
35
|
-
import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
35
|
+
import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
36
36
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
37
37
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
38
38
|
import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
|
|
@@ -3041,11 +3041,16 @@ export async function pollUserTasks(
|
|
|
3041
3041
|
// A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
|
|
3042
3042
|
// and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
|
|
3043
3043
|
// context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
|
|
3044
|
-
// surface seeds no form variables, the operator has no idea what the run is waiting on.
|
|
3044
|
+
// surface seeds no form variables, the operator has no idea what the run is waiting on. A URL the
|
|
3045
|
+
// author embedded in the node instruction is lifted onto `subject_url` so the row's "Link" column /
|
|
3046
|
+
// detail link is clickable (issue #813); a delivery run carries no other subject URL (`subj.url` is
|
|
3047
|
+
// null), so this is the only link a delivery human task can offer.
|
|
3048
|
+
let deliverySubjectUrl: string | null = null;
|
|
3045
3049
|
if (question === null && isDeliveryHumanElement(elementId)) {
|
|
3046
3050
|
question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
|
|
3051
|
+
deliverySubjectUrl = deliveryHumanContextUrl(subj?.humanLabels, elementId);
|
|
3047
3052
|
}
|
|
3048
|
-
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ??
|
|
3053
|
+
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? deliverySubjectUrl, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
3049
3054
|
};
|
|
3050
3055
|
|
|
3051
3056
|
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
@@ -5,7 +5,7 @@ Date: 2026-08-13.
|
|
|
5
5
|
|
|
6
6
|
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app's* agent
|
|
7
7
|
> workforce decomposes and integrates epics. The platform-wide ADRs live in
|
|
8
|
-
> `
|
|
8
|
+
> `nanobpm/nano-bpm/docs/adr` (referenced here by their number + repo, e.g. "nano-bpm ADR 0056").
|
|
9
9
|
> nano-workforce's own decisions start their own series here at 0001.
|
|
10
10
|
|
|
11
11
|
Relates to:
|
|
@@ -4,7 +4,7 @@ Status: **Proposed.**
|
|
|
4
4
|
Date: 2026-08-13.
|
|
5
5
|
|
|
6
6
|
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app's* agent workforce
|
|
7
|
-
> models human (and agent) decision points. Platform-wide ADRs live in `
|
|
7
|
+
> models human (and agent) decision points. Platform-wide ADRs live in `nanobpm/nano-bpm/docs/adr`
|
|
8
8
|
> (referenced by number + repo, e.g. "nano-bpm ADR 0026"). nano-workforce's own series continues here
|
|
9
9
|
> after ADR 0001.
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ Status: **Proposed.**
|
|
|
4
4
|
Date: 2026-08-13.
|
|
5
5
|
|
|
6
6
|
> **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* admits an epic for
|
|
7
|
-
> execution. Platform-wide ADRs live in `
|
|
7
|
+
> execution. Platform-wide ADRs live in `nanobpm/nano-bpm/docs/adr` (referenced by number + repo).
|
|
8
8
|
> Continues nano-workforce's series after ADR 0001 (ADR 0002 is planned but not yet written; see below).
|
|
9
9
|
|
|
10
10
|
Relates to:
|
|
@@ -5,7 +5,7 @@ Date: 2026-08-14.
|
|
|
5
5
|
|
|
6
6
|
> **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* coordinates cross-cutting
|
|
7
7
|
> contracts across parallel/sliced agent work. Platform-wide ADRs live in
|
|
8
|
-
> `
|
|
8
|
+
> `nanobpm/nano-bpm/docs/adr` (referenced by number + repo). Continues nano-workforce's series after
|
|
9
9
|
> ADR 0001–0003.
|
|
10
10
|
|
|
11
11
|
Relates to:
|
|
@@ -5,7 +5,7 @@ Date: 2026-08-20.
|
|
|
5
5
|
|
|
6
6
|
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app* runs
|
|
7
7
|
> heterogeneous cross-repo delivery work that mixes automated and human steps. Platform-wide ADRs live
|
|
8
|
-
> in `
|
|
8
|
+
> in `nanobpm/nano-bpm/docs/adr` (referenced by number + repo, e.g. "nano-bpm ADR 0051").
|
|
9
9
|
> nano-workforce's own series continues here after ADR 0004.
|
|
10
10
|
|
|
11
11
|
Relates to:
|
|
@@ -4,7 +4,7 @@ Status: **Proposed.**
|
|
|
4
4
|
Date: 2026-08-22.
|
|
5
5
|
|
|
6
6
|
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app* represents a
|
|
7
|
-
> unit of delivery work internally. Platform-wide ADRs live in `
|
|
7
|
+
> unit of delivery work internally. Platform-wide ADRs live in `nanobpm/nano-bpm/docs/adr`
|
|
8
8
|
> (referenced by number + repo). nano-workforce's own series continues here after ADR 0005.
|
|
9
9
|
|
|
10
10
|
Relates to:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.191.
|
|
3
|
+
"version": "0.191.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -80,8 +80,8 @@ test("pr.merge short-circuits an already-merged PR without re-running the land p
|
|
|
80
80
|
const out = (await handler(
|
|
81
81
|
{
|
|
82
82
|
variables: {
|
|
83
|
-
prKey: "
|
|
84
|
-
repo: "
|
|
83
|
+
prKey: "nanobpm/nano-bpm#723",
|
|
84
|
+
repo: "nanobpm/nano-bpm",
|
|
85
85
|
prNumber: 723,
|
|
86
86
|
},
|
|
87
87
|
} as any,
|
package/workers/merge/worker.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// • blocked — GitHub refused (conflict / failing gate / perms) → escalate to a human, who
|
|
7
7
|
// resolves it and replies to retry (the process re-arms and re-polls).
|
|
8
8
|
// HOW it lands is governed by the target repo's published merge protocol (#43): a `mergify-queue`
|
|
9
|
-
// repo (e.g.
|
|
9
|
+
// repo (e.g. nanobpm/nano-bpm, auto-merge OFF) is landed by posting `@mergifyio queue` and
|
|
10
10
|
// waiting for the queue, NOT a direct `gh pr merge` — which that repo refuses. The actual gh/API
|
|
11
11
|
// calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
|
|
12
12
|
// shapes the escalation payload on a block.
|