@nanobpm/nano-workforce 0.147.0 → 0.148.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/agentCompletion.ts +9 -3
- package/app/agentic/cockpit/transcript-derive.ts +2 -7
- package/app/agentic/permission-bridge.test.ts +288 -0
- package/app/agentic/permission-bridge.ts +268 -0
- package/app/agentic/transcript-events.ts +9 -0
- package/app/contracts.ts +8 -0
- package/app/userTasks.ts +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.148.0](https://github.com/nanobpm/nano-workforce/compare/v0.147.0...v0.148.0) (2026-08-26)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **agentic:** bridge escalate-policy ACP permission requests to the nwf Tasks inbox ([#562](https://github.com/nanobpm/nano-workforce/issues/562)) ([44e5cfb](https://github.com/nanobpm/nano-workforce/commit/44e5cfb3ff262fa50db17466f690b64eb3405e56)), closes [#559](https://github.com/nanobpm/nano-workforce/issues/559) [#setMode](https://github.com/nanobpm/nano-workforce/issues/setMode) [#structuredRegion](https://github.com/nanobpm/nano-workforce/issues/structuredRegion) [#561](https://github.com/nanobpm/nano-workforce/issues/561)
|
|
6
|
+
|
|
1
7
|
## [0.147.0](https://github.com/nanobpm/nano-workforce/compare/v0.146.0...v0.147.0) (2026-08-26)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/app/agentCompletion.ts
CHANGED
|
@@ -24,6 +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 } from "./userTasks.ts";
|
|
27
28
|
|
|
28
29
|
const now = () => new Date().toISOString();
|
|
29
30
|
|
|
@@ -92,13 +93,18 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
|
|
|
92
93
|
|
|
93
94
|
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
94
95
|
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
95
|
-
* and `conformance-escalation` acknowledgements
|
|
96
|
-
* `
|
|
97
|
-
*
|
|
96
|
+
* and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
|
|
97
|
+
* (`ACP_PERMISSION_ELEMENT`, issue #559) a bridged escalate-policy `session/request_permission` raises.
|
|
98
|
+
* The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (none of these three), so widening the
|
|
99
|
+
* human surface never lets an agent retire a blocked run, a conformance review, or an agent-permission
|
|
100
|
+
* prompt. The permission element has no static `.form` (it is not a BPMN user task), so
|
|
101
|
+
* `validateEscalationVariables` leaves its Allow/Deny variables unenforced — the permission bridge
|
|
102
|
+
* translates them into a RESOLUTION frame (`app/agentic/permission-bridge.ts`). */
|
|
98
103
|
export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
99
104
|
...ESCALATION_TASK_ELEMENTS,
|
|
100
105
|
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
101
106
|
CONFORMANCE_ESCALATION_TASK_ELEMENT,
|
|
107
|
+
ACP_PERMISSION_ELEMENT,
|
|
102
108
|
]);
|
|
103
109
|
|
|
104
110
|
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type DerivedTool,
|
|
19
19
|
type DerivedView,
|
|
20
20
|
deriveViewFromChunks,
|
|
21
|
-
|
|
21
|
+
optionKindAllows,
|
|
22
22
|
} from "../transcript-events.ts";
|
|
23
23
|
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
24
24
|
|
|
@@ -198,11 +198,6 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
|
198
198
|
return card;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/** Does a permission option kind allow (true) or reject (false) the proposed action? */
|
|
202
|
-
function optionAllows(kind: PermissionOptionKind): boolean {
|
|
203
|
-
return kind === "allow-once" || kind === "allow-always";
|
|
204
|
-
}
|
|
205
|
-
|
|
206
201
|
/**
|
|
207
202
|
* Render one permission prompt card from a {@link DerivedPermission}:
|
|
208
203
|
* - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
|
|
@@ -241,7 +236,7 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
|
|
|
241
236
|
card.setAttribute("data-status", "pending");
|
|
242
237
|
const actions = el(doc, "div", "cockpit-transcript-permission-actions");
|
|
243
238
|
for (const option of perm.options) {
|
|
244
|
-
const allowed =
|
|
239
|
+
const allowed = optionKindAllows(option.kind);
|
|
245
240
|
const button = el(doc, "button", "cockpit-transcript-permission-option", option.name);
|
|
246
241
|
button.setAttribute("type", "button");
|
|
247
242
|
button.setAttribute("data-option-id", option.optionId);
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// The escalation-bridge acceptance tests (issue #559, ADR 0056). They drive the REAL in-repo operator
|
|
2
|
+
// path end to end with in-memory fakes:
|
|
3
|
+
// escalate-policy permission REQUEST → a Tasks-inbox row raised (the new ACP_PERMISSION_ELEMENT kind) →
|
|
4
|
+
// an operator Allow/Deny answered through the canonical `completeEscalationAsHuman` door →
|
|
5
|
+
// a permission RESOLUTION frame sent down the relay CONTROL lane with the matching callId/optionId/allowed.
|
|
6
|
+
// A focused unit test proves the exported `onPermissionResolve` adapter converges on the SAME frame, and
|
|
7
|
+
// a yolo/opt-out test proves the bridge is bypassed by default.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import {
|
|
11
|
+
buildPermissionResolutionFrame,
|
|
12
|
+
completePermissionEscalationAsHuman,
|
|
13
|
+
createOnPermissionResolve,
|
|
14
|
+
optionKindAllows,
|
|
15
|
+
type PermissionBridgeDeps,
|
|
16
|
+
permissionEscalationEnabled,
|
|
17
|
+
permissionOptionAllows,
|
|
18
|
+
permissionQuestion,
|
|
19
|
+
permissionUserTaskRow,
|
|
20
|
+
} from "./permission-bridge.ts";
|
|
21
|
+
import { jobStream } from "./correlation.ts";
|
|
22
|
+
import {
|
|
23
|
+
type DerivedPermission,
|
|
24
|
+
parseTranscriptEvent,
|
|
25
|
+
type PermissionResolutionEvent,
|
|
26
|
+
} from "./transcript-events.ts";
|
|
27
|
+
|
|
28
|
+
// ── In-memory fakes (mirroring the shared patterns in app/agentCompletion.test.ts) ────────────────
|
|
29
|
+
|
|
30
|
+
/** A minimal in-memory `Table<T>` with AUTOINCREMENT ids on insert and structural find/get/delete. */
|
|
31
|
+
function memTable(rows: any[], key: string) {
|
|
32
|
+
let seq = rows.reduce((m, r) => Math.max(m, Number(r[key]) || 0), 0);
|
|
33
|
+
return {
|
|
34
|
+
insert: (row: any) => {
|
|
35
|
+
const id = ++seq;
|
|
36
|
+
const stored = key === "id" ? { ...row, id } : { ...row };
|
|
37
|
+
rows.push(stored);
|
|
38
|
+
return Promise.resolve(key === "id" ? id : stored[key]);
|
|
39
|
+
},
|
|
40
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k)),
|
|
41
|
+
find: (q: any = {}) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
42
|
+
findOne: (q: any = {}) => Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
43
|
+
update: (k: any, patch: any) => {
|
|
44
|
+
const r = rows.find((x) => x[key] === k);
|
|
45
|
+
if (r) Object.assign(r, patch);
|
|
46
|
+
return Promise.resolve(r ? 1 : 0);
|
|
47
|
+
},
|
|
48
|
+
delete: (k: any) => {
|
|
49
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
50
|
+
if (i >= 0) rows.splice(i, 1);
|
|
51
|
+
return Promise.resolve(i >= 0 ? 1 : 0);
|
|
52
|
+
},
|
|
53
|
+
count: (q: any = {}) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
54
|
+
all: () => Promise.resolve(rows.slice()),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
59
|
+
return {
|
|
60
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
61
|
+
} as any;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A stub engine that records every `completeUserTask` against a seeded set of open user tasks (the
|
|
65
|
+
* completer resolves via `openUserTasks`, CREATED only). */
|
|
66
|
+
function fakeEngine(openTasks: Array<{ userTaskKey: string; elementId?: string }>) {
|
|
67
|
+
const completed: Array<{ userTaskKey: string; variables?: Record<string, unknown> }> = [];
|
|
68
|
+
const engine = {
|
|
69
|
+
openUserTasks: () => Promise.resolve(openTasks),
|
|
70
|
+
searchUserTasks: () => Promise.reject(new Error("completer must resolve via openUserTasks")),
|
|
71
|
+
completeUserTask: (userTaskKey: string, variables?: Record<string, unknown>) => {
|
|
72
|
+
completed.push({ userTaskKey, variables });
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
},
|
|
75
|
+
} as any;
|
|
76
|
+
return { engine, completed };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A relay-send spy: records every frame the bridge emits. */
|
|
80
|
+
function sendSpy() {
|
|
81
|
+
const frames: any[] = [];
|
|
82
|
+
const deps: PermissionBridgeDeps = { send: (frame) => frames.push(frame) };
|
|
83
|
+
return { deps, frames };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Decode a produced resolution chunk back through the ONE parser (never re-parsing bytes by hand). */
|
|
87
|
+
function decodeResolution(frame: any): PermissionResolutionEvent {
|
|
88
|
+
const chunk = frame.payload.chunk as string;
|
|
89
|
+
const event = parseTranscriptEvent({ offset: 0, chunk });
|
|
90
|
+
assert(event.kind === "permission" && event.phase === "resolution", "expected a permission RESOLUTION");
|
|
91
|
+
return event;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An escalate-policy permission REQUEST offering Allow (allow-once) and Deny (reject-once). */
|
|
95
|
+
function escalateRequest(callId = "job-1"): DerivedPermission {
|
|
96
|
+
return {
|
|
97
|
+
callId,
|
|
98
|
+
policy: "escalate",
|
|
99
|
+
options: [
|
|
100
|
+
{ optionId: "allow", name: "Allow", kind: "allow-once" },
|
|
101
|
+
{ optionId: "deny", name: "Deny", kind: "reject-once" },
|
|
102
|
+
],
|
|
103
|
+
toolName: "write_file",
|
|
104
|
+
title: "Write /etc/hosts",
|
|
105
|
+
reason: "The agent wants to modify a protected file.",
|
|
106
|
+
offset: 3,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Acceptance: the full real-operator path, Allow and Deny ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
test("escalate REQUEST → Tasks-inbox row → operator ALLOW via the completion door → RESOLUTION down the control lane", async () => {
|
|
113
|
+
const request = escalateRequest("job-allow");
|
|
114
|
+
const stream = jobStream(request.callId);
|
|
115
|
+
|
|
116
|
+
// 1. The request surfaces as an answerable Tasks-inbox row of the new permission kind.
|
|
117
|
+
const row = permissionUserTaskRow(request, { userTaskKey: "ut-perm-1", subjectKey: "hire-42" }, { enabled: true });
|
|
118
|
+
assert(row !== null, "an escalate-policy request must raise a row");
|
|
119
|
+
assertEquals(row.element_id, "acp-permission");
|
|
120
|
+
assertEquals(row.kind_label, "Agent permission");
|
|
121
|
+
assertEquals(row.subject_type, "agent");
|
|
122
|
+
assertEquals(row.user_task_key, "ut-perm-1");
|
|
123
|
+
assert(row.question?.includes("Write /etc/hosts"), "the row carries the request's title/reason as the question");
|
|
124
|
+
assert(row.question?.includes("modify a protected file"), "the row carries the request's reason");
|
|
125
|
+
|
|
126
|
+
// 2. The operator answers Allow through the ONE canonical completion door.
|
|
127
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
128
|
+
const data = memData(stores);
|
|
129
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-perm-1", elementId: "acp-permission" }]);
|
|
130
|
+
const { deps, frames } = sendSpy();
|
|
131
|
+
|
|
132
|
+
const result = await completePermissionEscalationAsHuman(data, engine, deps, {
|
|
133
|
+
permission: request,
|
|
134
|
+
userTaskKey: "ut-perm-1",
|
|
135
|
+
optionId: "allow",
|
|
136
|
+
operatorId: "op:ada",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The completion took through the canonical door (recorded attribution + engine completion).
|
|
140
|
+
assertEquals(result.completion.ok, true);
|
|
141
|
+
assertEquals(result.completion.elementId, "acp-permission");
|
|
142
|
+
assertEquals(completed.length, 1);
|
|
143
|
+
assertEquals(completed[0].userTaskKey, "ut-perm-1");
|
|
144
|
+
assertEquals(completed[0].variables, { optionId: "allow", allowed: true });
|
|
145
|
+
assertEquals(stores.task_completions.rows.length, 1);
|
|
146
|
+
assertEquals(stores.task_completions.rows[0].actor_kind, "human");
|
|
147
|
+
|
|
148
|
+
// 3. A RESOLUTION frame flowed back down the relay CONTROL lane, releasing the blocked request.
|
|
149
|
+
assertEquals(frames.length, 1);
|
|
150
|
+
assertEquals(frames[0].lane, "control");
|
|
151
|
+
assertEquals(frames[0].family, "relay");
|
|
152
|
+
assertEquals(frames[0].payload.op, "produce");
|
|
153
|
+
assertEquals(frames[0].payload.stream, stream);
|
|
154
|
+
const resolution = decodeResolution(frames[0]);
|
|
155
|
+
assertEquals(resolution.callId, "job-allow");
|
|
156
|
+
assertEquals(resolution.optionId, "allow");
|
|
157
|
+
assertEquals(resolution.allowed, true);
|
|
158
|
+
assertEquals(resolution.by, "operator");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("escalate REQUEST → operator DENY via the completion door → RESOLUTION allowed=false down the control lane", async () => {
|
|
162
|
+
const request = escalateRequest("job-deny");
|
|
163
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
164
|
+
const data = memData(stores);
|
|
165
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-perm-2", elementId: "acp-permission" }]);
|
|
166
|
+
const { deps, frames } = sendSpy();
|
|
167
|
+
|
|
168
|
+
const result = await completePermissionEscalationAsHuman(data, engine, deps, {
|
|
169
|
+
permission: request,
|
|
170
|
+
userTaskKey: "ut-perm-2",
|
|
171
|
+
optionId: "deny",
|
|
172
|
+
operatorId: "op:ada",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
assertEquals(result.completion.ok, true);
|
|
176
|
+
assertEquals(completed[0].variables, { optionId: "deny", allowed: false });
|
|
177
|
+
assertEquals(frames.length, 1);
|
|
178
|
+
const resolution = decodeResolution(frames[0]);
|
|
179
|
+
assertEquals(resolution.callId, "job-deny");
|
|
180
|
+
assertEquals(resolution.optionId, "deny");
|
|
181
|
+
assertEquals(resolution.allowed, false);
|
|
182
|
+
assertEquals(resolution.by, "operator");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a failed completion (no open task) NEVER sends a resolution — the block is only released on a real answer", async () => {
|
|
186
|
+
const request = escalateRequest("job-missing");
|
|
187
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
188
|
+
const data = memData(stores);
|
|
189
|
+
const { engine } = fakeEngine([]); // no open task with this key
|
|
190
|
+
const { deps, frames } = sendSpy();
|
|
191
|
+
|
|
192
|
+
const result = await completePermissionEscalationAsHuman(data, engine, deps, {
|
|
193
|
+
permission: request,
|
|
194
|
+
userTaskKey: "ut-nope",
|
|
195
|
+
optionId: "allow",
|
|
196
|
+
operatorId: "op:ada",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
assertEquals(result.completion.ok, false);
|
|
200
|
+
assertEquals(result.resolution, undefined);
|
|
201
|
+
assertEquals(frames.length, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── Convergence: the onPermissionResolve adapter and the completion door build the SAME frame ───────
|
|
205
|
+
|
|
206
|
+
test("the exported onPermissionResolve adapter produces the SAME relay RESOLUTION as the completion door", async () => {
|
|
207
|
+
const request = escalateRequest("job-converge");
|
|
208
|
+
const stream = jobStream(request.callId);
|
|
209
|
+
|
|
210
|
+
// Path A: the completion-door path.
|
|
211
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
212
|
+
const data = memData(stores);
|
|
213
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-perm-3", elementId: "acp-permission" }]);
|
|
214
|
+
const doorSpy = sendSpy();
|
|
215
|
+
await completePermissionEscalationAsHuman(data, engine, doorSpy.deps, {
|
|
216
|
+
permission: request,
|
|
217
|
+
userTaskKey: "ut-perm-3",
|
|
218
|
+
optionId: "allow",
|
|
219
|
+
operatorId: "op:ada",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Path B: the exported cockpit seam adapter, invoked with the cockpit-derived {callId, optionId, allowed}.
|
|
223
|
+
const seamSpy = sendSpy();
|
|
224
|
+
const onPermissionResolve = createOnPermissionResolve(seamSpy.deps);
|
|
225
|
+
onPermissionResolve({ callId: request.callId, optionId: "allow", allowed: true });
|
|
226
|
+
|
|
227
|
+
assertEquals(doorSpy.frames.length, 1);
|
|
228
|
+
assertEquals(seamSpy.frames.length, 1);
|
|
229
|
+
// Byte-identical frames: same lane, family, seq, and produce payload (stream + encoded chunk).
|
|
230
|
+
assertEquals(seamSpy.frames[0], doorSpy.frames[0]);
|
|
231
|
+
assertEquals(seamSpy.frames[0].payload.stream, stream);
|
|
232
|
+
// And they equal the pure builder's frame for the same decision + stream.
|
|
233
|
+
const pure = buildPermissionResolutionFrame(stream, { callId: request.callId, optionId: "allow", allowed: true, by: "operator" });
|
|
234
|
+
assertEquals(doorSpy.frames[0], pure.frame);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── yolo / opt-out: the bridge is bypassed by default ──────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
test("a yolo-policy request NEVER raises a user task and never emits a bridge resolution", () => {
|
|
240
|
+
const yolo: DerivedPermission = {
|
|
241
|
+
callId: "job-yolo",
|
|
242
|
+
policy: "yolo",
|
|
243
|
+
options: [{ optionId: "allow", name: "Allow", kind: "allow-once" }],
|
|
244
|
+
offset: 1,
|
|
245
|
+
};
|
|
246
|
+
// No row for yolo, even when the bridge is enabled (yolo auto-allows elsewhere; the bridge is inert).
|
|
247
|
+
assertEquals(permissionUserTaskRow(yolo, { userTaskKey: "ut-y", subjectKey: "hire-1" }, { enabled: true }), null);
|
|
248
|
+
// No completion door is invoked for yolo, so no send edge fires — nothing to assert beyond the null row:
|
|
249
|
+
// the bridge only ever emits from `completePermissionEscalationAsHuman`/`createOnPermissionResolve`.
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the bridge is opt-in: a disabled bridge raises no row even for an escalate-policy request", () => {
|
|
253
|
+
const request = escalateRequest("job-off");
|
|
254
|
+
assertEquals(permissionUserTaskRow(request, { userTaskKey: "ut-off", subjectKey: "hire-1" }, { enabled: false }), null);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("permissionEscalationEnabled defaults OFF and honours the truthy forms of the master switch", () => {
|
|
258
|
+
assertEquals(permissionEscalationEnabled({}), false);
|
|
259
|
+
assertEquals(permissionEscalationEnabled({ NANO_WORKFORCE_PERMISSION_ESCALATION: "off" }), false);
|
|
260
|
+
assertEquals(permissionEscalationEnabled({ NANO_WORKFORCE_PERMISSION_ESCALATION: "false" }), false);
|
|
261
|
+
for (const on of ["1", "true", "on", "yes", "TRUE", "On"]) {
|
|
262
|
+
assertEquals(permissionEscalationEnabled({ NANO_WORKFORCE_PERMISSION_ESCALATION: on }), true, `expected ${on} to enable`);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── Pure derivations ────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
test("optionKindAllows / permissionOptionAllows derive allow vs reject from the option kind (fail-closed)", () => {
|
|
269
|
+
assertEquals(optionKindAllows("allow-once"), true);
|
|
270
|
+
assertEquals(optionKindAllows("allow-always"), true);
|
|
271
|
+
assertEquals(optionKindAllows("reject-once"), false);
|
|
272
|
+
assertEquals(optionKindAllows("reject-always"), false);
|
|
273
|
+
const request = escalateRequest();
|
|
274
|
+
assertEquals(permissionOptionAllows(request, "allow"), true);
|
|
275
|
+
assertEquals(permissionOptionAllows(request, "deny"), false);
|
|
276
|
+
assertEquals(permissionOptionAllows(request, "unknown-option"), false); // fail-closed
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("permissionQuestion folds title + reason, falling back to the tool name", () => {
|
|
280
|
+
assertEquals(
|
|
281
|
+
permissionQuestion(escalateRequest()),
|
|
282
|
+
"Write /etc/hosts — The agent wants to modify a protected file.",
|
|
283
|
+
);
|
|
284
|
+
assertEquals(
|
|
285
|
+
permissionQuestion({ callId: "c", policy: "escalate", options: [], toolName: "run_shell", offset: 0 }),
|
|
286
|
+
"Permission requested for run_shell",
|
|
287
|
+
);
|
|
288
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// The ACP permission → nano-workforce escalation bridge (issue #559, ADR 0056 — the advisory agentic
|
|
2
|
+
// app-tier plane). It does NOT touch BPMN, the agent job envelope, or the worker⇄engine protocol.
|
|
3
|
+
//
|
|
4
|
+
// When an ACP-driven agent emits a permission REQUEST tagged `policy: "escalate"` on the relay lane
|
|
5
|
+
// (a blocked `session/request_permission`), this bridge:
|
|
6
|
+
// 1. raises it as an answerable row in the unified Tasks inbox (a new `ACP_PERMISSION_ELEMENT` kind,
|
|
7
|
+
// via the pure `buildUserTaskRow` derivation), carrying the request's title/reason as the question;
|
|
8
|
+
// 2. lets a human operator answer Allow/Deny through the ONE canonical completion door
|
|
9
|
+
// (`completeEscalationAsHuman`), the exact seam every other escalation uses; then
|
|
10
|
+
// 3. flows the answer BACK DOWN the relay as a permission RESOLUTION frame on the CONTROL lane (a
|
|
11
|
+
// permission answer is high-priority control), releasing the agent's blocked request.
|
|
12
|
+
//
|
|
13
|
+
// A `yolo`-policy request NEVER reaches this path (it auto-allows elsewhere), and the whole bridge is
|
|
14
|
+
// OPT-IN per hire — the default (`NANO_WORKFORCE_PERMISSION_ESCALATION` off) is yolo: no user task, no
|
|
15
|
+
// prompt, no bridge resolution.
|
|
16
|
+
//
|
|
17
|
+
// Derivation over duplication: the core is a set of PURE functions (raise the row / build the
|
|
18
|
+
// resolution frame), with the side-effecting relay `send` and the completion door kept as thin edges.
|
|
19
|
+
// Because the cockpit-render slice exposed a `RenderDerivedTranscriptOptions.onPermissionResolve` seam,
|
|
20
|
+
// this module ALSO exports an adapter that produces an `onPermissionResolve` handler backed by the SAME
|
|
21
|
+
// pure resolution builder, so the seam and the completion door converge on one bridge. Wiring the live
|
|
22
|
+
// in-browser Allow/Deny of `pages/cockpit/mount.js` to this bridge is a deferred follow-up (there is no
|
|
23
|
+
// in-repo cockpit boot site), OUT OF SCOPE here.
|
|
24
|
+
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
25
|
+
import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
|
|
26
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
27
|
+
import { type AgentCompleteResult, completeEscalationAsHuman } from "../agentCompletion.ts";
|
|
28
|
+
import { readEnvOr } from "../contracts.ts";
|
|
29
|
+
import { ACP_PERMISSION_ELEMENT, buildUserTaskRow, type UserTaskRow } from "../userTasks.ts";
|
|
30
|
+
import type { RenderDerivedTranscriptOptions } from "./cockpit/transcript-derive.ts";
|
|
31
|
+
import { jobStream } from "./correlation.ts";
|
|
32
|
+
import {
|
|
33
|
+
type DerivedPermission,
|
|
34
|
+
encodeTranscriptEvent,
|
|
35
|
+
optionKindAllows,
|
|
36
|
+
type PermissionResolutionEvent,
|
|
37
|
+
} from "./transcript-events.ts";
|
|
38
|
+
|
|
39
|
+
/** The exact `onPermissionResolve` seam the cockpit-render slice exported — consumed VERBATIM (not
|
|
40
|
+
* re-declared) so a drift in the cockpit's prop shape is a compile error here, never a silent skew. */
|
|
41
|
+
export type OnPermissionResolve = NonNullable<RenderDerivedTranscriptOptions["onPermissionResolve"]>;
|
|
42
|
+
|
|
43
|
+
/** Read the per-hire opt-in master switch for the permission-escalation bridge. Default OFF (yolo).
|
|
44
|
+
* Governed by the one typed env schema (`NANO_WORKFORCE_PERMISSION_ESCALATION`, `app/contracts.ts`). */
|
|
45
|
+
export function permissionEscalationEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
46
|
+
const raw = readEnvOr("NANO_WORKFORCE_PERMISSION_ESCALATION", "off", env).toLowerCase();
|
|
47
|
+
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Re-exported canonical allow/deny derivation (defined beside `PermissionOptionKind` in
|
|
51
|
+
* `transcript-events.ts`). The bridge and the cockpit render seam share this ONE implementation, so the
|
|
52
|
+
* completion-door path and the `onPermissionResolve` seam can never disagree on what a chosen option means. */
|
|
53
|
+
export { optionKindAllows };
|
|
54
|
+
|
|
55
|
+
/** Pure: whether the chosen `optionId` allows the action, derived from the REQUEST's own options.
|
|
56
|
+
* Returns false for an unknown option (fail-closed — an unrecognised answer denies). */
|
|
57
|
+
export function permissionOptionAllows(permission: DerivedPermission, optionId: string): boolean {
|
|
58
|
+
const option = permission.options.find((o) => o.optionId === optionId);
|
|
59
|
+
return option !== undefined && optionKindAllows(option.kind);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Pure: fold a permission request's `title`/`reason` (falling back to its `toolName`) into the one
|
|
63
|
+
* human-readable `question` the Tasks inbox row shows. */
|
|
64
|
+
export function permissionQuestion(permission: DerivedPermission): string {
|
|
65
|
+
const parts: string[] = [];
|
|
66
|
+
if (permission.title) parts.push(permission.title);
|
|
67
|
+
if (permission.reason) parts.push(permission.reason);
|
|
68
|
+
if (parts.length === 0 && permission.toolName) parts.push(`Permission requested for ${permission.toolName}`);
|
|
69
|
+
return parts.join(" — ");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The denormalised context a bridged permission request needs to raise its Tasks-inbox row. */
|
|
73
|
+
export interface PermissionUserTaskContext {
|
|
74
|
+
/** The completable key the raised row carries (the operator answers this key through the door). */
|
|
75
|
+
readonly userTaskKey: string;
|
|
76
|
+
/** The subject the row is keyed on (the hire / job / session the permission belongs to). */
|
|
77
|
+
readonly subjectKey: string;
|
|
78
|
+
readonly subjectTitle?: string | null;
|
|
79
|
+
readonly subjectUrl?: string | null;
|
|
80
|
+
readonly processKey?: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pure: the Tasks-inbox row an escalate-policy permission REQUEST raises, or `null` when the bridge is
|
|
84
|
+
* disabled (opt-out default) or the policy is `yolo` (auto-allow — no user task, no prompt). The row is
|
|
85
|
+
* the `ACP_PERMISSION_ELEMENT` kind, carrying the request's title/reason as its `question`, so a bridged
|
|
86
|
+
* permission surfaces in the SAME inbox as every other escalation. */
|
|
87
|
+
export function permissionUserTaskRow(
|
|
88
|
+
permission: DerivedPermission,
|
|
89
|
+
ctx: PermissionUserTaskContext,
|
|
90
|
+
opts: { enabled: boolean },
|
|
91
|
+
at?: string,
|
|
92
|
+
): UserTaskRow | null {
|
|
93
|
+
if (!opts.enabled) return null;
|
|
94
|
+
if (permission.policy !== "escalate") return null;
|
|
95
|
+
return buildUserTaskRow(
|
|
96
|
+
{
|
|
97
|
+
userTaskKey: ctx.userTaskKey,
|
|
98
|
+
elementId: ACP_PERMISSION_ELEMENT,
|
|
99
|
+
subjectType: "agent",
|
|
100
|
+
subjectKey: ctx.subjectKey,
|
|
101
|
+
subjectTitle: ctx.subjectTitle,
|
|
102
|
+
subjectUrl: ctx.subjectUrl,
|
|
103
|
+
question: permissionQuestion(permission),
|
|
104
|
+
processKey: ctx.processKey,
|
|
105
|
+
},
|
|
106
|
+
at,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The chosen answer to a permission request: the option id and whether it allows the action. */
|
|
111
|
+
export interface PermissionDecision {
|
|
112
|
+
readonly callId: string;
|
|
113
|
+
readonly optionId: string;
|
|
114
|
+
readonly allowed: boolean;
|
|
115
|
+
/** Provenance of the decision — an operator answer (default) or an auto policy. */
|
|
116
|
+
readonly by?: "operator" | "auto";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A produced permission RESOLUTION: the typed event, its encoded wire chunk, and the control-lane
|
|
120
|
+
* relay frame that carries it back down to the blocked agent. */
|
|
121
|
+
export interface PermissionResolution {
|
|
122
|
+
readonly event: PermissionResolutionEvent;
|
|
123
|
+
readonly chunk: string;
|
|
124
|
+
readonly frame: Frame;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Pure: the typed RESOLUTION event that releases a blocked `session/request_permission`. The `offset`
|
|
128
|
+
* is a placeholder — `encodeTranscriptEvent` strips it (the hub assigns the authoritative offset). */
|
|
129
|
+
export function buildPermissionResolutionEvent(decision: PermissionDecision): PermissionResolutionEvent {
|
|
130
|
+
return {
|
|
131
|
+
kind: "permission",
|
|
132
|
+
phase: "resolution",
|
|
133
|
+
offset: 0,
|
|
134
|
+
callId: decision.callId,
|
|
135
|
+
optionId: decision.optionId,
|
|
136
|
+
allowed: decision.allowed,
|
|
137
|
+
...(decision.by !== undefined ? { by: decision.by } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Options for the wire framing of a resolution — the producer generation and per-stream sequence the
|
|
142
|
+
* edge assigns. Both default to `0` so two independent callers with the same decision + stream produce
|
|
143
|
+
* byte-identical frames (the convergence the completion door and the `onPermissionResolve` seam rely on). */
|
|
144
|
+
export interface ResolutionFrameOptions {
|
|
145
|
+
readonly seq?: number;
|
|
146
|
+
readonly incarnation?: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pure: encode a permission RESOLUTION into the CONTROL-lane relay frame that carries it back down to
|
|
150
|
+
* the blocked agent. The chunk speaks the one envelope grammar (`encodeTranscriptEvent`); the frame
|
|
151
|
+
* rides the control lane because a permission answer is high-priority control. This is the single
|
|
152
|
+
* builder BOTH the completion-door path and the `onPermissionResolve` adapter converge on. */
|
|
153
|
+
export function buildPermissionResolutionFrame(
|
|
154
|
+
stream: string,
|
|
155
|
+
decision: PermissionDecision,
|
|
156
|
+
opts: ResolutionFrameOptions = {},
|
|
157
|
+
): PermissionResolution {
|
|
158
|
+
const event = buildPermissionResolutionEvent(decision);
|
|
159
|
+
const chunk = encodeTranscriptEvent(event);
|
|
160
|
+
const frame: Frame = {
|
|
161
|
+
lane: "control",
|
|
162
|
+
family: RELAY_FAMILY,
|
|
163
|
+
seq: opts.seq ?? 0,
|
|
164
|
+
payload: { op: "produce", stream, incarnation: opts.incarnation ?? 0, chunk },
|
|
165
|
+
};
|
|
166
|
+
return { event, chunk, frame };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The thin side-effecting edge: emit a resolution frame down the relay to the blocked agent. */
|
|
170
|
+
export type RelayResolutionSend = (frame: Frame) => void;
|
|
171
|
+
|
|
172
|
+
/** The bridge's edge dependencies: the relay `send`, plus how a permission `callId` maps to the relay
|
|
173
|
+
* stream its resolution travels back down and the frame's per-stream `seq`/`incarnation`. */
|
|
174
|
+
export interface PermissionBridgeDeps {
|
|
175
|
+
/** Emit the RESOLUTION frame down the relay (control lane). */
|
|
176
|
+
readonly send: RelayResolutionSend;
|
|
177
|
+
/** Resolve the relay stream a permission `callId` is answered on. Defaults to `job:<callId>` (the
|
|
178
|
+
* request's `callId` is the blocked job's key); a bridge with richer correlation supplies its own. */
|
|
179
|
+
readonly streamForCallId?: (callId: string) => string;
|
|
180
|
+
/** The frame `seq` for a resolution on a `callId`'s stream (default 0). */
|
|
181
|
+
readonly seq?: (callId: string) => number;
|
|
182
|
+
/** The producer generation stamped on the resolution frame (default 0). */
|
|
183
|
+
readonly incarnation?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveStream(deps: PermissionBridgeDeps, callId: string): string {
|
|
187
|
+
return deps.streamForCallId?.(callId) ?? jobStream(callId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sendResolution(deps: PermissionBridgeDeps, stream: string, decision: PermissionDecision): PermissionResolution {
|
|
191
|
+
const resolution = buildPermissionResolutionFrame(stream, decision, {
|
|
192
|
+
seq: deps.seq?.(decision.callId),
|
|
193
|
+
incarnation: deps.incarnation,
|
|
194
|
+
});
|
|
195
|
+
deps.send(resolution.frame);
|
|
196
|
+
return resolution;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The exported adapter behind the cockpit slice's `RenderDerivedTranscriptOptions.onPermissionResolve`
|
|
201
|
+
* seam. Given the bridge's relay-send dependency, returns a handler matching the seam signature verbatim
|
|
202
|
+
* that emits the SAME control-lane RESOLUTION frame the completion-door path emits (both route through
|
|
203
|
+
* `buildPermissionResolutionFrame`). Provided so a future cockpit boot site can plug it in — this module
|
|
204
|
+
* does NOT wire it into a live boot (there is no in-repo cockpit boot site; that is a deferred follow-up).
|
|
205
|
+
*/
|
|
206
|
+
export function createOnPermissionResolve(deps: PermissionBridgeDeps): OnPermissionResolve {
|
|
207
|
+
return (resolution) => {
|
|
208
|
+
sendResolution(deps, resolveStream(deps, resolution.callId), {
|
|
209
|
+
callId: resolution.callId,
|
|
210
|
+
optionId: resolution.optionId,
|
|
211
|
+
allowed: resolution.allowed,
|
|
212
|
+
by: "operator",
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** What an operator supplies to answer a bridged permission escalation through the completion door. */
|
|
218
|
+
export interface PermissionEscalationAnswer {
|
|
219
|
+
/** The request being answered (its `callId`/`options` are the source of truth for the resolution). */
|
|
220
|
+
readonly permission: DerivedPermission;
|
|
221
|
+
/** The completable key of the raised Tasks-inbox row. */
|
|
222
|
+
readonly userTaskKey: string;
|
|
223
|
+
/** The relay stream the resolution travels back down. Defaults to the deps' `streamForCallId`. */
|
|
224
|
+
readonly stream?: string;
|
|
225
|
+
/** The option the operator chose (Allow/Deny). `allowed` is derived from the request's option kind. */
|
|
226
|
+
readonly optionId: string;
|
|
227
|
+
/** The operator's audit handle. */
|
|
228
|
+
readonly operatorId: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The result of answering a bridged permission escalation: the completion-door result plus (on
|
|
232
|
+
* success) the RESOLUTION that was sent down the relay. */
|
|
233
|
+
export interface PermissionEscalationResult {
|
|
234
|
+
readonly completion: AgentCompleteResult;
|
|
235
|
+
readonly resolution?: PermissionResolution;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Answer a bridged permission escalation AS A HUMAN operator through the ONE canonical completion door
|
|
240
|
+
* (`completeEscalationAsHuman`), then flow the answer back down the relay as a control-lane RESOLUTION
|
|
241
|
+
* frame that releases the agent's blocked `session/request_permission`. The completion and the relay
|
|
242
|
+
* `send` are the thin edges; the resolution itself is the pure `buildPermissionResolutionFrame`, so this
|
|
243
|
+
* converges with the `onPermissionResolve` adapter on one bridge. When the completion door refuses the
|
|
244
|
+
* task (a 404-style no-op or a failed engine completion) NO resolution is sent — the block is only
|
|
245
|
+
* released when the operator's answer actually took.
|
|
246
|
+
*/
|
|
247
|
+
export async function completePermissionEscalationAsHuman(
|
|
248
|
+
data: DataLayer,
|
|
249
|
+
engine: EngineClient,
|
|
250
|
+
deps: PermissionBridgeDeps,
|
|
251
|
+
answer: PermissionEscalationAnswer,
|
|
252
|
+
): Promise<PermissionEscalationResult> {
|
|
253
|
+
const allowed = permissionOptionAllows(answer.permission, answer.optionId);
|
|
254
|
+
const completion = await completeEscalationAsHuman(data, engine, {
|
|
255
|
+
userTaskKey: answer.userTaskKey,
|
|
256
|
+
variables: { optionId: answer.optionId, allowed },
|
|
257
|
+
operatorId: answer.operatorId,
|
|
258
|
+
});
|
|
259
|
+
if (!completion.ok) return { completion };
|
|
260
|
+
const stream = answer.stream ?? resolveStream(deps, answer.permission.callId);
|
|
261
|
+
const resolution = sendResolution(deps, stream, {
|
|
262
|
+
callId: answer.permission.callId,
|
|
263
|
+
optionId: answer.optionId,
|
|
264
|
+
allowed,
|
|
265
|
+
by: "operator",
|
|
266
|
+
});
|
|
267
|
+
return { completion, resolution };
|
|
268
|
+
}
|
|
@@ -143,6 +143,15 @@ export type PermissionPolicy = "escalate" | "yolo";
|
|
|
143
143
|
/** The kind of a permission option — mirrors ACP's option kinds (allow/reject × once/always). */
|
|
144
144
|
export type PermissionOptionKind = "allow-once" | "allow-always" | "reject-once" | "reject-always";
|
|
145
145
|
|
|
146
|
+
/** Pure, canonical: does a permission option kind ALLOW (true) or REJECT (false) the proposed action?
|
|
147
|
+
* The `allow-*` vs `reject-*` prefix is the single source of truth. This lives beside
|
|
148
|
+
* {@link PermissionOptionKind} so every consumer (the cockpit render seam and the permission-escalation
|
|
149
|
+
* bridge) derives allow/deny from ONE implementation — the two paths can never disagree on what a
|
|
150
|
+
* chosen option means (no drift surface). */
|
|
151
|
+
export function optionKindAllows(kind: PermissionOptionKind): boolean {
|
|
152
|
+
return kind === "allow-once" || kind === "allow-always";
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
/** One offered permission option (ACP `options[]` member): a stable id, a label, and its kind. */
|
|
147
156
|
export interface PermissionOption {
|
|
148
157
|
readonly optionId: string;
|
package/app/contracts.ts
CHANGED
|
@@ -292,6 +292,14 @@ export const ENV_CONTRACTS = {
|
|
|
292
292
|
owner: "app/agentGuide.ts",
|
|
293
293
|
semantics: "Engine transport selector.",
|
|
294
294
|
},
|
|
295
|
+
NANO_WORKFORCE_PERMISSION_ESCALATION: {
|
|
296
|
+
category: "env",
|
|
297
|
+
name: "NANO_WORKFORCE_PERMISSION_ESCALATION",
|
|
298
|
+
owner: "app/agentic/permission-bridge.ts",
|
|
299
|
+
semantics:
|
|
300
|
+
"Per-hire opt-in master switch for the ACP permission-escalation bridge (issue #559, ADR 0056). When on ('1'/'true'/'on'/'yes'), an escalate-policy session/request_permission is bridged to a nano-workforce Tasks-inbox escalation and the operator's Allow/Deny answer is flowed back down the relay as a permission RESOLUTION. Default OFF → yolo auto-allow: no user task, no prompt. A yolo-policy request never reaches the bridge regardless.",
|
|
301
|
+
default: "off",
|
|
302
|
+
},
|
|
295
303
|
} as const satisfies Record<string, EnvContract>;
|
|
296
304
|
|
|
297
305
|
/** The set of declared config-key names — the single typed vocabulary of env keys. */
|
package/app/userTasks.ts
CHANGED
|
@@ -44,6 +44,15 @@ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
|
|
|
44
44
|
* canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
|
|
45
45
|
export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
46
46
|
|
|
47
|
+
/** The ACP permission-prompt escalation (issue #559, ADR 0056) — the Tasks-inbox kind a bridged
|
|
48
|
+
* `session/request_permission` surfaces under when an escalate-policy agent asks a human to Allow/Deny
|
|
49
|
+
* a proposed action. Unlike the other escalation elements this is NOT a BPMN user-task element; it is
|
|
50
|
+
* the advisory app-tier permission bridge's row kind, raised from a derived permission REQUEST and
|
|
51
|
+
* answered through the same canonical `completeEscalationAsHuman` door as every other escalation, with
|
|
52
|
+
* the operator's answer flowed back down the relay as a permission RESOLUTION. The bridge is OPT-IN per
|
|
53
|
+
* hire — a `yolo`-policy request never reaches this path (see `app/agentic/permission-bridge.ts`). */
|
|
54
|
+
export const ACP_PERMISSION_ELEMENT = "acp-permission";
|
|
55
|
+
|
|
47
56
|
/** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
|
|
48
57
|
* the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
|
|
49
58
|
* task open; `pollUserTasks` deletes it once the task is gone. */
|
|
@@ -83,6 +92,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
83
92
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
84
93
|
[CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
|
|
85
94
|
[DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
|
|
95
|
+
[ACP_PERMISSION_ELEMENT]: "Agent permission",
|
|
86
96
|
};
|
|
87
97
|
|
|
88
98
|
/** The Tasks-inbox label for an open user-task `elementId`, or `undefined` when the element is not a
|
|
@@ -99,7 +109,7 @@ export function userTaskKindLabel(elementId: string): string | undefined {
|
|
|
99
109
|
export interface UserTaskContext {
|
|
100
110
|
userTaskKey: string;
|
|
101
111
|
elementId: string;
|
|
102
|
-
subjectType: "feature" | "plan" | "pr" | "delivery";
|
|
112
|
+
subjectType: "feature" | "plan" | "pr" | "delivery" | "agent";
|
|
103
113
|
subjectKey: string;
|
|
104
114
|
/** The subject's human-readable title from its own row (`feature_runs`/`plans`/`pull_requests`.
|
|
105
115
|
* `title`). Optional/blank tolerated — `buildUserTaskRow` coalesces it to `subjectKey` so the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.148.0",
|
|
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",
|