@signalridge/pi-workflows 1.8.0 → 1.10.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,143 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const STATUS_QUERY = "pi-ui:status-actions:v1:query";
4
+ export const STATUS_OPEN = "pi-ui:status-actions:v1:open";
5
+ const FAILURE = "Status action is unavailable.";
6
+
7
+ export interface StatusSurface {
8
+ surfaceId: string;
9
+ sessionId: string;
10
+ /** Private identity, never placed on the wire. */
11
+ owner: object;
12
+ busy(): boolean;
13
+ open(): Promise<void>;
14
+ }
15
+
16
+ /** Inspect descriptors, never values on an untrusted object (including getters). */
17
+ function request(raw: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
18
+ try {
19
+ if (!raw || typeof raw !== "object") return;
20
+ const prototype = Object.getPrototypeOf(raw);
21
+ if (prototype !== Object.prototype && prototype !== null) return;
22
+ const descriptors = Object.getOwnPropertyDescriptors(raw);
23
+ if (Reflect.ownKeys(descriptors).length !== keys.length) return;
24
+ const values: Record<string, unknown> = Object.create(null);
25
+ for (const key of keys) {
26
+ const descriptor = descriptors[key];
27
+ if (!descriptor || !("value" in descriptor)) return;
28
+ const value: unknown = descriptor.value;
29
+ if (key === "version" ? value !== 1 : typeof value !== "string" || value.length < 1 || value.length > 128) return;
30
+ values[key] = value;
31
+ }
32
+ return values;
33
+ } catch {
34
+ return;
35
+ }
36
+ }
37
+
38
+ /** Optional presentation-only endpoint; the source must fail closed on live divergence. */
39
+ export class StatusActions {
40
+ private readonly unsubscribe: Array<() => void>;
41
+ private disposed = false;
42
+ private opening = false;
43
+ private readonly admitted = new Set<string>();
44
+
45
+ constructor(
46
+ private readonly events: ExtensionAPI["events"],
47
+ private readonly descriptor: { providerId: string; statusKey: string; actionId: string; label: string },
48
+ private readonly source: () => StatusSurface | undefined,
49
+ ) {
50
+ this.unsubscribe = [
51
+ events.on(STATUS_QUERY, (raw: unknown) => this.query(raw)),
52
+ events.on(STATUS_OPEN, (raw: unknown) => this.open(raw)),
53
+ ];
54
+ }
55
+
56
+ dispose(): void {
57
+ if (this.disposed) return;
58
+ this.disposed = true;
59
+ for (const unsubscribe of this.unsubscribe) unsubscribe();
60
+ this.admitted.clear();
61
+ }
62
+
63
+ private query(raw: unknown): void {
64
+ if (this.disposed) return;
65
+ const parsed = request(raw, ["version", "requestId", "sessionId"]);
66
+ if (!parsed) return;
67
+ const surface = this.source();
68
+ if (!surface || parsed.sessionId !== surface.sessionId || this.disposed) return;
69
+ this.events.emit(`${STATUS_QUERY}:reply:${parsed.requestId}`, {
70
+ success: true,
71
+ data: {
72
+ version: 1,
73
+ providerId: this.descriptor.providerId,
74
+ surfaceId: surface.surfaceId,
75
+ sessionId: surface.sessionId,
76
+ entries: [
77
+ { statusKey: this.descriptor.statusKey, actionId: this.descriptor.actionId, label: this.descriptor.label },
78
+ ],
79
+ },
80
+ });
81
+ }
82
+
83
+ private open(raw: unknown): void {
84
+ if (this.disposed) return;
85
+ const parsed = request(raw, ["version", "requestId", "providerId", "surfaceId", "sessionId", "actionId"]);
86
+ if (!parsed || parsed.providerId !== this.descriptor.providerId) return;
87
+ const surface = this.source();
88
+ if (!surface || this.disposed || parsed.sessionId !== surface.sessionId || parsed.surfaceId !== surface.surfaceId)
89
+ return; // Other owners, including duplicate providers, may share this bus.
90
+ const channel = `${STATUS_OPEN}:reply:${parsed.requestId}`;
91
+ const id = String(parsed.requestId);
92
+ if (parsed.actionId !== this.descriptor.actionId || this.opening || surface.busy() || this.admitted.has(id)) {
93
+ this.events.emit(channel, { success: false, error: FAILURE });
94
+ return;
95
+ }
96
+ this.admitted.add(id);
97
+ if (this.admitted.size > 256) this.admitted.delete(this.admitted.values().next().value as string);
98
+ this.opening = true;
99
+ // Admission is synchronous; the selector can relinquish focus before mount.
100
+ this.events.emit(channel, { success: true, data: { accepted: true } });
101
+ void Promise.resolve()
102
+ .then(() => {
103
+ if (this.disposed) return;
104
+ const current = this.source();
105
+ if (
106
+ this.disposed ||
107
+ !current ||
108
+ current.owner !== surface.owner ||
109
+ current.surfaceId !== surface.surfaceId ||
110
+ current.sessionId !== surface.sessionId
111
+ )
112
+ return;
113
+ return surface.open();
114
+ })
115
+ .catch(() => {
116
+ // Admission does not promise successful mount. Never redirect to another owner.
117
+ })
118
+ .finally(() => {
119
+ this.opening = false;
120
+ });
121
+ }
122
+ }
123
+
124
+ /** The host may defer a custom factory after admission; fence that boundary too. */
125
+ export function guardStatusMount(ctx: ExtensionContext, guard: () => void): ExtensionContext {
126
+ const custom: ExtensionContext["ui"]["custom"] = (factory, options) =>
127
+ ctx.ui.custom((...args) => {
128
+ guard();
129
+ return factory(...args);
130
+ }, options);
131
+ return { ...ctx, ui: { ...ctx.ui, custom } };
132
+ }
133
+
134
+ /** Count run authority only; child completion cannot establish run completion. */
135
+ export function workflowStatusSummary(runs: ReadonlyArray<{ status: string }>): string | undefined {
136
+ if (!runs.length) return undefined;
137
+ const count = (statuses: string[]) => runs.filter((run) => statuses.includes(run.status)).length;
138
+ const active = count(["pending", "running", "pausing", "stopping"]);
139
+ const paused = count(["paused", "interrupted"]);
140
+ return active || paused
141
+ ? `Workflows · A${active} P${paused}`
142
+ : `Workflows · C${count(["completed"])} F${count(["failed"])} S${count(["stopped"])}`;
143
+ }
package/src/ui-bus.ts ADDED
@@ -0,0 +1,243 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ type EventBus,
4
+ type ManagedOwner,
5
+ parseOwnedConversationOpenRequest,
6
+ parseWorkflowUiOpenRequest,
7
+ parseWorkflowUiQueryRequest,
8
+ parseWorkflowUiSnapshot,
9
+ replyChannel,
10
+ WORKFLOW_UI_CHANNELS,
11
+ type WorkflowUiRunSummary,
12
+ type WorkflowUiSnapshot,
13
+ } from "@signalridge/pi-subagents-protocol";
14
+ import type { WorkflowEngine } from "./engine.js";
15
+
16
+ /** Compact peer projection: declared phases and retained children are not run authority. */
17
+ export function buildWorkflowUiSnapshot(engine: WorkflowEngine, surfaceId: string): WorkflowUiSnapshot {
18
+ const runs: WorkflowUiRunSummary[] = [];
19
+ const priority = (status: string): number =>
20
+ ["pending", "running", "pausing", "stopping"].includes(status)
21
+ ? 0
22
+ : ["paused", "interrupted"].includes(status)
23
+ ? 1
24
+ : 2;
25
+ const ordered = engine
26
+ .list()
27
+ .map((summary) => engine.getRun(String(summary.runId)))
28
+ .filter((run): run is NonNullable<typeof run> => run !== undefined)
29
+ .sort(
30
+ (a, b) => priority(a.status) - priority(b.status) || b.startedAt - a.startedAt || a.runId.localeCompare(b.runId),
31
+ );
32
+ for (const run of ordered.slice(0, 256)) {
33
+ const header = engine.getInspectionHeader(run.runId);
34
+ const ids = new Set([...Object.keys(run.callStatus), ...Object.keys(run.callResults)]);
35
+ const statuses = [...ids].map((id) => run.callStatus[id] ?? run.callResults[id]?.status);
36
+ const tokenValues = Object.values(run.callResults)
37
+ .map((result) => result.tokenCount)
38
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0);
39
+ const terminal = run.status === "completed" || run.status === "failed" || run.status === "stopped";
40
+ const duration = (terminal ? run.updatedAt : Date.now()) - run.startedAt;
41
+ runs.push({
42
+ version: 1,
43
+ surfaceId,
44
+ runId: run.runId,
45
+ name: run.meta.name.slice(0, 512),
46
+ status: run.status,
47
+ ...(header?.currentPhase?.trim() ? { currentPhase: header.currentPhase.slice(0, 512) } : {}),
48
+ callCount: ids.size,
49
+ runningCalls: statuses.filter((status) => status === "running").length,
50
+ completedCalls: statuses.filter((status) => status === "completed").length,
51
+ failedCalls: statuses.filter((status) => status === "failed").length,
52
+ elapsedMs: Number.isFinite(duration) ? Math.max(0, duration) : 0,
53
+ ...(tokenValues.length ? { tokenCount: Math.floor(tokenValues.reduce((total, value) => total + value, 0)) } : {}),
54
+ restored: header?.restored ?? true,
55
+ });
56
+ }
57
+ return parseWorkflowUiSnapshot({ version: 1, surfaceId, runs });
58
+ }
59
+
60
+ /** Open an exact retained attempt through the session owner's optional TUI endpoint. */
61
+ export function openOwnedConversation(
62
+ events: EventBus,
63
+ agentId: string,
64
+ owner: ManagedOwner & { attemptId: string },
65
+ isCurrent: () => boolean,
66
+ ): Promise<void> {
67
+ const request = parseOwnedConversationOpenRequest({ requestId: randomUUID(), agentId, owner });
68
+ return new Promise<void>((resolve, reject) => {
69
+ let settled = false;
70
+ let timer: ReturnType<typeof setTimeout> | undefined;
71
+ let unsubscribe = () => {};
72
+ const finish = (error?: Error): void => {
73
+ if (settled) return;
74
+ settled = true;
75
+ if (timer) clearTimeout(timer);
76
+ unsubscribe();
77
+ if (error) reject(error);
78
+ else resolve();
79
+ };
80
+ try {
81
+ if (!isCurrent()) {
82
+ finish(new Error("Agent selection is no longer active."));
83
+ return;
84
+ }
85
+ unsubscribe = events.on(replyChannel(WORKFLOW_UI_CHANNELS.openConversation, request.requestId), (raw) => {
86
+ if (!isCurrent()) {
87
+ finish(new Error("Agent selection is no longer active."));
88
+ return;
89
+ }
90
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
91
+ finish(new Error("Invalid agent conversation admission."));
92
+ return;
93
+ }
94
+ const reply = raw as Record<string, unknown>;
95
+ const data = reply.data;
96
+ if (
97
+ reply.success === true &&
98
+ data !== null &&
99
+ typeof data === "object" &&
100
+ !Array.isArray(data) &&
101
+ (data as Record<string, unknown>).accepted === true
102
+ )
103
+ finish();
104
+ else finish(new Error("Agent conversation is not available; use /agents to inspect retained sessions."));
105
+ });
106
+ timer = setTimeout(
107
+ () => finish(new Error("Agent conversation UI is unavailable; update the subagent package or use /agents.")),
108
+ 2_000,
109
+ );
110
+ events.emit(WORKFLOW_UI_CHANNELS.openConversation, request);
111
+ } catch {
112
+ finish(new Error("Cannot open agent conversation; try again."));
113
+ }
114
+ });
115
+ }
116
+
117
+ /** A snapshot and opener belonging to one active TUI session/branch surface. */
118
+ export interface WorkflowUiSurface {
119
+ snapshot: WorkflowUiSnapshot;
120
+ open(runId: string): Promise<void>;
121
+ notifyError(message: string): void;
122
+ }
123
+
124
+ /**
125
+ * Optional UI-only peer bridge. It never controls a run or interprets child
126
+ * state: an explicit user action enters the workflow owner's own navigator.
127
+ * The source must enforce live session/branch affinity on every invocation.
128
+ */
129
+ export class WorkflowUiBridge {
130
+ private readonly unsubscribe: Array<() => void>;
131
+ private opening: WorkflowUiSurface | undefined;
132
+ private disposed = false;
133
+
134
+ constructor(
135
+ private readonly events: EventBus,
136
+ private readonly source: () => WorkflowUiSurface | undefined,
137
+ ) {
138
+ this.unsubscribe = [
139
+ events.on(WORKFLOW_UI_CHANNELS.query, (raw) => this.query(raw)),
140
+ events.on(WORKFLOW_UI_CHANNELS.open, (raw) => this.open(raw)),
141
+ ];
142
+ }
143
+
144
+ publish(): void {
145
+ if (this.disposed) return;
146
+ try {
147
+ const surface = this.source();
148
+ if (surface) this.events.emit(WORKFLOW_UI_CHANNELS.changed, parseWorkflowUiSnapshot(surface.snapshot));
149
+ } catch {
150
+ // Presentation observers cannot fail execution or journal publication.
151
+ }
152
+ }
153
+
154
+ retire(surfaceId: string): void {
155
+ if (this.disposed) return;
156
+ try {
157
+ this.events.emit(WORKFLOW_UI_CHANNELS.changed, parseWorkflowUiSnapshot({ version: 1, surfaceId, runs: [] }));
158
+ } catch {
159
+ // Retirement is best-effort display cleanup, never a lifecycle lock.
160
+ }
161
+ }
162
+
163
+ dispose(): void {
164
+ if (this.disposed) return;
165
+ this.disposed = true;
166
+ this.opening = undefined;
167
+ for (const unsubscribe of this.unsubscribe) unsubscribe();
168
+ }
169
+
170
+ private query(raw: unknown): void {
171
+ if (this.disposed) return;
172
+ let request: ReturnType<typeof parseWorkflowUiQueryRequest>;
173
+ try {
174
+ request = parseWorkflowUiQueryRequest(raw);
175
+ } catch {
176
+ return; // A malformed request has no admitted reply identity.
177
+ }
178
+ const channel = replyChannel(WORKFLOW_UI_CHANNELS.query, request.requestId);
179
+ try {
180
+ const surface = this.source();
181
+ if (!surface) {
182
+ this.events.emit(channel, { success: false, error: "Workflow TUI is not active." });
183
+ return;
184
+ }
185
+ this.events.emit(channel, { success: true, data: parseWorkflowUiSnapshot(surface.snapshot) });
186
+ } catch {
187
+ this.events.emit(channel, { success: false, error: "Workflow TUI snapshot is unavailable." });
188
+ }
189
+ }
190
+
191
+ private open(raw: unknown): void {
192
+ if (this.disposed) return;
193
+ let request: ReturnType<typeof parseWorkflowUiOpenRequest>;
194
+ try {
195
+ request = parseWorkflowUiOpenRequest(raw);
196
+ } catch {
197
+ return;
198
+ }
199
+ const channel = replyChannel(WORKFLOW_UI_CHANNELS.open, request.requestId);
200
+ let surface: WorkflowUiSurface | undefined;
201
+ try {
202
+ surface = this.source();
203
+ if (
204
+ !surface ||
205
+ surface.snapshot.surfaceId !== request.surfaceId ||
206
+ !surface.snapshot.runs.some((run) => run.runId === request.runId)
207
+ ) {
208
+ this.events.emit(channel, { success: false, error: "Workflow selection is no longer active." });
209
+ return;
210
+ }
211
+ if (this.opening) {
212
+ this.events.emit(channel, { success: false, error: "Workflow inspector is already open." });
213
+ return;
214
+ }
215
+ this.opening = surface;
216
+ // Acknowledge admission, not dialog completion: interactive inspection
217
+ // legitimately lasts longer than an RPC timeout.
218
+ this.events.emit(channel, { success: true, data: { accepted: true } });
219
+ const ownedSurface = surface;
220
+ void Promise.resolve()
221
+ .then(() => {
222
+ const current = this.source();
223
+ if (this.disposed || current?.snapshot.surfaceId !== request.surfaceId) return;
224
+ if (!current.snapshot.runs.some((run) => run.runId === request.runId)) return;
225
+ return ownedSurface.open(request.runId);
226
+ })
227
+ .catch(() => {
228
+ try {
229
+ if (!this.disposed && this.source()?.snapshot.surfaceId === request.surfaceId)
230
+ ownedSurface.notifyError("Cannot open workflow inspector; try again.");
231
+ } catch {
232
+ // A retired UI must not mask or rethrow an async opener failure.
233
+ }
234
+ })
235
+ .finally(() => {
236
+ if (this.opening === ownedSurface) this.opening = undefined;
237
+ });
238
+ } catch {
239
+ if (this.opening === surface) this.opening = undefined;
240
+ this.events.emit(channel, { success: false, error: "Workflow inspector is unavailable." });
241
+ }
242
+ }
243
+ }