@osolmaz/pi-workflows 0.1.0 → 0.3.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/README.md +107 -26
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +234 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +212 -0
- package/dist/controllers/sqlite.js +1009 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +93 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.d.ts +14 -1
- package/dist/extension/executor.js +12 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1118 -89
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +91 -0
- package/dist/extension/recorder.js +537 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/widget.js +25 -24
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +49 -0
- package/dist/host/runner.js +350 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/render/canvas.d.ts +1 -1
- package/dist/render/canvas.js +5 -0
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +5 -0
- package/dist/render/graph-render.js +211 -48
- package/dist/render/graph-render.js.map +1 -1
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.js +19 -3
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +45 -0
- package/dist/viewer/session-reducer.js +266 -0
- package/dist/viewer/session-reducer.js.map +1 -0
- package/dist/workflows/artifacts.d.ts +40 -0
- package/dist/workflows/artifacts.js +155 -0
- package/dist/workflows/artifacts.js.map +1 -0
- package/dist/workflows/engine.d.ts +38 -0
- package/dist/workflows/engine.js +281 -20
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -2
- package/dist/workflows/index.js +2 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -3
- package/dist/workflows/loader.js +10 -1
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +98 -10
- package/dist/workflows/store.js +921 -46
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +136 -3
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +51 -24
- package/docs/live-replay-protocol.md +155 -0
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
- package/docs/plans/session-event-replay-implementation-plan.md +494 -0
- package/docs/plans/tui-viewer-implementation-plan.md +64 -0
- package/docs/run-bundles.md +359 -55
- package/docs/session-event-journal.md +470 -0
- package/docs/tui-viewer.md +218 -0
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +11 -2
- package/src/builtins/monitor.workflow.ts +278 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1427 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +145 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +29 -2
- package/src/extension/index.ts +1315 -109
- package/src/extension/recorder.ts +662 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/widget.ts +26 -24
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +406 -0
- package/src/render/canvas.ts +19 -1
- package/src/render/graph-render.ts +277 -44
- package/src/viewer/cli.ts +167 -21
- package/src/viewer/render.ts +21 -3
- package/src/viewer/session-reducer.ts +347 -0
- package/src/workflows/artifacts.ts +188 -0
- package/src/workflows/engine.ts +365 -19
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/index.ts +15 -0
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1157 -48
- package/src/workflows/types.ts +151 -3
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
SESSION_BINDING_SCHEMA,
|
|
5
|
+
SESSION_CAPTURE_SCHEMA,
|
|
6
|
+
SESSION_EVENT_MAX_BYTES,
|
|
7
|
+
SESSION_EVENT_SCHEMA,
|
|
8
|
+
type WorkflowRunStore,
|
|
9
|
+
} from "../workflows/store.js";
|
|
10
|
+
import type {
|
|
11
|
+
AgentStepContract,
|
|
12
|
+
ConversationRange,
|
|
13
|
+
WorkflowSessionCaptureFailure,
|
|
14
|
+
WorkflowSessionEventRecord,
|
|
15
|
+
WorkflowSessionEventType,
|
|
16
|
+
} from "../workflows/types.js";
|
|
17
|
+
import {
|
|
18
|
+
messageRole,
|
|
19
|
+
normalizeAssistantEvent,
|
|
20
|
+
toolCallIdFromAssistantEvent,
|
|
21
|
+
toolFinishedPayload,
|
|
22
|
+
toolStartedPayload,
|
|
23
|
+
turnFinishedPayload,
|
|
24
|
+
type MessageEndEventLike,
|
|
25
|
+
type MessageStartEventLike,
|
|
26
|
+
type MessageUpdateEventLike,
|
|
27
|
+
type ToolExecutionEndEventLike,
|
|
28
|
+
type ToolExecutionStartEventLike,
|
|
29
|
+
type ToolExecutionUpdateEventLike,
|
|
30
|
+
type TurnEndEventLike,
|
|
31
|
+
type TurnStartEventLike,
|
|
32
|
+
} from "./session-events.js";
|
|
33
|
+
|
|
34
|
+
const FLUSH_INTERVAL_MS = 25;
|
|
35
|
+
const FINISH_TURN_TIMEOUT_MS = 30_000;
|
|
36
|
+
const FLUSH_MAX_RECORDS = 256;
|
|
37
|
+
const FLUSH_MAX_BYTES = 256 * 1024;
|
|
38
|
+
const QUEUE_MAX_RECORDS = 8_192;
|
|
39
|
+
const QUEUE_MAX_BYTES = 16 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
type AttemptOwner = { nodeId: string; attemptId: string };
|
|
42
|
+
type TurnOwner = AttemptOwner & { turnId: string; turnIndex: number };
|
|
43
|
+
type MessageOwner = TurnOwner & { messageId: string; role: string };
|
|
44
|
+
type QueuedEvent = { record: WorkflowSessionEventRecord; bytes: number };
|
|
45
|
+
type PendingMessageEnd = { queued: QueuedEvent; role: string; message: unknown };
|
|
46
|
+
type RecordedEntry = { id: string; entry: Record<string, unknown>; claimed: boolean };
|
|
47
|
+
|
|
48
|
+
function objectKey(value: unknown): object | null {
|
|
49
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stableMessageKey(value: unknown): string | null {
|
|
53
|
+
if (typeof value !== "object" || value === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const message = value as { id?: unknown; role?: unknown; timestamp?: unknown };
|
|
57
|
+
if (typeof message.id === "string" && message.id.length > 0) {
|
|
58
|
+
return `id:${message.id}`;
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
(typeof message.timestamp === "number" || typeof message.timestamp === "string") &&
|
|
62
|
+
typeof message.role === "string"
|
|
63
|
+
) {
|
|
64
|
+
return `timestamp:${message.role}:${message.timestamp}`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function entryRole(entry: Record<string, unknown>): string {
|
|
70
|
+
const message = entry.message;
|
|
71
|
+
return messageRole(message);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function failureMessage(error: unknown): string {
|
|
75
|
+
return error instanceof Error ? error.message : String(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Records both settled Pi entries and normalized temporal events into one run
|
|
80
|
+
* bundle. The hot event path only stamps and queues records; filesystem work
|
|
81
|
+
* runs on a separate ordered append chain.
|
|
82
|
+
*/
|
|
83
|
+
export class SessionRecorder {
|
|
84
|
+
private readonly store: WorkflowRunStore;
|
|
85
|
+
private readonly runDir: string;
|
|
86
|
+
private readonly runId: string;
|
|
87
|
+
/**
|
|
88
|
+
* Capture segment for this recorder. Undefined writes the legacy flat
|
|
89
|
+
* stream; a second recorder attaching to the same bundle (resume or
|
|
90
|
+
* handoff) writes a segment instead so the first capture stays intact.
|
|
91
|
+
*/
|
|
92
|
+
private segmentId: string | undefined;
|
|
93
|
+
private cursor: string | null = null;
|
|
94
|
+
private readonly recorded: string[] = [];
|
|
95
|
+
private readonly unclaimedEntries: RecordedEntry[] = [];
|
|
96
|
+
private bound = false;
|
|
97
|
+
private acceptingEntries = true;
|
|
98
|
+
private acceptingEvents = true;
|
|
99
|
+
private entryChain: Promise<unknown> = Promise.resolve();
|
|
100
|
+
private stopPromise: Promise<void> | null = null;
|
|
101
|
+
|
|
102
|
+
private nextEventSeq = 1;
|
|
103
|
+
private nextTurnId = 1;
|
|
104
|
+
private nextMessageId = 1;
|
|
105
|
+
private currentAttempt: AttemptOwner | null = null;
|
|
106
|
+
private currentTurn: TurnOwner | null = null;
|
|
107
|
+
private currentMessage: MessageOwner | null = null;
|
|
108
|
+
private lastFinishedAttempt: AttemptOwner | null = null;
|
|
109
|
+
private finishPromise: Promise<void> | null = null;
|
|
110
|
+
private resolveFinish: (() => void) | null = null;
|
|
111
|
+
private finishTimer: NodeJS.Timeout | null = null;
|
|
112
|
+
private readonly messageOwners = new WeakMap<object, MessageOwner>();
|
|
113
|
+
private readonly stableMessageOwners = new Map<string, MessageOwner>();
|
|
114
|
+
private readonly toolOwners = new Map<string, MessageOwner>();
|
|
115
|
+
private readonly turnToolCallIds = new Map<string, string[]>();
|
|
116
|
+
|
|
117
|
+
private eventQueue: QueuedEvent[] = [];
|
|
118
|
+
private readonly pendingMessageEnds: PendingMessageEnd[] = [];
|
|
119
|
+
private outstandingRecords = 0;
|
|
120
|
+
private outstandingBytes = 0;
|
|
121
|
+
private flushTimer: NodeJS.Timeout | null = null;
|
|
122
|
+
private flushPromise: Promise<void> | null = null;
|
|
123
|
+
private captureFailure: WorkflowSessionCaptureFailure | null = null;
|
|
124
|
+
|
|
125
|
+
constructor(store: WorkflowRunStore, runDir: string, runId: string) {
|
|
126
|
+
this.store = store;
|
|
127
|
+
this.runDir = runDir;
|
|
128
|
+
this.runId = runId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async bind(ctx: ExtensionContext): Promise<void> {
|
|
132
|
+
if (this.bound) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.bound = true;
|
|
136
|
+
this.cursor = ctx.sessionManager.getLeafId();
|
|
137
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
138
|
+
// A bundle that already has a binding belongs to an earlier capture
|
|
139
|
+
// attempt; this recorder writes its own segment under session/segments.
|
|
140
|
+
if (await this.store.hasSessionBinding(this.runDir)) {
|
|
141
|
+
this.segmentId = randomUUID();
|
|
142
|
+
}
|
|
143
|
+
await this.store.writeSessionBinding(
|
|
144
|
+
this.runDir,
|
|
145
|
+
{
|
|
146
|
+
schema: SESSION_BINDING_SCHEMA,
|
|
147
|
+
runId: this.runId,
|
|
148
|
+
piSessionId: ctx.sessionManager.getSessionId(),
|
|
149
|
+
...(sessionFile !== undefined ? { piSessionFile: sessionFile } : {}),
|
|
150
|
+
cwd: ctx.cwd,
|
|
151
|
+
boundAt: new Date().toISOString(),
|
|
152
|
+
},
|
|
153
|
+
this.segmentId,
|
|
154
|
+
);
|
|
155
|
+
await this.store.writeSessionCapture(
|
|
156
|
+
this.runDir,
|
|
157
|
+
{
|
|
158
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
159
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
160
|
+
status: "recording",
|
|
161
|
+
eventCount: 0,
|
|
162
|
+
entryCount: 0,
|
|
163
|
+
lastEventSeq: 0,
|
|
164
|
+
},
|
|
165
|
+
this.segmentId,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Fix the owner before the executor delivers an agent-step prompt. */
|
|
170
|
+
beginAttempt(contract: AgentStepContract): void {
|
|
171
|
+
this.currentAttempt = { nodeId: contract.nodeId, attemptId: contract.attemptId };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Release ownership of the turn that Pi has fully settled. */
|
|
175
|
+
settleAttempt(): void {
|
|
176
|
+
const finished = this.lastFinishedAttempt;
|
|
177
|
+
this.lastFinishedAttempt = null;
|
|
178
|
+
if (
|
|
179
|
+
this.currentTurn === null &&
|
|
180
|
+
(finished === null ||
|
|
181
|
+
(this.currentAttempt?.nodeId === finished.nodeId &&
|
|
182
|
+
this.currentAttempt.attemptId === finished.attemptId))
|
|
183
|
+
) {
|
|
184
|
+
this.currentAttempt = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
handleTurnStart(event: TurnStartEventLike): void {
|
|
189
|
+
const owner = this.currentAttempt;
|
|
190
|
+
if (!owner) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const turn: TurnOwner = {
|
|
194
|
+
...owner,
|
|
195
|
+
turnId: `t${this.nextTurnId}`,
|
|
196
|
+
turnIndex: event.turnIndex,
|
|
197
|
+
};
|
|
198
|
+
this.nextTurnId += 1;
|
|
199
|
+
this.currentTurn = turn;
|
|
200
|
+
this.turnToolCallIds.set(turn.turnId, []);
|
|
201
|
+
this.enqueue(turn, "turn_started", { turnIndex: event.turnIndex });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async handleTurnEnd(event: TurnEndEventLike, ctx: ExtensionContext): Promise<void> {
|
|
205
|
+
const turn = this.currentTurn;
|
|
206
|
+
if (!turn) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const message = this.ownerForMessage(event.message);
|
|
210
|
+
try {
|
|
211
|
+
await this.synchronize(ctx);
|
|
212
|
+
this.enqueue(
|
|
213
|
+
turn,
|
|
214
|
+
"turn_finished",
|
|
215
|
+
turnFinishedPayload(event, message?.messageId, this.turnToolCallIds.get(turn.turnId) ?? []),
|
|
216
|
+
);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.failCapture("entry_write_failed", failureMessage(error));
|
|
219
|
+
}
|
|
220
|
+
this.currentTurn = null;
|
|
221
|
+
this.currentMessage = null;
|
|
222
|
+
this.lastFinishedAttempt = { nodeId: turn.nodeId, attemptId: turn.attemptId };
|
|
223
|
+
if (this.finishPromise) {
|
|
224
|
+
await this.stop();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async handleMessageStart(event: MessageStartEventLike, ctx: ExtensionContext): Promise<void> {
|
|
229
|
+
const turn = this.currentTurn;
|
|
230
|
+
if (!turn) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await this.synchronize(ctx);
|
|
234
|
+
const owner: MessageOwner = {
|
|
235
|
+
...turn,
|
|
236
|
+
messageId: `m${this.nextMessageId}`,
|
|
237
|
+
role: messageRole(event.message),
|
|
238
|
+
};
|
|
239
|
+
this.nextMessageId += 1;
|
|
240
|
+
const key = objectKey(event.message);
|
|
241
|
+
if (key) {
|
|
242
|
+
this.messageOwners.set(key, owner);
|
|
243
|
+
}
|
|
244
|
+
const stableKey = stableMessageKey(event.message);
|
|
245
|
+
if (stableKey) {
|
|
246
|
+
this.stableMessageOwners.set(stableKey, owner);
|
|
247
|
+
}
|
|
248
|
+
this.currentMessage = owner;
|
|
249
|
+
this.enqueue(owner, "message_started", { role: owner.role });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
handleMessageUpdate(event: MessageUpdateEventLike): void {
|
|
253
|
+
const owner = this.ownerForMessage(event.message);
|
|
254
|
+
if (!owner) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const normalized = normalizeAssistantEvent(event.assistantMessageEvent);
|
|
258
|
+
const toolCallId = toolCallIdFromAssistantEvent(normalized);
|
|
259
|
+
if (toolCallId) {
|
|
260
|
+
this.toolOwners.set(toolCallId, owner);
|
|
261
|
+
const ids = this.turnToolCallIds.get(owner.turnId) ?? [];
|
|
262
|
+
if (!ids.includes(toolCallId)) {
|
|
263
|
+
ids.push(toolCallId);
|
|
264
|
+
this.turnToolCallIds.set(owner.turnId, ids);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.enqueue(owner, "assistant_event", normalized as unknown as Record<string, unknown>);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
handleMessageEnd(event: MessageEndEventLike): void {
|
|
271
|
+
const owner = this.ownerForMessage(event.message);
|
|
272
|
+
if (!owner) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Pi appends the final session entry after message_end handlers return.
|
|
276
|
+
// Keep this record and every later event queued until a documented hook
|
|
277
|
+
// with synchronized session state can attach the exact entry id.
|
|
278
|
+
this.clearFlushTimer();
|
|
279
|
+
const queued = this.enqueue(
|
|
280
|
+
owner,
|
|
281
|
+
"message_finished",
|
|
282
|
+
{ role: owner.role, settled: false },
|
|
283
|
+
undefined,
|
|
284
|
+
{},
|
|
285
|
+
true,
|
|
286
|
+
);
|
|
287
|
+
if (queued) {
|
|
288
|
+
this.pendingMessageEnds.push({ queued, role: owner.role, message: event.message });
|
|
289
|
+
this.clearFlushTimer();
|
|
290
|
+
}
|
|
291
|
+
if (this.currentMessage?.messageId === owner.messageId) {
|
|
292
|
+
this.currentMessage = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
handleToolStart(event: ToolExecutionStartEventLike): void {
|
|
297
|
+
const owner = this.toolOwners.get(event.toolCallId) ?? this.currentMessage;
|
|
298
|
+
if (!owner) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.toolOwners.set(event.toolCallId, owner);
|
|
302
|
+
this.enqueue(owner, "tool_execution_started", toolStartedPayload(event), undefined, {
|
|
303
|
+
toolCallId: event.toolCallId,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
handleToolUpdate(event: ToolExecutionUpdateEventLike): void {
|
|
308
|
+
const owner = this.toolOwners.get(event.toolCallId);
|
|
309
|
+
if (!owner) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.enqueue(owner, "tool_execution_updated", {}, undefined, {
|
|
313
|
+
toolCallId: event.toolCallId,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
handleToolEnd(event: ToolExecutionEndEventLike): void {
|
|
318
|
+
const owner = this.toolOwners.get(event.toolCallId);
|
|
319
|
+
if (!owner) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.enqueue(owner, "tool_execution_finished", toolFinishedPayload(event), undefined, {
|
|
323
|
+
toolCallId: event.toolCallId,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Flush new entries on the current branch into the bundle. */
|
|
328
|
+
record(ctx: ExtensionContext): Promise<RecordedEntry[]> {
|
|
329
|
+
if (!this.bound || !this.acceptingEntries) {
|
|
330
|
+
return Promise.resolve([]);
|
|
331
|
+
}
|
|
332
|
+
const task = this.entryChain.then(async () => {
|
|
333
|
+
const branch = ctx.sessionManager.getBranch() as unknown as Array<
|
|
334
|
+
Record<string, unknown> & { id: string }
|
|
335
|
+
>;
|
|
336
|
+
let startIndex = 0;
|
|
337
|
+
if (this.cursor !== null) {
|
|
338
|
+
const cursorIndex = branch.findIndex((entry) => entry.id === this.cursor);
|
|
339
|
+
if (cursorIndex === -1) {
|
|
340
|
+
this.cursor = branch.at(-1)?.id ?? this.cursor;
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
startIndex = cursorIndex + 1;
|
|
344
|
+
}
|
|
345
|
+
const appended: RecordedEntry[] = [];
|
|
346
|
+
for (const entry of branch.slice(startIndex)) {
|
|
347
|
+
await this.store.appendSessionEntry(this.runDir, entry, this.segmentId);
|
|
348
|
+
const recorded = { id: entry.id, entry, claimed: false };
|
|
349
|
+
appended.push(recorded);
|
|
350
|
+
this.unclaimedEntries.push(recorded);
|
|
351
|
+
this.recorded.push(entry.id);
|
|
352
|
+
this.cursor = entry.id;
|
|
353
|
+
}
|
|
354
|
+
return appended;
|
|
355
|
+
});
|
|
356
|
+
this.entryChain = task.catch((error: unknown) => {
|
|
357
|
+
this.failCapture("entry_write_failed", failureMessage(error));
|
|
358
|
+
});
|
|
359
|
+
return task;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Record durable entries, then release deferred message-end events in order. */
|
|
363
|
+
async synchronize(ctx: ExtensionContext): Promise<void> {
|
|
364
|
+
if (this.pendingMessageEnds.length === 0) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
await this.record(ctx);
|
|
368
|
+
for (const pending of this.pendingMessageEnds.splice(0)) {
|
|
369
|
+
const entryId = this.claimEntry(pending.role, pending.message);
|
|
370
|
+
pending.queued.record.payload = entryId
|
|
371
|
+
? { role: pending.role, settled: true, entryId }
|
|
372
|
+
: { role: pending.role, settled: false };
|
|
373
|
+
const bytes = Buffer.byteLength(JSON.stringify(pending.queued.record), "utf8") + 1;
|
|
374
|
+
this.outstandingBytes += bytes - pending.queued.bytes;
|
|
375
|
+
pending.queued.bytes = bytes;
|
|
376
|
+
}
|
|
377
|
+
if (this.eventQueue.length >= FLUSH_MAX_RECORDS || this.queuedBytes() >= FLUSH_MAX_BYTES) {
|
|
378
|
+
this.startFlush();
|
|
379
|
+
} else {
|
|
380
|
+
this.scheduleFlush();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Finalize immediately when no Pi turn is active. Otherwise wait for
|
|
386
|
+
* `turn_end`, which is the last documented lifecycle boundary needed to
|
|
387
|
+
* include the workflow tool result and any following assistant output.
|
|
388
|
+
*/
|
|
389
|
+
async finish(): Promise<void> {
|
|
390
|
+
if (this.stopPromise || this.currentTurn === null) {
|
|
391
|
+
return await this.stop();
|
|
392
|
+
}
|
|
393
|
+
if (!this.finishPromise) {
|
|
394
|
+
this.finishPromise = new Promise<void>((resolve) => {
|
|
395
|
+
this.resolveFinish = resolve;
|
|
396
|
+
});
|
|
397
|
+
this.finishTimer = setTimeout(() => {
|
|
398
|
+
this.failCapture(
|
|
399
|
+
"turn_settle_timeout",
|
|
400
|
+
`active Pi turn did not finish within ${FINISH_TURN_TIMEOUT_MS}ms`,
|
|
401
|
+
);
|
|
402
|
+
void this.stop();
|
|
403
|
+
}, FINISH_TURN_TIMEOUT_MS);
|
|
404
|
+
this.finishTimer.unref?.();
|
|
405
|
+
}
|
|
406
|
+
return await this.finishPromise;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async stop(): Promise<void> {
|
|
410
|
+
if (this.stopPromise) {
|
|
411
|
+
return await this.stopPromise;
|
|
412
|
+
}
|
|
413
|
+
if (this.currentTurn !== null) {
|
|
414
|
+
this.failCapture(
|
|
415
|
+
"turn_interrupted",
|
|
416
|
+
"session capture stopped before the active Pi turn finished",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
this.acceptingEntries = false;
|
|
420
|
+
this.acceptingEvents = false;
|
|
421
|
+
// If Pi closes before another synchronized hook, preserve the message-end
|
|
422
|
+
// receipts as explicitly unsettled rather than hanging finalization.
|
|
423
|
+
this.pendingMessageEnds.splice(0);
|
|
424
|
+
this.clearFlushTimer();
|
|
425
|
+
this.stopPromise = (async () => {
|
|
426
|
+
try {
|
|
427
|
+
await this.flushAllEvents();
|
|
428
|
+
await this.entryChain;
|
|
429
|
+
const counts = await this.store.sessionCounts(this.runDir, this.segmentId);
|
|
430
|
+
await this.store.writeSessionCapture(
|
|
431
|
+
this.runDir,
|
|
432
|
+
{
|
|
433
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
434
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
435
|
+
status: this.captureFailure ? "failed" : "complete",
|
|
436
|
+
...counts,
|
|
437
|
+
...(this.captureFailure ? { failure: this.captureFailure } : {}),
|
|
438
|
+
},
|
|
439
|
+
this.segmentId,
|
|
440
|
+
);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
// Capture is observational. A finalization failure must never reject
|
|
443
|
+
// the workflow's terminal persistence hook.
|
|
444
|
+
this.failCapture("capture_finalize_failed", failureMessage(error));
|
|
445
|
+
try {
|
|
446
|
+
const counts = await this.store.sessionCounts(this.runDir, this.segmentId);
|
|
447
|
+
await this.store.writeSessionCapture(
|
|
448
|
+
this.runDir,
|
|
449
|
+
{
|
|
450
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
451
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
452
|
+
status: "failed",
|
|
453
|
+
...counts,
|
|
454
|
+
failure: this.captureFailure ?? {
|
|
455
|
+
failedAt: new Date().toISOString(),
|
|
456
|
+
code: "capture_finalize_failed",
|
|
457
|
+
message: failureMessage(error),
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
this.segmentId,
|
|
461
|
+
);
|
|
462
|
+
} catch {
|
|
463
|
+
// The viewer will report the missing/invalid capture file.
|
|
464
|
+
}
|
|
465
|
+
} finally {
|
|
466
|
+
if (this.finishTimer) {
|
|
467
|
+
clearTimeout(this.finishTimer);
|
|
468
|
+
this.finishTimer = null;
|
|
469
|
+
}
|
|
470
|
+
this.resolveFinish?.();
|
|
471
|
+
this.resolveFinish = null;
|
|
472
|
+
}
|
|
473
|
+
})();
|
|
474
|
+
return await this.stopPromise;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
mark(): number {
|
|
478
|
+
return this.recorded.length;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
rangeSince(mark: number): ConversationRange | undefined {
|
|
482
|
+
if (this.recorded.length <= mark) {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
firstEntryId: this.recorded[mark] as string,
|
|
487
|
+
lastEntryId: this.recorded.at(-1) as string,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private ownerForMessage(message: unknown): MessageOwner | null {
|
|
492
|
+
const key = objectKey(message);
|
|
493
|
+
const stableKey = stableMessageKey(message);
|
|
494
|
+
return (
|
|
495
|
+
(key ? this.messageOwners.get(key) : undefined) ??
|
|
496
|
+
(stableKey ? this.stableMessageOwners.get(stableKey) : undefined) ??
|
|
497
|
+
this.currentMessage
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private claimEntry(role: string, message: unknown): string | undefined {
|
|
502
|
+
const messageKey = stableMessageKey(message);
|
|
503
|
+
const entry = this.unclaimedEntries.find((candidate) => {
|
|
504
|
+
if (candidate.claimed || entryRole(candidate.entry) !== role) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
return messageKey === null || stableMessageKey(candidate.entry.message) === messageKey;
|
|
508
|
+
});
|
|
509
|
+
if (!entry) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
entry.claimed = true;
|
|
513
|
+
return entry.id;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private enqueue(
|
|
517
|
+
owner: TurnOwner | MessageOwner,
|
|
518
|
+
type: WorkflowSessionEventType,
|
|
519
|
+
payload: Record<string, unknown>,
|
|
520
|
+
at: string = new Date().toISOString(),
|
|
521
|
+
extra: { toolCallId?: string } = {},
|
|
522
|
+
deferFlush = false,
|
|
523
|
+
): QueuedEvent | null {
|
|
524
|
+
if (!this.bound || !this.acceptingEvents || this.captureFailure) {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
const messageId = "messageId" in owner ? owner.messageId : undefined;
|
|
528
|
+
const record: WorkflowSessionEventRecord = {
|
|
529
|
+
seq: this.nextEventSeq,
|
|
530
|
+
at,
|
|
531
|
+
nodeId: owner.nodeId,
|
|
532
|
+
attemptId: owner.attemptId,
|
|
533
|
+
turnId: owner.turnId,
|
|
534
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
535
|
+
...(extra.toolCallId === undefined ? {} : { toolCallId: extra.toolCallId }),
|
|
536
|
+
type,
|
|
537
|
+
payload,
|
|
538
|
+
};
|
|
539
|
+
const bytes = Buffer.byteLength(JSON.stringify(record), "utf8") + 1;
|
|
540
|
+
const externalizable =
|
|
541
|
+
type === "tool_execution_started" ||
|
|
542
|
+
type === "tool_execution_finished" ||
|
|
543
|
+
(type === "assistant_event" && payload.type === "toolcall_end");
|
|
544
|
+
if (bytes > SESSION_EVENT_MAX_BYTES && !externalizable) {
|
|
545
|
+
this.failCapture(
|
|
546
|
+
"event_too_large",
|
|
547
|
+
`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`,
|
|
548
|
+
);
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
this.outstandingRecords + 1 > QUEUE_MAX_RECORDS ||
|
|
553
|
+
this.outstandingBytes + bytes > QUEUE_MAX_BYTES
|
|
554
|
+
) {
|
|
555
|
+
this.failCapture("event_queue_overflow", "session event queue limit exceeded");
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
this.nextEventSeq += 1;
|
|
559
|
+
const queued = { record, bytes };
|
|
560
|
+
this.eventQueue.push(queued);
|
|
561
|
+
this.outstandingRecords += 1;
|
|
562
|
+
this.outstandingBytes += bytes;
|
|
563
|
+
if (!deferFlush) {
|
|
564
|
+
if (this.eventQueue.length >= FLUSH_MAX_RECORDS || this.queuedBytes() >= FLUSH_MAX_BYTES) {
|
|
565
|
+
this.startFlush();
|
|
566
|
+
} else {
|
|
567
|
+
this.scheduleFlush();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return queued;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private queuedBytes(): number {
|
|
574
|
+
return this.eventQueue.reduce((sum, queued) => sum + queued.bytes, 0);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private scheduleFlush(): void {
|
|
578
|
+
if (this.pendingMessageEnds.length > 0 || this.flushTimer || this.flushPromise) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
this.flushTimer = setTimeout(() => {
|
|
582
|
+
this.flushTimer = null;
|
|
583
|
+
this.startFlush();
|
|
584
|
+
}, FLUSH_INTERVAL_MS);
|
|
585
|
+
this.flushTimer.unref?.();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private startFlush(): void {
|
|
589
|
+
if (this.pendingMessageEnds.length > 0 || this.flushPromise || this.eventQueue.length === 0) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.clearFlushTimer();
|
|
593
|
+
const batch: QueuedEvent[] = [];
|
|
594
|
+
let bytes = 0;
|
|
595
|
+
while (this.eventQueue.length > 0 && batch.length < FLUSH_MAX_RECORDS) {
|
|
596
|
+
const next = this.eventQueue[0] as QueuedEvent;
|
|
597
|
+
if (batch.length > 0 && bytes + next.bytes > FLUSH_MAX_BYTES) {
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
batch.push(this.eventQueue.shift() as QueuedEvent);
|
|
601
|
+
bytes += next.bytes;
|
|
602
|
+
}
|
|
603
|
+
this.flushPromise = this.store
|
|
604
|
+
.appendSessionEventBatch(
|
|
605
|
+
this.runDir,
|
|
606
|
+
batch.map((queued) => queued.record),
|
|
607
|
+
this.segmentId,
|
|
608
|
+
)
|
|
609
|
+
.catch((error: unknown) => {
|
|
610
|
+
const message = failureMessage(error);
|
|
611
|
+
this.failCapture(
|
|
612
|
+
message.includes(`exceeded ${SESSION_EVENT_MAX_BYTES} bytes`)
|
|
613
|
+
? "event_too_large"
|
|
614
|
+
: "event_write_failed",
|
|
615
|
+
message,
|
|
616
|
+
);
|
|
617
|
+
for (const queued of this.eventQueue.splice(0)) {
|
|
618
|
+
this.outstandingRecords -= 1;
|
|
619
|
+
this.outstandingBytes -= queued.bytes;
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
.finally(() => {
|
|
623
|
+
for (const queued of batch) {
|
|
624
|
+
this.outstandingRecords -= 1;
|
|
625
|
+
this.outstandingBytes -= queued.bytes;
|
|
626
|
+
}
|
|
627
|
+
this.flushPromise = null;
|
|
628
|
+
if (this.eventQueue.length > 0 && this.captureFailure?.code !== "event_write_failed") {
|
|
629
|
+
this.startFlush();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private async flushAllEvents(): Promise<void> {
|
|
635
|
+
this.clearFlushTimer();
|
|
636
|
+
while (this.eventQueue.length > 0 || this.flushPromise) {
|
|
637
|
+
if (!this.flushPromise) {
|
|
638
|
+
this.startFlush();
|
|
639
|
+
}
|
|
640
|
+
await this.flushPromise;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private clearFlushTimer(): void {
|
|
645
|
+
if (this.flushTimer) {
|
|
646
|
+
clearTimeout(this.flushTimer);
|
|
647
|
+
this.flushTimer = null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private failCapture(code: string, message: string): void {
|
|
652
|
+
if (this.captureFailure) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
this.captureFailure = {
|
|
656
|
+
failedAt: new Date().toISOString(),
|
|
657
|
+
code,
|
|
658
|
+
message,
|
|
659
|
+
};
|
|
660
|
+
this.acceptingEvents = false;
|
|
661
|
+
}
|
|
662
|
+
}
|