mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix: dream --source/--source-id plumbs sourceId to runCycle (supersedes #1559) Closes the silent-no-op class where `gbrain dream --source <id>` ran the cycle but never wrote `last_full_cycle_at`, leaving `gbrain doctor`'s cycle_freshness check stuck red forever. Changes to src/commands/dream.ts: - DreamArgs.source field; parseArgs recognizes --source <id> AND the --source-id alias (matches v0.37.7.0 #1167 naming across import/extract/graph-query) - Argv validation: missing value → exit 2; repeated different values → exit 2; --source X --source-id Y conflict → exit 2; same-value repetition → accepted - --help short-circuit ordering preserved with IRON-RULE comment + structural test guard - runDream engine-null guard: --source requires a connected brain - runDream resolveSourceId → archived-source guard via fetchSource from src/core/sources-load.ts (single-row SELECT that projects archived + handles pre-v0.26.5 schema via isUndefinedColumnError) - Typed-error try/catch via isResolverUserError predicate: only swallows known resolver-user errors; TypeError / postgres errors propagate uncaught with stack trace so genuine programmer bugs aren't hidden behind operator-error UX - Forwarded sourceId to runCycle; existing v0.38 writeback at cycle.ts:1947-1967 now actually fires - --help text documents both flag names Tests: - test/dream-cli-flags.test.ts: structural assertions for new flags, help text, IRON-RULE comment guard, resolver/predicate wiring - test/dream.test.ts: 13 PGLite integration cases covering happy path (the regression that closes PR #1559), back-compat, alias equivalence, all argv edge cases, engine-null, archived, --help short-circuit ordering, T3 typed-error propagation, and D5 end-to-end dream→checkCycleFreshness column-name drift guard Plan + 11 decisions: ~/.claude/plans/system-instruction-you-are-working-starry-papert.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: judgeSignificance uses canonical safeSplitIndex (closes #1559/#1561 emoji crash) Closes the 2026-05-24 production SYNTH_PHASE_FAIL: 🤖 (U+1F916, surrogate pair U+D83E U+DD16) at offset 3999 in a long telegram transcript made the raw 4000-char slice produce a lone high surrogate; Anthropic's JSON parser rejected the payload with "no low surrogate in string"; the synthesize phase failed. Changes to src/core/cycle/synthesize.ts: - judgeSignificance head+tail slice routed through safeSplitIndex from src/core/text-safe.ts (already imported) - Did NOT introduce safeSliceEnd from PRs #1559+#1561 — that helper re-introduces the case-3 bug src/core/text-safe.ts:18-21 documents - Did NOT touch findBoundary — master already routes through safeSplitIndex per the v0.42.0.0 wave Tests in test/cycle-synthesize.test.ts: - New describe('judgeSignificance — UTF-16 safety') block - test.each over head boundaries (offsets 3998-4001) AND tail boundaries (offsets 3999-4002) for an 8001-char content with the robot emoji placed at each - Primary assertion: explicit unpaired-surrogate scan over the captured prompt (NOT JSON.stringify per codex C-11 — V8/JSCore do not throw on lone surrogates, so that assertion was weak) - Sub-8000 short-content branch case: no slicing, emoji passes through unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: expand error_page_title + add cloudflare_challenge_title (supersedes #1561) Closes the bug class where scraper error pages with titles like "Forbidden", "Access Denied", "Service Unavailable", "Robot Check", and "Just a moment..." were slipping through the ingest gate because the matcher only caught bare numeric codes (403/404/500...) and "page not found". 232+ pages observed (202+ from straylight- brain) were inflating page counts and tripping content_sanity_audit_recent on every doctor run. Changes to src/core/content-sanity.ts BUILT_IN_JUNK_PATTERNS: - Expanded error_page_title regex to also catch forbidden, access denied, service unavailable, robot check, verify you are human (case-insensitive, anchored — so long-form essays about these topics still ingest fine) - New cloudflare_challenge_title pattern with DISTINCT name from error_page_title (PR #1561 collapsed both into one name and lost audit signal — the new name preserves diagnosability in ~/.gbrain/audit/content-sanity-YYYY-Www.jsonl and doctor's content_sanity_audit_recent aggregation) - Dropped PR #1561's bare-`error` matcher — too aggressive on legitimate concept/taxonomy pages titled exactly "Error" Tests: - test/content-sanity.test.ts: pattern-count locked at 7, new matches via test.each, over-match regression guard (legitimate prose titled "How to Handle Access Denied Errors" / "Error Boundary in React" etc. must pass), audit-name distinctness pinned - test/import-file-content-sanity.test.ts: end-to-end ContentSanityBlockError via importFromContent for each new pattern family (D6 — assessor wiring coverage, not just regex) Out of scope, filed in TODOS.md as TODO-V13-C: gbrain pages audit-junk-titles legacy-cleanup command. Dropped from this PR per codex outside-voice tension (T1) for ship-and-validate- matchers-first discipline. The 200+ pre-existing scraper pages already in the DB will get the destructive-cleanup operator surface after ~1 week of production observation against this matcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.41.23.0 + CHANGELOG + follow-up TODOs VERSION + package.json bump to 0.41.23.0. CHANGELOG voice: ELI10 lead naming the bug ("`gbrain dream --source <id>` finally counts as a cycle"), then per-fix detail, then a "To take advantage of v0.41.23.0" operator-action block and itemized changes. TODOS.md v0.41.23.x follow-ups: - TODO-V13-A (P2): --max-pages plumbing (PR #1559's flag, deferred because CycleOpts has no maxPages field today) - TODO-V13-B (P3): --source vs --source-id flag-name unification across all CLI commands - TODO-V13-C (P2): gbrain pages audit-junk-titles legacy cleanup (deferred for ~1 week of matcher production observation) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.41.25.0 → v0.41.26.0 (leave headroom for in-flight PR) Master shipped v0.41.23.0 + v0.41.24.0 mid-review; this branch originally bumped to v0.41.25.0 post-merge. User flagged v0.41.26.0 to leave a slot open for another in-flight PR. No code changes; VERSION + package.json + CHANGELOG header + "To take advantage" section updated in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
/**
|
|
* gbrain dream — run one brain maintenance cycle.
|
|
*
|
|
* The README brand promise: "the agent runs while I sleep, the dream
|
|
* cycle ... I wake up and the brain is smarter." Cron-friendly, JSON
|
|
* report, phase-selectable.
|
|
*
|
|
* Thin alias over runCycle (src/core/cycle.ts). Both this command and
|
|
* `gbrain autopilot` converge on the same primitive so there's one
|
|
* source of truth for what "overnight maintenance" means.
|
|
*
|
|
* Usage:
|
|
* gbrain dream # full 6-phase cycle
|
|
* gbrain dream --dry-run # preview, no writes
|
|
* gbrain dream --json # CycleReport JSON (for agents)
|
|
* gbrain dream --phase lint # run a single phase
|
|
* gbrain dream --pull # also git pull the brain repo
|
|
* gbrain dream --dir /path/to/brain # explicit brain location
|
|
*
|
|
* Cron: 0 2 * * * gbrain dream --json >> /var/log/gbrain-dream.log
|
|
*
|
|
* Related: `gbrain autopilot --install` for continuous daemonized
|
|
* maintenance. dream is the one-shot, autopilot is the scheduler.
|
|
*/
|
|
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import {
|
|
runCycle,
|
|
ALL_PHASES,
|
|
type CyclePhase,
|
|
type CycleReport,
|
|
} from '../core/cycle.ts';
|
|
import { resolveSourceId } from '../core/source-resolver.ts';
|
|
import { fetchSource } from '../core/sources-load.ts';
|
|
import { existsSync } from 'fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
interface DreamArgs {
|
|
json: boolean;
|
|
dryRun: boolean;
|
|
pull: boolean;
|
|
phase: CyclePhase | null;
|
|
dir: string | null;
|
|
help: boolean;
|
|
/** v0.21: ad-hoc transcript file path; implies --phase synthesize. */
|
|
inputFile: string | null;
|
|
/** v0.21: restrict synthesize to a single date (YYYY-MM-DD). */
|
|
date: string | null;
|
|
/** v0.21: backfill range start (YYYY-MM-DD). */
|
|
from: string | null;
|
|
/** v0.21: backfill range end (YYYY-MM-DD). */
|
|
to: string | null;
|
|
/**
|
|
* v0.23.2: disable the synthesize phase's self-consumption guard.
|
|
* Long-form flag name to discourage casual use; loud stderr warning fires when set.
|
|
* Never auto-applied for --input (codex finding #3).
|
|
*/
|
|
bypassDreamGuard: boolean;
|
|
/**
|
|
* v0.41.13: per-source cycle scoping. Threaded into runCycle as
|
|
* `sourceId` so `cycle.ts:1947-1967` writes `last_full_cycle_at`
|
|
* to `sources.config` on success — without it, `gbrain doctor`'s
|
|
* `cycle_freshness` check stays stale forever. Accepts `--source
|
|
* <id>` and the alias `--source-id <id>` (the v0.37.7.0 #1167
|
|
* canonical name across import/extract/graph-query); both work
|
|
* until a follow-up CLI cleanup picks one. Supersedes PR #1559.
|
|
*/
|
|
source: string | null;
|
|
}
|
|
|
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/**
|
|
* Collect every occurrence of `--<flag> <value>` in argv. Used to
|
|
* detect repeated flags with different values (e.g.
|
|
* `--source X --source Y`) and to surface a clean usage error
|
|
* instead of silently last-wins. Repeated identical values are
|
|
* collapsed to one (no-op). Missing values (flag at end of argv)
|
|
* return null to let the caller raise an explicit usage error
|
|
* rather than fall through with `undefined`.
|
|
*/
|
|
function collectFlagValues(args: string[], flag: string): string[] | null {
|
|
const values: string[] = [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] !== flag) continue;
|
|
const v = args[i + 1];
|
|
if (v === undefined) return null; // flag at end of argv
|
|
values.push(v);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function parseArgs(args: string[]): DreamArgs {
|
|
const phaseIdx = args.indexOf('--phase');
|
|
const rawPhase = phaseIdx !== -1 ? args[phaseIdx + 1] : null;
|
|
let phase = rawPhase && (ALL_PHASES as string[]).includes(rawPhase)
|
|
? (rawPhase as CyclePhase)
|
|
: null;
|
|
if (rawPhase && !phase) {
|
|
console.error(`Unknown phase "${rawPhase}". Valid: ${ALL_PHASES.join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const dirIdx = args.indexOf('--dir');
|
|
const dir = dirIdx !== -1 ? args[dirIdx + 1] : null;
|
|
|
|
const inputIdx = args.indexOf('--input');
|
|
const inputFile = inputIdx !== -1 ? args[inputIdx + 1] ?? null : null;
|
|
|
|
const dateIdx = args.indexOf('--date');
|
|
const date = dateIdx !== -1 ? args[dateIdx + 1] ?? null : null;
|
|
if (date && !ISO_DATE_RE.test(date)) {
|
|
console.error(`--date must be YYYY-MM-DD; got "${date}"`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const fromIdx = args.indexOf('--from');
|
|
const from = fromIdx !== -1 ? args[fromIdx + 1] ?? null : null;
|
|
if (from && !ISO_DATE_RE.test(from)) {
|
|
console.error(`--from must be YYYY-MM-DD; got "${from}"`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const toIdx = args.indexOf('--to');
|
|
const to = toIdx !== -1 ? args[toIdx + 1] ?? null : null;
|
|
if (to && !ISO_DATE_RE.test(to)) {
|
|
console.error(`--to must be YYYY-MM-DD; got "${to}"`);
|
|
process.exit(2);
|
|
}
|
|
if (from && to && from > to) {
|
|
console.error(`--from (${from}) is after --to (${to}); empty range`);
|
|
process.exit(2);
|
|
}
|
|
|
|
// --input + --date / --from / --to is incoherent: --input is a single
|
|
// file, the date filters scan a directory.
|
|
if (inputFile && (date || from || to)) {
|
|
console.error('--input cannot be combined with --date / --from / --to');
|
|
process.exit(2);
|
|
}
|
|
|
|
// --input implies --phase synthesize.
|
|
if (inputFile && !phase) phase = 'synthesize';
|
|
|
|
// v0.41.13: --source <id> (and the --source-id alias) drives per-source
|
|
// cycle scoping. Resolution rules:
|
|
// - missing value (flag at end of argv) → exit 2 with usage
|
|
// - repeated with different values (e.g. --source X --source Y) → exit 2
|
|
// - --source X --source-id Y (conflicting flag aliases) → exit 2
|
|
// - --source X --source X (or --source-id repeated with same value) → accepted
|
|
// - --help short-circuits BEFORE this block fires (see runDream).
|
|
// Closes the PR #1559 silent-no-op class through a clean argv contract.
|
|
const sourceValues = collectFlagValues(args, '--source');
|
|
const sourceIdValues = collectFlagValues(args, '--source-id');
|
|
if (sourceValues === null) {
|
|
console.error('--source <id>: missing value. Usage: gbrain dream --source <source-id>');
|
|
process.exit(2);
|
|
}
|
|
if (sourceIdValues === null) {
|
|
console.error('--source-id <id>: missing value. Usage: gbrain dream --source-id <source-id>');
|
|
process.exit(2);
|
|
}
|
|
const uniqSource = Array.from(new Set(sourceValues));
|
|
const uniqSourceId = Array.from(new Set(sourceIdValues));
|
|
if (uniqSource.length > 1) {
|
|
console.error(`specify --source once; got [${uniqSource.map(v => `"${v}"`).join(', ')}]`);
|
|
process.exit(2);
|
|
}
|
|
if (uniqSourceId.length > 1) {
|
|
console.error(`specify --source-id once; got [${uniqSourceId.map(v => `"${v}"`).join(', ')}]`);
|
|
process.exit(2);
|
|
}
|
|
if (uniqSource.length === 1 && uniqSourceId.length === 1 && uniqSource[0] !== uniqSourceId[0]) {
|
|
console.error(
|
|
`use --source OR --source-id, not both (different values): ` +
|
|
`--source="${uniqSource[0]}" vs --source-id="${uniqSourceId[0]}"`,
|
|
);
|
|
process.exit(2);
|
|
}
|
|
const source = uniqSource[0] ?? uniqSourceId[0] ?? null;
|
|
|
|
return {
|
|
json: args.includes('--json'),
|
|
dryRun: args.includes('--dry-run'),
|
|
pull: args.includes('--pull'),
|
|
phase,
|
|
dir,
|
|
help: args.includes('--help') || args.includes('-h'),
|
|
inputFile,
|
|
date,
|
|
from,
|
|
to,
|
|
bypassDreamGuard: args.includes('--unsafe-bypass-dream-guard'),
|
|
source,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the brain directory without the `findRepoRoot` footgun.
|
|
*
|
|
* Prior dream.ts walked up 10 levels of cwd looking for `.git` and would
|
|
* happily run lint + sync against an unrelated git repo the user happened
|
|
* to be cd'd into. This resolver only trusts two sources:
|
|
* 1. An explicit --dir argument.
|
|
* 2. The `sync.repo_path` config key set by `gbrain init` (engine-backed).
|
|
*
|
|
* If neither is available, we error out instead of guessing.
|
|
*/
|
|
async function resolveBrainDir(
|
|
engine: BrainEngine | null,
|
|
explicit: string | null,
|
|
): Promise<string> {
|
|
if (explicit) {
|
|
if (!existsSync(explicit)) {
|
|
console.error(`--dir path does not exist: ${explicit}`);
|
|
process.exit(1);
|
|
}
|
|
// Resolve to absolute so downstream writeFileSync(join(brainDir, slug))
|
|
// can't silently land at cwd when explicit is `.` / `./brain` / etc.
|
|
return resolve(explicit);
|
|
}
|
|
|
|
if (engine) {
|
|
const configured = await engine.getConfig('sync.repo_path');
|
|
if (configured && existsSync(configured)) {
|
|
return resolve(configured);
|
|
}
|
|
}
|
|
|
|
console.error(
|
|
'No brain directory found. Pass --dir <path> or configure one via `gbrain init`.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: gbrain dream [options]
|
|
|
|
Run one brain maintenance cycle. Eight phases:
|
|
lint -> backlinks -> sync -> synthesize -> extract -> patterns -> embed -> orphans
|
|
|
|
The synthesize + patterns phases (v0.21) consolidate yesterday's
|
|
conversation transcripts into reflections, originals, and cross-session
|
|
pattern pages. Designed for cron (exits when done).
|
|
|
|
Options:
|
|
--dry-run Preview all fixes without writing. Note: synthesize
|
|
runs the cheap Haiku significance filter (caches
|
|
verdicts), but skips the Sonnet synthesis pass.
|
|
"--dry-run" does NOT mean "zero LLM calls."
|
|
--json Emit the CycleReport as JSON (agent-readable)
|
|
--phase <name> Run a single phase: ${ALL_PHASES.join(' | ')}
|
|
--pull git pull the brain repo before syncing (default: no pull)
|
|
--dir <path> Brain directory (default: configured brain)
|
|
|
|
--source <id> Scope the cycle to one source so doctor's
|
|
cycle_freshness check sees a fresh stamp on
|
|
completion. Without this, gbrain dream's
|
|
timestamp never lands and federated brains
|
|
see "stale cycle" forever.
|
|
--source-id <id> Alias for --source. Matches the v0.37.7.0+
|
|
naming used by import/extract/graph-query.
|
|
|
|
--input <file> Synthesize a specific transcript file (implies
|
|
--phase synthesize). Bypasses corpus-dir scan.
|
|
--date YYYY-MM-DD Synthesize transcripts dated for one specific day.
|
|
--from YYYY-MM-DD Backfill range start (use with --to).
|
|
--to YYYY-MM-DD Backfill range end.
|
|
|
|
--unsafe-bypass-dream-guard
|
|
Disable the self-consumption guard. Use only when you
|
|
know the input file is NOT dream-cycle output but the
|
|
guard is firing. Loud stderr warning + cost reminder
|
|
fires every run.
|
|
|
|
--help, -h Show this help
|
|
|
|
Examples:
|
|
gbrain dream
|
|
gbrain dream --dry-run --json
|
|
gbrain dream --phase lint
|
|
gbrain dream --phase synthesize --input ~/transcripts/2026-04-25.txt
|
|
gbrain dream --phase synthesize --from 2026-04-01 --to 2026-04-25
|
|
0 2 * * * gbrain dream --json # nightly via cron
|
|
|
|
Configure synthesize:
|
|
gbrain config set dream.synthesize.session_corpus_dir /path/to/transcripts
|
|
gbrain config set dream.synthesize.session_corpus_dir /path/to/transcripts
|
|
|
|
Related:
|
|
gbrain autopilot --install # continuous maintenance as a daemon
|
|
gbrain autopilot # same maintenance cycle, scheduled
|
|
`);
|
|
}
|
|
|
|
// ─── Human-friendly report printing ────────────────────────────────
|
|
|
|
function printHuman(report: CycleReport) {
|
|
if (report.status === 'skipped') {
|
|
if (report.reason === 'cycle_already_running') {
|
|
console.log(`Skipped: another cycle is already running. (locked)`);
|
|
} else if (report.reason === 'no_database') {
|
|
console.log(`Skipped: no database available.`);
|
|
} else {
|
|
console.log(`Skipped: ${report.reason ?? 'unknown reason'}.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (report.status === 'clean') {
|
|
console.log(
|
|
`Brain is healthy. ${report.phases.length} phase(s) checked in ${(report.duration_ms / 1000).toFixed(1)}s.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`Dream cycle (${report.status}) in ${(report.duration_ms / 1000).toFixed(1)}s:`);
|
|
for (const p of report.phases) {
|
|
const icon =
|
|
p.status === 'ok' ? '✓' :
|
|
p.status === 'warn' ? '!' :
|
|
p.status === 'skipped' ? '-' : '✗';
|
|
const line = ` ${icon} ${p.phase.padEnd(10)} ${p.summary}`;
|
|
console.log(line);
|
|
if (p.error) {
|
|
const hint = p.error.hint ? ` (${p.error.hint})` : '';
|
|
console.log(` [${p.error.class}/${p.error.code}] ${p.error.message}${hint}`);
|
|
}
|
|
}
|
|
|
|
const t = report.totals;
|
|
const hasTotals =
|
|
t.lint_fixes > 0 || t.backlinks_added > 0 || t.pages_synced > 0 ||
|
|
t.pages_extracted > 0 || t.pages_embedded > 0 || t.orphans_found > 0 ||
|
|
t.transcripts_processed > 0 || t.synth_pages_written > 0 || t.patterns_written > 0;
|
|
if (hasTotals) {
|
|
console.log(
|
|
` totals: lint=${t.lint_fixes} backlinks=${t.backlinks_added} synced=${t.pages_synced} ` +
|
|
`extracted=${t.pages_extracted} embedded=${t.pages_embedded} orphans=${t.orphans_found} ` +
|
|
`synth_transcripts=${t.transcripts_processed} synth_pages=${t.synth_pages_written} ` +
|
|
`patterns=${t.patterns_written}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── CLI entry ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Predicate: is this error one of the resolver's user-facing throws
|
|
* we want to surface as a clean stderr line + exit 1?
|
|
*
|
|
* Matches the message prefixes thrown from
|
|
* `src/core/source-resolver.ts:resolveSourceId` and
|
|
* `assertSourceExists`. Anything else (TypeError / ReferenceError /
|
|
* postgres connection failures / unexpected bugs) is intentionally
|
|
* NOT caught — those propagate to Bun's default unhandled handler
|
|
* with a stack trace so genuine programmer bugs aren't hidden as
|
|
* if they were operator errors. (Plan D-T3, codex C-7.)
|
|
*/
|
|
function isResolverUserError(e: unknown): boolean {
|
|
if (!(e instanceof Error)) return false;
|
|
const m = e.message;
|
|
return (m.startsWith('Source "') && m.includes(' not found.'))
|
|
|| m.startsWith('Invalid --source value')
|
|
|| m.startsWith('Invalid GBRAIN_SOURCE value');
|
|
}
|
|
|
|
export async function runDream(engine: BrainEngine | null, args: string[]): Promise<CycleReport | void> {
|
|
const opts = parseArgs(args);
|
|
|
|
// ─── IRON RULE: --help short-circuits BEFORE any engine-bearing work ─
|
|
// Tests pin this ordering so `gbrain dream --help --source whatever`
|
|
// ALWAYS prints help and exits 0, never reaching the engine-null gate
|
|
// below. If you reorder this, dream-cli-flags.test.ts will fail.
|
|
if (opts.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
// v0.41.13: --source <id> resolution. Three guards in order:
|
|
// 1. engine null → exit 1 (the writeback in cycle.ts requires a
|
|
// DB connection; without engine we'd silently fail the same way
|
|
// PR #1559 was created to fix)
|
|
// 2. resolveSourceId throws on unknown id → typed-error catch
|
|
// surfaces clean message; non-resolver throws propagate
|
|
// 3. archived source → exit 1 with restore hint (writing
|
|
// last_full_cycle_at to an archived source would mask data
|
|
// staleness when the source is later restored)
|
|
let resolvedSourceId: string | undefined;
|
|
if (opts.source !== null) {
|
|
if (engine === null) {
|
|
console.error(
|
|
'gbrain dream --source <id> requires a connected brain ' +
|
|
'(no engine available); omit --source or run `gbrain init` first',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
try {
|
|
resolvedSourceId = await resolveSourceId(engine, opts.source);
|
|
} catch (e) {
|
|
if (isResolverUserError(e)) {
|
|
console.error((e as Error).message);
|
|
process.exit(1);
|
|
}
|
|
throw e; // genuine bugs propagate with stack trace
|
|
}
|
|
// Archived-source guard via fetchSource from sources-load.ts
|
|
// (single-row SELECT that projects `archived` and falls back to
|
|
// pre-v0.26.5 schemas via isUndefinedColumnError catch — same
|
|
// legacy-safety net the rest of the codebase uses). engine's
|
|
// built-in listAllSources defaults to includeArchived=false AND
|
|
// doesn't project the archived column, so it cannot be used here.
|
|
const src = await fetchSource(engine, resolvedSourceId);
|
|
if (src?.archived === true) {
|
|
console.error(
|
|
`source ${resolvedSourceId} is archived; restore with ` +
|
|
`\`gbrain sources restore ${resolvedSourceId}\` before cycling`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const brainDir = await resolveBrainDir(engine, opts.dir);
|
|
const phases: CyclePhase[] | undefined = opts.phase ? [opts.phase] : undefined;
|
|
|
|
const report = await runCycle(engine, {
|
|
brainDir,
|
|
dryRun: opts.dryRun,
|
|
pull: opts.pull,
|
|
phases,
|
|
sourceId: resolvedSourceId, // undefined when --source not set → legacy back-compat
|
|
synthInputFile: opts.inputFile ?? undefined,
|
|
synthDate: opts.date ?? undefined,
|
|
synthFrom: opts.from ?? undefined,
|
|
synthTo: opts.to ?? undefined,
|
|
synthBypassDreamGuard: opts.bypassDreamGuard,
|
|
});
|
|
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(report, null, 2));
|
|
} else {
|
|
printHuman(report);
|
|
}
|
|
|
|
// Exit non-zero when the cycle failed overall (helps cron spot real problems).
|
|
// 'partial' is not a failure — it means some phase warned but the cycle ran.
|
|
if (report.status === 'failed') {
|
|
process.exit(1);
|
|
}
|
|
|
|
return report;
|
|
}
|