@signalridge/pi-workflows 1.8.0 → 1.9.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 +10 -0
- package/README.md +13 -1
- package/package.json +2 -2
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/engine.ts +1088 -395
- package/src/index.ts +202 -53
- package/src/inspection.ts +544 -0
- package/src/journal.ts +33 -11
- package/src/orchestrator.ts +22 -4
- package/src/runtime.ts +387 -148
- package/src/ui-bus.ts +243 -0
- package/src/ui-command-center.ts +1032 -0
- package/src/ui-inspection.ts +206 -0
- package/src/ui-presentation.ts +196 -0
- package/src/ui.ts +43 -265
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
|
+
}
|