opencode-drive 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -38
- package/package.json +1 -1
- package/src/cli/commands.ts +101 -68
- package/src/cli/control.ts +43 -0
- package/src/cli/describe.ts +4 -5
- package/src/cli/index.ts +83 -110
- package/src/cli/instance.ts +193 -148
- package/src/cli/mock-backend.ts +25 -0
- package/src/cli/registry.ts +41 -83
- package/src/cli/restart.ts +3 -66
- package/src/cli/script.ts +66 -0
- package/src/cli/send.ts +19 -39
- package/src/cli/start.ts +93 -68
- package/src/cli/stop.ts +4 -28
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +94 -32
- package/src/client/protocol.ts +115 -29
- package/src/client/protocol.types.ts +17 -23
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -43
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
connectBackendSimulation,
|
|
3
|
+
connectSimulation,
|
|
4
|
+
type OpenedExchange,
|
|
5
|
+
} from "../client/index.js"
|
|
6
|
+
import {
|
|
7
|
+
flowProperties,
|
|
8
|
+
type FlowPropertyContext,
|
|
9
|
+
type FlowResult,
|
|
10
|
+
type FlowScenario,
|
|
11
|
+
type TurnOutcome,
|
|
12
|
+
} from "./flows/index.js"
|
|
3
13
|
|
|
4
14
|
const scenarioPath = process.argv[2]
|
|
5
15
|
const resultPath = process.argv[3]
|
|
6
|
-
if (scenarioPath === undefined)
|
|
16
|
+
if (scenarioPath === undefined)
|
|
17
|
+
throw new Error("usage: flow-driver SCENARIO [RESULT]")
|
|
7
18
|
|
|
8
19
|
const scenario: FlowScenario = await Bun.file(scenarioPath).json()
|
|
9
20
|
const stepDelay = Number(process.env.OPENCODE_PROBE_STEP_DELAY ?? "0")
|
|
10
21
|
const chunkDelay = Number(process.env.OPENCODE_PROBE_CHUNK_DELAY ?? "30")
|
|
11
22
|
const started = Date.now()
|
|
12
|
-
const ui = await connectSimulation({
|
|
13
|
-
|
|
23
|
+
const ui = await connectSimulation({
|
|
24
|
+
url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
|
|
25
|
+
})
|
|
26
|
+
const backend = await connectBackendSimulation({
|
|
27
|
+
url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
|
|
28
|
+
})
|
|
14
29
|
const responses = scenario.turns.flatMap((turn) => turn.responses)
|
|
15
30
|
let assistantExchanges = 0
|
|
16
31
|
let responseCursor = 0
|
|
@@ -25,19 +40,27 @@ await backend.attach(async (request: OpenedExchange) => {
|
|
|
25
40
|
try {
|
|
26
41
|
if (isTitleRequest(request)) {
|
|
27
42
|
titleExchanges++
|
|
28
|
-
await backend.chunk(request.id, [
|
|
43
|
+
await backend.chunk(request.id, [
|
|
44
|
+
{ type: "textDelta", text: scenario.name },
|
|
45
|
+
])
|
|
29
46
|
await backend.finish(request.id, "stop")
|
|
30
47
|
return
|
|
31
48
|
}
|
|
32
49
|
if (isSubagentRequest(request)) {
|
|
33
50
|
subagentExchanges++
|
|
34
|
-
await backend.chunk(request.id, [
|
|
51
|
+
await backend.chunk(request.id, [
|
|
52
|
+
{
|
|
53
|
+
type: "textDelta",
|
|
54
|
+
text: "The nested simulation fixture is consistent.",
|
|
55
|
+
},
|
|
56
|
+
])
|
|
35
57
|
await backend.finish(request.id, "stop")
|
|
36
58
|
return
|
|
37
59
|
}
|
|
38
60
|
const response = responses[responseCursor++]
|
|
39
61
|
assistantExchanges++
|
|
40
|
-
if (response === undefined)
|
|
62
|
+
if (response === undefined)
|
|
63
|
+
throw new Error(`unexpected assistant exchange ${assistantExchanges}`)
|
|
41
64
|
active.add(request.id)
|
|
42
65
|
for (const chunk of response.chunks) {
|
|
43
66
|
await backend.chunk(request.id, chunk)
|
|
@@ -46,9 +69,11 @@ await backend.attach(async (request: OpenedExchange) => {
|
|
|
46
69
|
}
|
|
47
70
|
if (chunkDelay > 0) await Bun.sleep(Math.max(chunkDelay * 3, 100))
|
|
48
71
|
if (response.terminal === "disconnect") await backend.disconnect(request.id)
|
|
49
|
-
else if (response.terminal !== "invalid-provider-event")
|
|
72
|
+
else if (response.terminal !== "invalid-provider-event")
|
|
73
|
+
await backend.finish(request.id, response.finish)
|
|
50
74
|
} catch (error) {
|
|
51
|
-
if (!interrupted.has(request.id))
|
|
75
|
+
if (!interrupted.has(request.id))
|
|
76
|
+
failure = error instanceof Error ? error : new Error(String(error))
|
|
52
77
|
} finally {
|
|
53
78
|
active.delete(request.id)
|
|
54
79
|
}
|
|
@@ -59,7 +84,10 @@ try {
|
|
|
59
84
|
let expectedExchanges = 0
|
|
60
85
|
for (const turn of scenario.turns) {
|
|
61
86
|
if (failure !== undefined) throw failure
|
|
62
|
-
await waitFor(
|
|
87
|
+
await waitFor(
|
|
88
|
+
"prompt editor before submit",
|
|
89
|
+
async () => (await ui.state()).focused.editor,
|
|
90
|
+
)
|
|
63
91
|
expectedExchanges += turn.responses.length
|
|
64
92
|
const beforeChunks = chunksSent
|
|
65
93
|
await ui.typeText(turn.prompt)
|
|
@@ -67,26 +95,38 @@ try {
|
|
|
67
95
|
await runProperties("afterSubmit", { turn, ui, backend, waitFor })
|
|
68
96
|
if (turn.interaction === "double-submit") await ui.pressEnter()
|
|
69
97
|
let outcome: TurnOutcome = "completed"
|
|
70
|
-
const hasTools = turn.responses.some(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
const hasTools = turn.responses.some(
|
|
99
|
+
(response) => (response.toolNames?.length ?? 0) > 0,
|
|
100
|
+
)
|
|
101
|
+
const settleTools =
|
|
102
|
+
turn.interaction === "interrupt" || !hasTools
|
|
103
|
+
? undefined
|
|
104
|
+
: settleBlockingUi(expectedExchanges)
|
|
74
105
|
if (turn.interaction === "steer") {
|
|
75
|
-
await waitFor(
|
|
106
|
+
await waitFor(
|
|
107
|
+
"active stream",
|
|
108
|
+
async () => active.size > 0 && chunksSent > beforeChunks,
|
|
109
|
+
)
|
|
76
110
|
await ui.typeText(turn.steerPrompt ?? "Also inspect the active boundary.")
|
|
77
111
|
await ui.pressEnter()
|
|
78
112
|
}
|
|
79
113
|
if (turn.interaction === "interrupt") {
|
|
80
|
-
await waitFor(
|
|
114
|
+
await waitFor(
|
|
115
|
+
"interruptible stream",
|
|
116
|
+
async () => active.size > 0 && chunksSent > beforeChunks,
|
|
117
|
+
)
|
|
81
118
|
for (const id of active) interrupted.add(id)
|
|
82
119
|
await ui.pressKey("escape")
|
|
83
120
|
await ui.pressKey("escape")
|
|
84
|
-
await waitFor("interrupted provider drain", async () => active.size === 0
|
|
121
|
+
await waitFor("interrupted provider drain", async () => active.size === 0)
|
|
85
122
|
responseCursor = expectedExchanges
|
|
86
123
|
outcome = "interrupted"
|
|
87
124
|
} else if (turn.interaction === "provider-drop") {
|
|
88
|
-
await waitFor(
|
|
89
|
-
|
|
125
|
+
await waitFor(
|
|
126
|
+
"dropped provider exchange",
|
|
127
|
+
async () => responseCursor >= expectedExchanges,
|
|
128
|
+
)
|
|
129
|
+
await waitFor("dropped provider drain", async () => active.size === 0)
|
|
90
130
|
outcome = "provider-error"
|
|
91
131
|
} else {
|
|
92
132
|
await settleTools
|
|
@@ -96,10 +136,16 @@ try {
|
|
|
96
136
|
return responseCursor >= expectedExchanges
|
|
97
137
|
})
|
|
98
138
|
}
|
|
99
|
-
await runProperties("afterTerminal", {
|
|
139
|
+
await runProperties("afterTerminal", {
|
|
140
|
+
turn,
|
|
141
|
+
ui,
|
|
142
|
+
backend,
|
|
143
|
+
outcome,
|
|
144
|
+
waitFor,
|
|
145
|
+
})
|
|
100
146
|
if (stepDelay > 0) await Bun.sleep(stepDelay)
|
|
101
147
|
}
|
|
102
|
-
await waitFor("provider idle", async () =>
|
|
148
|
+
await waitFor("provider idle", async () => active.size === 0)
|
|
103
149
|
const result: FlowResult = {
|
|
104
150
|
seed: scenario.seed,
|
|
105
151
|
name: scenario.name,
|
|
@@ -110,18 +156,26 @@ try {
|
|
|
110
156
|
durationMs: Date.now() - started,
|
|
111
157
|
finalState: await ui.state(),
|
|
112
158
|
}
|
|
113
|
-
if (resultPath !== undefined)
|
|
159
|
+
if (resultPath !== undefined)
|
|
160
|
+
await Bun.write(resultPath, `${JSON.stringify(result, undefined, 2)}\n`)
|
|
114
161
|
console.log(JSON.stringify({ ...result, finalState: undefined }))
|
|
115
162
|
} catch (error) {
|
|
116
163
|
if (resultPath !== undefined) {
|
|
117
|
-
await Bun.write(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
await Bun.write(
|
|
165
|
+
`${resultPath}.failure.json`,
|
|
166
|
+
`${JSON.stringify(
|
|
167
|
+
{
|
|
168
|
+
error: error instanceof Error ? error.message : String(error),
|
|
169
|
+
assistantExchanges,
|
|
170
|
+
subagentExchanges,
|
|
171
|
+
titleExchanges,
|
|
172
|
+
activeExchanges: [...active],
|
|
173
|
+
state: await ui.state(),
|
|
174
|
+
},
|
|
175
|
+
undefined,
|
|
176
|
+
2,
|
|
177
|
+
)}\n`,
|
|
178
|
+
)
|
|
125
179
|
}
|
|
126
180
|
throw error
|
|
127
181
|
} finally {
|
|
@@ -136,25 +190,51 @@ function requiredEnv(name: string) {
|
|
|
136
190
|
}
|
|
137
191
|
|
|
138
192
|
function isTitleRequest(request: OpenedExchange) {
|
|
139
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
typeof request.body !== "object" ||
|
|
195
|
+
request.body === null ||
|
|
196
|
+
!("messages" in request.body)
|
|
197
|
+
)
|
|
198
|
+
return false
|
|
140
199
|
const messages = request.body.messages
|
|
141
200
|
if (!Array.isArray(messages)) return false
|
|
142
201
|
const first = messages[0]
|
|
143
|
-
if (typeof first !== "object" || first === null || !("content" in first))
|
|
144
|
-
|
|
202
|
+
if (typeof first !== "object" || first === null || !("content" in first))
|
|
203
|
+
return false
|
|
204
|
+
return (
|
|
205
|
+
typeof first.content === "string" &&
|
|
206
|
+
first.content.includes("You are a title generator")
|
|
207
|
+
)
|
|
145
208
|
}
|
|
146
209
|
|
|
147
210
|
function isSubagentRequest(request: OpenedExchange) {
|
|
148
|
-
if (
|
|
211
|
+
if (
|
|
212
|
+
typeof request.body !== "object" ||
|
|
213
|
+
request.body === null ||
|
|
214
|
+
!("messages" in request.body)
|
|
215
|
+
)
|
|
216
|
+
return false
|
|
149
217
|
const messages = request.body.messages
|
|
150
218
|
if (!Array.isArray(messages)) return false
|
|
151
219
|
return messages.some((message) => {
|
|
152
|
-
if (
|
|
153
|
-
|
|
220
|
+
if (
|
|
221
|
+
typeof message !== "object" ||
|
|
222
|
+
message === null ||
|
|
223
|
+
!("content" in message)
|
|
224
|
+
)
|
|
225
|
+
return false
|
|
226
|
+
return (
|
|
227
|
+
typeof message.content === "string" &&
|
|
228
|
+
message.content.includes("Inspect the simulation fixture.")
|
|
229
|
+
)
|
|
154
230
|
})
|
|
155
231
|
}
|
|
156
232
|
|
|
157
|
-
async function waitFor(
|
|
233
|
+
async function waitFor(
|
|
234
|
+
label: string,
|
|
235
|
+
check: () => Promise<boolean>,
|
|
236
|
+
timeout = 30_000,
|
|
237
|
+
) {
|
|
158
238
|
const deadline = Date.now() + timeout
|
|
159
239
|
while (Date.now() < deadline) {
|
|
160
240
|
if (await check()) return
|
|
@@ -163,16 +243,22 @@ async function waitFor(label: string, check: () => Promise<boolean>, timeout = 3
|
|
|
163
243
|
throw new Error(`timed out waiting for ${label}`)
|
|
164
244
|
}
|
|
165
245
|
|
|
166
|
-
async function runProperties(
|
|
246
|
+
async function runProperties(
|
|
247
|
+
stage: "afterSubmit" | "afterTerminal",
|
|
248
|
+
context: FlowPropertyContext,
|
|
249
|
+
) {
|
|
167
250
|
for (const property of flowProperties) {
|
|
168
251
|
const check = property[stage]
|
|
169
252
|
if (check === undefined) continue
|
|
170
253
|
try {
|
|
171
254
|
await check(context)
|
|
172
255
|
} catch (error) {
|
|
173
|
-
throw new Error(
|
|
174
|
-
|
|
175
|
-
|
|
256
|
+
throw new Error(
|
|
257
|
+
`property ${property.name} failed for ${context.turn.marker}: ${error instanceof Error ? error.message : String(error)}`,
|
|
258
|
+
{
|
|
259
|
+
cause: error,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
176
262
|
}
|
|
177
263
|
}
|
|
178
264
|
}
|
|
@@ -183,5 +269,6 @@ async function settleBlockingUi(expectedExchanges: number) {
|
|
|
183
269
|
await ui.pressEnter()
|
|
184
270
|
await Bun.sleep(25)
|
|
185
271
|
}
|
|
186
|
-
if (responseCursor < expectedExchanges)
|
|
272
|
+
if (responseCursor < expectedExchanges)
|
|
273
|
+
throw new Error("timed out settling blocking tool UI")
|
|
187
274
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BackendSimulationClient,
|
|
3
|
+
SimulationClient,
|
|
4
|
+
UiState,
|
|
5
|
+
} from "../../client/index.js"
|
|
2
6
|
import type { FlowTurn } from "./types.js"
|
|
3
7
|
|
|
4
8
|
export type TurnOutcome = "completed" | "interrupted" | "provider-error"
|
|
@@ -8,7 +12,10 @@ export interface FlowPropertyContext {
|
|
|
8
12
|
readonly ui: SimulationClient
|
|
9
13
|
readonly backend: BackendSimulationClient
|
|
10
14
|
readonly outcome?: TurnOutcome
|
|
11
|
-
readonly waitFor: (
|
|
15
|
+
readonly waitFor: (
|
|
16
|
+
label: string,
|
|
17
|
+
check: () => Promise<boolean>,
|
|
18
|
+
) => Promise<void>
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export interface FlowProperty {
|
|
@@ -23,17 +30,16 @@ export const flowProperties: ReadonlyArray<FlowProperty> = [
|
|
|
23
30
|
defineProperty({
|
|
24
31
|
name: "submitted-turn-shows-running",
|
|
25
32
|
afterSubmit: (context) => {
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
async () => (await context.backend.pendingExchanges()).exchanges.length === 0,
|
|
33
|
+
if (
|
|
34
|
+
context.turn.responses.some(
|
|
35
|
+
(response) =>
|
|
36
|
+
response.terminal === "invalid-provider-event" ||
|
|
37
|
+
response.terminal === "disconnect",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
return Promise.resolve()
|
|
41
|
+
return context.waitFor("submitted turn to show running", async () =>
|
|
42
|
+
isRunning(await context.ui.state()),
|
|
37
43
|
)
|
|
38
44
|
},
|
|
39
45
|
}),
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
connectBackendSimulation,
|
|
3
|
+
connectSimulation,
|
|
4
|
+
type OpenedExchange,
|
|
5
|
+
} from "../client/index.js"
|
|
2
6
|
|
|
3
|
-
const ui = await connectSimulation({
|
|
4
|
-
|
|
7
|
+
const ui = await connectSimulation({
|
|
8
|
+
url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
|
|
9
|
+
})
|
|
10
|
+
const backend = await connectBackendSimulation({
|
|
11
|
+
url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
|
|
12
|
+
})
|
|
5
13
|
let completed!: () => void
|
|
6
14
|
const responseCompleted = new Promise<void>((resolve) => {
|
|
7
15
|
completed = resolve
|
|
@@ -18,7 +26,6 @@ try {
|
|
|
18
26
|
await ui.typeText("Say hello")
|
|
19
27
|
await ui.pressEnter()
|
|
20
28
|
await responseCompleted
|
|
21
|
-
await waitFor(async () => (await backend.pendingExchanges()).exchanges.length === 0)
|
|
22
29
|
await ui.state()
|
|
23
30
|
await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
|
|
24
31
|
} finally {
|
|
@@ -1,20 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
connectBackendSimulation,
|
|
3
|
+
connectSimulation,
|
|
4
|
+
type OpenedExchange,
|
|
5
|
+
} from "../client/index.js"
|
|
2
6
|
import { isRunning } from "./flows/index.js"
|
|
3
7
|
|
|
4
|
-
const ui = await connectSimulation({
|
|
5
|
-
|
|
8
|
+
const ui = await connectSimulation({
|
|
9
|
+
url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
|
|
10
|
+
})
|
|
11
|
+
const backend = await connectBackendSimulation({
|
|
12
|
+
url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
|
|
13
|
+
})
|
|
6
14
|
const requestOpened = deferred()
|
|
7
15
|
const releaseResponse = deferred()
|
|
8
16
|
const responseFinished = deferred()
|
|
9
17
|
|
|
10
18
|
await backend.attach(async (request: OpenedExchange) => {
|
|
11
19
|
if (isTitleRequest(request)) {
|
|
12
|
-
await backend.chunk(request.id, [
|
|
20
|
+
await backend.chunk(request.id, [
|
|
21
|
+
{ type: "textDelta", text: "Stale running reproduction" },
|
|
22
|
+
])
|
|
13
23
|
await backend.finish(request.id, "stop")
|
|
14
24
|
return
|
|
15
25
|
}
|
|
16
26
|
requestOpened.resolve()
|
|
17
|
-
await backend.chunk(request.id, [
|
|
27
|
+
await backend.chunk(request.id, [
|
|
28
|
+
{
|
|
29
|
+
type: "textDelta",
|
|
30
|
+
text: "The provider turn is finishing while events are disconnected.",
|
|
31
|
+
},
|
|
32
|
+
])
|
|
18
33
|
await releaseResponse.promise
|
|
19
34
|
await backend.finish(request.id, "stop")
|
|
20
35
|
responseFinished.resolve()
|
|
@@ -22,19 +37,23 @@ await backend.attach(async (request: OpenedExchange) => {
|
|
|
22
37
|
|
|
23
38
|
try {
|
|
24
39
|
await waitFor("prompt editor", async () => (await ui.state()).focused.editor)
|
|
25
|
-
await ui.typeText(
|
|
40
|
+
await ui.typeText(
|
|
41
|
+
"Reproduce stale running status across an event-stream reconnect",
|
|
42
|
+
)
|
|
26
43
|
await ui.pressEnter()
|
|
27
44
|
await requestOpened.promise
|
|
28
45
|
await waitFor("running TUI", async () => isRunning(await ui.state()))
|
|
29
46
|
|
|
30
47
|
releaseResponse.resolve()
|
|
31
48
|
await responseFinished.promise
|
|
32
|
-
await waitFor("provider drain", async () => (await backend.pendingExchanges()).exchanges.length === 0)
|
|
33
49
|
await Bun.sleep(1_000)
|
|
34
50
|
|
|
35
51
|
const state = await ui.state()
|
|
36
|
-
if (!isRunning(state))
|
|
37
|
-
|
|
52
|
+
if (!isRunning(state))
|
|
53
|
+
throw new Error("stale running status was not reproduced")
|
|
54
|
+
console.log(
|
|
55
|
+
"REPRODUCED: backend provider work is idle while the TUI still displays running.",
|
|
56
|
+
)
|
|
38
57
|
await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
|
|
39
58
|
} finally {
|
|
40
59
|
ui.close()
|
|
@@ -56,15 +75,28 @@ function requiredEnv(name: string) {
|
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
function isTitleRequest(request: OpenedExchange) {
|
|
59
|
-
if (
|
|
78
|
+
if (
|
|
79
|
+
typeof request.body !== "object" ||
|
|
80
|
+
request.body === null ||
|
|
81
|
+
!("messages" in request.body)
|
|
82
|
+
)
|
|
83
|
+
return false
|
|
60
84
|
const messages = request.body.messages
|
|
61
85
|
if (!Array.isArray(messages)) return false
|
|
62
86
|
const first = messages[0]
|
|
63
|
-
if (typeof first !== "object" || first === null || !("content" in first))
|
|
64
|
-
|
|
87
|
+
if (typeof first !== "object" || first === null || !("content" in first))
|
|
88
|
+
return false
|
|
89
|
+
return (
|
|
90
|
+
typeof first.content === "string" &&
|
|
91
|
+
first.content.includes("You are a title generator")
|
|
92
|
+
)
|
|
65
93
|
}
|
|
66
94
|
|
|
67
|
-
async function waitFor(
|
|
95
|
+
async function waitFor(
|
|
96
|
+
label: string,
|
|
97
|
+
check: () => Promise<boolean>,
|
|
98
|
+
timeout = 30_000,
|
|
99
|
+
) {
|
|
68
100
|
const deadline = Date.now() + timeout
|
|
69
101
|
while (Date.now() < deadline) {
|
|
70
102
|
if (await check()) return
|
package/src/index.ts
CHANGED
package/src/cli/driver-runner.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path"
|
|
2
|
-
import { pathToFileURL } from "node:url"
|
|
3
|
-
import { connectDrive } from "../experimental/drive.js"
|
|
4
|
-
import type { Driver } from "../experimental/drive.js"
|
|
5
|
-
|
|
6
|
-
const driver = process.argv[2]
|
|
7
|
-
if (!driver) throw new Error("driver file is required")
|
|
8
|
-
const name = requiredArgument(3, "instance name")
|
|
9
|
-
const ui = requiredArgument(4, "frontend WebSocket URL")
|
|
10
|
-
const backend = requiredArgument(5, "backend WebSocket URL")
|
|
11
|
-
const artifacts = requiredArgument(6, "artifacts directory")
|
|
12
|
-
|
|
13
|
-
const module: { readonly default?: unknown } = await import(pathToFileURL(resolve(driver)).href)
|
|
14
|
-
if (!isDriver(module.default)) throw new Error("driver must default-export defineDriver(...)")
|
|
15
|
-
const controller = new AbortController()
|
|
16
|
-
process.once("SIGINT", () => controller.abort())
|
|
17
|
-
process.once("SIGTERM", () => controller.abort())
|
|
18
|
-
const session = await connectDrive({ ui, backend })
|
|
19
|
-
try {
|
|
20
|
-
await module.default({
|
|
21
|
-
name,
|
|
22
|
-
ui: session.ui,
|
|
23
|
-
backend: session.backend,
|
|
24
|
-
artifacts,
|
|
25
|
-
signal: controller.signal,
|
|
26
|
-
})
|
|
27
|
-
} finally {
|
|
28
|
-
session.close()
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isDriver(value: unknown): value is Driver {
|
|
32
|
-
return typeof value === "function"
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function requiredArgument(index: number, name: string) {
|
|
36
|
-
const value = process.argv[index]
|
|
37
|
-
if (!value) throw new Error(`${name} is required`)
|
|
38
|
-
return value
|
|
39
|
-
}
|
package/src/cli/driver.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises"
|
|
2
|
-
import { resolve } from "node:path"
|
|
3
|
-
import type { InstanceManifest } from "./types.js"
|
|
4
|
-
|
|
5
|
-
export async function runDriver(driver: string, manifest: InstanceManifest) {
|
|
6
|
-
await mkdir(manifest.artifacts, { recursive: true })
|
|
7
|
-
const child = Bun.spawn([
|
|
8
|
-
process.execPath,
|
|
9
|
-
resolve(import.meta.dir, "driver-runner.ts"),
|
|
10
|
-
resolve(driver),
|
|
11
|
-
manifest.name,
|
|
12
|
-
manifest.endpoints.ui,
|
|
13
|
-
manifest.endpoints.backend,
|
|
14
|
-
manifest.artifacts,
|
|
15
|
-
], {
|
|
16
|
-
cwd: process.cwd(),
|
|
17
|
-
env: cleanEnv(process.env),
|
|
18
|
-
stdin: "inherit",
|
|
19
|
-
stdout: "inherit",
|
|
20
|
-
stderr: "inherit",
|
|
21
|
-
})
|
|
22
|
-
const status = await child.exited
|
|
23
|
-
if (status !== 0) throw new Error(`driver exited with status ${status}`)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
|
|
27
|
-
return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined))
|
|
28
|
-
}
|