Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions src/commands/dream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ import {
type CyclePhase,
type CycleReport,
} from '../core/cycle.ts';
import { existsSync } from 'fs';
import {
existsSync,
mkdirSync,
renameSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { join } from 'path';

interface DreamArgs {
json: boolean;
Expand All @@ -53,6 +61,14 @@ interface DreamArgs {
* Never auto-applied for --input (codex finding #3).
*/
bypassDreamGuard: boolean;
/**
* Persist the CycleReport JSON to a per-cycle file plus a `latest.json`
* symlink. Replaces the common cron pattern where downstream wrappers
* (e.g. fork-side launchd glue) capture stdout, archive it, and atomically
* swap a `latest` pointer. Pairs with `--json`; no-op without it.
* The directory is auto-created.
*/
archiveDir: string | null;
}

const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
Expand Down Expand Up @@ -99,6 +115,9 @@ function parseArgs(args: string[]): DreamArgs {
process.exit(2);
}

const archiveDirIdx = args.indexOf('--archive-dir');
const archiveDir = archiveDirIdx !== -1 ? args[archiveDirIdx + 1] ?? null : null;

// --input + --date / --from / --to is incoherent: --input is a single
// file, the date filters scan a directory.
if (inputFile && (date || from || to)) {
Expand All @@ -121,9 +140,52 @@ function parseArgs(args: string[]): DreamArgs {
from,
to,
bypassDreamGuard: args.includes('--unsafe-bypass-dream-guard'),
archiveDir,
};
}

/**
* File-system-safe timestamp: `2026-04-23T19-42-07Z`. Strips colons and
* milliseconds so the result is sortable, portable, and acceptable on
* filesystems that reject `:` (e.g. SMB/Windows-shared NAS).
*/
function isoStamp(d = new Date()): string {
return d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
}

/**
* Write the CycleReport JSON to `<dir>/<iso>.json` and atomically swap the
* `<dir>/latest.json` symlink to point at it. The dir is created if missing.
*
* The atomic swap pattern (`symlink → rename onto target`) guarantees that
* readers never observe a dangling symlink — `rename` on the same fs is
* atomic. Best-effort: on platforms or filesystems where `symlink` isn't
* supported (rare on macOS/Linux), the per-cycle file still lands; the
* `latest.json` swap simply no-ops with a stderr warning.
*
* Returns the absolute path of the per-cycle file.
*/
function archiveReport(dir: string, json: string, stamp: string = isoStamp()): string {
mkdirSync(dir, { recursive: true });

const target = join(dir, `${stamp}.json`);
writeFileSync(target, json);

const latest = join(dir, 'latest.json');
const tmp = join(dir, '.latest.json.tmp');
try {
if (existsSync(tmp)) unlinkSync(tmp);
symlinkSync(`${stamp}.json`, tmp);
renameSync(tmp, latest);
} catch (err) {
console.error(
`[dream] archive: per-cycle file written but latest.json symlink swap failed: ${(err as Error).message}`,
);
}

return target;
}

/**
* Resolve the brain directory without the `findRepoRoot` footgun.
*
Expand Down Expand Up @@ -192,6 +254,12 @@ Options:
guard is firing. Loud stderr warning + cost reminder
fires every run.

--archive-dir <path>
Persist the CycleReport JSON to <path>/<iso>.json and
atomically swap a <path>/latest.json symlink. Pairs with
--json; no-op without it. Replaces ad-hoc downstream
stdout-capture wrappers. Directory is auto-created.

--help, -h Show this help

Examples:
Expand Down Expand Up @@ -288,9 +356,24 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom
});

if (opts.json) {
console.log(JSON.stringify(report, null, 2));
const json = JSON.stringify(report, null, 2);
console.log(json);
if (opts.archiveDir) {
try {
const path = archiveReport(opts.archiveDir, json);
console.error(`[dream] archived report → ${path}`);
} catch (err) {
console.error(`[dream] archive write failed: ${(err as Error).message}`);
process.exit(2);
}
}
} else {
printHuman(report);
if (opts.archiveDir) {
console.error(
'[dream] --archive-dir requires --json; report not archived (printed human format only)',
);
}
}

// Exit non-zero when the cycle failed overall (helps cron spot real problems).
Expand Down
25 changes: 25 additions & 0 deletions test/dream-cli-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,29 @@ describe('dream CLI flag wiring', () => {
expect(dreamSrc).toContain('skips the Sonnet');
expect(dreamSrc.toLowerCase()).toContain('zero llm calls');
});

test('declares --archive-dir flag with archiveDir field', () => {
expect(dreamSrc).toContain("'--archive-dir'");
expect(dreamSrc).toContain('archiveDir');
});

test('archiveReport helper writes per-cycle file + atomic latest.json symlink', () => {
// Source-text introspection — the runtime path is exercised end-to-end
// in test/dream.test.ts.
expect(dreamSrc).toMatch(/function\s+archiveReport/);
expect(dreamSrc).toContain('latest.json');
expect(dreamSrc).toContain('renameSync');
expect(dreamSrc).toContain('symlinkSync');
});

test('--archive-dir is no-op without --json (help text says so)', () => {
expect(dreamSrc).toContain('Pairs with');
expect(dreamSrc).toMatch(/requires --json/);
});

test('isoStamp returns colon-free, sortable, fs-safe timestamp', () => {
// Filename-safety is a contract — must not contain ':' or '.'
expect(dreamSrc).toMatch(/function\s+isoStamp/);
expect(dreamSrc).toContain("replace(/[:.]/g, '-')");
});
});
96 changes: 95 additions & 1 deletion test/dream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/

import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { existsSync, lstatSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execSync } from 'child_process';
Expand Down Expand Up @@ -241,3 +241,97 @@ describe('runDream — exit-code semantics', () => {
spy.mockRestore();
});
});

// ─── --archive-dir behavior ────────────────────────────────────────

describe('runDream — --archive-dir persists CycleReport + latest.json symlink', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
let archiveDir: string;

beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
archiveDir = mkdtempSync(join(tmpdir(), 'gbrain-dream-archive-'));
}, 60_000);

afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
rmSync(archiveDir, { recursive: true, force: true });
}, 60_000);

test('writes per-cycle JSON file + latest.json symlink pointing at it', async () => {
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', archiveDir]);

const entries = readdirSync(archiveDir);
// exactly one .json + one latest.json symlink
const cycleFiles = entries.filter((f) => f.endsWith('.json') && f !== 'latest.json');
expect(cycleFiles.length).toBe(1);
expect(entries).toContain('latest.json');

// latest.json is a symlink (lstat) pointing at the cycle file
const latestPath = join(archiveDir, 'latest.json');
expect(lstatSync(latestPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(latestPath)).toBe(cycleFiles[0]);

// The cycle file itself contains a valid CycleReport JSON
const content = readFileSync(join(archiveDir, cycleFiles[0]), 'utf-8');
const report = JSON.parse(content);
expect(report.status).toBeDefined();
expect(report.phases).toBeInstanceOf(Array);
expect(report.brain_dir).toBe(repo);
});

test('cycle filenames are colon-free + sortable (fs-safe ISO timestamp)', async () => {
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', archiveDir]);

const cycleFiles = readdirSync(archiveDir).filter((f) => f.endsWith('.json') && f !== 'latest.json');
expect(cycleFiles.length).toBe(1);
expect(cycleFiles[0]).not.toContain(':');
expect(cycleFiles[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.json$/);
});

test('second run atomically swaps latest.json to the new cycle file', async () => {
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', archiveDir]);
const firstTarget = readlinkSync(join(archiveDir, 'latest.json'));

// 1 s sleep so ISO timestamps differ (sec precision).
await new Promise((r) => setTimeout(r, 1100));

await runDream(engine, ['--dir', repo, '--phase', 'orphans', '--json', '--archive-dir', archiveDir]);
const secondTarget = readlinkSync(join(archiveDir, 'latest.json'));

expect(secondTarget).not.toBe(firstTarget);

// Both per-cycle files still exist (we never delete history).
const cycleFiles = readdirSync(archiveDir).filter((f) => f.endsWith('.json') && f !== 'latest.json');
expect(cycleFiles.length).toBe(2);
expect(cycleFiles).toContain(firstTarget);
expect(cycleFiles).toContain(secondTarget);
});

test('--archive-dir auto-creates a missing destination directory', async () => {
const deepDir = join(archiveDir, 'does', 'not', 'exist', 'yet');
expect(existsSync(deepDir)).toBe(false);

await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', deepDir]);

expect(existsSync(deepDir)).toBe(true);
expect(existsSync(join(deepDir, 'latest.json'))).toBe(true);
});

test('--archive-dir without --json is a no-op + emits a stderr warning', async () => {
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--archive-dir', archiveDir]);

// No files were written (no --json, no archive)
expect(readdirSync(archiveDir).length).toBe(0);
// Warning was emitted
const warned = errSpy.mock.calls.some((c) =>
String(c[0]).includes('--archive-dir requires --json'),
);
expect(warned).toBe(true);
errSpy.mockRestore();
});
});