@signalridge/pi-goal 0.49.7

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,355 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { validateObjective } from "./command.js";
3
+ import type { GoalCommandController } from "./commands.js";
4
+ import {
5
+ formatError,
6
+ type GoalRuntime,
7
+ type GoalStateSnapshot,
8
+ isTerminalGoalStatus,
9
+ type StatusContext,
10
+ } from "./runtime.js";
11
+
12
+ export const GOAL_RUN_START_CHANNEL = "pi-goal:start";
13
+ export const GOAL_RUN_CANCEL_CHANNEL = "pi-goal:cancel";
14
+
15
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
16
+ const MAX_CANCEL_REASON_LENGTH = 1_000;
17
+
18
+ export type GoalRunStatus = Exclude<GoalStateSnapshot["status"], "queued">;
19
+ export type GoalRunErrorCode =
20
+ | "RPC_DISABLED"
21
+ | "INVALID_REQUEST"
22
+ | "NO_ACTIVE_SESSION"
23
+ | "RUN_ID_IN_USE"
24
+ | "RUN_NOT_FOUND"
25
+ | "GOAL_ALREADY_EXISTS"
26
+ | "ACTIVATION_FAILED"
27
+ | "SUPERSEDED";
28
+
29
+ export type GoalRunEvent =
30
+ | {
31
+ type: "state";
32
+ runId: string;
33
+ goalId: string;
34
+ status: GoalRunStatus;
35
+ summary?: string;
36
+ reason?: string;
37
+ }
38
+ | {
39
+ type: "error";
40
+ runId: string;
41
+ operation: "start" | "cancel";
42
+ error: { code: GoalRunErrorCode; message: string };
43
+ };
44
+
45
+ interface GoalRunStartPayload {
46
+ runId: string;
47
+ objective: string;
48
+ tokenBudget?: number;
49
+ }
50
+
51
+ interface BoundSession {
52
+ generation: number;
53
+ ctx: StatusContext;
54
+ }
55
+
56
+ interface ManagedRun {
57
+ runId: string;
58
+ generation: number;
59
+ goalId?: string;
60
+ lastStatus?: GoalRunStatus;
61
+ closed: boolean;
62
+ cancelRequested: boolean;
63
+ cancelReason?: string;
64
+ }
65
+
66
+ export function goalRunEventChannel(runId: string) {
67
+ return `pi-goal:event:${runId}`;
68
+ }
69
+
70
+ function isPayloadRecord(data: unknown): data is object {
71
+ if (!data || typeof data !== "object") return false;
72
+ try {
73
+ return !Array.isArray(data);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function readPayloadProperty(
80
+ data: object,
81
+ key: "runId" | "objective" | "tokenBudget" | "reason",
82
+ ): { ok: true; value: unknown } | { ok: false } {
83
+ try {
84
+ return { ok: true, value: Reflect.get(data, key) };
85
+ } catch {
86
+ return { ok: false };
87
+ }
88
+ }
89
+
90
+ function parseRunId(data: unknown): string | undefined {
91
+ if (!isPayloadRecord(data)) return undefined;
92
+ const runId = readPayloadProperty(data, "runId");
93
+ return runId.ok && typeof runId.value === "string" && RUN_ID_PATTERN.test(runId.value) ? runId.value : undefined;
94
+ }
95
+
96
+ function currentActiveGoal(runtime: GoalRuntime) {
97
+ return runtime.activeGoal;
98
+ }
99
+
100
+ function parseStartPayload(data: unknown): string | Omit<GoalRunStartPayload, "runId"> {
101
+ if (!isPayloadRecord(data)) return "start payload must be an object";
102
+ const objectiveValue = readPayloadProperty(data, "objective");
103
+ if (!objectiveValue.ok || typeof objectiveValue.value !== "string") {
104
+ return "objective must be a string";
105
+ }
106
+ const objective = objectiveValue.value.trim();
107
+ const objectiveError = validateObjective(objective);
108
+ if (objectiveError) return objectiveError;
109
+ const tokenBudgetValue = readPayloadProperty(data, "tokenBudget");
110
+ if (
111
+ !tokenBudgetValue.ok ||
112
+ (tokenBudgetValue.value !== undefined &&
113
+ (typeof tokenBudgetValue.value !== "number" ||
114
+ !Number.isFinite(tokenBudgetValue.value) ||
115
+ !Number.isSafeInteger(tokenBudgetValue.value) ||
116
+ tokenBudgetValue.value <= 0))
117
+ ) {
118
+ return "tokenBudget must be a positive integer";
119
+ }
120
+ return { objective, tokenBudget: tokenBudgetValue.value as number | undefined };
121
+ }
122
+
123
+ function parseCancelReason(data: unknown): string | undefined | { error: string } {
124
+ if (!isPayloadRecord(data)) return { error: "cancel payload must be an object" };
125
+ const reasonValue = readPayloadProperty(data, "reason");
126
+ if (!reasonValue.ok || (reasonValue.value !== undefined && typeof reasonValue.value !== "string")) {
127
+ return { error: "reason must be a string" };
128
+ }
129
+ if (reasonValue.value === undefined) return undefined;
130
+ const reason = reasonValue.value.trim();
131
+ if (reason.length > MAX_CANCEL_REASON_LENGTH) {
132
+ return { error: `reason must be at most ${MAX_CANCEL_REASON_LENGTH} characters` };
133
+ }
134
+ return reason || undefined;
135
+ }
136
+
137
+ export class GoalRunController {
138
+ private readonly runtime: GoalRuntime;
139
+ private readonly commands: GoalCommandController;
140
+ private generation = 0;
141
+ private session?: BoundSession;
142
+ private run?: ManagedRun;
143
+ private readonly usedRunIds = new Set<string>();
144
+
145
+ constructor(runtime: GoalRuntime, commands: GoalCommandController) {
146
+ this.runtime = runtime;
147
+ this.commands = commands;
148
+ this.runtime.setGoalStateSink((snapshot) => this.handleGoalState(snapshot));
149
+ }
150
+
151
+ register(pi: ExtensionAPI) {
152
+ pi.events.on(GOAL_RUN_START_CHANNEL, (data) => this.handleStart(data));
153
+ pi.events.on(GOAL_RUN_CANCEL_CHANNEL, (data) => {
154
+ this.handleCancel(data);
155
+ });
156
+ }
157
+
158
+ bindSession(ctx: StatusContext) {
159
+ this.generation += 1;
160
+ this.session = { generation: this.generation, ctx };
161
+ this.closeCurrentRun();
162
+ this.usedRunIds.clear();
163
+ }
164
+
165
+ unbindSession() {
166
+ this.generation += 1;
167
+ this.session = undefined;
168
+ this.closeCurrentRun();
169
+ this.usedRunIds.clear();
170
+ }
171
+
172
+ private async handleStart(data: unknown) {
173
+ const runId = parseRunId(data);
174
+ if (!runId) return;
175
+ const session = this.session;
176
+ if (!session) {
177
+ this.emitError(runId, "start", "NO_ACTIVE_SESSION", "No active pi-goal session.");
178
+ return;
179
+ }
180
+ if (!this.runtime.settings.rpc.enabled) {
181
+ this.emitError(runId, "start", "RPC_DISABLED", "Managed run RPC is disabled.");
182
+ return;
183
+ }
184
+ if (this.usedRunIds.has(runId)) {
185
+ this.emitError(runId, "start", "RUN_ID_IN_USE", "runId was already used in this session.");
186
+ return;
187
+ }
188
+ const parsed = parseStartPayload(data);
189
+ if (typeof parsed === "string") {
190
+ this.emitError(runId, "start", "INVALID_REQUEST", parsed);
191
+ return;
192
+ }
193
+ if (this.session !== session || this.generation !== session.generation) {
194
+ this.emitError(runId, "start", "SUPERSEDED", "The pi-goal session changed while validating the request.");
195
+ return;
196
+ }
197
+ if (this.usedRunIds.has(runId)) {
198
+ this.emitError(runId, "start", "RUN_ID_IN_USE", "runId was already used in this session.");
199
+ return;
200
+ }
201
+ if (this.runtime.activeGoal || (this.run && !this.run.closed)) {
202
+ this.emitError(runId, "start", "GOAL_ALREADY_EXISTS", "A Goal already exists.");
203
+ return;
204
+ }
205
+
206
+ const run: ManagedRun = {
207
+ runId,
208
+ generation: session.generation,
209
+ closed: false,
210
+ cancelRequested: false,
211
+ };
212
+ this.run = run;
213
+ this.usedRunIds.add(runId);
214
+
215
+ try {
216
+ await this.commands.startGoal(
217
+ parsed.objective,
218
+ parsed.tokenBudget,
219
+ session.ctx,
220
+ (goal) => {
221
+ if (this.ownsRun(run, session.generation)) run.goalId = goal.id;
222
+ },
223
+ () => this.ownsRun(run, session.generation),
224
+ );
225
+ } catch (error) {
226
+ if (this.ownsRun(run, session.generation)) {
227
+ this.closeCurrentRun();
228
+ this.emitError(runId, "start", "ACTIVATION_FAILED", `Goal activation failed: ${formatError(error)}`);
229
+ }
230
+ return;
231
+ }
232
+
233
+ if (!this.ownsRun(run, session.generation)) return;
234
+ if (!run.goalId) {
235
+ this.closeCurrentRun();
236
+ this.emitError(runId, "start", "ACTIVATION_FAILED", "Goal activation did not create a Goal.");
237
+ return;
238
+ }
239
+ if (currentActiveGoal(this.runtime)?.id !== run.goalId) {
240
+ this.closeCurrentRun();
241
+ this.emitError(runId, "start", "SUPERSEDED", "The managed Goal was superseded.");
242
+ return;
243
+ }
244
+ if (run.cancelRequested) this.cancelActiveRun(run, session, run.cancelReason);
245
+ }
246
+
247
+ private handleCancel(data: unknown) {
248
+ const runId = parseRunId(data);
249
+ if (!runId) return;
250
+ const session = this.session;
251
+ if (!session) {
252
+ this.emitError(runId, "cancel", "NO_ACTIVE_SESSION", "No active pi-goal session.");
253
+ return;
254
+ }
255
+ const reason = parseCancelReason(data);
256
+ if (reason && typeof reason === "object") {
257
+ this.emitError(runId, "cancel", "INVALID_REQUEST", reason.error);
258
+ return;
259
+ }
260
+ const run = this.run;
261
+ if (!run || run.closed || run.runId !== runId || run.generation !== session.generation) {
262
+ this.emitError(runId, "cancel", "RUN_NOT_FOUND", "No active managed run matches runId.");
263
+ return;
264
+ }
265
+ if (!run.goalId) {
266
+ run.cancelRequested = true;
267
+ run.cancelReason = reason;
268
+ return;
269
+ }
270
+ this.cancelActiveRun(run, session, reason);
271
+ }
272
+
273
+ private cancelActiveRun(run: ManagedRun, session: BoundSession, reason: string | undefined) {
274
+ if (!this.ownsRun(run, session.generation)) return;
275
+ const goal = this.runtime.activeGoal;
276
+ if (!goal || goal.id !== run.goalId) {
277
+ this.closeCurrentRun();
278
+ this.emitError(run.runId, "cancel", "SUPERSEDED", "The managed Goal was superseded.");
279
+ return;
280
+ }
281
+ if (goal.status !== "active") {
282
+ this.closeCurrentRun();
283
+ this.emitError(run.runId, "cancel", "RUN_NOT_FOUND", "The managed run is no longer active.");
284
+ return;
285
+ }
286
+ this.runtime.setTerminalReason(goal.id, reason ?? "goal cancelled by managed run");
287
+ this.commands.pauseGoal(session.ctx);
288
+ }
289
+
290
+ private handleGoalState(snapshot: GoalStateSnapshot) {
291
+ const run = this.run;
292
+ if (!run || run.closed || !run.goalId) return;
293
+ if (run.goalId !== snapshot.goalId) {
294
+ if (currentActiveGoal(this.runtime)?.id !== snapshot.goalId) return;
295
+ this.publishStateEvent(run, {
296
+ goalId: run.goalId,
297
+ status: "cleared",
298
+ reason: "managed Goal superseded by another Goal",
299
+ });
300
+ return;
301
+ }
302
+ if (snapshot.status === "queued" || run.lastStatus === snapshot.status) return;
303
+ this.publishStateEvent(run, snapshot);
304
+ }
305
+
306
+ private publishStateEvent(
307
+ run: ManagedRun,
308
+ snapshot: Pick<GoalStateSnapshot, "goalId" | "status" | "summary" | "reason">,
309
+ ) {
310
+ const status = snapshot.status;
311
+ if (status === "queued") return;
312
+ run.lastStatus = status;
313
+ const event: GoalRunEvent = {
314
+ type: "state",
315
+ runId: run.runId,
316
+ goalId: snapshot.goalId,
317
+ status,
318
+ ...(snapshot.summary ? { summary: snapshot.summary } : {}),
319
+ ...(snapshot.reason ? { reason: snapshot.reason } : {}),
320
+ };
321
+ if (!isTerminalGoalStatus(status)) {
322
+ this.runtime.pi.events.emit(goalRunEventChannel(run.runId), event);
323
+ return;
324
+ }
325
+
326
+ run.closed = true;
327
+ this.run = undefined;
328
+ const generation = run.generation;
329
+ // Active stays synchronous so a listener can cancel before kickoff. Terminal
330
+ // publication waits until the transition finishes, preventing listener work
331
+ // from being overwritten by the old transition's remaining UI or cleanup.
332
+ queueMicrotask(() => {
333
+ if (this.generation !== generation) return;
334
+ this.runtime.pi.events.emit(goalRunEventChannel(run.runId), event);
335
+ });
336
+ }
337
+
338
+ private emitError(runId: string, operation: "start" | "cancel", code: GoalRunErrorCode, message: string) {
339
+ this.runtime.pi.events.emit(goalRunEventChannel(runId), {
340
+ type: "error",
341
+ runId,
342
+ operation,
343
+ error: { code, message },
344
+ } satisfies GoalRunEvent);
345
+ }
346
+
347
+ private ownsRun(run: ManagedRun, generation: number) {
348
+ return this.session?.generation === generation && this.generation === generation && this.run === run && !run.closed;
349
+ }
350
+
351
+ private closeCurrentRun() {
352
+ if (this.run) this.run.closed = true;
353
+ this.run = undefined;
354
+ }
355
+ }