-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
136 lines (121 loc) · 4.06 KB
/
Copy pathclient.ts
File metadata and controls
136 lines (121 loc) · 4.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
* Ix Core Runtime HTTP client.
*
* Calls the Ix Core Runtime API (v2) when available.
* Returns null when the runtime is unavailable — all callers must fall back to CLI.
*
* The runtime is the v2 contract target documented in IX_PLUGIN_OVERHAUL_SPEC.md.
* Until the runtime is deployed (target: 2026-07-15), all tools use the ix CLI directly.
* This client exists so tools can route through the runtime as soon as it is reachable,
* without requiring any code changes in the tools themselves.
*/
import { scrubPayload, redactSecrets } from "./secrets.ts";
const RUNTIME_BASE = process.env.IX_RUNTIME_URL ?? "http://127.0.0.1:7743";
const API_VERSION = "2.0";
const SURFACE = "opencode-plugin";
const SURFACE_VERSION = "1.0.0";
const DEFAULT_TIMEOUT_MS = 5000;
const HEALTH_TIMEOUT_MS = 2000;
export type RuntimeResponse = Record<string, unknown>;
export interface RuntimeCallOpts {
workspaceId?: string;
dir?: string;
timeoutMs?: number;
skill?: string;
}
/**
* Call the Ix Core Runtime API.
*
* Returns the parsed JSON response body, or null if the runtime is unreachable
* or returns a non-2xx status. Never throws.
*/
export async function callRuntime(
endpoint: string,
payload: Record<string, unknown>,
opts: RuntimeCallOpts = {}
): Promise<RuntimeResponse | null> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const rawEnvelope = {
api_version: API_VERSION,
...payload,
workspace: {
workspace_id: opts.workspaceId ?? opts.dir ?? "local",
...(opts.dir ? { root_uri: `file://${opts.dir}` } : {}),
...((payload.workspace as Record<string, unknown> | undefined) ?? {}),
},
caller: {
surface: SURFACE,
surface_version: SURFACE_VERSION,
...(opts.skill ? { skill: opts.skill } : {}),
...((payload.caller as Record<string, unknown> | undefined) ?? {}),
},
};
const body = JSON.stringify(scrubPayload(rawEnvelope));
const controller = new AbortController();
// Cleared in `finally`, not after the await: when the runtime is unreachable
// the fetch rejects, and a timer cleared only on the success path stays
// pending for its full duration. It keeps the event loop alive, so the host
// process hangs for `timeoutMs` at exit on every call that falls back to the
// CLI -- which is every call on a machine without the runtime running.
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${RUNTIME_BASE}${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: controller.signal,
});
if (!res.ok) return null;
const json = (await res.json()) as RuntimeResponse;
if (typeof json.preview_markdown === "string") {
json.preview_markdown = redactSecrets(json.preview_markdown);
}
return json;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* GET variant for endpoints like /v2/status.
* Returns null on any failure.
*/
export async function getRuntime(
endpoint: string,
opts: Pick<RuntimeCallOpts, "timeoutMs"> = {}
): Promise<RuntimeResponse | null> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${RUNTIME_BASE}${endpoint}`, {
signal: controller.signal,
});
if (!res.ok) return null;
return (await res.json()) as RuntimeResponse;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* Probe whether the runtime is reachable via GET /v2/status.
* Returns false on any failure.
*/
export async function isRuntimeAvailable(): Promise<boolean> {
const controller = new AbortController();
// This one was never captured at all, so it leaked on every path.
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
try {
const res = await fetch(`${RUNTIME_BASE}/v2/status`, {
signal: controller.signal,
});
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}