@naxodev/apnea 0.2.0 → 0.2.2
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 +18 -1
- package/SECURITY.md +36 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8375 -15093
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +15 -3
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +17 -4
- package/extension/adapters/commit.ts +5 -1
- package/extension/adapters/dispatch.ts +9 -1
- package/extension/adapters/setup.ts +15 -1
- package/extension/adapters/start.ts +5 -1
- package/extension/adapters/status.ts +17 -2
- package/extension/adapters/wait.ts +6 -1
- package/extension/api.ts +7 -1
- package/extension/cli/main.ts +67 -7
- package/extension/cli/parse.ts +172 -5
- package/extension/domain/paths.ts +2 -11
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +65 -3
- package/extension/errors.ts +51 -16
- package/extension/operation-hooks.ts +6 -0
- package/extension/registry.ts +29 -15
- package/extension/run-tool.ts +19 -2
- package/extension/schema/config.ts +58 -16
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +210 -13
- package/extension/services/app-live.ts +2 -1
- package/extension/services/config.ts +6 -4
- package/extension/services/file-system.ts +346 -75
- package/extension/services/herdr.ts +466 -260
- package/extension/services/operation-lock.ts +452 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1258 -328
- package/extension/workflows/commit.ts +214 -13
- package/extension/workflows/dispatch.ts +305 -67
- package/extension/workflows/setup.ts +59 -32
- package/extension/workflows/start.ts +6 -4
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +62 -77
- package/package.json +2 -2
- package/schemas/config.schema.json +5 -1
- package/schemas/state.schema.json +165 -11
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { Context, Effect, Layer } from "effect"
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024
|
|
5
|
+
export const DEFAULT_PROCESS_TERMINATION_GRACE_MS = 250
|
|
6
|
+
|
|
7
|
+
export type ProcessResult = {
|
|
8
|
+
readonly exitCode: number
|
|
9
|
+
readonly stdout: string
|
|
10
|
+
readonly stderr: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type ProcessFailureFields = {
|
|
14
|
+
readonly command: string
|
|
15
|
+
readonly stdout: string
|
|
16
|
+
readonly stderr: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ProcessSpawnError extends Error {
|
|
20
|
+
readonly _tag = "ProcessSpawnError"
|
|
21
|
+
constructor(
|
|
22
|
+
readonly command: string,
|
|
23
|
+
readonly reason: unknown,
|
|
24
|
+
) {
|
|
25
|
+
super(reason instanceof Error ? reason.message : String(reason), {
|
|
26
|
+
cause: reason,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ProcessExitError extends Error implements ProcessFailureFields {
|
|
32
|
+
readonly _tag = "ProcessExitError"
|
|
33
|
+
constructor(
|
|
34
|
+
readonly command: string,
|
|
35
|
+
readonly exitCode: number,
|
|
36
|
+
readonly stdout: string,
|
|
37
|
+
readonly stderr: string,
|
|
38
|
+
) {
|
|
39
|
+
super(`${command} exited with code ${exitCode}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ProcessTimeoutError extends Error implements ProcessFailureFields {
|
|
44
|
+
readonly _tag = "ProcessTimeoutError"
|
|
45
|
+
constructor(
|
|
46
|
+
readonly command: string,
|
|
47
|
+
readonly timeoutMs: number,
|
|
48
|
+
readonly stdout: string,
|
|
49
|
+
readonly stderr: string,
|
|
50
|
+
) {
|
|
51
|
+
super(`${command} timed out after ${timeoutMs}ms`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ProcessCancelledError
|
|
56
|
+
extends Error
|
|
57
|
+
implements ProcessFailureFields
|
|
58
|
+
{
|
|
59
|
+
readonly _tag = "ProcessCancelledError"
|
|
60
|
+
constructor(
|
|
61
|
+
readonly command: string,
|
|
62
|
+
readonly stdout: string,
|
|
63
|
+
readonly stderr: string,
|
|
64
|
+
) {
|
|
65
|
+
super(`${command} was cancelled`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class ProcessOutputError extends Error implements ProcessFailureFields {
|
|
70
|
+
readonly _tag = "ProcessOutputError"
|
|
71
|
+
constructor(
|
|
72
|
+
readonly command: string,
|
|
73
|
+
readonly stream: "stdout" | "stderr",
|
|
74
|
+
readonly limitBytes: number,
|
|
75
|
+
readonly stdout: string,
|
|
76
|
+
readonly stderr: string,
|
|
77
|
+
readonly reason?: unknown,
|
|
78
|
+
) {
|
|
79
|
+
super(
|
|
80
|
+
reason === undefined
|
|
81
|
+
? `${stream} exceeded the ${limitBytes}-byte output limit`
|
|
82
|
+
: `${stream} capture failed: ${reason instanceof Error ? reason.message : String(reason)}`,
|
|
83
|
+
reason === undefined ? undefined : { cause: reason },
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ProcessError =
|
|
89
|
+
| ProcessSpawnError
|
|
90
|
+
| ProcessExitError
|
|
91
|
+
| ProcessTimeoutError
|
|
92
|
+
| ProcessCancelledError
|
|
93
|
+
| ProcessOutputError
|
|
94
|
+
|
|
95
|
+
export type ProcessOptions = {
|
|
96
|
+
readonly command: string
|
|
97
|
+
readonly args?: readonly string[]
|
|
98
|
+
readonly cwd?: string
|
|
99
|
+
readonly env?: NodeJS.ProcessEnv
|
|
100
|
+
readonly stdin?: string
|
|
101
|
+
readonly timeoutMs: number
|
|
102
|
+
readonly outputLimitBytes?: number
|
|
103
|
+
readonly terminationGraceMs?: number
|
|
104
|
+
readonly signal?: AbortSignal
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ProcessService {
|
|
108
|
+
readonly run: (
|
|
109
|
+
options: ProcessOptions,
|
|
110
|
+
) => Effect.Effect<ProcessResult, ProcessError>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class Process extends Context.Service<Process, ProcessService>()(
|
|
114
|
+
"apnea/Process",
|
|
115
|
+
) {}
|
|
116
|
+
|
|
117
|
+
export type ProcessChild = Pick<ChildProcess, "pid" | "kill">
|
|
118
|
+
|
|
119
|
+
type ProcessKill = (
|
|
120
|
+
pid: number,
|
|
121
|
+
signal?: NodeJS.Signals | number,
|
|
122
|
+
) => boolean | void
|
|
123
|
+
|
|
124
|
+
export type ProcessRuntimeDeps = {
|
|
125
|
+
readonly spawn?: typeof spawn
|
|
126
|
+
readonly platform: NodeJS.Platform
|
|
127
|
+
readonly kill: ProcessKill
|
|
128
|
+
readonly sleep: (milliseconds: number) => Promise<void>
|
|
129
|
+
readonly snapshotDescendants: (pid: number) => Promise<number[]>
|
|
130
|
+
readonly taskkill: (pid: number, timeoutMs: number) => Promise<boolean>
|
|
131
|
+
readonly processRunning: (pid: number) => boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeKill(
|
|
135
|
+
kill: ProcessKill,
|
|
136
|
+
pid: number,
|
|
137
|
+
signal: NodeJS.Signals,
|
|
138
|
+
): void {
|
|
139
|
+
try {
|
|
140
|
+
kill(pid, signal)
|
|
141
|
+
} catch {
|
|
142
|
+
// A concurrently exited process needs no further termination.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function childKill(child: ProcessChild, signal: NodeJS.Signals): void {
|
|
147
|
+
try {
|
|
148
|
+
child.kill(signal)
|
|
149
|
+
} catch {
|
|
150
|
+
// A concurrently exited process needs no further termination.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function waitUntilStopped(
|
|
155
|
+
pids: readonly number[],
|
|
156
|
+
timeoutMs: number,
|
|
157
|
+
deps: ProcessRuntimeDeps,
|
|
158
|
+
): Promise<boolean> {
|
|
159
|
+
const deadline = performance.now() + timeoutMs
|
|
160
|
+
while (pids.some((pid) => deps.processRunning(pid))) {
|
|
161
|
+
const remaining = deadline - performance.now()
|
|
162
|
+
if (remaining <= 0) return false
|
|
163
|
+
await deps.sleep(Math.min(25, remaining))
|
|
164
|
+
}
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function terminateProcessTree(
|
|
169
|
+
child: ProcessChild,
|
|
170
|
+
graceMs: number,
|
|
171
|
+
deps: ProcessRuntimeDeps = defaultRuntimeDeps,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const pid = child.pid
|
|
174
|
+
if (pid === undefined) {
|
|
175
|
+
childKill(child, "SIGKILL")
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (deps.platform === "win32") {
|
|
180
|
+
if (await deps.taskkill(pid, graceMs)) return
|
|
181
|
+
childKill(child, "SIGKILL")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Snapshot first. A descendant may create a new session and escape the
|
|
186
|
+
// process group, then become reparented as soon as the group leader exits.
|
|
187
|
+
const descendants = await deps.snapshotDescendants(pid)
|
|
188
|
+
const targets = [pid, ...descendants]
|
|
189
|
+
safeKill(deps.kill, -pid, "SIGTERM")
|
|
190
|
+
for (const descendant of descendants) {
|
|
191
|
+
safeKill(deps.kill, descendant, "SIGTERM")
|
|
192
|
+
}
|
|
193
|
+
if (await waitUntilStopped(targets, graceMs, deps)) return
|
|
194
|
+
|
|
195
|
+
safeKill(deps.kill, -pid, "SIGKILL")
|
|
196
|
+
for (const descendant of descendants) {
|
|
197
|
+
safeKill(deps.kill, descendant, "SIGKILL")
|
|
198
|
+
}
|
|
199
|
+
await waitUntilStopped(targets, graceMs, deps)
|
|
200
|
+
childKill(child, "SIGKILL")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function processRunning(pid: number): boolean {
|
|
204
|
+
try {
|
|
205
|
+
process.kill(pid, 0)
|
|
206
|
+
return true
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH"
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function collectBounded(
|
|
213
|
+
command: string,
|
|
214
|
+
args: readonly string[],
|
|
215
|
+
timeoutMs: number,
|
|
216
|
+
limitBytes: number,
|
|
217
|
+
): Promise<{ exitCode: number; stdout: string }> {
|
|
218
|
+
return new Promise((resolve) => {
|
|
219
|
+
let child: ChildProcess
|
|
220
|
+
try {
|
|
221
|
+
child = spawn(command, [...args], {
|
|
222
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
223
|
+
windowsHide: true,
|
|
224
|
+
})
|
|
225
|
+
} catch {
|
|
226
|
+
resolve({ exitCode: 1, stdout: "" })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const chunks: Buffer[] = []
|
|
230
|
+
let bytes = 0
|
|
231
|
+
let settled = false
|
|
232
|
+
const finish = (exitCode: number) => {
|
|
233
|
+
if (settled) return
|
|
234
|
+
settled = true
|
|
235
|
+
clearTimeout(timer)
|
|
236
|
+
resolve({ exitCode, stdout: Buffer.concat(chunks).toString("utf8") })
|
|
237
|
+
}
|
|
238
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
239
|
+
const available = limitBytes - bytes
|
|
240
|
+
if (available > 0) chunks.push(chunk.subarray(0, available))
|
|
241
|
+
bytes += Math.min(chunk.length, Math.max(0, available))
|
|
242
|
+
if (chunk.length > available) childKill(child, "SIGKILL")
|
|
243
|
+
})
|
|
244
|
+
child.once("error", () => finish(1))
|
|
245
|
+
child.once("close", (code) => finish(code ?? 1))
|
|
246
|
+
const timer = setTimeout(() => {
|
|
247
|
+
childKill(child, "SIGKILL")
|
|
248
|
+
finish(1)
|
|
249
|
+
}, timeoutMs)
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function snapshotDescendants(rootPid: number): Promise<number[]> {
|
|
254
|
+
const snapshot = await collectBounded(
|
|
255
|
+
"ps",
|
|
256
|
+
["-axo", "pid=,ppid="],
|
|
257
|
+
2_000,
|
|
258
|
+
DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES,
|
|
259
|
+
)
|
|
260
|
+
if (snapshot.exitCode !== 0) return []
|
|
261
|
+
const children = new Map<number, number[]>()
|
|
262
|
+
for (const line of snapshot.stdout.split("\n")) {
|
|
263
|
+
const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
|
|
264
|
+
if (!match) continue
|
|
265
|
+
const pid = Number(match[1])
|
|
266
|
+
const parent = Number(match[2])
|
|
267
|
+
const siblings = children.get(parent)
|
|
268
|
+
if (siblings) siblings.push(pid)
|
|
269
|
+
else children.set(parent, [pid])
|
|
270
|
+
}
|
|
271
|
+
const descendants: number[] = []
|
|
272
|
+
const pending = [...(children.get(rootPid) ?? [])]
|
|
273
|
+
for (let index = 0; index < pending.length; index++) {
|
|
274
|
+
const pid = pending[index]!
|
|
275
|
+
descendants.push(pid)
|
|
276
|
+
pending.push(...(children.get(pid) ?? []))
|
|
277
|
+
}
|
|
278
|
+
return descendants
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function taskkill(pid: number, timeoutMs: number): Promise<boolean> {
|
|
282
|
+
const result = await collectBounded(
|
|
283
|
+
"taskkill",
|
|
284
|
+
["/PID", String(pid), "/T", "/F"],
|
|
285
|
+
timeoutMs,
|
|
286
|
+
64 * 1024,
|
|
287
|
+
)
|
|
288
|
+
return result.exitCode === 0
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const defaultRuntimeDeps: ProcessRuntimeDeps = {
|
|
292
|
+
platform: process.platform,
|
|
293
|
+
kill: process.kill,
|
|
294
|
+
sleep: (milliseconds) =>
|
|
295
|
+
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
296
|
+
snapshotDescendants,
|
|
297
|
+
taskkill,
|
|
298
|
+
processRunning,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
type Capture = {
|
|
302
|
+
readonly chunks: Buffer[]
|
|
303
|
+
bytes: number
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function text(capture: Capture): string {
|
|
307
|
+
return Buffer.concat(capture.chunks).toString("utf8")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function makeProcessService(
|
|
311
|
+
deps: ProcessRuntimeDeps = defaultRuntimeDeps,
|
|
312
|
+
): ProcessService {
|
|
313
|
+
return Process.of({
|
|
314
|
+
run: (options) =>
|
|
315
|
+
Effect.callback<ProcessResult, ProcessError>((resume) => {
|
|
316
|
+
const args = [...(options.args ?? [])]
|
|
317
|
+
const label = [options.command, ...args].join(" ")
|
|
318
|
+
const limit =
|
|
319
|
+
options.outputLimitBytes ?? DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES
|
|
320
|
+
const grace =
|
|
321
|
+
options.terminationGraceMs ?? DEFAULT_PROCESS_TERMINATION_GRACE_MS
|
|
322
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
323
|
+
resume(
|
|
324
|
+
Effect.fail(
|
|
325
|
+
new ProcessTimeoutError(label, options.timeoutMs, "", ""),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
331
|
+
resume(
|
|
332
|
+
Effect.fail(new ProcessOutputError(label, "stdout", limit, "", "")),
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let child: ChildProcess
|
|
338
|
+
try {
|
|
339
|
+
child = (deps.spawn ?? spawn)(options.command, args, {
|
|
340
|
+
cwd: options.cwd,
|
|
341
|
+
env: options.env,
|
|
342
|
+
detached: deps.platform !== "win32",
|
|
343
|
+
stdio: [
|
|
344
|
+
options.stdin === undefined ? "ignore" : "pipe",
|
|
345
|
+
"pipe",
|
|
346
|
+
"pipe",
|
|
347
|
+
],
|
|
348
|
+
windowsHide: true,
|
|
349
|
+
})
|
|
350
|
+
} catch (error) {
|
|
351
|
+
resume(Effect.fail(new ProcessSpawnError(label, error)))
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const stdout: Capture = { chunks: [], bytes: 0 }
|
|
356
|
+
const stderr: Capture = { chunks: [], bytes: 0 }
|
|
357
|
+
let spawned = false
|
|
358
|
+
let settled = false
|
|
359
|
+
let pendingError: ProcessError | undefined
|
|
360
|
+
let cleanup: Promise<void> | undefined
|
|
361
|
+
const terminate = () =>
|
|
362
|
+
(cleanup ??= terminateProcessTree(child, grace, deps))
|
|
363
|
+
const diagnostics = () => ({
|
|
364
|
+
stdout: text(stdout),
|
|
365
|
+
stderr: text(stderr),
|
|
366
|
+
})
|
|
367
|
+
const finish = (effect: Effect.Effect<ProcessResult, ProcessError>) => {
|
|
368
|
+
if (settled) return
|
|
369
|
+
settled = true
|
|
370
|
+
clearTimeout(timer)
|
|
371
|
+
options.signal?.removeEventListener("abort", onAbort)
|
|
372
|
+
resume(effect)
|
|
373
|
+
}
|
|
374
|
+
const failAfterCleanup = (error: ProcessError) => {
|
|
375
|
+
if (settled || pendingError !== undefined) return
|
|
376
|
+
pendingError = error
|
|
377
|
+
void terminate().then(() => finish(Effect.fail(error)))
|
|
378
|
+
}
|
|
379
|
+
const capture = (name: "stdout" | "stderr", chunk: Buffer) => {
|
|
380
|
+
const target = name === "stdout" ? stdout : stderr
|
|
381
|
+
const available = limit - target.bytes
|
|
382
|
+
if (available > 0) target.chunks.push(chunk.subarray(0, available))
|
|
383
|
+
target.bytes += Math.min(chunk.length, Math.max(0, available))
|
|
384
|
+
if (chunk.length > available) {
|
|
385
|
+
const captured = diagnostics()
|
|
386
|
+
failAfterCleanup(
|
|
387
|
+
new ProcessOutputError(
|
|
388
|
+
label,
|
|
389
|
+
name,
|
|
390
|
+
limit,
|
|
391
|
+
captured.stdout,
|
|
392
|
+
captured.stderr,
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const outputError = (name: "stdout" | "stderr", reason: unknown) => {
|
|
398
|
+
const captured = diagnostics()
|
|
399
|
+
failAfterCleanup(
|
|
400
|
+
new ProcessOutputError(
|
|
401
|
+
label,
|
|
402
|
+
name,
|
|
403
|
+
limit,
|
|
404
|
+
captured.stdout,
|
|
405
|
+
captured.stderr,
|
|
406
|
+
reason,
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
const onAbort = () => {
|
|
411
|
+
const captured = diagnostics()
|
|
412
|
+
failAfterCleanup(
|
|
413
|
+
new ProcessCancelledError(label, captured.stdout, captured.stderr),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
child.once("spawn", () => {
|
|
418
|
+
spawned = true
|
|
419
|
+
if (options.stdin !== undefined) child.stdin?.end(options.stdin)
|
|
420
|
+
})
|
|
421
|
+
child.stdout?.on("data", (chunk: Buffer) => capture("stdout", chunk))
|
|
422
|
+
child.stderr?.on("data", (chunk: Buffer) => capture("stderr", chunk))
|
|
423
|
+
child.stdout?.once("error", (error) => outputError("stdout", error))
|
|
424
|
+
child.stderr?.once("error", (error) => outputError("stderr", error))
|
|
425
|
+
child.stdin?.once("error", (error) => {
|
|
426
|
+
if (
|
|
427
|
+
child.exitCode !== null ||
|
|
428
|
+
(spawned && (error as NodeJS.ErrnoException).code === "EPIPE")
|
|
429
|
+
) {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
failAfterCleanup(new ProcessSpawnError(label, error))
|
|
433
|
+
})
|
|
434
|
+
child.once("error", (error) => {
|
|
435
|
+
if (!spawned) finish(Effect.fail(new ProcessSpawnError(label, error)))
|
|
436
|
+
else outputError("stderr", error)
|
|
437
|
+
})
|
|
438
|
+
child.once("close", (code) => {
|
|
439
|
+
if (pendingError !== undefined) return
|
|
440
|
+
const captured = diagnostics()
|
|
441
|
+
const exitCode = code ?? 1
|
|
442
|
+
finish(
|
|
443
|
+
exitCode === 0
|
|
444
|
+
? Effect.succeed({ exitCode, ...captured })
|
|
445
|
+
: Effect.fail(
|
|
446
|
+
new ProcessExitError(
|
|
447
|
+
label,
|
|
448
|
+
exitCode,
|
|
449
|
+
captured.stdout,
|
|
450
|
+
captured.stderr,
|
|
451
|
+
),
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
const timer = setTimeout(() => {
|
|
457
|
+
const captured = diagnostics()
|
|
458
|
+
failAfterCleanup(
|
|
459
|
+
new ProcessTimeoutError(
|
|
460
|
+
label,
|
|
461
|
+
options.timeoutMs,
|
|
462
|
+
captured.stdout,
|
|
463
|
+
captured.stderr,
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
}, options.timeoutMs)
|
|
467
|
+
if (options.signal?.aborted) onAbort()
|
|
468
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true })
|
|
469
|
+
|
|
470
|
+
// Effect interruption runs this finalizer and waits for the process
|
|
471
|
+
// tree to stop before the caller's cancellation can complete.
|
|
472
|
+
return Effect.promise(terminate)
|
|
473
|
+
}),
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export const ProcessLive = Layer.succeed(Process, makeProcessService())
|
|
@@ -6,18 +6,25 @@ import {
|
|
|
6
6
|
statePath,
|
|
7
7
|
tasksDir,
|
|
8
8
|
} from "../domain/paths.ts"
|
|
9
|
-
import { NoRunState, StateCorrupt } from "../errors.ts"
|
|
9
|
+
import { ConfigError, NoRunState, StateCorrupt } from "../errors.ts"
|
|
10
10
|
import type { RunState } from "../domain/types.ts"
|
|
11
11
|
import { decodeRunState } from "../schema/state.ts"
|
|
12
12
|
import { FileSystem, type FileSystemService } from "./file-system.ts"
|
|
13
13
|
|
|
14
14
|
export interface RunStoreService {
|
|
15
|
-
readonly load: (
|
|
16
|
-
|
|
15
|
+
readonly load: (
|
|
16
|
+
root: string,
|
|
17
|
+
) => Effect.Effect<RunState | null, StateCorrupt | ConfigError>
|
|
18
|
+
readonly save: (
|
|
19
|
+
state: RunState,
|
|
20
|
+
root: string,
|
|
21
|
+
) => Effect.Effect<void, ConfigError>
|
|
17
22
|
readonly require: (
|
|
18
23
|
root: string,
|
|
19
|
-
) => Effect.Effect<RunState, NoRunState | StateCorrupt>
|
|
20
|
-
readonly abandon: (
|
|
24
|
+
) => Effect.Effect<RunState, NoRunState | StateCorrupt | ConfigError>
|
|
25
|
+
readonly abandon: (
|
|
26
|
+
root: string,
|
|
27
|
+
) => Effect.Effect<string, NoRunState | ConfigError>
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export class RunStore extends Context.Service<RunStore, RunStoreService>()(
|
|
@@ -27,14 +34,14 @@ export class RunStore extends Context.Service<RunStore, RunStoreService>()(
|
|
|
27
34
|
function ensureApneaDirs(
|
|
28
35
|
fs: FileSystemService,
|
|
29
36
|
root: string,
|
|
30
|
-
): Effect.Effect<void> {
|
|
37
|
+
): Effect.Effect<void, ConfigError> {
|
|
31
38
|
const dirs = [
|
|
32
39
|
apneaRoot(root),
|
|
33
40
|
artifactsDir(root),
|
|
34
41
|
tasksDir(root),
|
|
35
42
|
path.join(artifactsDir(root), "plan-review"),
|
|
36
43
|
]
|
|
37
|
-
return Effect.forEach(dirs, (d) => fs.
|
|
44
|
+
return Effect.forEach(dirs, (d) => fs.mkdirProject(root, d), {
|
|
38
45
|
discard: true,
|
|
39
46
|
})
|
|
40
47
|
}
|
|
@@ -44,12 +51,22 @@ export const RunStoreLive = Layer.effect(
|
|
|
44
51
|
Effect.gen(function* () {
|
|
45
52
|
const fs = yield* FileSystem
|
|
46
53
|
|
|
47
|
-
const load = (
|
|
54
|
+
const load = (
|
|
55
|
+
root: string,
|
|
56
|
+
): Effect.Effect<RunState | null, StateCorrupt | ConfigError> =>
|
|
48
57
|
Effect.gen(function* () {
|
|
49
58
|
const p = statePath(root)
|
|
50
|
-
const present = yield* fs.
|
|
59
|
+
const present = yield* fs.projectPathExists(root, p)
|
|
51
60
|
if (!present) return null
|
|
52
|
-
const text = yield* fs.
|
|
61
|
+
const text = yield* fs.readProjectFile(root, p).pipe(
|
|
62
|
+
Effect.mapError(
|
|
63
|
+
(error) =>
|
|
64
|
+
new StateCorrupt({
|
|
65
|
+
path: p,
|
|
66
|
+
message: error.message,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
53
70
|
let json: unknown
|
|
54
71
|
try {
|
|
55
72
|
json = JSON.parse(text)
|
|
@@ -66,31 +83,36 @@ export const RunStoreLive = Layer.effect(
|
|
|
66
83
|
return decoded.success
|
|
67
84
|
})
|
|
68
85
|
|
|
69
|
-
const save = (
|
|
86
|
+
const save = (
|
|
87
|
+
state: RunState,
|
|
88
|
+
root: string,
|
|
89
|
+
): Effect.Effect<void, ConfigError> =>
|
|
70
90
|
Effect.gen(function* () {
|
|
71
91
|
yield* ensureApneaDirs(fs, root)
|
|
72
92
|
const p = statePath(root)
|
|
73
93
|
const body = `${JSON.stringify(state, null, 2)}\n`
|
|
74
|
-
yield* fs.
|
|
94
|
+
yield* fs.writeProjectFile(root, p, body)
|
|
75
95
|
})
|
|
76
96
|
|
|
77
97
|
const require = (
|
|
78
98
|
root: string,
|
|
79
|
-
): Effect.Effect<RunState, NoRunState | StateCorrupt> =>
|
|
99
|
+
): Effect.Effect<RunState, NoRunState | StateCorrupt | ConfigError> =>
|
|
80
100
|
Effect.gen(function* () {
|
|
81
101
|
const s = yield* load(root)
|
|
82
102
|
if (!s) return yield* new NoRunState({})
|
|
83
103
|
return s
|
|
84
104
|
})
|
|
85
105
|
|
|
86
|
-
const abandon = (
|
|
106
|
+
const abandon = (
|
|
107
|
+
root: string,
|
|
108
|
+
): Effect.Effect<string, NoRunState | ConfigError> =>
|
|
87
109
|
Effect.gen(function* () {
|
|
88
110
|
const p = statePath(root)
|
|
89
|
-
const present = yield* fs.
|
|
111
|
+
const present = yield* fs.projectPathExists(root, p)
|
|
90
112
|
if (!present) return yield* new NoRunState({})
|
|
91
113
|
const millis = yield* Clock.currentTimeMillis
|
|
92
114
|
const bak = `${p}.abandoned.${millis}`
|
|
93
|
-
yield* fs.
|
|
115
|
+
yield* fs.renameProjectFile(root, p, bak)
|
|
94
116
|
return bak
|
|
95
117
|
})
|
|
96
118
|
|