@opencode-ai/client 0.0.0-next-15611 → 0.0.0-next-15614
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/dist/effect/api/api.d.ts +0 -1
- package/dist/effect/generated/client.d.ts +0 -14
- package/dist/effect/generated/client.js +1 -1
- package/dist/effect/index.d.ts +0 -1
- package/dist/effect/index.js +0 -1
- package/dist/effect/service.d.ts +33 -43
- package/dist/effect/service.js +29 -63
- package/dist/promise/generated/client.js +1 -1
- package/dist/promise/generated/types.d.ts +4 -23
- package/dist/promise/service.d.ts +19 -0
- package/dist/promise/service.js +230 -0
- package/dist/service.d.ts +48 -0
- package/dist/service.js +0 -0
- package/package.json +12 -4
package/dist/effect/api/api.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Outpu
|
|
|
9
9
|
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0];
|
|
10
10
|
export type Endpoint0_1Input = {
|
|
11
11
|
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"];
|
|
12
|
-
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"];
|
|
13
12
|
};
|
|
14
13
|
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>;
|
|
15
14
|
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>;
|
|
@@ -8,7 +8,6 @@ type RawClient = HttpApiClient.ForApi<typeof ClientApi>;
|
|
|
8
8
|
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0];
|
|
9
9
|
type Endpoint0_1Input = {
|
|
10
10
|
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"];
|
|
11
|
-
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"];
|
|
12
11
|
};
|
|
13
12
|
type Endpoint2_0Request = Parameters<RawClient["server.location"]["location.get"]>[0];
|
|
14
13
|
type Endpoint2_0Input = {
|
|
@@ -511,19 +510,6 @@ export declare const make: (options?: {
|
|
|
511
510
|
readonly healthy: true;
|
|
512
511
|
readonly version: string;
|
|
513
512
|
readonly pid: number;
|
|
514
|
-
readonly status: {
|
|
515
|
-
readonly type: "starting";
|
|
516
|
-
} | {
|
|
517
|
-
readonly type: "ready";
|
|
518
|
-
} | {
|
|
519
|
-
readonly type: "stopping";
|
|
520
|
-
readonly targetVersion?: string | undefined;
|
|
521
|
-
} | {
|
|
522
|
-
readonly type: "failed";
|
|
523
|
-
readonly message: string;
|
|
524
|
-
readonly action: string;
|
|
525
|
-
};
|
|
526
|
-
readonly instanceID?: string | undefined;
|
|
527
513
|
}, Schema.SchemaError | import("@opencode-ai/protocol/errors").InvalidRequestError | import("@opencode-ai/protocol/errors").UnauthorizedError | HttpClientError.HttpClientError | ClientError, never>;
|
|
528
514
|
stop: (input: Endpoint0_1Input) => Effect.Effect<{
|
|
529
515
|
readonly accepted: boolean;
|
|
@@ -9,7 +9,7 @@ const mapClientError = (error) => HttpClientError.isHttpClientError(error) || Sc
|
|
|
9
9
|
? new ClientError({ cause: error })
|
|
10
10
|
: error;
|
|
11
11
|
const Endpoint0_0 = (raw) => () => raw["health.get"]({}).pipe(Effect.mapError(mapClientError));
|
|
12
|
-
const Endpoint0_1 = (raw) => (input) => raw["health.stop"]({ payload: { instanceID: input["instanceID"]
|
|
12
|
+
const Endpoint0_1 = (raw) => (input) => raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError));
|
|
13
13
|
const adaptGroup0 = (raw) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) });
|
|
14
14
|
const Endpoint1_0 = (raw) => () => raw["server.get"]({}).pipe(Effect.mapError(mapClientError));
|
|
15
15
|
const adaptGroup1 = (raw) => ({ get: Endpoint1_0(raw) });
|
package/dist/effect/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Effect } from "effect";
|
|
2
2
|
export * from "./generated/index";
|
|
3
3
|
export type { AgentApi, AppApi, CatalogApi, CommandApi, EventApi, IntegrationApi, ModelApi, PluginApi, ProviderApi, ReferenceApi, SessionApi, SkillApi, } from "./api.js";
|
|
4
|
-
export { Service } from "./service.js";
|
|
5
4
|
export { Agent } from "@opencode-ai/schema/agent";
|
|
6
5
|
export { Command } from "@opencode-ai/schema/command";
|
|
7
6
|
export { Credential } from "@opencode-ai/schema/credential";
|
package/dist/effect/index.js
CHANGED
package/dist/effect/service.d.ts
CHANGED
|
@@ -1,39 +1,10 @@
|
|
|
1
|
-
import { ServiceStatus } from "@opencode-ai/protocol/groups/health";
|
|
2
1
|
import { Effect, FileSystem, Schema } from "effect";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
};
|
|
10
|
-
};
|
|
11
|
-
export type Options = {
|
|
12
|
-
readonly file?: string;
|
|
13
|
-
readonly version?: string;
|
|
14
|
-
readonly command?: ReadonlyArray<string>;
|
|
15
|
-
};
|
|
16
|
-
export type StartReason = "missing" | "version-mismatch";
|
|
17
|
-
export type StartOptions = Options & {
|
|
18
|
-
readonly onStart?: (reason: StartReason, existing?: Info) => void;
|
|
19
|
-
readonly onStatus?: (status: Status) => void;
|
|
20
|
-
};
|
|
21
|
-
export type Status = {
|
|
22
|
-
readonly type: "missing";
|
|
23
|
-
} | {
|
|
24
|
-
readonly type: "unreachable";
|
|
25
|
-
} | {
|
|
26
|
-
readonly type: "unresponsive";
|
|
27
|
-
} | (ServiceStatus.State & {
|
|
28
|
-
readonly version?: string;
|
|
29
|
-
});
|
|
30
|
-
declare const FailedError_base: Schema.Class<FailedError, Schema.TaggedStruct<"ServiceFailedError", {
|
|
31
|
-
readonly message: Schema.String;
|
|
32
|
-
readonly action: Schema.String;
|
|
33
|
-
}>, import("effect/Cause").YieldableError>;
|
|
34
|
-
export declare class FailedError extends FailedError_base {
|
|
35
|
-
}
|
|
36
|
-
export declare const discover: (options?: Options | undefined) => Effect.Effect<{
|
|
2
|
+
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js";
|
|
3
|
+
export * from "../service.js";
|
|
4
|
+
/** Contents of the local service registration file. */
|
|
5
|
+
export type Info = import("../service.js").Info;
|
|
6
|
+
/** Discover a healthy, compatible local service without starting one. */
|
|
7
|
+
export declare const discover: (options?: DiscoverOptions | undefined) => Effect.Effect<{
|
|
37
8
|
url: string;
|
|
38
9
|
auth: {
|
|
39
10
|
type: "basic";
|
|
@@ -41,15 +12,15 @@ export declare const discover: (options?: Options | undefined) => Effect.Effect<
|
|
|
41
12
|
password: string;
|
|
42
13
|
} | undefined;
|
|
43
14
|
} | undefined, never, FileSystem.FileSystem>;
|
|
44
|
-
|
|
45
|
-
export declare const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
export declare const stop: (options?: Options | undefined, metadata?: StopMetadata | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
|
|
15
|
+
/** Ensure a healthy, compatible local service is running. */
|
|
16
|
+
export declare const ensure: (options?: EnsureOptions | undefined) => Effect.Effect<Endpoint, Error, FileSystem.FileSystem>;
|
|
17
|
+
/** Stop the registered local service. */
|
|
18
|
+
export declare const stop: (options?: StopOptions | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
|
|
19
|
+
/** Create HTTP authentication headers for a service endpoint. */
|
|
50
20
|
export declare function headers(endpoint: Endpoint): {
|
|
51
21
|
authorization: string;
|
|
52
22
|
} | undefined;
|
|
23
|
+
/** Schema for the local service registration file. */
|
|
53
24
|
export declare const Info: Schema.Struct<{
|
|
54
25
|
readonly id: Schema.optional<Schema.String>;
|
|
55
26
|
readonly version: Schema.optional<Schema.String>;
|
|
@@ -57,5 +28,24 @@ export declare const Info: Schema.Struct<{
|
|
|
57
28
|
readonly pid: Schema.Int;
|
|
58
29
|
readonly password: Schema.optional<Schema.String>;
|
|
59
30
|
}>;
|
|
60
|
-
|
|
61
|
-
export
|
|
31
|
+
/** Effect-based local service lifecycle operations. */
|
|
32
|
+
export declare const Service: {
|
|
33
|
+
discover: (options?: DiscoverOptions | undefined) => Effect.Effect<{
|
|
34
|
+
url: string;
|
|
35
|
+
auth: {
|
|
36
|
+
type: "basic";
|
|
37
|
+
username: string;
|
|
38
|
+
password: string;
|
|
39
|
+
} | undefined;
|
|
40
|
+
} | undefined, never, FileSystem.FileSystem>;
|
|
41
|
+
ensure: (options?: EnsureOptions | undefined) => Effect.Effect<Endpoint, Error, FileSystem.FileSystem>;
|
|
42
|
+
stop: (options?: StopOptions | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
|
|
43
|
+
headers: typeof headers;
|
|
44
|
+
Info: Schema.Struct<{
|
|
45
|
+
readonly id: Schema.optional<Schema.String>;
|
|
46
|
+
readonly version: Schema.optional<Schema.String>;
|
|
47
|
+
readonly url: Schema.String;
|
|
48
|
+
readonly pid: Schema.Int;
|
|
49
|
+
readonly password: Schema.optional<Schema.String>;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
package/dist/effect/service.js
CHANGED
|
@@ -3,30 +3,16 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
export
|
|
7
|
-
message: Schema.String,
|
|
8
|
-
action: Schema.String,
|
|
9
|
-
}) {
|
|
10
|
-
}
|
|
6
|
+
export * from "../service.js";
|
|
11
7
|
// Read-only lookup: registration file plus health check and version gate.
|
|
12
|
-
// Never spawns; escalation to
|
|
8
|
+
// Never spawns; escalation to ensure() is the caller's policy.
|
|
9
|
+
/** Discover a healthy, compatible local service without starting one. */
|
|
13
10
|
export const discover = Effect.fn("service.discover")(function* (options = {}) {
|
|
14
11
|
return (yield* discoverLocal(options))?.endpoint;
|
|
15
12
|
});
|
|
16
|
-
export const status = Effect.fn("service.status")(function* (options = {}) {
|
|
17
|
-
const result = yield* registered(options.file, true);
|
|
18
|
-
if (result.info === undefined)
|
|
19
|
-
return { type: "missing" };
|
|
20
|
-
if (result.service === undefined)
|
|
21
|
-
return { type: "unreachable" };
|
|
22
|
-
return publicStatus(result.service);
|
|
23
|
-
});
|
|
24
|
-
function publicStatus(service) {
|
|
25
|
-
return { ...service.status, version: service.version };
|
|
26
|
-
}
|
|
27
13
|
const discoverLocal = Effect.fnUntraced(function* (options) {
|
|
28
14
|
const found = (yield* registered(options.file)).service;
|
|
29
|
-
if (found?.
|
|
15
|
+
if (found?.state !== "ready")
|
|
30
16
|
return undefined;
|
|
31
17
|
if (options.version !== undefined && found.version !== options.version)
|
|
32
18
|
return undefined;
|
|
@@ -35,18 +21,18 @@ const discoverLocal = Effect.fnUntraced(function* (options) {
|
|
|
35
21
|
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
|
36
22
|
// version-mismatched one, and otherwise spawns small contenders until a server
|
|
37
23
|
// becomes discoverable. A contender is never killed merely for slow startup.
|
|
38
|
-
|
|
24
|
+
/** Ensure a healthy, compatible local service is running. */
|
|
25
|
+
export const ensure = Effect.fn("service.ensure")(function* (options = {}) {
|
|
39
26
|
const contenders = new Set();
|
|
40
27
|
let announced = false;
|
|
41
|
-
let reported;
|
|
42
28
|
let lastSpawn = 0;
|
|
43
29
|
let spawnDelay = 5_000;
|
|
44
30
|
let ownerHeld = false;
|
|
45
|
-
const announce = (reason,
|
|
31
|
+
const announce = (reason, previousVersion) => Effect.sync(() => {
|
|
46
32
|
if (announced)
|
|
47
33
|
return;
|
|
48
34
|
announced = true;
|
|
49
|
-
options.onStart?.(reason,
|
|
35
|
+
options.onStart?.(reason, previousVersion);
|
|
50
36
|
});
|
|
51
37
|
const spawnContender = Effect.gen(function* () {
|
|
52
38
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
|
|
@@ -69,26 +55,18 @@ export const start = Effect.fn("service.start")(function* (options = {}) {
|
|
|
69
55
|
const registration = yield* registered(options.file, true);
|
|
70
56
|
const info = registration.info;
|
|
71
57
|
const service = registration.service;
|
|
72
|
-
const current = service === undefined ? { type: info === undefined ? "missing" : "unreachable" } : publicStatus(service);
|
|
73
|
-
const next = ownerHeld && service === undefined ? { type: "unresponsive" } : current;
|
|
74
|
-
yield* Effect.sync(() => {
|
|
75
|
-
if (sameStatus(reported, next))
|
|
76
|
-
return;
|
|
77
|
-
reported = next;
|
|
78
|
-
options.onStatus?.(next);
|
|
79
|
-
});
|
|
80
58
|
if (service !== undefined) {
|
|
81
59
|
ownerHeld = false;
|
|
82
60
|
spawnDelay = 5_000;
|
|
83
61
|
const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
|
|
84
|
-
if (compatible && service.
|
|
62
|
+
if (compatible && service.state === "ready")
|
|
85
63
|
return Option.some(service);
|
|
86
|
-
if (compatible && service.
|
|
87
|
-
return yield* new
|
|
88
|
-
if (compatible
|
|
64
|
+
if (compatible && service.state === "failed")
|
|
65
|
+
return yield* Effect.fail(new Error("Background service failed to start"));
|
|
66
|
+
if (compatible)
|
|
89
67
|
return Option.none();
|
|
90
|
-
yield* announce("version-mismatch", service.
|
|
91
|
-
yield* kill(service, options
|
|
68
|
+
yield* announce("version-mismatch", service.version);
|
|
69
|
+
yield* kill(service, options).pipe(Effect.ignore);
|
|
92
70
|
lastSpawn = 0;
|
|
93
71
|
return Option.none();
|
|
94
72
|
}
|
|
@@ -113,20 +91,6 @@ export const start = Effect.fn("service.start")(function* (options = {}) {
|
|
|
113
91
|
}).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }));
|
|
114
92
|
return Option.getOrThrow(found).endpoint;
|
|
115
93
|
});
|
|
116
|
-
function sameStatus(left, right) {
|
|
117
|
-
if (left?.type !== right.type)
|
|
118
|
-
return false;
|
|
119
|
-
if (right.type === "failed")
|
|
120
|
-
return (left.type === "failed" &&
|
|
121
|
-
left.version === right.version &&
|
|
122
|
-
left.message === right.message &&
|
|
123
|
-
left.action === right.action);
|
|
124
|
-
if (right.type === "stopping")
|
|
125
|
-
return left.type === "stopping" && left.version === right.version && left.targetVersion === right.targetVersion;
|
|
126
|
-
if (right.type === "starting" || right.type === "ready")
|
|
127
|
-
return left.type === right.type && left.version === right.version;
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
94
|
function contenderFailure(contender) {
|
|
131
95
|
const error = contender.error();
|
|
132
96
|
if (error !== undefined)
|
|
@@ -140,20 +104,23 @@ function contenderFailure(contender) {
|
|
|
140
104
|
function contenderFinished(contender) {
|
|
141
105
|
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
|
|
142
106
|
}
|
|
143
|
-
|
|
107
|
+
/** Stop the registered local service. */
|
|
108
|
+
export const stop = Effect.fn("service.stop")(function* (options = {}) {
|
|
144
109
|
const existing = yield* find(options);
|
|
145
110
|
if (existing !== undefined)
|
|
146
|
-
yield* kill(existing, options
|
|
111
|
+
yield* kill(existing, options);
|
|
147
112
|
});
|
|
148
113
|
function fallback() {
|
|
149
114
|
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
|
|
150
115
|
return join(state, "opencode", "service.json");
|
|
151
116
|
}
|
|
117
|
+
/** Create HTTP authentication headers for a service endpoint. */
|
|
152
118
|
export function headers(endpoint) {
|
|
153
119
|
if (endpoint.auth === undefined)
|
|
154
120
|
return undefined;
|
|
155
121
|
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) };
|
|
156
122
|
}
|
|
123
|
+
/** Schema for the local service registration file. */
|
|
157
124
|
export const Info = Schema.Struct({
|
|
158
125
|
id: Schema.optional(Schema.String),
|
|
159
126
|
version: Schema.optional(Schema.String),
|
|
@@ -193,13 +160,11 @@ const probe = Effect.fnUntraced(function* (info, allowLegacy = false) {
|
|
|
193
160
|
return undefined;
|
|
194
161
|
if (info.version !== undefined && health.value.version !== info.version)
|
|
195
162
|
return undefined;
|
|
196
|
-
if (info.id !== undefined && health.value.instanceID !== undefined && health.value.instanceID !== info.id)
|
|
197
|
-
return undefined;
|
|
198
163
|
return {
|
|
199
164
|
info,
|
|
200
165
|
endpoint,
|
|
201
166
|
version: health.value.version,
|
|
202
|
-
|
|
167
|
+
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
|
203
168
|
legacy: false,
|
|
204
169
|
};
|
|
205
170
|
}
|
|
@@ -207,7 +172,7 @@ const probe = Effect.fnUntraced(function* (info, allowLegacy = false) {
|
|
|
207
172
|
Option.isNone(decodeLegacyHealth(body)) ||
|
|
208
173
|
(typeof body === "object" && body !== null && ("version" in body || "pid" in body)))
|
|
209
174
|
return undefined;
|
|
210
|
-
return { info, endpoint,
|
|
175
|
+
return { info, endpoint, state: "ready", legacy: true };
|
|
211
176
|
});
|
|
212
177
|
const registered = Effect.fnUntraced(function* (file, allowLegacy = false) {
|
|
213
178
|
const info = yield* read(file);
|
|
@@ -215,12 +180,12 @@ const registered = Effect.fnUntraced(function* (file, allowLegacy = false) {
|
|
|
215
180
|
return { info: undefined, service: undefined };
|
|
216
181
|
return { info, service: yield* probe(info, allowLegacy) };
|
|
217
182
|
});
|
|
218
|
-
// Health-checked lookup without the version gate:
|
|
183
|
+
// Health-checked lookup without the version gate: lifecycle operations must be
|
|
219
184
|
// able to see (and replace or stop) a server from a different version.
|
|
220
185
|
const find = Effect.fnUntraced(function* (options) {
|
|
221
186
|
return (yield* registered(options.file, true)).service;
|
|
222
187
|
});
|
|
223
|
-
// 50ms cadence bounded at ~5s, shared by stop escalation and each
|
|
188
|
+
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
|
224
189
|
// discovery window.
|
|
225
190
|
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)));
|
|
226
191
|
const signal = (pid, name) => Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore);
|
|
@@ -233,8 +198,8 @@ const stopped = Effect.fnUntraced(function* (pid) {
|
|
|
233
198
|
function same(left, right) {
|
|
234
199
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid;
|
|
235
200
|
}
|
|
236
|
-
const kill = Effect.fnUntraced(function* (service, options
|
|
237
|
-
const requested = yield* requestStop(service
|
|
201
|
+
const kill = Effect.fnUntraced(function* (service, options) {
|
|
202
|
+
const requested = yield* requestStop(service);
|
|
238
203
|
if (requested === "rejected")
|
|
239
204
|
return;
|
|
240
205
|
if (requested === "unsupported") {
|
|
@@ -255,13 +220,13 @@ const kill = Effect.fnUntraced(function* (service, options, targetVersion) {
|
|
|
255
220
|
yield* stopped(service.info.pid).pipe(Effect.retry(poll));
|
|
256
221
|
});
|
|
257
222
|
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse);
|
|
258
|
-
const requestStop = Effect.fnUntraced(function* (service
|
|
223
|
+
const requestStop = Effect.fnUntraced(function* (service) {
|
|
259
224
|
if (service.info.id === undefined || service.legacy)
|
|
260
225
|
return "unsupported";
|
|
261
226
|
const response = yield* Effect.tryPromise(() => fetch(new URL("/api/service/stop", service.info.url), {
|
|
262
227
|
method: "POST",
|
|
263
228
|
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
|
264
|
-
body: JSON.stringify({ instanceID: service.info.id
|
|
229
|
+
body: JSON.stringify({ instanceID: service.info.id }),
|
|
265
230
|
signal: AbortSignal.timeout(2_000),
|
|
266
231
|
})).pipe(Effect.option, Effect.map(Option.getOrUndefined));
|
|
267
232
|
if (response === undefined || response.status === 404 || response.status === 405)
|
|
@@ -272,4 +237,5 @@ const requestStop = Effect.fnUntraced(function* (service, targetVersion) {
|
|
|
272
237
|
return "rejected";
|
|
273
238
|
return "accepted";
|
|
274
239
|
});
|
|
275
|
-
|
|
240
|
+
/** Effect-based local service lifecycle operations. */
|
|
241
|
+
export const Service = { discover, ensure, stop, headers, Info };
|
|
@@ -132,7 +132,7 @@ export function make(options) {
|
|
|
132
132
|
stop: (input, requestOptions) => request({
|
|
133
133
|
method: "POST",
|
|
134
134
|
path: `/api/service/stop`,
|
|
135
|
-
body: { instanceID: input["instanceID"]
|
|
135
|
+
body: { instanceID: input["instanceID"] },
|
|
136
136
|
successStatus: 200,
|
|
137
137
|
declaredStatuses: [401, 400],
|
|
138
138
|
empty: false,
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
|
2
2
|
[key: string]: JsonValue;
|
|
3
3
|
};
|
|
4
|
-
export type
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} | {
|
|
9
|
-
type: "stopping";
|
|
10
|
-
targetVersion?: string | null;
|
|
11
|
-
} | {
|
|
12
|
-
type: "failed";
|
|
13
|
-
message: string;
|
|
14
|
-
action: string;
|
|
4
|
+
export type ServiceHealth = {
|
|
5
|
+
healthy: true;
|
|
6
|
+
version: string;
|
|
7
|
+
pid: number;
|
|
15
8
|
};
|
|
16
9
|
export type ServiceStopResponse = {
|
|
17
10
|
accepted: boolean;
|
|
@@ -752,13 +745,6 @@ export type VcsFileStatus = {
|
|
|
752
745
|
deletions: number;
|
|
753
746
|
status: "added" | "deleted" | "modified";
|
|
754
747
|
};
|
|
755
|
-
export type ServiceHealth = {
|
|
756
|
-
healthy: true;
|
|
757
|
-
version: string;
|
|
758
|
-
pid: number;
|
|
759
|
-
instanceID?: string | null;
|
|
760
|
-
status?: ServiceStatus;
|
|
761
|
-
};
|
|
762
748
|
export type SessionMessageModelSelected = {
|
|
763
749
|
id: string;
|
|
764
750
|
metadata?: {
|
|
@@ -3178,12 +3164,7 @@ export type HealthGetOutput = ServiceHealth;
|
|
|
3178
3164
|
export type HealthStopInput = {
|
|
3179
3165
|
readonly instanceID: {
|
|
3180
3166
|
readonly instanceID: string;
|
|
3181
|
-
readonly targetVersion?: string | undefined;
|
|
3182
3167
|
}["instanceID"];
|
|
3183
|
-
readonly targetVersion?: {
|
|
3184
|
-
readonly instanceID: string;
|
|
3185
|
-
readonly targetVersion?: string | undefined;
|
|
3186
|
-
}["targetVersion"];
|
|
3187
3168
|
};
|
|
3188
3169
|
export type HealthStopOutput = ServiceStopResponse;
|
|
3189
3170
|
export type ServerGetOutput = {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js";
|
|
2
|
+
export * from "../service.js";
|
|
3
|
+
/** Discover a healthy, compatible local service without starting one. */
|
|
4
|
+
export declare function discover(options?: DiscoverOptions): Promise<Endpoint | undefined>;
|
|
5
|
+
/** Ensure a healthy, compatible local service is running. */
|
|
6
|
+
export declare function ensure(options?: EnsureOptions): Promise<Endpoint>;
|
|
7
|
+
/** Stop the registered local service. */
|
|
8
|
+
export declare function stop(options?: StopOptions): Promise<void>;
|
|
9
|
+
/** Create HTTP authentication headers for a service endpoint. */
|
|
10
|
+
export declare function headers(endpoint: Endpoint): {
|
|
11
|
+
authorization: string;
|
|
12
|
+
} | undefined;
|
|
13
|
+
/** Promise-based local service lifecycle operations. */
|
|
14
|
+
export declare const Service: {
|
|
15
|
+
discover: typeof discover;
|
|
16
|
+
ensure: typeof ensure;
|
|
17
|
+
stop: typeof stop;
|
|
18
|
+
headers: typeof headers;
|
|
19
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export * from "../service.js";
|
|
6
|
+
/** Discover a healthy, compatible local service without starting one. */
|
|
7
|
+
export async function discover(options = {}) {
|
|
8
|
+
return (await discoverLocal(options))?.endpoint;
|
|
9
|
+
}
|
|
10
|
+
async function discoverLocal(options) {
|
|
11
|
+
const found = (await registered(options.file)).service;
|
|
12
|
+
if (found?.state !== "ready")
|
|
13
|
+
return undefined;
|
|
14
|
+
if (options.version !== undefined && found.version !== options.version)
|
|
15
|
+
return undefined;
|
|
16
|
+
return found;
|
|
17
|
+
}
|
|
18
|
+
/** Ensure a healthy, compatible local service is running. */
|
|
19
|
+
export async function ensure(options = {}) {
|
|
20
|
+
const contenders = new Set();
|
|
21
|
+
let announced = false;
|
|
22
|
+
let lastSpawn = 0;
|
|
23
|
+
let spawnDelay = 5_000;
|
|
24
|
+
let ownerHeld = false;
|
|
25
|
+
const announce = (reason, previousVersion) => {
|
|
26
|
+
if (announced)
|
|
27
|
+
return;
|
|
28
|
+
announced = true;
|
|
29
|
+
options.onStart?.(reason, previousVersion);
|
|
30
|
+
};
|
|
31
|
+
const spawnContender = () => {
|
|
32
|
+
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
|
|
33
|
+
if (command === undefined)
|
|
34
|
+
throw new Error("Missing service command");
|
|
35
|
+
try {
|
|
36
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
37
|
+
let error;
|
|
38
|
+
child.once("error", (cause) => {
|
|
39
|
+
error = new Error("Failed to start server", { cause });
|
|
40
|
+
});
|
|
41
|
+
child.unref();
|
|
42
|
+
return { child, error: () => error };
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw new Error("Failed to start server", { cause });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
while (true) {
|
|
49
|
+
const registration = await registered(options.file, true);
|
|
50
|
+
if (registration.service !== undefined) {
|
|
51
|
+
ownerHeld = false;
|
|
52
|
+
spawnDelay = 5_000;
|
|
53
|
+
const service = registration.service;
|
|
54
|
+
const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
|
|
55
|
+
if (compatible && service.state === "ready")
|
|
56
|
+
return service.endpoint;
|
|
57
|
+
if (compatible && service.state === "failed")
|
|
58
|
+
throw new Error("Background service failed to start");
|
|
59
|
+
if (!compatible) {
|
|
60
|
+
announce("version-mismatch", service.version);
|
|
61
|
+
await kill(service, options).catch(() => undefined);
|
|
62
|
+
lastSpawn = 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
if (lastSpawn === 0 && registration.info !== undefined)
|
|
67
|
+
lastSpawn = Date.now();
|
|
68
|
+
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined);
|
|
69
|
+
if (failure !== undefined)
|
|
70
|
+
throw failure;
|
|
71
|
+
const finished = [...contenders].filter(contenderFinished);
|
|
72
|
+
if (finished.some((item) => item.child.exitCode === 0)) {
|
|
73
|
+
ownerHeld = true;
|
|
74
|
+
spawnDelay = Math.min(spawnDelay * 2, 30_000);
|
|
75
|
+
}
|
|
76
|
+
finished.forEach((item) => contenders.delete(item));
|
|
77
|
+
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
|
78
|
+
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
|
79
|
+
announce("missing");
|
|
80
|
+
contenders.add(spawnContender());
|
|
81
|
+
lastSpawn = Date.now();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
await delay(1_000);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function contenderFailure(contender) {
|
|
88
|
+
const error = contender.error();
|
|
89
|
+
if (error !== undefined)
|
|
90
|
+
return error;
|
|
91
|
+
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
|
92
|
+
return new Error(`Server process exited with code ${contender.child.exitCode}`);
|
|
93
|
+
if (contender.child.signalCode !== null)
|
|
94
|
+
return new Error(`Server process terminated by ${contender.child.signalCode}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
function contenderFinished(contender) {
|
|
98
|
+
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
|
|
99
|
+
}
|
|
100
|
+
/** Stop the registered local service. */
|
|
101
|
+
export async function stop(options = {}) {
|
|
102
|
+
const existing = await find(options);
|
|
103
|
+
if (existing !== undefined)
|
|
104
|
+
await kill(existing, options);
|
|
105
|
+
}
|
|
106
|
+
function fallback() {
|
|
107
|
+
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json");
|
|
108
|
+
}
|
|
109
|
+
/** Create HTTP authentication headers for a service endpoint. */
|
|
110
|
+
export function headers(endpoint) {
|
|
111
|
+
if (endpoint.auth === undefined)
|
|
112
|
+
return undefined;
|
|
113
|
+
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") };
|
|
114
|
+
}
|
|
115
|
+
async function read(file) {
|
|
116
|
+
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined);
|
|
117
|
+
if (text === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function probe(info, allowLegacy = false) {
|
|
127
|
+
const endpoint = {
|
|
128
|
+
url: info.url,
|
|
129
|
+
auth: info.password === undefined
|
|
130
|
+
? undefined
|
|
131
|
+
: { type: "basic", username: "opencode", password: info.password },
|
|
132
|
+
};
|
|
133
|
+
const response = await fetch(new URL("/api/health", info.url), {
|
|
134
|
+
headers: headers(endpoint),
|
|
135
|
+
signal: AbortSignal.timeout(2_000),
|
|
136
|
+
}).catch(() => undefined);
|
|
137
|
+
const body = (await response?.json().catch(() => undefined));
|
|
138
|
+
if (body !== undefined && "version" in body && "pid" in body) {
|
|
139
|
+
if (body.pid !== info.pid)
|
|
140
|
+
return undefined;
|
|
141
|
+
if (info.version !== undefined && body.version !== info.version)
|
|
142
|
+
return undefined;
|
|
143
|
+
return {
|
|
144
|
+
info,
|
|
145
|
+
endpoint,
|
|
146
|
+
version: body.version,
|
|
147
|
+
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
|
148
|
+
legacy: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!allowLegacy || body?.healthy !== true)
|
|
152
|
+
return undefined;
|
|
153
|
+
return { info, endpoint, state: "ready", legacy: true };
|
|
154
|
+
}
|
|
155
|
+
async function registered(file, allowLegacy = false) {
|
|
156
|
+
const info = await read(file);
|
|
157
|
+
if (info === undefined)
|
|
158
|
+
return { info: undefined, service: undefined };
|
|
159
|
+
return { info, service: await probe(info, allowLegacy) };
|
|
160
|
+
}
|
|
161
|
+
async function find(options) {
|
|
162
|
+
return (await registered(options.file, true)).service;
|
|
163
|
+
}
|
|
164
|
+
function signal(pid, name) {
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, name);
|
|
167
|
+
}
|
|
168
|
+
catch { }
|
|
169
|
+
}
|
|
170
|
+
function stopped(pid) {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(pid, 0);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function waitUntilStopped(pid) {
|
|
180
|
+
for (let attempt = 0; attempt <= 100; attempt++) {
|
|
181
|
+
if (stopped(pid))
|
|
182
|
+
return true;
|
|
183
|
+
if (attempt < 100)
|
|
184
|
+
await delay(50);
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
function same(left, right) {
|
|
189
|
+
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid;
|
|
190
|
+
}
|
|
191
|
+
async function kill(service, options) {
|
|
192
|
+
const requested = await requestStop(service);
|
|
193
|
+
if (requested === "rejected")
|
|
194
|
+
return;
|
|
195
|
+
if (requested === "unsupported") {
|
|
196
|
+
const current = await find(options);
|
|
197
|
+
if (current === undefined || !same(current.info, service.info))
|
|
198
|
+
return;
|
|
199
|
+
signal(service.info.pid, "SIGTERM");
|
|
200
|
+
}
|
|
201
|
+
if (await waitUntilStopped(service.info.pid))
|
|
202
|
+
return;
|
|
203
|
+
const latest = await find(options);
|
|
204
|
+
if (latest === undefined || !same(latest.info, service.info))
|
|
205
|
+
return;
|
|
206
|
+
signal(service.info.pid, "SIGKILL");
|
|
207
|
+
if (!(await waitUntilStopped(service.info.pid)))
|
|
208
|
+
throw new Error(`Server process ${service.info.pid} is still running`);
|
|
209
|
+
}
|
|
210
|
+
async function requestStop(service) {
|
|
211
|
+
if (service.info.id === undefined || service.legacy)
|
|
212
|
+
return "unsupported";
|
|
213
|
+
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
|
216
|
+
body: JSON.stringify({ instanceID: service.info.id }),
|
|
217
|
+
signal: AbortSignal.timeout(2_000),
|
|
218
|
+
}).catch(() => undefined);
|
|
219
|
+
if (response === undefined || response.status === 404 || response.status === 405)
|
|
220
|
+
return "unsupported";
|
|
221
|
+
const body = (await response.json().catch(() => undefined));
|
|
222
|
+
if (!response.ok || body?.accepted !== true)
|
|
223
|
+
return "rejected";
|
|
224
|
+
return "accepted";
|
|
225
|
+
}
|
|
226
|
+
function delay(milliseconds) {
|
|
227
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
228
|
+
}
|
|
229
|
+
/** Promise-based local service lifecycle operations. */
|
|
230
|
+
export const Service = { discover, ensure, stop, headers };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Connection details for a local OpenCode service. */
|
|
2
|
+
export type Endpoint = {
|
|
3
|
+
/** Base URL of the service. */
|
|
4
|
+
readonly url: string;
|
|
5
|
+
/** Authentication required by the service, when configured. */
|
|
6
|
+
readonly auth?: {
|
|
7
|
+
/** HTTP authentication scheme. */
|
|
8
|
+
readonly type: "basic";
|
|
9
|
+
/** Basic authentication username. */
|
|
10
|
+
readonly username: string;
|
|
11
|
+
/** Basic authentication password. */
|
|
12
|
+
readonly password: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
/** Options used to discover the local OpenCode service. */
|
|
16
|
+
export type DiscoverOptions = {
|
|
17
|
+
/** Absolute registration file path. Defaults to the XDG state directory. */
|
|
18
|
+
readonly file?: string;
|
|
19
|
+
/** Required service version. */
|
|
20
|
+
readonly version?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Reason ensuring the service requires a new process. */
|
|
23
|
+
export type EnsureReason = "missing" | "version-mismatch";
|
|
24
|
+
/** Options used to ensure the local OpenCode service is running. */
|
|
25
|
+
export type EnsureOptions = DiscoverOptions & {
|
|
26
|
+
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
|
27
|
+
readonly command?: ReadonlyArray<string>;
|
|
28
|
+
/** Called once before spawning a new service process. */
|
|
29
|
+
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void;
|
|
30
|
+
};
|
|
31
|
+
/** Options used to stop the local OpenCode service. */
|
|
32
|
+
export type StopOptions = {
|
|
33
|
+
/** Absolute registration file path. Defaults to the XDG state directory. */
|
|
34
|
+
readonly file?: string;
|
|
35
|
+
};
|
|
36
|
+
/** Contents of the local service registration file. */
|
|
37
|
+
export type Info = {
|
|
38
|
+
/** Unique service instance identifier. */
|
|
39
|
+
readonly id?: string;
|
|
40
|
+
/** OpenCode version served by the process. */
|
|
41
|
+
readonly version?: string;
|
|
42
|
+
/** Base URL advertised by the service. */
|
|
43
|
+
readonly url: string;
|
|
44
|
+
/** Operating system process identifier. */
|
|
45
|
+
readonly pid: number;
|
|
46
|
+
/** Private service password, when authentication is enabled. */
|
|
47
|
+
readonly password?: string;
|
|
48
|
+
};
|
package/dist/service.js
ADDED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@opencode-ai/client",
|
|
4
|
-
"version": "0.0.0-next-
|
|
4
|
+
"version": "0.0.0-next-15614",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"import": "./dist/promise/api.js",
|
|
29
29
|
"types": "./dist/promise/api.d.ts"
|
|
30
30
|
},
|
|
31
|
+
"./service": {
|
|
32
|
+
"import": "./dist/promise/service.js",
|
|
33
|
+
"types": "./dist/promise/service.d.ts"
|
|
34
|
+
},
|
|
31
35
|
"./effect": {
|
|
32
36
|
"import": "./dist/effect/index.js",
|
|
33
37
|
"types": "./dist/effect/index.d.ts"
|
|
@@ -35,6 +39,10 @@
|
|
|
35
39
|
"./effect/api": {
|
|
36
40
|
"import": "./dist/effect/api.js",
|
|
37
41
|
"types": "./dist/effect/api.d.ts"
|
|
42
|
+
},
|
|
43
|
+
"./effect/service": {
|
|
44
|
+
"import": "./dist/effect/service.js",
|
|
45
|
+
"types": "./dist/effect/service.d.ts"
|
|
38
46
|
}
|
|
39
47
|
},
|
|
40
48
|
"scripts": {
|
|
@@ -45,8 +53,8 @@
|
|
|
45
53
|
"typecheck": "tsgo --noEmit"
|
|
46
54
|
},
|
|
47
55
|
"dependencies": {
|
|
48
|
-
"@opencode-ai/schema": "0.0.0-next-
|
|
49
|
-
"@opencode-ai/protocol": "0.0.0-next-
|
|
56
|
+
"@opencode-ai/schema": "0.0.0-next-15614",
|
|
57
|
+
"@opencode-ai/protocol": "0.0.0-next-15614"
|
|
50
58
|
},
|
|
51
59
|
"peerDependencies": {
|
|
52
60
|
"effect": "4.0.0-beta.83"
|
|
@@ -58,7 +66,7 @@
|
|
|
58
66
|
},
|
|
59
67
|
"devDependencies": {
|
|
60
68
|
"@effect/platform-node": "4.0.0-beta.83",
|
|
61
|
-
"@opencode-ai/httpapi-codegen": "0.0.0-next-
|
|
69
|
+
"@opencode-ai/httpapi-codegen": "0.0.0-next-15614",
|
|
62
70
|
"@tsconfig/bun": "1.0.9",
|
|
63
71
|
"@types/bun": "1.3.13",
|
|
64
72
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|