diff --git a/docs/reference.md b/docs/reference.md index ebbd4b8a..3d59d756 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -462,6 +462,23 @@ Most commands accept the same root controls: - `--scope merged|global|project`: choose a discovery view - `--source builtin|global|project`: filter provenance in list/find/show/graph flows + +### Scheduler ownership recovery + +New evolution-loop enrollments store an identity-bound ownership receipt in +fclt machine-local state under `automations/codex/`. It binds the automation ID, creation time, +and working directories, so native edits that drop custom TOML fields do not +lose ownership. Re-enabling preserves the existing prompt, model, target, +notification settings, and memory; an explicit cadence update changes only the +requested cadence and status fields. + +For an older configured loop whose ownership marker was already lost, inspect +`fclt ai loop repair-scheduler --project --root .ai --dry-run --json`. After +confirming the exact task, use `--approve` instead of `--dry-run` to record its +ownership receipt. Global loops use `--global` and their canonical root. Repair +requires the configured task ID and expected working directory to match, rejects +an explicit different owner, and leaves the task contents and paused/active +status unchanged. It does not enable a schedule or adopt a task by name alone. ### Scheduled review preflight Before a scheduled review, verify that `fclt --version` succeeds from its configured diff --git a/src/activity-action.test.ts b/src/activity-action.test.ts index 527c3e23..00f3fb39 100644 --- a/src/activity-action.test.ts +++ b/src/activity-action.test.ts @@ -215,17 +215,33 @@ function captureConsole( const errors: string[] = []; const originalLog = console.log; const originalError = console.error; + const originalWrite = process.stdout.write; + process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ) => { + logs.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString() + ); + const done = + typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stdout.write; console.log = (...args: unknown[]) => logs.push(args.join(" ")); console.error = (...args: unknown[]) => errors.push(args.join(" ")); return operation().then( () => { console.log = originalLog; console.error = originalError; + process.stdout.write = originalWrite; return { errors, logs }; }, (error) => { console.log = originalLog; console.error = originalError; + process.stdout.write = originalWrite; throw error; } ); diff --git a/src/ai-cli.test.ts b/src/ai-cli.test.ts index d6bad662..913c9a32 100644 --- a/src/ai-cli.test.ts +++ b/src/ai-cli.test.ts @@ -28,6 +28,20 @@ async function captureConsole(fn: () => Promise) { const errors: string[] = []; const prevLog = console.log; const prevError = console.error; + const prevWrite = process.stdout.write; + process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ) => { + logs.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString() + ); + const done = + typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stdout.write; console.log = (...args: Parameters) => { logs.push(args.map((value) => String(value)).join(" ")); }; @@ -39,6 +53,7 @@ async function captureConsole(fn: () => Promise) { } finally { console.log = prevLog; console.error = prevError; + process.stdout.write = prevWrite; } return { logs, errors }; } @@ -65,6 +80,61 @@ afterEach(async () => { }); describe("ai CLI", () => { + it("repairs a configured scheduler through the CLI without activating it", async () => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + process.env.FACULT_ROOT_DIR = join(tempHome, ".ai"); + process.env.FACULT_LOCAL_STATE_DIR = join(tempHome, "state"); + process.chdir(tempHome); + const { enableEvolutionLoop } = await import("./evolution-loop"); + const { facultCodexAutomationOwnershipPath } = await import("./paths"); + const enabled = await enableEvolutionLoop({ + homeDir: tempHome, + rootDir: process.env.FACULT_ROOT_DIR, + scope: "global", + }); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await Bun.file(path).text()) + .replace('managed_by = "fclt-evolution-loop"\n', "") + .replace('status = "ACTIVE"', 'status = "PAUSED"'); + await Bun.write(path, current); + await rm( + facultCodexAutomationOwnershipPath( + tempHome, + enabled.config.automationName + ) + ); + const preview = await captureConsole(async () => { + await aiCommand([ + "loop", + "repair-scheduler", + "--global", + "--dry-run", + "--json", + ]); + }); + expect(preview.errors).toEqual([]); + expect(JSON.parse(preview.logs.join("\n"))).toMatchObject({ + repaired: false, + status: "PAUSED", + }); + const applied = await captureConsole(async () => { + await aiCommand([ + "loop", + "repair-scheduler", + "--global", + "--approve", + "--json", + ]); + }); + expect(applied.errors).toEqual([]); + expect(JSON.parse(applied.logs.join("\n"))).toMatchObject({ + repaired: true, + status: "PAUSED", + }); + expect(await Bun.file(path).text()).toBe(current); + }); + it("returns JSON recovery when the loop fails before a report exists", async () => { tempHome = await makeTempHome(); process.env.HOME = tempHome; @@ -418,7 +488,7 @@ describe("ai CLI", () => { automationPath, (await Bun.file(automationPath).text()).replace( 'managed_by = "fclt-evolution-loop"\n', - "" + 'managed_by = "another-owner"\n' ) ); const disabledOut = await captureConsole(async () => { diff --git a/src/ai.ts b/src/ai.ts index 1a239f61..e8e68f17 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -2832,6 +2832,7 @@ Usage: fclt ai loop activity [--all|--global|--project] [--json] fclt ai loop resolve [--json] fclt ai loop history [--all|--global|--project] [--since ] [--until ] [--item ] [--scope-id ] [--event ] [--limit <1-200>] [--cursor ] [--json] + fclt ai loop repair-scheduler [--approve] [--dry-run] [--json] fclt ai loop preflight [--json] fclt ai loop run [--since ] [--until ] [--source ] [--dry-run] [--scheduled] [--json] @@ -3018,7 +3019,23 @@ async function loopCommand(argv: string[]) { evolutionLoopStatus, latestEvolutionLoopReport, runEvolutionLoop, + repairEvolutionLoopScheduler, } = await import("./evolution-loop"); + if (sub === "repair-scheduler") { + const result = await repairEvolutionLoopScheduler({ + homeDir, + rootDir, + scope: loopScope, + approve: commandArgs.includes("--approve"), + dryRun: commandArgs.includes("--dry-run"), + }); + await writeCliOutput( + json + ? JSON.stringify(result, null, 2) + : `${result.repaired ? "Repaired" : "Would repair"} scheduler ownership at ${result.automationPath}` + ); + return; + } if (sub === "preflight") { const { preflightEvolutionLoop } = await import("./evolution-preflight"); const result = await preflightEvolutionLoop({ diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index ac206b91..1606393a 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -33,6 +33,7 @@ import { disableEvolutionLoop, enableEvolutionLoop, evolutionLoopStatus, + repairEvolutionLoopScheduler, runEvolutionLoop, } from "./evolution-loop"; import { @@ -45,10 +46,17 @@ import { facultAiReconciliationLockPath, facultAiReconciliationStatePath, facultAiWritebackQueuePath, + facultCodexAutomationOwnershipPath, withFacultRootScope, } from "./paths"; import { reconcileSources } from "./reconciliation"; +const AUTOMATION_PROMPT_RE = /^prompt = .*$/m; +const AUTOMATION_MODEL_RE = /^model = .*$/m; +const AUTOMATION_REASONING_RE = /^reasoning_effort = .*$/m; +const AUTOMATION_RRULE_RE = /^rrule = .*$/m; +const AUTOMATION_CREATED_RE = /^created_at = .*$/m; +const AUTOMATION_CWDS_RE = /^cwds = .*$/m; const SIGNAL_FAMILY_ID_RE = /^SF-/; const COMPLETED_RUN_STATUS_RE = /^(complete|degraded)$/; const temporaryRoots: string[] = []; @@ -1350,33 +1358,144 @@ describe("evolution loop", () => { ).toBe(false); }); - it("refuses to update a scheduler after its ownership marker is removed", async () => { - const project = await makeProject(); - const enabled = await enableEvolutionLoop({ - ...project, - now: () => new Date("2026-01-03T00:00:00.000Z"), - }); + it.each([ + "project", + "global", + ] as const)("preserves %s scheduler ownership and native edits after a marker-stripping update", async (scope) => { + const fixture = await makeProject(); + const project = { + ...fixture, + rootDir: + scope === "global" ? join(fixture.homeDir, ".ai") : fixture.rootDir, + scope, + }; + await mkdir(project.rootDir, { recursive: true }); + const enabled = await enableEvolutionLoop(project); const automationPath = join(enabled.automationPath, "automation.toml"); - const current = await readFile(automationPath, "utf8"); + const updated = + (await readFile(automationPath, "utf8")) + .replace('managed_by = "fclt-evolution-loop"\n', "") + .replace( + AUTOMATION_PROMPT_RE, + 'prompt = "Custom approved review and archive policy"' + ) + .replace(AUTOMATION_MODEL_RE, 'model = "custom-model"') + .replace(AUTOMATION_REASONING_RE, 'reasoning_effort = "medium"') + .replace(AUTOMATION_RRULE_RE, 'rrule = "RRULE:FREQ=WEEKLY;BYDAY=FR"') + + '\nnotification_policy = "failed_runs_only"\nexecution_environment = "local"\ntarget = { type = "project", project_id = "saved-project" }\n'; + await Bun.write(automationPath, updated); await Bun.write( - automationPath, - current.replace('managed_by = "fclt-evolution-loop"\n', "") + join(enabled.automationPath, "memory.md"), + "Retained automation history\n" + ); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + true + ); + const reenabled = await enableEvolutionLoop(project); + expect(reenabled.config.rrule).toContain("WEEKLY"); + const after = Bun.TOML.parse( + await readFile(automationPath, "utf8") + ) as Record; + const before = Bun.TOML.parse(updated) as Record; + for (const key of [ + "prompt", + "model", + "reasoning_effort", + "cwds", + "rrule", + "created_at", + "target", + "notification_policy", + "execution_environment", + ]) { + expect(after[key]).toEqual(before[key]); + } + expect( + await readFile(join(enabled.automationPath, "memory.md"), "utf8") + ).toBe("Retained automation history\n"); + expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(true); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + true ); + }); + + it("refuses a status edit that would match authored multiline prompt content", async () => { + const project = await makeProject(); + const enabled = await enableEvolutionLoop(project); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await readFile(path, "utf8")).replace( + AUTOMATION_PROMPT_RE, + "prompt = '''\nstatus = \"ACTIVE\"\nupdated_at = 1\nKeep this authored example.\n'''" + ); + await Bun.write(path, current); + expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(false); + expect(await readFile(path, "utf8")).toBe(current); + }); + it("repairs legacy ownership only with explicit approval and preserves the complete paused task", async () => { + const project = await makeProject(); + const enabled = await enableEvolutionLoop(project); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await readFile(path, "utf8")) + .replace('managed_by = "fclt-evolution-loop"\n', "") + .replace('status = "ACTIVE"', 'status = "PAUSED"'); + await Bun.write(path, current); + await rm( + facultCodexAutomationOwnershipPath( + project.homeDir, + enabled.config.automationName + ) + ); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + false + ); await expect( - enableEvolutionLoop({ - ...project, - rrule: "RRULE:FREQ=WEEKLY;BYDAY=FR", - }) - ).rejects.toThrow("not owned by the fclt evolution loop"); - expect(await readFile(automationPath, "utf8")).not.toContain( - "RRULE:FREQ=WEEKLY" + repairEvolutionLoopScheduler({ ...project, scope: "project" }) + ).rejects.toThrow("--approve"); + const preview = await repairEvolutionLoopScheduler({ + ...project, + scope: "project", + dryRun: true, + }); + expect(preview.repaired).toBe(false); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + false ); - const disabled = await disableEvolutionLoop(project); - expect(disabled.config?.enabled).toBe(false); - expect(disabled.scheduler?.paused).toBe(false); - expect(disabled.scheduler?.error).toContain("not owned"); - expect((await evolutionLoopStatus(project)).health).toBe("disabled"); + await repairEvolutionLoopScheduler({ + ...project, + scope: "project", + approve: true, + }); + expect((await evolutionLoopStatus(project)).scheduler).toMatchObject({ + registered: true, + status: "PAUSED", + }); + expect(await readFile(path, "utf8")).toBe(current); + }); + + it("rejects a replaced or moved scheduler despite a same-name ownership receipt", async () => { + const project = await makeProject(); + const enabled = await enableEvolutionLoop(project); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await readFile(path, "utf8")).replace( + 'managed_by = "fclt-evolution-loop"\n', + "" + ); + for (const edited of [ + current.replace(AUTOMATION_CREATED_RE, "created_at = 1"), + current.replace(AUTOMATION_CWDS_RE, 'cwds = ["/different-project"]'), + `${current}\nmanaged_by = "another-owner"\n`, + ]) { + await Bun.write(path, edited); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + false + ); + await expect(enableEvolutionLoop(project)).rejects.toThrow("not owned"); + expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe( + false + ); + expect(await readFile(path, "utf8")).toBe(edited); + } }); it("refuses to replace a partial scheduler directory", async () => { diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index c74fe49a..65b8988f 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -51,6 +51,8 @@ import type { } from "./reconciliation-types"; import { assertSafeCodexAutomationTarget, + hasCodexAutomationOwnership, + repairCodexAutomationOwnership, scaffoldCodexAutomationTemplate, setCodexAutomationStatus, } from "./remote"; @@ -528,6 +530,7 @@ async function automationStatus(args: { exists: boolean; registered: boolean; status?: "ACTIVE" | "PAUSED"; + rrule?: string; error?: string; }> { try { @@ -579,8 +582,9 @@ async function automationStatus(args: { : undefined; return { exists: true, - registered: parsed.managed_by === "fclt-evolution-loop", + registered: await hasCodexAutomationOwnership({ ...args, parsed }), status, + rrule: typeof parsed.rrule === "string" ? parsed.rrule : undefined, }; } @@ -666,19 +670,24 @@ async function enableEvolutionLoopScoped(args: { `Refusing to replace an automation not owned by the fclt evolution loop: ${name}` ); } - const scaffold = await scaffoldCodexAutomationTemplate({ - homeDir: args.homeDir, - cwd: projectRoot ?? args.homeDir, - templateId: "closed-loop-review", - name, - scope, - projectRoot, - rootDir: args.rootDir, - rrule: config.rrule, - status: "PAUSED", - force: existingAutomation.exists, - dryRun: args.dryRun, - }); + if (existingAutomation.exists && !args.rrule && existingAutomation.rrule) { + config.rrule = normalizeRrule(existingAutomation.rrule); + } + const scaffold = existingAutomation.exists + ? { path: join(args.homeDir, ".codex", "automations", name) } + : await scaffoldCodexAutomationTemplate({ + homeDir: args.homeDir, + cwd: projectRoot ?? args.homeDir, + templateId: "closed-loop-review", + name, + scope, + projectRoot, + rootDir: args.rootDir, + rrule: config.rrule, + status: "PAUSED", + force: existingAutomation.exists, + dryRun: args.dryRun, + }); if (!args.dryRun) { await atomicWrite( facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir), @@ -688,6 +697,7 @@ async function enableEvolutionLoopScoped(args: { homeDir: args.homeDir, name, status: "ACTIVE", + rrule: config.rrule, }); await appendLoopAudit(args, { generatedAt: now, @@ -2440,3 +2450,35 @@ export async function runEvolutionLoop( async () => await runEvolutionLoopScoped({ ...args, scope }) ); } + +export async function repairEvolutionLoopScheduler(args: { + homeDir: string; + rootDir: string; + scope: "global" | "project"; + approve?: boolean; + dryRun?: boolean; +}) { + return await withFacultRootScope( + { rootDir: args.rootDir, scope: args.scope }, + async () => { + const config = await loadEvolutionLoopConfig(args); + if (!config || config.scope !== args.scope) { + throw new Error("No matching configured loop to repair"); + } + const expectedCwd = + config.scope === "global" + ? args.homeDir + : projectRootFromAiRoot(args.rootDir, args.homeDir); + if (!expectedCwd) { + throw new Error( + "Cannot resolve the configured project for scheduler repair" + ); + } + return await repairCodexAutomationOwnership({ + ...args, + name: config.automationName, + expectedCwd, + }); + } + ); +} diff --git a/src/paths.ts b/src/paths.ts index 3ee37a4c..d54ed7cc 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1035,3 +1035,15 @@ export function facultContextRootDir(args?: { return facultRootDir(home, config); } + +export function facultCodexAutomationOwnershipPath( + home: string, + name: string +): string { + return join( + facultLocalStateRoot(home), + "automations", + "codex", + `${encodeURIComponent(name)}.json` + ); +} diff --git a/src/remote.ts b/src/remote.ts index 3ecf851c..2d1af6d2 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -47,6 +47,7 @@ import { legacyManagedMutationApproved, } from "./legacy-mutation-policy"; import { + facultCodexAutomationOwnershipPath, facultRootDir, pathsPhysicallyEquivalent, projectRootFromAiRoot, @@ -1431,6 +1432,13 @@ updated_at = ${timestamp} changedPaths.push(automationTomlPath); if (!args.dryRun) { await atomicWriteFile(automationTomlPath, `${automationToml}\n`); + if (template.id === "closed-loop-review") { + await recordAutomationOwnership( + home, + safeName, + Bun.TOML.parse(automationToml) as Record + ); + } } } @@ -1450,10 +1458,141 @@ updated_at = ${timestamp} }; } +const AUTOMATION_RRULE_LINE_RE = /^rrule\s*=.*$/m; + +function automationIdentity(parsed: Record): string | null { + if ( + typeof parsed.id !== "string" || + typeof parsed.created_at !== "number" || + !Number.isSafeInteger(parsed.created_at) || + !Array.isArray(parsed.cwds) || + parsed.cwds.length === 0 || + parsed.cwds.some((cwd) => typeof cwd !== "string" || !isAbsolute(cwd)) + ) { + return null; + } + return createHash("sha256") + .update( + JSON.stringify({ + id: parsed.id, + createdAt: parsed.created_at, + cwds: parsed.cwds, + }) + ) + .digest("hex"); +} + +async function recordAutomationOwnership( + home: string, + name: string, + parsed: Record +): Promise { + const identity = automationIdentity(parsed); + if (parsed.id !== name || !identity) { + throw new Error("Cannot record an invalid automation identity"); + } + await atomicWriteFile( + facultCodexAutomationOwnershipPath(home, name), + `${JSON.stringify({ version: 1, owner: "fclt-evolution-loop", identity })}\n` + ); +} + +export async function repairCodexAutomationOwnership(args: { + homeDir: string; + name: string; + expectedCwd: string; + approve?: boolean; + dryRun?: boolean; +}) { + if (!(args.approve || args.dryRun)) { + throw new Error("Scheduler ownership repair requires explicit --approve"); + } + const safeName = sanitizeAutomationName(args.name); + await assertSafeAutomationTarget({ home: args.homeDir, safeName }); + const path = join( + args.homeDir, + ".codex", + "automations", + safeName, + "automation.toml" + ); + const current = await readFile(path, "utf8"); + const parsed = Bun.TOML.parse(current) as Record; + if ( + safeName !== args.name || + parsed.id !== safeName || + (parsed.status !== "ACTIVE" && parsed.status !== "PAUSED") || + !automationIdentity(parsed) || + JSON.stringify(parsed.cwds) !== + JSON.stringify([resolve(args.expectedCwd)]) || + (parsed.managed_by !== undefined && + parsed.managed_by !== "fclt-evolution-loop") + ) { + throw new Error( + "Scheduler identity, cwd, or explicit owner does not match this configured loop" + ); + } + if (!args.dryRun) { + if ((await readFile(path, "utf8")) !== current) { + throw new Error("Automation changed during ownership repair"); + } + await recordAutomationOwnership(args.homeDir, safeName, parsed); + } + return { + repaired: !args.dryRun, + dryRun: Boolean(args.dryRun), + automationPath: path, + status: parsed.status, + }; +} + +export async function hasCodexAutomationOwnership(args: { + homeDir: string; + name: string; + parsed: Record; +}): Promise { + if ( + args.parsed.id !== args.name || + sanitizeAutomationName(args.name) !== args.name + ) { + return false; + } + if (args.parsed.managed_by === "fclt-evolution-loop") { + return true; + } + if (args.parsed.managed_by !== undefined) { + return false; + } + const identity = automationIdentity(args.parsed); + if (!identity) { + return false; + } + try { + await assertSafeAutomationTarget({ + home: args.homeDir, + safeName: sanitizeAutomationName(args.name), + }); + const receipt = JSON.parse( + await readFile( + facultCodexAutomationOwnershipPath(args.homeDir, args.name), + "utf8" + ) + ); + return ( + receipt.version === 1 && + receipt.owner === "fclt-evolution-loop" && + receipt.identity === identity + ); + } catch { + return false; + } +} + export async function setCodexAutomationStatus(args: { homeDir?: string; name: string; status: "ACTIVE" | "PAUSED"; + rrule?: string; dryRun?: boolean; }): Promise<{ path: string; @@ -1479,7 +1618,13 @@ export async function setCodexAutomationStatus(args: { if (parsed.id !== safeName) { throw new Error(`Codex automation id mismatch at ${pathValue}`); } - if (parsed.managed_by !== "fclt-evolution-loop") { + if ( + !(await hasCodexAutomationOwnership({ + homeDir: home, + name: safeName, + parsed, + })) + ) { throw new Error( `Refusing to change an automation not owned by the fclt evolution loop: ${pathValue}` ); @@ -1488,7 +1633,9 @@ export async function setCodexAutomationStatus(args: { if (currentStatus !== "ACTIVE" && currentStatus !== "PAUSED") { throw new Error(`Codex automation has an invalid status at ${pathValue}`); } - const changed = currentStatus !== args.status; + const changed = + currentStatus !== args.status || + (args.rrule !== undefined && parsed.rrule !== args.rrule); if (changed && !args.dryRun) { if ( !( @@ -1501,13 +1648,41 @@ export async function setCodexAutomationStatus(args: { ); } const timestamp = String(Date.now()); - const next = current + let next = current .replace(AUTOMATION_STATUS_LINE_RE, `status = "${args.status}"`) .replace(AUTOMATION_UPDATED_AT_LINE_RE, `updated_at = ${timestamp}`); + if (args.rrule !== undefined) { + if (!AUTOMATION_RRULE_LINE_RE.test(next)) { + throw new Error("Automation recurrence line is missing"); + } + const recurrence = args.rrule; + next = next.replace( + AUTOMATION_RRULE_LINE_RE, + () => `rrule = ${quoteTomlString(recurrence)}` + ); + } + const expected = { + ...parsed, + status: args.status, + updated_at: Number(timestamp), + ...(args.rrule === undefined ? {} : { rrule: args.rrule }), + }; + if (JSON.stringify(Bun.TOML.parse(next)) !== JSON.stringify(expected)) { + throw new Error( + "Automation layout cannot be updated without changing authored fields" + ); + } + if ((await readFile(pathValue, "utf8")) !== current) { + throw new Error("Automation changed during status update"); + } const temporaryPath = `${pathValue}.${process.pid}.${timestamp}.tmp`; await Bun.write(temporaryPath, next); await rename(temporaryPath, pathValue); } + if (!args.dryRun) { + await recordAutomationOwnership(home, safeName, parsed); + } + return { path: pathValue, status: args.status, diff --git a/src/util/cli-output.test.ts b/src/util/cli-output.test.ts index 09d7bb18..703301d9 100644 --- a/src/util/cli-output.test.ts +++ b/src/util/cli-output.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "bun:test"; describe("writeCliOutput", () => { - it("flushes output larger than Bun's buffered stdout window", async () => { - const expectedBytes = 200_001; + it.each([ + 0, 512, 4096, 49_000, 65_536, 200_000, + ])("flushes %i bytes completely through a pipe", async (bytes) => { + const expectedBytes = bytes + 1; const proc = Bun.spawn( [ process.execPath, "-e", - 'import { writeCliOutput } from "./src/util/cli-output"; await writeCliOutput("x".repeat(200_000));', + `import { writeCliOutput } from "./src/util/cli-output"; await writeCliOutput("x".repeat(${bytes}));`, ], { cwd: process.cwd(), diff --git a/src/util/cli-output.ts b/src/util/cli-output.ts index 4d99bd2e..23cac399 100644 --- a/src/util/cli-output.ts +++ b/src/util/cli-output.ts @@ -1,12 +1,5 @@ -const BUFFERED_STDOUT_THRESHOLD_BYTES = 64 * 1024; - export async function writeCliOutput(output: string): Promise { const terminated = `${output}\n`; - if (Buffer.byteLength(terminated, "utf8") < BUFFERED_STDOUT_THRESHOLD_BYTES) { - console.log(output); - return; - } - await new Promise((resolve, reject) => { process.stdout.write(terminated, (error) => { if (error) {