@nanobpm/nano-workforce 0.171.0 → 0.171.2
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 +12 -0
- package/app/agentCompletion.test.ts +32 -0
- package/app/agentCompletion.ts +15 -1
- package/app/agentic/transcript-events.drift.test.ts +30 -19
- package/app/agentic/transcript-events.ts +17 -719
- package/app/pollUserTasks.test.ts +63 -0
- package/app/service.ts +18 -2
- package/app/userTaskInboxDriftGuard.test.ts +102 -0
- package/app/userTasks.ts +35 -2
- package/package.json +2 -2
- package/pages/cockpit/generated/transcript-events.js +105 -122
- package/scripts/build-cockpit-browser.ts +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* surface + complete readiness/preflight escalations in the Tasks inbox ([#675](https://github.com/nanobpm/nano-workforce/issues/675)) ([9d874e8](https://github.com/nanobpm/nano-workforce/commit/9d874e889f3155b7887bb48032e39c5f78e33ce3)), closes [#674](https://github.com/nanobpm/nano-workforce/issues/674)
|
|
6
|
+
|
|
7
|
+
## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
|
|
8
|
+
|
|
9
|
+
### Code Refactoring
|
|
10
|
+
|
|
11
|
+
* **agentic:** consume @nanobpm/agentic/transcript, drop the duplicated grammar ([#677](https://github.com/nanobpm/nano-workforce/issues/677)) ([8c8d3fc](https://github.com/nanobpm/nano-workforce/commit/8c8d3fc393134862557a65e2aab2dd9e16292c66)), closes [#676](https://github.com/nanobpm/nano-workforce/issues/676)
|
|
12
|
+
|
|
1
13
|
## [0.171.0](https://github.com/nanobpm/nano-workforce/compare/v0.170.1...v0.171.0) (2026-08-31)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -286,6 +286,38 @@ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issu
|
|
|
286
286
|
assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (issue #674)", async () => {
|
|
290
|
+
// A readiness/preflight gate adjudicates whether upstream is ACTUALLY ready (proceed) or the gate
|
|
291
|
+
// should be abandoned. Like feature-blocked/conformance/empty-plan it is a HUMAN operator decision —
|
|
292
|
+
// an agent must never auto-answer it, or the fleet would silently defeat the very readiness gate the
|
|
293
|
+
// task exists to enforce. So both ids stay OUTSIDE `ESCALATION_TASK_ELEMENTS` (agent-refused) but are
|
|
294
|
+
// retired by the HUMAN completer via the one canonical `complete-user-task` door.
|
|
295
|
+
for (const elementId of ["readiness-escalation-pf", "readiness-escalation"] as const) {
|
|
296
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
297
|
+
const data = memData(stores);
|
|
298
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-r", elementId }]);
|
|
299
|
+
|
|
300
|
+
const asAgent = await completeEscalationAsAgent(data, engine, {
|
|
301
|
+
userTaskKey: "ut-r",
|
|
302
|
+
agentId: "bot",
|
|
303
|
+
variables: { resolution: "acknowledge" },
|
|
304
|
+
});
|
|
305
|
+
assertEquals(asAgent.ok, false, `the agent completer refuses ${elementId}`);
|
|
306
|
+
assertEquals(asAgent.reason, "not a completable task");
|
|
307
|
+
assertEquals(completed.length, 0);
|
|
308
|
+
|
|
309
|
+
const asHuman = await completeEscalationAsHuman(data, engine, {
|
|
310
|
+
userTaskKey: "ut-r",
|
|
311
|
+
operatorId: "alice",
|
|
312
|
+
variables: { resolution: "abandon", answer: "upstream never published" },
|
|
313
|
+
});
|
|
314
|
+
assertEquals(asHuman.ok, true, `the human completer retires ${elementId}`);
|
|
315
|
+
assertEquals(asHuman.elementId, elementId);
|
|
316
|
+
assertEquals(completed.length, 1);
|
|
317
|
+
assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published" });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
289
321
|
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
290
322
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
291
323
|
const data = memData(stores);
|
package/app/agentCompletion.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
|
|
|
24
24
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
25
25
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
26
26
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
27
|
-
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
|
|
27
|
+
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
28
28
|
|
|
29
29
|
const now = () => new Date().toISOString();
|
|
30
30
|
|
|
@@ -101,6 +101,16 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
|
|
|
101
101
|
* Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
|
|
102
102
|
export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
|
|
103
103
|
|
|
104
|
+
/** The readiness/preflight escalation user-task element ids (`readiness-escalation-pf` in feature.bpmn's
|
|
105
|
+
* readiness preflight + plan-fanout.bpmn's producer-capability preflight; `readiness-escalation` in
|
|
106
|
+
* readiness-gate.bpmn / wait-gate.bpmn). Like `feature-blocked`, `conformance-escalation` and
|
|
107
|
+
* `empty-plan-escalation` these are HUMAN-only decisions — an agent must never auto-answer a readiness
|
|
108
|
+
* gate (that would silently defeat the "is upstream actually ready?" adjudication the gate exists for),
|
|
109
|
+
* so they live OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts them (issue
|
|
110
|
+
* #674). Re-exported from the canonical constants in app/userTasks.ts — one source of truth. */
|
|
111
|
+
export const READINESS_ESCALATION_PF_TASK_ELEMENT = READINESS_ESCALATION_PF_ELEMENT;
|
|
112
|
+
export const READINESS_ESCALATION_TASK_ELEMENT = READINESS_ESCALATION_ELEMENT;
|
|
113
|
+
|
|
104
114
|
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
105
115
|
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
106
116
|
* and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
|
|
@@ -115,6 +125,8 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
115
125
|
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
116
126
|
CONFORMANCE_ESCALATION_TASK_ELEMENT,
|
|
117
127
|
EMPTY_PLAN_TASK_ELEMENT,
|
|
128
|
+
READINESS_ESCALATION_PF_TASK_ELEMENT,
|
|
129
|
+
READINESS_ESCALATION_TASK_ELEMENT,
|
|
118
130
|
ACP_PERMISSION_ELEMENT,
|
|
119
131
|
]);
|
|
120
132
|
|
|
@@ -131,6 +143,8 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
131
143
|
"feature-blocked": "feature-blocked",
|
|
132
144
|
[EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
|
|
133
145
|
[CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
|
|
146
|
+
[READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
|
|
147
|
+
[READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
|
|
134
148
|
// NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
|
|
135
149
|
// ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
|
|
136
150
|
// to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// Drift-guard:
|
|
1
|
+
// Drift-guard: the transcript grammar is DERIVED from its one owner, never re-forked here (#676).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// nano-workforce used to carry a byte-for-byte hand-rolled copy of the transcript-event grammar
|
|
4
|
+
// (marker/version, the ONE parser, the vocabulary, the derive fold). That grammar now lives in exactly
|
|
5
|
+
// one place — `@nanobpm/agentic/transcript` (agentic 0.10.0) — and `transcript-events.ts` is a thin
|
|
6
|
+
// re-export barrel over it. This guard secures the *class* of failure ("a consumer hand-rolls the
|
|
7
|
+
// transcript grammar instead of importing it") structurally, by scanning the app-tier source: it fails
|
|
8
|
+
// if the barrel stops importing agentic, if a local module re-defines the envelope marker literal, or
|
|
9
|
+
// if a transcript consumer re-parses a stored chunk itself instead of folding through the one parser.
|
|
9
10
|
import { test } from "node:test";
|
|
10
11
|
import { readdirSync, readFileSync } from "node:fs";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
@@ -26,26 +27,36 @@ function sourceFiles(dir: string): string[] {
|
|
|
26
27
|
return out;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const BARREL_MODULE = join(AGENTIC_DIR, "transcript-events.ts");
|
|
31
|
+
const AGENTIC_TRANSCRIPT_SPECIFIER = "@nanobpm/agentic/transcript";
|
|
30
32
|
|
|
31
|
-
test("the transcript
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
test("the transcript grammar is imported from @nanobpm/agentic/transcript, never redefined locally", () => {
|
|
34
|
+
// The single source of truth is agentic; nano-workforce derives from it via a re-export barrel.
|
|
35
|
+
const barrel = readFileSync(BARREL_MODULE, "utf8");
|
|
36
|
+
assert(
|
|
37
|
+
barrel.includes(AGENTIC_TRANSCRIPT_SPECIFIER),
|
|
38
|
+
`${BARREL_MODULE} must re-export the grammar from "${AGENTIC_TRANSCRIPT_SPECIFIER}"`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("no local module re-defines the transcript-event marker literal (no second grammar)", () => {
|
|
43
|
+
// Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only a
|
|
44
|
+
// second, forked grammar would embed the marker's string literal in nano-workforce source. Match
|
|
45
|
+
// every quote form (double, single, backtick) so a re-fork can't bypass the guard by hardcoding the
|
|
46
|
+
// marker in a different literal style. Expect ZERO owners — the literal lives in agentic now.
|
|
36
47
|
const quotedMarkerForms = ['"', "'", "`"].map((q) => `${q}${TRANSCRIPT_EVENT_MARKER}${q}`);
|
|
37
48
|
const owners = sourceFiles(AGENTIC_DIR).filter((path) => {
|
|
38
49
|
const src = readFileSync(path, "utf8");
|
|
39
50
|
return quotedMarkerForms.some((literal) => src.includes(literal));
|
|
40
51
|
});
|
|
41
|
-
assertEquals(owners, [
|
|
52
|
+
assertEquals(owners, []);
|
|
42
53
|
});
|
|
43
54
|
|
|
44
|
-
test("no transcript consumer re-parses raw chunks —
|
|
45
|
-
// The cockpit + read projections must fold through the single parser, never JSON.parse a
|
|
46
|
-
// themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
|
|
47
|
-
const consumers = sourceFiles(AGENTIC_DIR).filter(
|
|
48
|
-
|
|
55
|
+
test("no transcript consumer re-parses raw chunks — the log is folded only through the one parser", () => {
|
|
56
|
+
// The cockpit + read projections must fold through the single parser (in agentic), never JSON.parse a
|
|
57
|
+
// chunk themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
|
|
58
|
+
const consumers = sourceFiles(AGENTIC_DIR).filter((path) =>
|
|
59
|
+
/transcript-(read|render|view|derive|fork)\.ts$/.test(path),
|
|
49
60
|
);
|
|
50
61
|
assert(consumers.length >= 3, "expected to scan several transcript consumers");
|
|
51
62
|
for (const path of consumers) {
|