@pontia/pi-client-plugin 0.2.0 → 0.2.1
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 +17 -25
- package/package.json +26 -19
- package/src/context.ts +0 -104
- package/src/control-socket.ts +456 -0
- package/src/discovery.ts +2 -65
- package/src/events.ts +99 -20
- package/src/index.ts +468 -166
- package/src/live-output.ts +339 -0
- package/src/managed-runtime.ts +5 -1
- package/src/pi-message.ts +13 -3
- package/src/profile.ts +14 -22
- package/src/reporter.ts +62 -32
- package/src/runtime-binding.ts +22 -46
- package/src/session.ts +0 -1
- package/src/values.ts +9 -0
- package/src/workspace.ts +8 -13
- package/src/internal-api.ts +0 -29
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { TurnContext } from "./context.js";
|
|
3
|
+
import { RpcError, type PiConnection } from "./control-socket.js";
|
|
4
|
+
import { asRecord, optionalString } from "./values.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BATCH_DELAY_MS = 75;
|
|
7
|
+
const RETRY_DELAY_MS = 500;
|
|
8
|
+
|
|
9
|
+
export type ManagedToolUse = {
|
|
10
|
+
tool_name: string;
|
|
11
|
+
input:
|
|
12
|
+
| { type: "read"; path: string; start_line?: number; end_line?: number }
|
|
13
|
+
| { type: "edit"; path: string; edits_count: number }
|
|
14
|
+
| { type: "write"; path: string }
|
|
15
|
+
| { type: "bash"; command: string; timeout?: number };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type LiveOutputItem =
|
|
19
|
+
| { kind: "assistant_text"; item_id: string; text: string }
|
|
20
|
+
| {
|
|
21
|
+
kind: "tool_call";
|
|
22
|
+
item_id: string;
|
|
23
|
+
call_id: string;
|
|
24
|
+
tool_name: string;
|
|
25
|
+
arguments: unknown;
|
|
26
|
+
managed_tool_use?: ManagedToolUse;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type LiveOutputUpdate =
|
|
30
|
+
| { type: "assistant_text_delta"; item_id: string; delta: string }
|
|
31
|
+
| {
|
|
32
|
+
type: "tool_call";
|
|
33
|
+
item_id: string;
|
|
34
|
+
call_id: string;
|
|
35
|
+
tool_name: string;
|
|
36
|
+
arguments: unknown;
|
|
37
|
+
managed_tool_use?: ManagedToolUse;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface CompleteToolCall {
|
|
41
|
+
callId: string;
|
|
42
|
+
toolName: string;
|
|
43
|
+
arguments: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LiveOutputPublisherLike {
|
|
47
|
+
appendText(delta: string): void;
|
|
48
|
+
appendToolCall(toolCall: CompleteToolCall): void;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PendingUpdate {
|
|
53
|
+
sequence: number;
|
|
54
|
+
update: LiveOutputUpdate;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface LiveOutputResponse {
|
|
58
|
+
accepted: boolean;
|
|
59
|
+
accepted_sequence: number;
|
|
60
|
+
resync_required: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LiveOutputPublisherOptions {
|
|
64
|
+
connection: Pick<PiConnection, "request">;
|
|
65
|
+
streamId?: string;
|
|
66
|
+
batchDelayMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
70
|
+
private readonly connection: Pick<PiConnection, "request">;
|
|
71
|
+
private readonly context: TurnContext & { turnId: string };
|
|
72
|
+
private readonly streamId: string;
|
|
73
|
+
private readonly batchDelayMs: number;
|
|
74
|
+
private readonly items: LiveOutputItem[] = [];
|
|
75
|
+
private readonly pending: PendingUpdate[] = [];
|
|
76
|
+
private sequence = 0;
|
|
77
|
+
private itemSequence = 0;
|
|
78
|
+
private needsSnapshot = true;
|
|
79
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
private flushing: Promise<boolean> | undefined;
|
|
82
|
+
private closing = false;
|
|
83
|
+
private disabled = false;
|
|
84
|
+
|
|
85
|
+
constructor(context: TurnContext & { turnId: string }, options: LiveOutputPublisherOptions) {
|
|
86
|
+
this.context = context;
|
|
87
|
+
this.connection = options.connection;
|
|
88
|
+
this.streamId = options.streamId ?? `stream_${randomUUID()}`;
|
|
89
|
+
this.batchDelayMs = options.batchDelayMs ?? DEFAULT_BATCH_DELAY_MS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
appendText(delta: string): void {
|
|
93
|
+
if (this.closing || this.disabled || delta.length === 0) return;
|
|
94
|
+
let item = this.items.at(-1);
|
|
95
|
+
if (item?.kind !== "assistant_text") {
|
|
96
|
+
item = { kind: "assistant_text", item_id: this.nextItemId("text"), text: "" };
|
|
97
|
+
this.items.push(item);
|
|
98
|
+
}
|
|
99
|
+
item.text += delta;
|
|
100
|
+
this.enqueue({ type: "assistant_text_delta", item_id: item.item_id, delta });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
appendToolCall(toolCall: CompleteToolCall): void {
|
|
104
|
+
if (
|
|
105
|
+
this.closing ||
|
|
106
|
+
this.disabled ||
|
|
107
|
+
this.items.some((item) => item.kind === "tool_call" && item.call_id === toolCall.callId)
|
|
108
|
+
)
|
|
109
|
+
return;
|
|
110
|
+
const managedToolUse = managedToolUseFor(toolCall);
|
|
111
|
+
const item: LiveOutputItem = {
|
|
112
|
+
kind: "tool_call",
|
|
113
|
+
item_id: this.nextItemId("tool"),
|
|
114
|
+
call_id: toolCall.callId,
|
|
115
|
+
tool_name: toolCall.toolName,
|
|
116
|
+
arguments: toolCall.arguments,
|
|
117
|
+
...(managedToolUse ? { managed_tool_use: managedToolUse } : {}),
|
|
118
|
+
};
|
|
119
|
+
this.items.push(item);
|
|
120
|
+
this.enqueue({
|
|
121
|
+
type: "tool_call",
|
|
122
|
+
item_id: item.item_id,
|
|
123
|
+
call_id: item.call_id,
|
|
124
|
+
tool_name: item.tool_name,
|
|
125
|
+
arguments: item.arguments,
|
|
126
|
+
...(item.managed_tool_use ? { managed_tool_use: item.managed_tool_use } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async close(): Promise<void> {
|
|
131
|
+
if (this.closing || this.disabled) return;
|
|
132
|
+
this.closing = true;
|
|
133
|
+
this.clearTimers();
|
|
134
|
+
if (this.flushing) await this.flushing;
|
|
135
|
+
const synchronized = await this.flushCurrent();
|
|
136
|
+
if (!synchronized || this.sequence === 0) return;
|
|
137
|
+
|
|
138
|
+
const closeSequence = this.sequence + 1;
|
|
139
|
+
const response = await this.publish({
|
|
140
|
+
...this.baseRequest(),
|
|
141
|
+
type: "stream_closed",
|
|
142
|
+
sequence: closeSequence,
|
|
143
|
+
});
|
|
144
|
+
if (response?.accepted) this.sequence = closeSequence;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private enqueue(update: LiveOutputUpdate): void {
|
|
148
|
+
this.sequence += 1;
|
|
149
|
+
this.pending.push({ sequence: this.sequence, update });
|
|
150
|
+
this.scheduleFlush(this.batchDelayMs);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private nextItemId(prefix: string): string {
|
|
154
|
+
this.itemSequence += 1;
|
|
155
|
+
return `${prefix}_${this.itemSequence}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private clearTimers(): void {
|
|
159
|
+
if (this.timer) clearTimeout(this.timer);
|
|
160
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
161
|
+
this.timer = undefined;
|
|
162
|
+
this.retryTimer = undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private scheduleFlush(delay: number): void {
|
|
166
|
+
if (this.closing || this.disabled || this.timer || this.retryTimer) return;
|
|
167
|
+
this.timer = setTimeout(() => {
|
|
168
|
+
this.timer = undefined;
|
|
169
|
+
void this.flush().then((synchronized) => {
|
|
170
|
+
if (!synchronized && !this.closing) this.scheduleRetry();
|
|
171
|
+
});
|
|
172
|
+
}, delay);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private scheduleRetry(): void {
|
|
176
|
+
if (this.closing || this.disabled || this.retryTimer) return;
|
|
177
|
+
this.retryTimer = setTimeout(() => {
|
|
178
|
+
this.retryTimer = undefined;
|
|
179
|
+
void this.flush().then((synchronized) => {
|
|
180
|
+
if (!synchronized && !this.closing) this.scheduleRetry();
|
|
181
|
+
});
|
|
182
|
+
}, RETRY_DELAY_MS);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async flush(): Promise<boolean> {
|
|
186
|
+
if (this.flushing) return this.flushing;
|
|
187
|
+
this.flushing = this.flushCurrent().finally(() => {
|
|
188
|
+
this.flushing = undefined;
|
|
189
|
+
});
|
|
190
|
+
return this.flushing;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async flushCurrent(): Promise<boolean> {
|
|
194
|
+
if (this.sequence === 0) return true;
|
|
195
|
+
const targetSequence = this.sequence;
|
|
196
|
+
const response = this.needsSnapshot
|
|
197
|
+
? await this.publish({
|
|
198
|
+
...this.baseRequest(),
|
|
199
|
+
type: "snapshot",
|
|
200
|
+
sequence: targetSequence,
|
|
201
|
+
items: this.items,
|
|
202
|
+
})
|
|
203
|
+
: await this.publishAppend(targetSequence);
|
|
204
|
+
|
|
205
|
+
if (!response?.accepted) {
|
|
206
|
+
this.needsSnapshot = true;
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const firstUnaccepted = this.pending.findIndex(
|
|
211
|
+
(entry) => entry.sequence > response.accepted_sequence,
|
|
212
|
+
);
|
|
213
|
+
this.pending.splice(0, firstUnaccepted === -1 ? this.pending.length : firstUnaccepted);
|
|
214
|
+
this.needsSnapshot = response.resync_required;
|
|
215
|
+
if (this.pending.length > 0 && !this.closing) this.scheduleFlush(this.batchDelayMs);
|
|
216
|
+
return !this.needsSnapshot;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async publishAppend(targetSequence: number): Promise<LiveOutputResponse | undefined> {
|
|
220
|
+
const updates = this.pending.filter((entry) => entry.sequence <= targetSequence);
|
|
221
|
+
if (updates.length === 0)
|
|
222
|
+
return {
|
|
223
|
+
accepted: true,
|
|
224
|
+
accepted_sequence: targetSequence,
|
|
225
|
+
resync_required: false,
|
|
226
|
+
};
|
|
227
|
+
return this.publish({
|
|
228
|
+
...this.baseRequest(),
|
|
229
|
+
type: "append",
|
|
230
|
+
first_sequence: updates[0].sequence,
|
|
231
|
+
updates: updates.map((entry) => entry.update),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private baseRequest() {
|
|
236
|
+
return {
|
|
237
|
+
session_id: this.context.sessionId,
|
|
238
|
+
turn_id: this.context.turnId,
|
|
239
|
+
runtime_instance_id: this.context.runtimeInstanceId,
|
|
240
|
+
stream_id: this.streamId,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async publish(body: Record<string, unknown>): Promise<LiveOutputResponse | undefined> {
|
|
245
|
+
try {
|
|
246
|
+
// Freeze the batch before a reconnect can delay its serialization.
|
|
247
|
+
const response = await this.connection.request("liveOutput.publish", structuredClone(body));
|
|
248
|
+
const parsed = asRecord(response);
|
|
249
|
+
const acceptedSequence = parsed?.accepted_sequence;
|
|
250
|
+
const accepted = parsed?.accepted;
|
|
251
|
+
const resyncRequired = parsed?.resync_required;
|
|
252
|
+
if (
|
|
253
|
+
typeof acceptedSequence !== "number" ||
|
|
254
|
+
!Number.isSafeInteger(acceptedSequence) ||
|
|
255
|
+
acceptedSequence < 0 ||
|
|
256
|
+
typeof accepted !== "boolean" ||
|
|
257
|
+
typeof resyncRequired !== "boolean"
|
|
258
|
+
) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
accepted,
|
|
263
|
+
accepted_sequence: acceptedSequence,
|
|
264
|
+
resync_required: resyncRequired,
|
|
265
|
+
};
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof RpcError && error.code !== -32603) this.disabled = true;
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function managedToolUseFor(toolCall: CompleteToolCall): ManagedToolUse | undefined {
|
|
274
|
+
const input = asRecord(toolCall.arguments);
|
|
275
|
+
if (!input) return undefined;
|
|
276
|
+
|
|
277
|
+
switch (toolCall.toolName) {
|
|
278
|
+
case "read": {
|
|
279
|
+
const path = optionalString(input.path);
|
|
280
|
+
if (!path) return undefined;
|
|
281
|
+
const startLine = optionalNonNegativeInteger(input.start_line);
|
|
282
|
+
const endLine = optionalNonNegativeInteger(input.end_line);
|
|
283
|
+
return {
|
|
284
|
+
tool_name: toolCall.toolName,
|
|
285
|
+
input: {
|
|
286
|
+
type: "read",
|
|
287
|
+
path,
|
|
288
|
+
...(startLine !== undefined ? { start_line: startLine } : {}),
|
|
289
|
+
...(endLine !== undefined ? { end_line: endLine } : {}),
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
case "edit": {
|
|
294
|
+
const path = optionalString(input.path);
|
|
295
|
+
if (!path || !Array.isArray(input.edits)) return undefined;
|
|
296
|
+
return {
|
|
297
|
+
tool_name: toolCall.toolName,
|
|
298
|
+
input: {
|
|
299
|
+
type: "edit",
|
|
300
|
+
path,
|
|
301
|
+
edits_count: input.edits.length,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
case "write": {
|
|
306
|
+
const path = optionalString(input.path);
|
|
307
|
+
return path ? { tool_name: toolCall.toolName, input: { type: "write", path } } : undefined;
|
|
308
|
+
}
|
|
309
|
+
case "bash": {
|
|
310
|
+
const command = optionalString(input.command);
|
|
311
|
+
if (!command) return undefined;
|
|
312
|
+
const timeout = optionalNonNegativeInteger(input.timeout);
|
|
313
|
+
return {
|
|
314
|
+
tool_name: toolCall.toolName,
|
|
315
|
+
input: {
|
|
316
|
+
type: "bash",
|
|
317
|
+
command,
|
|
318
|
+
...(timeout !== undefined ? { timeout } : {}),
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
default:
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function optionalNonNegativeInteger(value: unknown): number | undefined {
|
|
328
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function completeToolCallFromMessageUpdate(event: unknown): CompleteToolCall | undefined {
|
|
332
|
+
const streamEvent = asRecord(asRecord(event)?.assistantMessageEvent);
|
|
333
|
+
if (streamEvent?.type !== "toolcall_end") return undefined;
|
|
334
|
+
const toolCall = asRecord(streamEvent.toolCall);
|
|
335
|
+
const callId = optionalString(toolCall?.id);
|
|
336
|
+
const toolName = optionalString(toolCall?.name);
|
|
337
|
+
if (!callId || !toolName || !("arguments" in (toolCall ?? {}))) return undefined;
|
|
338
|
+
return { callId, toolName, arguments: toolCall!.arguments };
|
|
339
|
+
}
|
package/src/managed-runtime.ts
CHANGED
|
@@ -14,7 +14,11 @@ export function hasTmuxPaneEnvironment(env: EnvLike = process.env): boolean {
|
|
|
14
14
|
return Boolean(socketPath && env.TMUX_PANE?.trim());
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
async function paneOption(
|
|
17
|
+
async function paneOption(
|
|
18
|
+
socketPath: string,
|
|
19
|
+
paneId: string,
|
|
20
|
+
option: string,
|
|
21
|
+
): Promise<string | undefined> {
|
|
18
22
|
try {
|
|
19
23
|
const { stdout } = await execFileAsync("tmux", [
|
|
20
24
|
"-S",
|
package/src/pi-message.ts
CHANGED
|
@@ -26,6 +26,8 @@ export function assistantDeltaFromEvent(event: unknown): string | undefined {
|
|
|
26
26
|
const streamEvent = record.assistantMessageEvent;
|
|
27
27
|
if (streamEvent && typeof streamEvent === "object") {
|
|
28
28
|
const streamRecord = streamEvent as Record<string, unknown>;
|
|
29
|
+
if (typeof streamRecord.type === "string" && streamRecord.type !== "text_delta")
|
|
30
|
+
return undefined;
|
|
29
31
|
for (const key of ["text_delta", "textDelta", "delta", "text"] as const) {
|
|
30
32
|
const value = streamRecord[key];
|
|
31
33
|
if (typeof value === "string" && value.length > 0) return value;
|
|
@@ -55,10 +57,16 @@ interface PiAgentEndEventLike {
|
|
|
55
57
|
messages: unknown[];
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
function lastAssistantMessageFromAgentEnd(
|
|
60
|
+
function lastAssistantMessageFromAgentEnd(
|
|
61
|
+
event: PiAgentEndEventLike,
|
|
62
|
+
): Record<string, unknown> | undefined {
|
|
59
63
|
for (let index = event.messages.length - 1; index >= 0; index -= 1) {
|
|
60
64
|
const message = event.messages[index];
|
|
61
|
-
if (
|
|
65
|
+
if (
|
|
66
|
+
message &&
|
|
67
|
+
typeof message === "object" &&
|
|
68
|
+
(message as Record<string, unknown>).role === "assistant"
|
|
69
|
+
) {
|
|
62
70
|
return message as Record<string, unknown>;
|
|
63
71
|
}
|
|
64
72
|
}
|
|
@@ -66,7 +74,9 @@ function lastAssistantMessageFromAgentEnd(event: PiAgentEndEventLike): Record<st
|
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
export function agentEndWasInterrupted(event: PiAgentEndEventLike, signal?: AbortSignal): boolean {
|
|
69
|
-
return
|
|
77
|
+
return (
|
|
78
|
+
signal?.aborted === true || lastAssistantMessageFromAgentEnd(event)?.stopReason === "aborted"
|
|
79
|
+
);
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
export function errorMessageFromAgentEnd(event: PiAgentEndEventLike): string | undefined {
|
package/src/profile.ts
CHANGED
|
@@ -1,29 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { PiConnection } from "./control-socket.js";
|
|
2
|
+
import { asRecord, optionalString } from "./values.js";
|
|
3
3
|
|
|
4
4
|
export async function loadProfileSystemPrompt(
|
|
5
|
-
|
|
6
|
-
fetchImpl: typeof fetch,
|
|
5
|
+
connection: Pick<PiConnection, "request">,
|
|
7
6
|
sessionId?: string,
|
|
8
7
|
): Promise<string | undefined> {
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const sessionBody = await fetchJson(fetchImpl, `${baseUrl}/sessions/${encodeURIComponent(sessionId)}`, token);
|
|
15
|
-
const session = responseDataRecord(sessionBody)?.session;
|
|
16
|
-
if (!session || typeof session !== "object" || Array.isArray(session)) return undefined;
|
|
17
|
-
const sessionRecord = session as Record<string, unknown>;
|
|
18
|
-
const profileId = optionalString(sessionRecord.execution_profile_id);
|
|
19
|
-
const profileVersion = optionalString(sessionRecord.execution_profile_version);
|
|
8
|
+
if (!sessionId) return undefined;
|
|
9
|
+
const sessionBody = await connection.request("session.get", { session_id: sessionId });
|
|
10
|
+
const session = asRecord(asRecord(sessionBody)?.session);
|
|
11
|
+
const profileId = optionalString(session?.execution_profile_id);
|
|
12
|
+
const profileVersion = optionalString(session?.execution_profile_version);
|
|
20
13
|
if (!profileId) return undefined;
|
|
21
14
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
:
|
|
25
|
-
|
|
26
|
-
const profile =
|
|
27
|
-
|
|
28
|
-
return optionalString((profile as Record<string, unknown>).system_prompt_template);
|
|
15
|
+
const profileBody = await connection.request("profile.get", {
|
|
16
|
+
profile_id: profileId,
|
|
17
|
+
...(profileVersion ? { version: profileVersion } : {}),
|
|
18
|
+
});
|
|
19
|
+
const profile = asRecord(asRecord(profileBody)?.agent_profile);
|
|
20
|
+
return optionalString(profile?.system_prompt_template);
|
|
29
21
|
}
|
package/src/reporter.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { appendDiagnostic } from "./diagnostics.js";
|
|
2
|
+
import { RpcError, type PiConnection } from "./control-socket.js";
|
|
2
3
|
import type { InternalEvent } from "./events.js";
|
|
3
4
|
|
|
4
|
-
type FetchLike = typeof fetch;
|
|
5
|
-
|
|
6
5
|
export interface EventReportResult {
|
|
7
6
|
accepted: boolean;
|
|
8
7
|
eventId?: string;
|
|
@@ -10,58 +9,89 @@ export interface EventReportResult {
|
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
export interface EventReporterOptions {
|
|
13
|
-
|
|
12
|
+
connection: Pick<PiConnection, "request">;
|
|
14
13
|
logFile: string;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export class EventReporter {
|
|
18
|
-
private readonly
|
|
17
|
+
private readonly connection: Pick<PiConnection, "request">;
|
|
19
18
|
private readonly logFile: string;
|
|
20
19
|
|
|
21
20
|
constructor(options: EventReporterOptions) {
|
|
22
|
-
this.
|
|
21
|
+
this.connection = options.connection;
|
|
23
22
|
this.logFile = options.logFile;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
async report(
|
|
25
|
+
async report(
|
|
26
|
+
context: { runtimeInstanceId: string },
|
|
27
|
+
event: InternalEvent,
|
|
28
|
+
): Promise<EventReportResult> {
|
|
27
29
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
body: JSON.stringify(event),
|
|
30
|
+
const body = await this.connection.request("event.report", {
|
|
31
|
+
runtime_instance_id: context.runtimeInstanceId,
|
|
32
|
+
event,
|
|
32
33
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
details: { event_type: event.type, status: response.status, body },
|
|
41
|
-
});
|
|
34
|
+
const record =
|
|
35
|
+
body && typeof body === "object" ? (body as Record<string, unknown>) : undefined;
|
|
36
|
+
if (
|
|
37
|
+
event.type === "turn.started" &&
|
|
38
|
+
(record?.accepted !== true || typeof record?.turn_id !== "string" || !record.turn_id)
|
|
39
|
+
) {
|
|
40
|
+
await this.reportStartFailure(event, "missing_turn_id");
|
|
42
41
|
return { accepted: false };
|
|
43
42
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
responseBody = body ? JSON.parse(body) : null;
|
|
48
|
-
} catch {
|
|
49
|
-
responseBody = null;
|
|
50
|
-
}
|
|
51
|
-
const record = responseBody && typeof responseBody === "object" ? responseBody as Record<string, unknown> : undefined;
|
|
43
|
+
if (record?.accepted !== true)
|
|
44
|
+
throw new Error("Pi event RPC returned an invalid acknowledgement");
|
|
52
45
|
return {
|
|
53
46
|
accepted: true,
|
|
54
|
-
eventId: typeof record
|
|
55
|
-
turnId: typeof record
|
|
47
|
+
eventId: typeof record.event_id === "string" ? record.event_id : undefined,
|
|
48
|
+
turnId: typeof record.turn_id === "string" ? record.turn_id : undefined,
|
|
56
49
|
};
|
|
57
50
|
} catch (error) {
|
|
58
51
|
await appendDiagnostic(this.logFile, {
|
|
59
52
|
level: "error",
|
|
60
|
-
code: "
|
|
61
|
-
message: `
|
|
62
|
-
details: {
|
|
53
|
+
code: "pi_event_report_failed",
|
|
54
|
+
message: `Pi event RPC failed for ${event.type}`,
|
|
55
|
+
details: {
|
|
56
|
+
event_type: event.type,
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
},
|
|
63
59
|
});
|
|
60
|
+
await this.reportStartFailure(
|
|
61
|
+
event,
|
|
62
|
+
error instanceof RpcError ? "event_rejected" : "transport_failed",
|
|
63
|
+
);
|
|
64
64
|
return { accepted: false };
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
private async reportStartFailure(
|
|
69
|
+
event: InternalEvent,
|
|
70
|
+
reason: "event_rejected" | "transport_failed" | "missing_turn_id",
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (event.type !== "turn.started" || typeof event.data.runtime_instance_id !== "string") return;
|
|
73
|
+
// A lost turn.started response is ambiguous: retrying it could create a
|
|
74
|
+
// second Turn. Only this fenced, idempotent failure notification is retried.
|
|
75
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
76
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
|
77
|
+
try {
|
|
78
|
+
const result = (await this.connection.request("turn.startFailure", {
|
|
79
|
+
session_id: event.session_id,
|
|
80
|
+
runtime_instance_id: event.data.runtime_instance_id,
|
|
81
|
+
reason,
|
|
82
|
+
})) as { accepted?: unknown } | null;
|
|
83
|
+
if (result?.accepted === true) return;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof RpcError && error.code !== -32603) break;
|
|
86
|
+
// Exhaustion is visible locally; an unreachable server cannot persist
|
|
87
|
+
// a state transition. Never claim that this notification succeeded.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await appendDiagnostic(this.logFile, {
|
|
91
|
+
level: "error",
|
|
92
|
+
code: "turn_start_failure_report_failed",
|
|
93
|
+
message: "could not persist turn.started reporting failure; workflow state is unconfirmed",
|
|
94
|
+
details: { session_id: event.session_id, reason },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
67
97
|
}
|