@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.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. 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: (root: string) => Effect.Effect<RunState | null, StateCorrupt>
16
- readonly save: (state: RunState, root: string) => Effect.Effect<void>
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: (root: string) => Effect.Effect<string, NoRunState>
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.mkdir(d, { recursive: true }), {
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 = (root: string): Effect.Effect<RunState | null, StateCorrupt> =>
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.exists(p)
59
+ const present = yield* fs.projectPathExists(root, p)
51
60
  if (!present) return null
52
- const text = yield* fs.readFile(p)
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 = (state: RunState, root: string): Effect.Effect<void> =>
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.writeFile(p, body)
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 = (root: string): Effect.Effect<string, NoRunState> =>
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.exists(p)
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.rename(p, bak)
115
+ yield* fs.renameProjectFile(root, p, bak)
94
116
  return bak
95
117
  })
96
118