@kata-sh/pi-symphony-extension 0.1.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.
@@ -0,0 +1,856 @@
1
+ import { SymphonyExtensionError } from "./errors.ts";
2
+ import { normalizeBaseUrl, type LastKnownSymphonyState } from "./state.ts";
3
+
4
+ export interface RunAttemptResponse {
5
+ issue_id: string;
6
+ issue_identifier: string;
7
+ issue_title?: string | null;
8
+ attempt?: number | null;
9
+ workspace_path: string;
10
+ started_at: string;
11
+ status: string;
12
+ error?: string | null;
13
+ worker_host?: string | null;
14
+ model?: string | null;
15
+ tracker_state?: string | null;
16
+ issue_url?: string | null;
17
+ }
18
+
19
+ export interface RunningSessionSnapshotResponse {
20
+ turn_count?: number;
21
+ last_activity_at?: string | null;
22
+ total_tokens?: number;
23
+ last_event?: string | null;
24
+ last_event_message?: string | null;
25
+ session_id?: string | null;
26
+ current_tool_name?: string | null;
27
+ current_tool_args_preview?: string | null;
28
+ last_error?: string | null;
29
+ }
30
+
31
+ export interface WorkerSessionInfoResponse {
32
+ turn_count?: number;
33
+ max_turns?: number;
34
+ last_activity_ms?: number | null;
35
+ session_tokens?: {
36
+ input_tokens?: number;
37
+ output_tokens?: number;
38
+ total_tokens?: number;
39
+ };
40
+ current_tool_name?: string | null;
41
+ current_tool_args_preview?: string | null;
42
+ last_error?: string | null;
43
+ }
44
+
45
+ export interface RetryQueueEntryResponse {
46
+ issue_id: string;
47
+ identifier: string;
48
+ attempt: number;
49
+ due_in_ms: number;
50
+ error?: string | null;
51
+ worker_host?: string | null;
52
+ workspace_path?: string | null;
53
+ }
54
+
55
+ export interface BlockedIssueResponse {
56
+ issue_id: string;
57
+ identifier: string;
58
+ title: string;
59
+ state: string;
60
+ blocker_identifiers: string[];
61
+ }
62
+
63
+ export interface CompletedIssueResponse {
64
+ issue_id: string;
65
+ identifier: string;
66
+ title: string;
67
+ completed_at?: string | null;
68
+ issue_url?: string | null;
69
+ }
70
+
71
+ export interface PendingEscalationResponse {
72
+ request_id: string;
73
+ issue_id: string;
74
+ issue_identifier: string;
75
+ method: string;
76
+ preview: string;
77
+ created_at: string;
78
+ timeout_ms: number;
79
+ }
80
+
81
+ export interface EscalationListResponse {
82
+ pending: PendingEscalationResponse[];
83
+ }
84
+
85
+ export interface EscalationRespondResponse {
86
+ ok: boolean;
87
+ }
88
+
89
+ export type ContextScopeResponse =
90
+ | { type: "project" }
91
+ | { type: "milestone"; value: string }
92
+ | { type: "label"; value: string };
93
+
94
+ export interface SharedContextEntryResponse {
95
+ id: string;
96
+ author_issue: string;
97
+ scope: ContextScopeResponse;
98
+ content: string;
99
+ created_at: string;
100
+ ttl_ms: number;
101
+ }
102
+
103
+ export interface SharedContextSummaryResponse {
104
+ total_entries: number;
105
+ entries_by_scope: Record<string, number>;
106
+ oldest_entry_at: string | null;
107
+ newest_entry_at: string | null;
108
+ }
109
+
110
+ export interface SharedContextListResponse {
111
+ entries: SharedContextEntryResponse[];
112
+ summary: SharedContextSummaryResponse;
113
+ }
114
+
115
+ export interface SharedContextCreateInput {
116
+ authorIssue: string;
117
+ scope: string;
118
+ content: string;
119
+ ttlMs?: number;
120
+ }
121
+
122
+ export interface SharedContextWriteResponse {
123
+ id: string;
124
+ created_at: string;
125
+ }
126
+
127
+ export interface SharedContextDeleteResponse {
128
+ deleted: number;
129
+ }
130
+
131
+ export interface SupervisorSnapshotResponse {
132
+ active?: boolean;
133
+ status?: string;
134
+ steers_issued: number;
135
+ conflicts_detected: number;
136
+ patterns_detected: number;
137
+ escalations_created: number;
138
+ }
139
+
140
+ export interface CodexTotalsResponse {
141
+ input_tokens: number;
142
+ output_tokens: number;
143
+ total_tokens: number;
144
+ event_count: number;
145
+ seconds_running: number;
146
+ }
147
+
148
+ export interface SymphonyStateResponse {
149
+ tracker_project_url?: string | null;
150
+ running?: Record<string, RunAttemptResponse>;
151
+ running_sessions?: Record<string, RunningSessionSnapshotResponse>;
152
+ running_session_info?: Record<string, WorkerSessionInfoResponse>;
153
+ retry_queue: RetryQueueEntryResponse[];
154
+ blocked: BlockedIssueResponse[];
155
+ pending_escalations?: PendingEscalationResponse[];
156
+ completed: CompletedIssueResponse[];
157
+ polling?: {
158
+ checking?: boolean;
159
+ next_poll_in_ms?: number;
160
+ poll_interval_ms?: number;
161
+ poll_count?: number;
162
+ last_poll_at?: string | null;
163
+ };
164
+ shared_context: SharedContextSummaryResponse;
165
+ supervisor: SupervisorSnapshotResponse;
166
+ codex_totals: CodexTotalsResponse;
167
+ codex_rate_limits: Record<string, unknown> | null;
168
+ }
169
+
170
+ export interface RefreshResponse {
171
+ queued: boolean;
172
+ coalesced: boolean;
173
+ pendingRequests: number;
174
+ }
175
+
176
+ export interface SteerResponse {
177
+ ok: boolean;
178
+ issueId: string;
179
+ issueIdentifier: string;
180
+ delivered: boolean;
181
+ instructionPreview: string;
182
+ }
183
+
184
+ export interface SymphonyEventEnvelope {
185
+ version: string;
186
+ sequence: number;
187
+ timestamp: string;
188
+ kind: string;
189
+ severity: string;
190
+ issue?: string;
191
+ event: string;
192
+ payload: unknown;
193
+ }
194
+
195
+ interface ApiErrorEnvelope {
196
+ error?: {
197
+ code?: string;
198
+ message?: string;
199
+ status?: number;
200
+ details?: unknown;
201
+ };
202
+ }
203
+
204
+ function normalizeOptionalContextScope(scope: string | undefined): string | undefined {
205
+ if (scope === undefined) return undefined;
206
+ const trimmedScope = scope.trim();
207
+ if (!trimmedScope) throw new TypeError("Shared context scope must not be empty");
208
+ return trimmedScope;
209
+ }
210
+
211
+ export class SymphonyHttpClient {
212
+ readonly baseUrl: string;
213
+
214
+ constructor(baseUrl: string) {
215
+ this.baseUrl = normalizeBaseUrl(baseUrl);
216
+ }
217
+
218
+ async getState(signal?: AbortSignal): Promise<SymphonyStateResponse> {
219
+ const path = "/api/v1/state";
220
+ const json = await this.requestJson(path, { method: "GET", signal });
221
+ return validateSymphonyStateResponse(json, { baseUrl: this.baseUrl, path });
222
+ }
223
+
224
+ async verify(signal?: AbortSignal): Promise<SymphonyStateResponse> {
225
+ return this.getState(signal);
226
+ }
227
+
228
+ async refresh(signal?: AbortSignal): Promise<RefreshResponse> {
229
+ const path = "/api/v1/refresh";
230
+ const json = await this.requestJson(path, { method: "POST", signal });
231
+ return validateRefreshResponse(json, { baseUrl: this.baseUrl, path });
232
+ }
233
+
234
+ async steer(issueIdentifier: string, instruction: string, signal?: AbortSignal): Promise<SteerResponse> {
235
+ const path = "/api/v1/steer";
236
+ const json = await this.requestJson(path, {
237
+ method: "POST",
238
+ signal,
239
+ headers: { "content-type": "application/json" },
240
+ body: JSON.stringify({ issue_identifier: issueIdentifier, instruction }),
241
+ });
242
+ return validateSteerResponse(json, { baseUrl: this.baseUrl, path, issueIdentifier });
243
+ }
244
+
245
+ async getEscalations(signal?: AbortSignal): Promise<EscalationListResponse> {
246
+ const path = "/api/v1/escalations";
247
+ const json = await this.requestJson(path, { method: "GET", signal });
248
+ return validateEscalationListResponse(json, { baseUrl: this.baseUrl, path });
249
+ }
250
+
251
+ async respondEscalation(requestId: string, response: unknown, responderId = "pi-dashboard", signal?: AbortSignal): Promise<EscalationRespondResponse> {
252
+ const path = `/api/v1/escalations/${encodeURIComponent(requestId)}/respond`;
253
+ const json = await this.requestJson(path, {
254
+ method: "POST",
255
+ signal,
256
+ headers: { "content-type": "application/json" },
257
+ body: JSON.stringify({ response, responder_id: responderId }),
258
+ });
259
+ return validateEscalationRespondResponse(json, { baseUrl: this.baseUrl, path, requestId });
260
+ }
261
+
262
+ async getContext(scope?: string, signal?: AbortSignal): Promise<SharedContextListResponse> {
263
+ const trimmedScope = normalizeOptionalContextScope(scope);
264
+ const path = trimmedScope ? `/api/v1/context?scope=${encodeURIComponent(trimmedScope)}` : "/api/v1/context";
265
+ const json = await this.requestJson(path, { method: "GET", signal });
266
+ return validateSharedContextListResponse(json, { baseUrl: this.baseUrl, path });
267
+ }
268
+
269
+ async createContext(input: SharedContextCreateInput, signal?: AbortSignal): Promise<SharedContextWriteResponse> {
270
+ const path = "/api/v1/context";
271
+ const body: Record<string, unknown> = {
272
+ author_issue: input.authorIssue,
273
+ scope: input.scope,
274
+ content: input.content,
275
+ };
276
+ if (input.ttlMs !== undefined) body.ttl_ms = input.ttlMs;
277
+ const json = await this.requestJson(path, {
278
+ method: "POST",
279
+ signal,
280
+ headers: { "content-type": "application/json" },
281
+ body: JSON.stringify(body),
282
+ });
283
+ return validateSharedContextWriteResponse(json, { baseUrl: this.baseUrl, path });
284
+ }
285
+
286
+ async deleteContext(scope?: string, signal?: AbortSignal): Promise<SharedContextDeleteResponse> {
287
+ const trimmedScope = normalizeOptionalContextScope(scope);
288
+ const path = trimmedScope ? `/api/v1/context?scope=${encodeURIComponent(trimmedScope)}` : "/api/v1/context";
289
+ const json = await this.requestJson(path, { method: "DELETE", signal });
290
+ return validateSharedContextDeleteResponse(json, { baseUrl: this.baseUrl, path });
291
+ }
292
+
293
+ async deleteContextEntry(entryId: string, signal?: AbortSignal): Promise<SharedContextDeleteResponse> {
294
+ const path = `/api/v1/context/${encodeURIComponent(entryId)}`;
295
+ const json = await this.requestJson(path, { method: "DELETE", signal });
296
+ return validateSharedContextDeleteResponse(json, { baseUrl: this.baseUrl, path, entryId });
297
+ }
298
+
299
+ toHealthSummary(state: SymphonyStateResponse): LastKnownSymphonyState {
300
+ return {
301
+ baseUrl: this.baseUrl,
302
+ trackerProjectUrl: state.tracker_project_url ?? undefined,
303
+ runningCount: Object.keys(state.running ?? {}).length,
304
+ retryCount: state.retry_queue?.length ?? 0,
305
+ blockedCount: state.blocked?.length ?? 0,
306
+ completedCount: state.completed?.length ?? 0,
307
+ pollingChecking: Boolean(state.polling?.checking),
308
+ nextPollInMs: state.polling?.next_poll_in_ms ?? 0,
309
+ updatedAt: new Date().toISOString(),
310
+ };
311
+ }
312
+
313
+ private async requestJson(path: string, init: RequestInit): Promise<unknown> {
314
+ const url = `${this.baseUrl}${path}`;
315
+ let response: Response;
316
+ try {
317
+ response = await fetch(url, { ...init, headers: { accept: "application/json", ...(init.headers ?? {}) } });
318
+ } catch (error) {
319
+ if (isAbortError(error, init.signal)) throw error;
320
+ throw new SymphonyExtensionError("attach_unreachable", "Could not reach Symphony HTTP API", {
321
+ url,
322
+ cause: error instanceof Error ? error.message : String(error),
323
+ });
324
+ }
325
+
326
+ let text: string;
327
+ try {
328
+ text = await response.text();
329
+ } catch (error) {
330
+ if (isAbortError(error, init.signal)) throw error;
331
+ throw new SymphonyExtensionError("non_symphony_response", "Could not read Symphony HTTP API response body", {
332
+ url,
333
+ status: response.status,
334
+ cause: error instanceof Error ? error.message : String(error),
335
+ });
336
+ }
337
+
338
+ let json: unknown;
339
+ try {
340
+ json = text ? JSON.parse(text) : {};
341
+ } catch (error) {
342
+ throw new SymphonyExtensionError("invalid_json", "Symphony HTTP API returned invalid JSON", {
343
+ url,
344
+ status: response.status,
345
+ bodyPreview: text.slice(0, 200),
346
+ cause: error instanceof Error ? error.message : String(error),
347
+ });
348
+ }
349
+
350
+ if (!response.ok) {
351
+ if (isRecord(json) && typeof json.error === "string") {
352
+ throw new SymphonyExtensionError("api_error", json.error, {
353
+ url,
354
+ status: response.status,
355
+ code: json.error,
356
+ });
357
+ }
358
+ const envelope = parseApiErrorEnvelope(json);
359
+ if (envelope?.error?.message) {
360
+ throw new SymphonyExtensionError("api_error", envelope.error.message, {
361
+ url,
362
+ status: response.status,
363
+ code: envelope.error.code,
364
+ details: envelope.error.details,
365
+ });
366
+ }
367
+ throw new SymphonyExtensionError("non_symphony_response", "Symphony HTTP API returned an unexpected error response", {
368
+ url,
369
+ status: response.status,
370
+ body: json,
371
+ });
372
+ }
373
+
374
+ return json;
375
+ }
376
+ }
377
+
378
+ function validateSymphonyStateResponse(value: unknown, details: Record<string, unknown>): SymphonyStateResponse {
379
+ if (!isRecord(value)) {
380
+ throwNonSymphonyState(details, "state response was not an object");
381
+ }
382
+
383
+ const missingFields = [
384
+ "running",
385
+ "retry_queue",
386
+ "blocked",
387
+ "completed",
388
+ "polling",
389
+ "shared_context",
390
+ "supervisor",
391
+ "codex_totals",
392
+ "codex_rate_limits",
393
+ ].filter((field) => !(field in value));
394
+ if (missingFields.length > 0) {
395
+ throwNonSymphonyState(details, "state response was missing Symphony state fields", { missingFields });
396
+ }
397
+
398
+ validateOptionalStringOrNull(value, "tracker_project_url", details);
399
+ validateOptionalRecord(value, "running", details);
400
+ validateRunningAttempts(value.running, details);
401
+ validateRetryQueueEntries(value, "retry_queue", details);
402
+ validateBlockedIssues(value, "blocked", details);
403
+ validateCompletedIssues(value, "completed", details);
404
+ validateOptionalNumber(value, "poll_interval_ms", details);
405
+ validateOptionalNumber(value, "max_concurrent_agents", details);
406
+ validateOptionalRecord(value, "running_sessions", details);
407
+ validateOptionalRecord(value, "running_session_info", details);
408
+ validateOptionalArray(value, "claimed", details);
409
+ validatePendingEscalations(value, "pending_escalations", details);
410
+ validateSharedContextSummary(value.shared_context, details, "shared_context", throwNonSymphonyState);
411
+ validateSupervisorSnapshot(value.supervisor, details, "supervisor", throwNonSymphonyState);
412
+ validateCodexTotals(value.codex_totals, details, "codex_totals", throwNonSymphonyState);
413
+ if (value.codex_rate_limits !== null && !isRecord(value.codex_rate_limits)) {
414
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: "codex_rate_limits", expected: "object or null" });
415
+ }
416
+
417
+ if (!isRecord(value.polling)) {
418
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: "polling", expected: "object" });
419
+ }
420
+ validateRequiredBoolean(value.polling, "checking", details, "polling.checking");
421
+ validateRequiredNumber(value.polling, "next_poll_in_ms", details, "polling.next_poll_in_ms");
422
+ validateRequiredNumber(value.polling, "poll_interval_ms", details, "polling.poll_interval_ms");
423
+ validateOptionalNumber(value.polling, "poll_count", details, "polling.poll_count");
424
+ validateOptionalStringOrNull(value.polling, "last_poll_at", details, "polling.last_poll_at");
425
+
426
+ return value as unknown as SymphonyStateResponse;
427
+ }
428
+
429
+ function validateEscalationListResponse(value: unknown, details: Record<string, unknown>): EscalationListResponse {
430
+ if (!isRecord(value)) {
431
+ throwNonSymphonyEscalationList(details, "escalation list response was not an object");
432
+ }
433
+ if (!Array.isArray(value.pending)) {
434
+ throwNonSymphonyEscalationList(details, "escalation list response field had an invalid shape", { field: "pending", expected: "array" });
435
+ }
436
+ value.pending.forEach((entry, index) => validatePendingEscalation(entry, details, `pending.${index}`, throwNonSymphonyEscalationList));
437
+ return { pending: value.pending as PendingEscalationResponse[] };
438
+ }
439
+
440
+ function validateEscalationRespondResponse(value: unknown, details: Record<string, unknown>): EscalationRespondResponse {
441
+ if (!isRecord(value)) {
442
+ throwNonSymphonyEscalationRespond(details, "escalation respond response was not an object");
443
+ }
444
+ if (typeof value.ok !== "boolean") {
445
+ throwNonSymphonyEscalationRespond(details, "escalation respond response field had an invalid shape", { field: "ok", expected: "boolean" });
446
+ }
447
+ return { ok: value.ok };
448
+ }
449
+
450
+ function validateSharedContextListResponse(value: unknown, details: Record<string, unknown>): SharedContextListResponse {
451
+ if (!isRecord(value)) throwNonSymphonyContext(details, "shared context response was not an object");
452
+ if (!Array.isArray(value.entries)) {
453
+ throwNonSymphonyContext(details, "shared context response field had an invalid shape", { field: "entries", expected: "array" });
454
+ }
455
+ value.entries.forEach((entry, index) => validateSharedContextEntry(entry, details, `entries.${index}`, throwNonSymphonyContext));
456
+ validateSharedContextSummary(value.summary, details, "summary", throwNonSymphonyContext);
457
+ return value as unknown as SharedContextListResponse;
458
+ }
459
+
460
+ function validateSharedContextWriteResponse(value: unknown, details: Record<string, unknown>): SharedContextWriteResponse {
461
+ if (!isRecord(value)) throwNonSymphonyContext(details, "shared context write response was not an object");
462
+ validateRequiredString(value, "id", details, "id", throwNonSymphonyContext);
463
+ validateRequiredString(value, "created_at", details, "created_at", throwNonSymphonyContext);
464
+ return { id: value.id as string, created_at: value.created_at as string };
465
+ }
466
+
467
+ function validateSharedContextDeleteResponse(value: unknown, details: Record<string, unknown>): SharedContextDeleteResponse {
468
+ if (!isRecord(value)) throwNonSymphonyContext(details, "shared context delete response was not an object");
469
+ validateRequiredNumber(value, "deleted", details, "deleted", throwNonSymphonyContext);
470
+ return { deleted: value.deleted as number };
471
+ }
472
+
473
+ function validateSharedContextEntry(value: unknown, details: Record<string, unknown>, detailField: string, thrower: NonSymphonyThrower): void {
474
+ if (!isRecord(value)) thrower(details, "shared context entry had an invalid shape", { field: detailField, expected: "object" });
475
+ validateRequiredString(value, "id", details, `${detailField}.id`, thrower);
476
+ validateRequiredString(value, "author_issue", details, `${detailField}.author_issue`, thrower);
477
+ validateContextScope(value.scope, details, `${detailField}.scope`, thrower);
478
+ validateRequiredString(value, "content", details, `${detailField}.content`, thrower);
479
+ validateRequiredString(value, "created_at", details, `${detailField}.created_at`, thrower);
480
+ validateRequiredNumber(value, "ttl_ms", details, `${detailField}.ttl_ms`, thrower);
481
+ }
482
+
483
+ function validateContextScope(value: unknown, details: Record<string, unknown>, detailField: string, thrower: NonSymphonyThrower): void {
484
+ if (!isRecord(value)) thrower(details, "shared context scope had an invalid shape", { field: detailField, expected: "object" });
485
+ if (value.type !== "project" && value.type !== "milestone" && value.type !== "label") {
486
+ thrower(details, "shared context scope had an invalid shape", { field: `${detailField}.type`, expected: "project | milestone | label" });
487
+ }
488
+ if ((value.type === "milestone" || value.type === "label") && typeof value.value !== "string") {
489
+ thrower(details, "shared context scope had an invalid shape", { field: `${detailField}.value`, expected: "string" });
490
+ }
491
+ }
492
+
493
+ function validateSharedContextSummary(value: unknown, details: Record<string, unknown>, detailField: string, thrower: NonSymphonyThrower): void {
494
+ if (!isRecord(value)) thrower(details, "shared context summary had an invalid shape", { field: detailField, expected: "object" });
495
+ validateRequiredNumber(value, "total_entries", details, `${detailField}.total_entries`, thrower);
496
+ if (!isRecord(value.entries_by_scope) || Object.values(value.entries_by_scope).some((entry) => !isFiniteNumber(entry))) {
497
+ thrower(details, "shared context summary had an invalid shape", { field: `${detailField}.entries_by_scope`, expected: "Record<string, number>" });
498
+ }
499
+ validateRequiredStringOrNull(value, "oldest_entry_at", details, `${detailField}.oldest_entry_at`, thrower);
500
+ validateRequiredStringOrNull(value, "newest_entry_at", details, `${detailField}.newest_entry_at`, thrower);
501
+ }
502
+
503
+ function validateSupervisorSnapshot(value: unknown, details: Record<string, unknown>, detailField: string, thrower: NonSymphonyThrower): void {
504
+ if (!isRecord(value)) thrower(details, "supervisor snapshot had an invalid shape", { field: detailField, expected: "object" });
505
+ if (value.active !== undefined && typeof value.active !== "boolean") {
506
+ thrower(details, "supervisor snapshot had an invalid shape", { field: `${detailField}.active`, expected: "boolean" });
507
+ }
508
+ validateOptionalStringOrNull(value, "status", details, `${detailField}.status`, thrower);
509
+ validateRequiredNumber(value, "steers_issued", details, `${detailField}.steers_issued`, thrower);
510
+ validateRequiredNumber(value, "conflicts_detected", details, `${detailField}.conflicts_detected`, thrower);
511
+ validateRequiredNumber(value, "patterns_detected", details, `${detailField}.patterns_detected`, thrower);
512
+ validateRequiredNumber(value, "escalations_created", details, `${detailField}.escalations_created`, thrower);
513
+ }
514
+
515
+ function validateCodexTotals(value: unknown, details: Record<string, unknown>, detailField: string, thrower: NonSymphonyThrower): void {
516
+ if (!isRecord(value)) thrower(details, "codex totals had an invalid shape", { field: detailField, expected: "object" });
517
+ validateRequiredNumber(value, "input_tokens", details, `${detailField}.input_tokens`, thrower);
518
+ validateRequiredNumber(value, "output_tokens", details, `${detailField}.output_tokens`, thrower);
519
+ validateRequiredNumber(value, "total_tokens", details, `${detailField}.total_tokens`, thrower);
520
+ validateRequiredNumber(value, "event_count", details, `${detailField}.event_count`, thrower);
521
+ validateRequiredNumber(value, "seconds_running", details, `${detailField}.seconds_running`, thrower);
522
+ }
523
+
524
+ function throwNonSymphonyContext(details: Record<string, unknown>, reason: string, extraDetails: Record<string, unknown> = {}): never {
525
+ throw new SymphonyExtensionError("non_symphony_response", "Response did not look like Symphony shared context response", {
526
+ ...details,
527
+ reason,
528
+ ...extraDetails,
529
+ });
530
+ }
531
+
532
+ function validateRefreshResponse(value: unknown, details: Record<string, unknown>): RefreshResponse {
533
+ if (!isRecord(value)) {
534
+ throwNonSymphonyRefresh(details, "refresh response was not an object");
535
+ }
536
+ if (typeof value.queued !== "boolean") {
537
+ throwNonSymphonyRefresh(details, "refresh response field had an invalid shape", { field: "queued", expected: "boolean" });
538
+ }
539
+ if (typeof value.coalesced !== "boolean") {
540
+ throwNonSymphonyRefresh(details, "refresh response field had an invalid shape", { field: "coalesced", expected: "boolean" });
541
+ }
542
+ if (!isFiniteNumber(value.pending_requests)) {
543
+ throwNonSymphonyRefresh(details, "refresh response field had an invalid shape", { field: "pending_requests", expected: "number" });
544
+ }
545
+
546
+ return {
547
+ queued: value.queued,
548
+ coalesced: value.coalesced,
549
+ pendingRequests: value.pending_requests,
550
+ };
551
+ }
552
+
553
+ function validateSteerResponse(value: unknown, details: Record<string, unknown>): SteerResponse {
554
+ if (!isRecord(value)) {
555
+ throwNonSymphonySteer(details, "steer response was not an object");
556
+ }
557
+ if (typeof value.ok !== "boolean") {
558
+ throwNonSymphonySteer(details, "steer response field had an invalid shape", { field: "ok", expected: "boolean" });
559
+ }
560
+ if (typeof value.issue_id !== "string") {
561
+ throwNonSymphonySteer(details, "steer response field had an invalid shape", { field: "issue_id", expected: "string" });
562
+ }
563
+ if (typeof value.issue_identifier !== "string") {
564
+ throwNonSymphonySteer(details, "steer response field had an invalid shape", { field: "issue_identifier", expected: "string" });
565
+ }
566
+ if (typeof value.delivered !== "boolean") {
567
+ throwNonSymphonySteer(details, "steer response field had an invalid shape", { field: "delivered", expected: "boolean" });
568
+ }
569
+ if (typeof value.instruction_preview !== "string") {
570
+ throwNonSymphonySteer(details, "steer response field had an invalid shape", { field: "instruction_preview", expected: "string" });
571
+ }
572
+
573
+ return {
574
+ ok: value.ok,
575
+ issueId: value.issue_id,
576
+ issueIdentifier: value.issue_identifier,
577
+ delivered: value.delivered,
578
+ instructionPreview: value.instruction_preview,
579
+ };
580
+ }
581
+
582
+ function throwNonSymphonyRefresh(details: Record<string, unknown>, reason: string, extraDetails: Record<string, unknown> = {}): never {
583
+ throw new SymphonyExtensionError("non_symphony_response", "Response did not look like Symphony refresh response", {
584
+ ...details,
585
+ reason,
586
+ ...extraDetails,
587
+ });
588
+ }
589
+
590
+ function throwNonSymphonySteer(details: Record<string, unknown>, reason: string, extraDetails: Record<string, unknown> = {}): never {
591
+ throw new SymphonyExtensionError("non_symphony_response", "Response did not look like Symphony steer response", {
592
+ ...details,
593
+ reason,
594
+ ...extraDetails,
595
+ });
596
+ }
597
+
598
+ function throwNonSymphonyEscalationList(details: Record<string, unknown>, reason: string, extraDetails: Record<string, unknown> = {}): never {
599
+ throw new SymphonyExtensionError("non_symphony_response", "Response did not look like Symphony escalation list response", {
600
+ ...details,
601
+ reason,
602
+ ...extraDetails,
603
+ });
604
+ }
605
+
606
+ function throwNonSymphonyEscalationRespond(details: Record<string, unknown>, reason: string, extraDetails: Record<string, unknown> = {}): never {
607
+ throw new SymphonyExtensionError("non_symphony_response", "Response did not look like Symphony escalation respond response", {
608
+ ...details,
609
+ reason,
610
+ ...extraDetails,
611
+ });
612
+ }
613
+
614
+ function parseApiErrorEnvelope(value: unknown): ApiErrorEnvelope | undefined {
615
+ if (!isRecord(value) || !isRecord(value.error)) return undefined;
616
+ return {
617
+ error: {
618
+ code: typeof value.error.code === "string" ? value.error.code : undefined,
619
+ message: typeof value.error.message === "string" ? value.error.message : undefined,
620
+ status: isFiniteNumber(value.error.status) ? value.error.status : undefined,
621
+ details: value.error.details,
622
+ },
623
+ };
624
+ }
625
+
626
+ function validateRunningAttempts(value: unknown, details: Record<string, unknown>): void {
627
+ if (value === undefined) return;
628
+ if (!isRecord(value)) return;
629
+ for (const [key, attempt] of Object.entries(value)) {
630
+ validateRunAttemptResponse(attempt, details, `running.${key}`);
631
+ }
632
+ }
633
+
634
+ function validateRunAttemptResponse(value: unknown, details: Record<string, unknown>, detailField: string): void {
635
+ if (!isRecord(value)) {
636
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "object" });
637
+ }
638
+ validateRequiredString(value, "issue_id", details, `${detailField}.issue_id`);
639
+ validateRequiredString(value, "issue_identifier", details, `${detailField}.issue_identifier`);
640
+ validateRequiredString(value, "workspace_path", details, `${detailField}.workspace_path`);
641
+ validateRequiredString(value, "started_at", details, `${detailField}.started_at`);
642
+ validateRequiredString(value, "status", details, `${detailField}.status`);
643
+ validateOptionalStringOrNull(value, "issue_title", details, `${detailField}.issue_title`);
644
+ validateOptionalNumberOrNull(value, "attempt", details, `${detailField}.attempt`);
645
+ validateOptionalStringOrNull(value, "error", details, `${detailField}.error`);
646
+ validateOptionalStringOrNull(value, "worker_host", details, `${detailField}.worker_host`);
647
+ validateOptionalStringOrNull(value, "model", details, `${detailField}.model`);
648
+ validateOptionalStringOrNull(value, "tracker_state", details, `${detailField}.tracker_state`);
649
+ validateOptionalStringOrNull(value, "issue_url", details, `${detailField}.issue_url`);
650
+ }
651
+
652
+ type NonSymphonyThrower = (details: Record<string, unknown>, reason: string, extraDetails?: Record<string, unknown>) => never;
653
+
654
+ function validateRetryQueueEntries(value: Record<string, unknown>, field: string, details: Record<string, unknown>): void {
655
+ const fieldValue = value[field];
656
+ if (fieldValue === undefined) return;
657
+ if (!Array.isArray(fieldValue)) {
658
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field, expected: "array" });
659
+ }
660
+ fieldValue.forEach((entry, index) => validateRetryQueueEntry(entry, details, `${field}.${index}`));
661
+ }
662
+
663
+ function validateRetryQueueEntry(value: unknown, details: Record<string, unknown>, detailField: string): void {
664
+ if (!isRecord(value)) {
665
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "object" });
666
+ }
667
+ validateRequiredString(value, "issue_id", details, `${detailField}.issue_id`);
668
+ validateRequiredString(value, "identifier", details, `${detailField}.identifier`);
669
+ validateRequiredNumber(value, "attempt", details, `${detailField}.attempt`);
670
+ validateRequiredNumber(value, "due_in_ms", details, `${detailField}.due_in_ms`);
671
+ validateOptionalStringOrNull(value, "error", details, `${detailField}.error`);
672
+ validateOptionalStringOrNull(value, "worker_host", details, `${detailField}.worker_host`);
673
+ validateOptionalStringOrNull(value, "workspace_path", details, `${detailField}.workspace_path`);
674
+ }
675
+
676
+ function validateBlockedIssues(value: Record<string, unknown>, field: string, details: Record<string, unknown>): void {
677
+ const fieldValue = value[field];
678
+ if (fieldValue === undefined) return;
679
+ if (!Array.isArray(fieldValue)) {
680
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field, expected: "array" });
681
+ }
682
+ fieldValue.forEach((entry, index) => validateBlockedIssue(entry, details, `${field}.${index}`));
683
+ }
684
+
685
+ function validateBlockedIssue(value: unknown, details: Record<string, unknown>, detailField: string): void {
686
+ if (!isRecord(value)) {
687
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "object" });
688
+ }
689
+ validateRequiredString(value, "issue_id", details, `${detailField}.issue_id`);
690
+ validateRequiredString(value, "identifier", details, `${detailField}.identifier`);
691
+ validateRequiredString(value, "title", details, `${detailField}.title`);
692
+ validateRequiredString(value, "state", details, `${detailField}.state`);
693
+ validateRequiredStringArray(value, "blocker_identifiers", details, `${detailField}.blocker_identifiers`);
694
+ }
695
+
696
+ function validateCompletedIssues(value: Record<string, unknown>, field: string, details: Record<string, unknown>): void {
697
+ const fieldValue = value[field];
698
+ if (fieldValue === undefined) return;
699
+ if (!Array.isArray(fieldValue)) {
700
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field, expected: "array" });
701
+ }
702
+ fieldValue.forEach((entry, index) => validateCompletedIssue(entry, details, `${field}.${index}`));
703
+ }
704
+
705
+ function validateCompletedIssue(value: unknown, details: Record<string, unknown>, detailField: string): void {
706
+ if (!isRecord(value)) {
707
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "object" });
708
+ }
709
+ validateRequiredString(value, "issue_id", details, `${detailField}.issue_id`);
710
+ validateRequiredString(value, "identifier", details, `${detailField}.identifier`);
711
+ validateRequiredString(value, "title", details, `${detailField}.title`);
712
+ validateOptionalStringOrNull(value, "completed_at", details, `${detailField}.completed_at`);
713
+ validateOptionalStringOrNull(value, "issue_url", details, `${detailField}.issue_url`);
714
+ }
715
+
716
+ function validatePendingEscalations(
717
+ value: Record<string, unknown>,
718
+ field: string,
719
+ details: Record<string, unknown>,
720
+ thrower: NonSymphonyThrower = throwNonSymphonyState,
721
+ ): void {
722
+ const fieldValue = value[field];
723
+ if (fieldValue === undefined) return;
724
+ if (!Array.isArray(fieldValue)) {
725
+ thrower(details, "state response field had an invalid shape", { field, expected: "array" });
726
+ }
727
+ fieldValue.forEach((entry, index) => validatePendingEscalation(entry, details, `${field}.${index}`, thrower));
728
+ }
729
+
730
+ function validatePendingEscalation(value: unknown, details: Record<string, unknown>, detailField: string, thrower: NonSymphonyThrower): void {
731
+ if (!isRecord(value)) {
732
+ thrower(details, "state response field had an invalid shape", { field: detailField, expected: "object" });
733
+ }
734
+ validateRequiredString(value, "request_id", details, `${detailField}.request_id`, thrower);
735
+ validateRequiredString(value, "issue_id", details, `${detailField}.issue_id`, thrower);
736
+ validateRequiredString(value, "issue_identifier", details, `${detailField}.issue_identifier`, thrower);
737
+ validateRequiredString(value, "method", details, `${detailField}.method`, thrower);
738
+ validateRequiredString(value, "preview", details, `${detailField}.preview`, thrower);
739
+ validateRequiredString(value, "created_at", details, `${detailField}.created_at`, thrower);
740
+ validateRequiredNumber(value, "timeout_ms", details, `${detailField}.timeout_ms`, thrower);
741
+ }
742
+
743
+ function validateOptionalArray(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
744
+ const fieldValue = value[field];
745
+ if (fieldValue === undefined) return;
746
+ if (!Array.isArray(fieldValue)) {
747
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "array" });
748
+ }
749
+ }
750
+
751
+ function validateOptionalRecord(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
752
+ const fieldValue = value[field];
753
+ if (fieldValue === undefined) return;
754
+ if (!isRecord(fieldValue)) {
755
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "object" });
756
+ }
757
+ }
758
+
759
+ function validateOptionalStringOrNull(
760
+ value: Record<string, unknown>,
761
+ field: string,
762
+ details: Record<string, unknown>,
763
+ detailField = field,
764
+ thrower: NonSymphonyThrower = throwNonSymphonyState,
765
+ ): void {
766
+ const fieldValue = value[field];
767
+ if (fieldValue === undefined || fieldValue === null) return;
768
+ if (typeof fieldValue !== "string") {
769
+ thrower(details, "state response field had an invalid shape", { field: detailField, expected: "string or null" });
770
+ }
771
+ }
772
+
773
+ function validateRequiredStringOrNull(
774
+ value: Record<string, unknown>,
775
+ field: string,
776
+ details: Record<string, unknown>,
777
+ detailField = field,
778
+ thrower: NonSymphonyThrower = throwNonSymphonyState,
779
+ ): void {
780
+ const fieldValue = value[field];
781
+ if (fieldValue === null || typeof fieldValue === "string") return;
782
+ thrower(details, "state response field had an invalid shape", { field: detailField, expected: "string or null" });
783
+ }
784
+
785
+ function validateOptionalNumber(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
786
+ const fieldValue = value[field];
787
+ if (fieldValue === undefined) return;
788
+ if (!isFiniteNumber(fieldValue)) {
789
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "number" });
790
+ }
791
+ }
792
+
793
+ function validateOptionalNumberOrNull(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
794
+ const fieldValue = value[field];
795
+ if (fieldValue === undefined || fieldValue === null) return;
796
+ if (!isFiniteNumber(fieldValue)) {
797
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "number or null" });
798
+ }
799
+ }
800
+
801
+ function validateRequiredString(
802
+ value: Record<string, unknown>,
803
+ field: string,
804
+ details: Record<string, unknown>,
805
+ detailField = field,
806
+ thrower: NonSymphonyThrower = throwNonSymphonyState,
807
+ ): void {
808
+ if (typeof value[field] !== "string") {
809
+ thrower(details, "state response field had an invalid shape", { field: detailField, expected: "string" });
810
+ }
811
+ }
812
+
813
+ function validateRequiredStringArray(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
814
+ const fieldValue = value[field];
815
+ if (!Array.isArray(fieldValue) || fieldValue.some((entry) => typeof entry !== "string")) {
816
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "string[]" });
817
+ }
818
+ }
819
+
820
+ function validateRequiredBoolean(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
821
+ if (typeof value[field] !== "boolean") {
822
+ throwNonSymphonyState(details, "state response field had an invalid shape", { field: detailField, expected: "boolean" });
823
+ }
824
+ }
825
+
826
+ function validateRequiredNumber(
827
+ value: Record<string, unknown>,
828
+ field: string,
829
+ details: Record<string, unknown>,
830
+ detailField = field,
831
+ thrower: NonSymphonyThrower = throwNonSymphonyState,
832
+ ): void {
833
+ if (!isFiniteNumber(value[field])) {
834
+ thrower(details, "state response field had an invalid shape", { field: detailField, expected: "number" });
835
+ }
836
+ }
837
+
838
+ function throwNonSymphonyState(details: Record<string, unknown>, reason: string, extraDetails: Record<string, unknown> = {}): never {
839
+ throw new SymphonyExtensionError("non_symphony_response", "Response did not look like Symphony state", {
840
+ ...details,
841
+ reason,
842
+ ...extraDetails,
843
+ });
844
+ }
845
+
846
+ function isRecord(value: unknown): value is Record<string, unknown> {
847
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
848
+ }
849
+
850
+ function isFiniteNumber(value: unknown): value is number {
851
+ return typeof value === "number" && Number.isFinite(value);
852
+ }
853
+
854
+ function isAbortError(error: unknown, signal?: AbortSignal | null): boolean {
855
+ return Boolean(signal?.aborted) || (error instanceof Error && error.name === "AbortError");
856
+ }