@nanobpm/nano-workforce 0.146.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 +12 -0
- package/app/agentCompletion.ts +9 -3
- package/app/agentic/cockpit/index.ts +1 -0
- package/app/agentic/cockpit/supply-boot.test.ts +94 -0
- package/app/agentic/cockpit/supply-boot.ts +36 -0
- package/app/agentic/cockpit/transcript-derive.test.ts +182 -1
- package/app/agentic/cockpit/transcript-derive.ts +233 -10
- package/app/agentic/permission-bridge.test.ts +288 -0
- package/app/agentic/permission-bridge.ts +268 -0
- package/app/agentic/transcript-events.test.ts +8 -0
- package/app/agentic/transcript-events.ts +13 -1
- package/app/contracts.ts +8 -0
- package/app/userTasks.ts +11 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -275,6 +275,14 @@ test("core vocab: malformed permission envelopes fall back to raw stream-chunk",
|
|
|
275
275
|
}).kind,
|
|
276
276
|
"stream-chunk",
|
|
277
277
|
);
|
|
278
|
+
// resolution with a present-but-NON-STRING `by` (e.g. 123) — rejected, not accepted with `by` dropped
|
|
279
|
+
assertEquals(
|
|
280
|
+
parseTranscriptEvent({
|
|
281
|
+
offset: 0,
|
|
282
|
+
chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: 123 }),
|
|
283
|
+
}).kind,
|
|
284
|
+
"stream-chunk",
|
|
285
|
+
);
|
|
278
286
|
// missing callId
|
|
279
287
|
assertEquals(
|
|
280
288
|
parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "request", policy: "escalate", options: REQUEST_OPTIONS }) }).kind,
|
|
@@ -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;
|
|
@@ -346,7 +355,10 @@ export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
|
|
|
346
355
|
if (typeof body.allowed !== "boolean") return undefined;
|
|
347
356
|
const by = str(body, "by");
|
|
348
357
|
// Reject a malformed `by` rather than silently dropping it: a present-but-unknown provenance is a
|
|
349
|
-
// producer bug, and swallowing it would make the typed event diverge from the on-wire JSON.
|
|
358
|
+
// producer bug, and swallowing it would make the typed event diverge from the on-wire JSON. This
|
|
359
|
+
// covers BOTH a present-but-non-string `by` (e.g. `by: 123`, where str() coerces to undefined) and
|
|
360
|
+
// a string that isn't a known provenance — either way the on-wire `by` is present but invalid.
|
|
361
|
+
if (body.by !== undefined && by === undefined) return undefined;
|
|
350
362
|
if (by !== undefined && by !== "operator" && by !== "auto") return undefined;
|
|
351
363
|
const event: PermissionResolutionEvent = {
|
|
352
364
|
kind: "permission",
|
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",
|