@signalridge/pi-workflows 1.7.5 → 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 +26 -0
- package/README.md +157 -10
- package/package.json +3 -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/arming.ts +264 -3
- package/src/engine.ts +1212 -379
- package/src/errors.ts +26 -0
- package/src/index.ts +578 -225
- package/src/inspection.ts +544 -0
- package/src/journal.ts +38 -11
- package/src/orchestrator.ts +22 -4
- package/src/runtime.ts +427 -168
- package/src/safe-text.ts +4 -35
- 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 -241
- package/src/vm-bootstrap.ts +218 -0
- package/src/vm-helper.ts +502 -0
package/src/safe-text.ts
CHANGED
|
@@ -1,42 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
* Terminal-safety for untrusted text rendered by the `/workflows` navigator.
|
|
3
|
-
*
|
|
4
|
-
* Task results, task errors, and run errors originate in child agent sessions, so they are
|
|
5
|
-
* attacker-influenced from this package's point of view. Duplicated rather than imported:
|
|
6
|
-
* extensions must not depend on another extension's source (see docs/package-boundaries.md).
|
|
7
|
-
*/
|
|
1
|
+
import { sanitizeDisplayText as encodeDisplayText } from "@signalridge/pi-ui";
|
|
8
2
|
|
|
9
|
-
/**
|
|
10
|
-
function isUnsafeTerminalCodePoint(codePoint: number): boolean {
|
|
11
|
-
return (
|
|
12
|
-
codePoint <= 0x1f ||
|
|
13
|
-
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
14
|
-
codePoint === 0x061c ||
|
|
15
|
-
codePoint === 0x200e ||
|
|
16
|
-
codePoint === 0x200f ||
|
|
17
|
-
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
|
18
|
-
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Replace every code point a terminal could interpret as control input with a space.
|
|
24
|
-
* Neutralizing the introducer (ESC, and the 8-bit C1 forms such as `0x9b` CSI) is what makes
|
|
25
|
-
* the remainder of a sequence inert payload text rather than a command.
|
|
26
|
-
*/
|
|
3
|
+
/** Display-only encoding. Never apply it to stored workflow values or journals. */
|
|
27
4
|
export function sanitizeTerminalText(value: string): string {
|
|
28
|
-
return
|
|
29
|
-
.map((character) => (isUnsafeTerminalCodePoint(character.codePointAt(0) ?? 0) ? " " : character))
|
|
30
|
-
.join("");
|
|
5
|
+
return encodeDisplayText(value);
|
|
31
6
|
}
|
|
32
7
|
|
|
33
|
-
/**
|
|
34
|
-
* Single-line, bounded rendering of untrusted text.
|
|
35
|
-
*
|
|
36
|
-
* Sanitizing strictly before truncating is load-bearing: slicing first can cut through an
|
|
37
|
-
* escape sequence and leave a dangling introducer that swallows whatever the terminal draws
|
|
38
|
-
* next. Slicing by code point additionally avoids splitting a surrogate pair.
|
|
39
|
-
*/
|
|
8
|
+
/** Retained public helper: single-line, code-point-bounded display copy. */
|
|
40
9
|
export function sanitizeDisplayText(value: string, maximumLength: number): string {
|
|
41
10
|
if (maximumLength < 1) return "";
|
|
42
11
|
const safe = sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
|
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
|
+
}
|