@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.
@@ -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.146.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",