mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat(schema): op_checkpoints table + doctor_run_id partial GIN (v67+v68) T1 of brain-health-100 wave. Two new migrations underpin autonomous remediation via Minions: - v67 op_checkpoints — shared checkpoint table for long-running ops (embed, extract, lint, backlinks, reindex, integrity). Pre-fix each op had its own file-backed checkpoint or none. PRIMARY KEY (op, fingerprint) lets `extract links` and `extract timeline` (or `reindex --markdown` vs `--code`) coexist without colliding on shared keys. - v68 minion_jobs_doctor_run_id_idx — partial GIN on `minion_jobs.data WHERE data ? 'doctor_run_id'`. Indexes only doctor-submitted jobs so audit-trail queries don't sequential-scan months of unrelated cron history. PGLite skips via empty sqlFor. Applied to src/schema.sql + src/core/pglite-schema.ts so both engines get the table on fresh-install. Bootstrap coverage test + 122-case migrate test both pass. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (D12 + folded scope B from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): op-checkpoint module — DB-backed checkpoint primitive T2 of brain-health-100 wave. Six exports plus per-op fingerprint helpers: loadOpCheckpoint(engine, key) → string[] (completed keys; [] if none) recordCompleted(engine, key, ks) → void (UPSERT atomic) clearOpCheckpoint(engine, key) → void (clean-exit drop) resumeFilter(all, completed) → string[] (pure; drives batched walks) purgeStaleCheckpoints(engine, ttl)→ number (cycle purge phase consumer) Fingerprint helpers: fingerprint(params) — sha8 of canonical-JSON embedFingerprint(p) — model+dim+slug+source variation extractFingerprint(p) — mode (links vs timeline) reindexFingerprint(p) — markdown vs code vs slug + chunker_version lintFingerprint, backlinksFingerprint, integrityFingerprint, importFingerprint Canonical-JSON over keys-sorted ensures the same params produce the same fingerprint across runs and hosts. sha8 (8 hex chars from sha256) is short enough for filenames + UI but collision-resistant for the expected per-op invocation diversity. DB-backed for both engines (PGLite has the table too via v67). Lost- write on partial DB failure is non-fatal — caller continues, next run re-walks (cheap for hash-short-circuited ops like embed/import). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (D12 + codex #10–16 from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): brain-score-recommendations — shared data layer T4 of brain-health-100 wave. Pure module — no engine I/O. Takes a BrainHealth snapshot + RecommendationContext, returns ordered Remediation[] ready to feed the doctor remediation plan OR features --auto-fix. Three public exports: computeRecommendations(health, ctx) → Remediation[] classifyChecks(checks, ctx) → CheckClassification[] maxReachableScore(health, classes) → number (0-100 ceiling) D13 — three-state classification per check: remediable / human_only / blocked. The plan ONLY emits remediable items; blocked surfaces alongside as informational with the missing prereq (no API key, etc.). Closes the spin-loop bug on empty / API-key-missing brains (codex #20). D14 — every Remediation has a stable string id (sync.repo, embed.stale, backlinks.fix, extract.all). depends_on references ids, not check names. D9 — idempotency_key is content-hash from canonical-JSON of params. Same intent across runs = same key; failed-row replay via :r<N> suffix is the --remediate loop's job, not this module's. Scope item +A (cost-budget gate) — Remediation.est_usd_cost populated for embed (chars × pricePerMTok from embedding-pricing.ts) and Anthropic jobs (estimateAnthropicCost helper). doctor --remediate --max-usd N gates submission against est_total_usd_cost. Both consumers (doctor + features per D15) import from here. Features executes inline (D15 contract preserved), doctor submits via queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(handlers): 11 new Minion handlers + 3 added to PROTECTED + sync noExtract fix T5 of brain-health-100 wave. PROTECTED_JOB_NAMES extension (D11): synthesize, patterns, consolidate. These cycle phases internally submit `subagent` jobs with allowProtectedSubmit=true, so they CAN spend Anthropic credits. Treating them as "data-quality maintenance" was a misread surfaced by the codex outside-voice review (#6). Protected gate ensures only trusted local callers (CLI, autopilot, doctor --remediate) can submit; an OAuth-scoped MCP client can't burn the user's API budget by submitting a synthesize job over HTTP. 11 new handlers registered in jobs.ts registerBuiltinHandlers: PROTECTED (3) — phase-wrappers that spawn subagent children: synthesize, patterns, consolidate Open (8) — DB/fs writes only, no LLM spend: reindex, repair-jsonb, orphans, integrity, purge, extract_facts, resolve_symbol_edges, recompute_emotional_weight Phase-wrappers all delegate to `runCycle({ phases: [name] })` rather than extracting standalone phase functions. Cycle.ts already owns the lock + abort signal + progress reporter per D10, so the wrapper is a one-liner and cycle.ts remains the single source of truth for phase semantics. Pragmatic deviation from the plan's "extract 6 standalone runXxxPhase functions" — smaller diff, equivalent correctness. Standalone `sync` handler now passes `noExtract: true` (codex #5 fix). Pre-fix, doctor's remediation plan emitting [sync, extract] caused double-extraction (performSync inline-extract + standalone extract job). Now sync defers extract to the dedicated handler. Callers that want inline extract pass { noExtract: false } in job params. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T5 + D10 + D11 + codex #5/#6 from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): --remediation-plan + --remediate CLI surfaces T6 of brain-health-100 wave. The headline user-facing capability: agents drive brain health to target score via autonomous Minions remediation. Two new flags on `gbrain doctor`: --remediation-plan [--json] [--target-score N] Read-only. Emits ordered Remediation[] from BrainHealth + context. Uses cheap path (D7) — engine.getHealth() + computeRecommendations, NOT a full doctor walk. JSON shape is stable agent contract. --remediate [--yes] [--target-score N] [--max-jobs N] [--max-usd N] [--dry-run] [--json] Sequential submit (D3) with D5 cascade on failure, D7 scoped recheck between steps, D9 content-hash idempotency keys, D13 three-state remediation filtering (only remediable jobs enter the loop), +A cost-budget gate via --max-usd. Check.remediation field added as additive optional (DoctorReport schema_version stays at 2 per D4). PGLite path: synchronous in-process execution with short polling. Postgres path: durable queue submission with waitForCompletion. The --remediate loop: 1. Compute initial plan from BrainHealth 2. Refuse if --target-score > maxReachableScore(health, classes) 3. Refuse if est_total_usd_cost > --max-usd 4. For each step in order: - Skip if depends_on intersects aborted set (D5) - queue.add with content-hash idempotency_key (D9) - waitForCompletion with timeout - Recompute plan from fresh health (D7 scoped recheck) 5. Exit 0 if all completed; 1 if any failed/aborted doctor_run_id UUID stamps every submitted job's data field so operators can later query `SELECT * FROM minion_jobs WHERE data->>'doctor_run_id' = '<uuid>'` (indexed via v68 partial GIN). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T6 + D1/D3/D5/D7/D9/D13 + folded scope A). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): maybeBackground helper + apply --background to embed T7 of brain-health-100 wave. New helper in src/core/cli-options.ts formalizes the --background flag pattern. Same semantics in TTY and cron per D9 (submit-and-exit always; --background --follow execs `gbrain jobs follow <id>` after submission). await maybeBackground({ engine, args, jobName: 'embed', paramBuilder: (cleanArgs) => ({ stale, all, ... }), }) // returns true if backgrounded → caller exits Content-hash idempotency key (D9): `cli:embed:sha8(canonical-JSON(params))`. No time-slot. Same intent across runs = same key. Failed-row replay is the doctor --remediate loop's job, not this path's. PGLite degrades to inline execution with a clear stderr note ("PGLite has no worker daemon; running inline"). NOT a no-op, NOT silent — doc-stated semantic difference because PGLite has no worker daemon. Applied to `gbrain embed` as the reference integration. The other 6 commands (extract, lint, backlinks, reindex, integrity, pages) adopt the same 4-line pattern at the top of their entry function — follow-up in a smaller diff once the helper proves out in production. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T7 + D9 + Gap 6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(autopilot): targeted-submit loop + op_checkpoints GC in purge phase T8 of brain-health-100 wave. Autopilot dispatch changes (src/commands/autopilot.ts): Pre-fix: every tick submitted ONE autopilot-cycle job, full phase set, regardless of brain state. On a healthy brain pure overhead; on a degraded brain bundled fast wins with slow phases so user waited for the slowest. New decision logic (T8 from plan): - score >= 95 AND empty plan AND <60min since last full → SLEEP - score >= 95 AND empty plan AND >=60min → submit autopilot-cycle (phase-coupling exercise) - plan <= 3 steps AND est_total < 5min → submit individual handlers (targeted; uses D9 content-hash idempotency keys per step; maxWaiting:1 per submit per codex #17) - else → submit autopilot-cycle (the hammer) D10 cycle-lock invariant guarantees targeted-submit and autopilot-cycle can never run concurrently (both acquire gbrain-cycle), closing the "60-min floor double-processes queued targeted jobs" failure mode. Computation uses cheap path (D7) — engine.getHealth() + computeRecommendations, NOT a full doctor walk. Adds ~1 SQL count query per tick; negligible on a 50K-page brain. PROTECTED handlers (synthesize/patterns/consolidate) are submitted with allowProtectedSubmit:true; autopilot is a trusted local caller. Cycle purge phase (src/core/cycle.ts): Added op_checkpoints GC (+C folded scope item). 7-day TTL — any reasonable long-running op finishes inside that window. Non-fatal on pre-v67 brains (table missing). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T8 + D7/D9/D10 + codex #17 + folded scope +C). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(core): brain-score-recommendations + op-checkpoint unit tests T10 of brain-health-100 wave — load-bearing decision-pinning tests. test/brain-score-recommendations.test.ts (22 cases): - Healthy brain → empty plan - Per-component remediation paths (sync, embed, backlinks, extract) - depends_on wiring (extract → sync; embed → sync when stale) - Severity ordering (critical > high > medium > low) - D6 #5 determinism: same input twice → byte-identical output - D9 idempotency keys: content-hash format, no time-slot - D9 source isolation: different --source → different key - D13 status field always 'remediable' in output - +A cost-estimate populated for embed - classifyChecks: remediable / blocked / human_only triage - maxReachableScore: all-remediable → 100; all-blocked → current test/op-checkpoint.test.ts (20 cases): - fingerprint stability + key-order invariance (canonical-JSON) - codex #11: extract links vs timeline get different fingerprints - codex #12: reindex markdown vs code get different fingerprints - codex #15: embed model+dim variation produces different fingerprints - reindex chunker_version bump invalidates checkpoint - DB round-trip (load → record → load) - Cross-fingerprint isolation (linksKey vs timelineKey) - clearOpCheckpoint idempotency on missing rows - resumeFilter purity (no I/O, deterministic) - purgeStaleCheckpoints TTL respect 42 new tests, all pass. PGLite engine + resetPgliteState pattern per CLAUDE.md test-isolation guide. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T10 + D6 #5 + D9 + D12 + D13 + codex #11/#12/#15). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): v0.36.0.0 — brain-health-100 wave + docs/llms refresh T12 of brain-health-100 wave. VERSION + package.json bumped 0.35.6.0 → 0.36.0.0. CHANGELOG entry leads ELI10 ("your agent can now drive your brain to 90/100 by itself, on a cron, without you watching") then drills into the precise mechanics per CLAUDE.md voice rules. llms.txt + llms-full.txt regenerated via bun run build:llms. Trio audit (CLAUDE.md mandatory pre-push check): VERSION: 0.36.0.0 package.json: 0.36.0.0 CHANGELOG: ## [0.36.0.0] - 2026-05-18 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update README/CLAUDE/AGENTS/maintain for v0.36.4.0 brain-health-100 wave - README.md: New-in-v0.36.4.0 callout — `gbrain doctor --remediate` headline, autopilot health-aware tick, eleven new background-job types, three PROTECTED. - CLAUDE.md: Key Files entries for `op-checkpoint.ts`, `brain-score-recommendations.ts`, doctor.ts / jobs.ts / protected-names.ts / autopilot.ts / cycle.ts / embed.ts / cli-options.ts extensions; new "Key commands added in v0.36.4.0" section. - AGENTS.md: Common-tasks entry pointing agents at the one-command remediation loop. - skills/maintain/SKILL.md: Autonomous Phase (gbrain doctor --remediate) at the top, manual per-dimension walk preserved as the fallback path. - llms-full.txt: regenerated to pick up the CLAUDE.md changes (project rule). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(changelog): respectful tone on spend caps for v0.36.4.0 Reframed the cost-budget callout. Pre-fix language said the spend cap prevents a synthesize loop from "burning $100 of Anthropic credits while you're at lunch" — casually treating $100 as the throwaway number is tone-deaf. $100 is a meaningful amount for many people. New language: "spend cap so a synthesize loop can't run up your Anthropic bill while you're at lunch. The cap is yours to set per run." And: "Pass --max-usd 5 (or whatever cap you're comfortable with)." And: "Pick the cap that fits your wallet." Also reframed three adjacent lines: - "healthy brains stop burning cycles" → "stop spending tokens on work that has nothing to do" - "agent can't submit them and burn your API budget" → "can't submit them on your behalf. Your provider bill stays in your hands" - Table cell "Cron with cost cap" / "--max-usd 5" → "Cron with spend cap" / "--max-usd N" llms-full.txt regenerated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
/**
|
|
* Global CLI flags parsed before command dispatch.
|
|
*
|
|
* Keeping this separate from per-command flag parsing so that
|
|
* `gbrain --progress-json doctor` works: the global flag is stripped
|
|
* before cli.ts looks at argv[0] for the subcommand.
|
|
*
|
|
* Threading: every command handler receives a resolved CliOptions object.
|
|
* Shared-operation handlers see the same values via OperationContext.cliOpts.
|
|
*/
|
|
|
|
import type { ProgressOptions } from './progress.ts';
|
|
|
|
export interface CliOptions {
|
|
quiet: boolean;
|
|
progressJson: boolean;
|
|
progressInterval: number; // ms
|
|
/**
|
|
* v0.31.1 (Issue #734, ENG-4): user-supplied per-call timeout for thin-client
|
|
* routed MCP calls. `null` means "use the per-command default" (30s for most
|
|
* ops, 180s for `think`). When set, applies to every routed call in the
|
|
* current invocation.
|
|
*/
|
|
timeoutMs: number | null;
|
|
}
|
|
|
|
export const DEFAULT_CLI_OPTIONS: CliOptions = {
|
|
quiet: false,
|
|
progressJson: false,
|
|
progressInterval: 1000,
|
|
timeoutMs: null,
|
|
};
|
|
|
|
/**
|
|
* Parse recognized global flags from the front / anywhere in argv and return
|
|
* the resolved options plus the remaining argv (with global flags stripped).
|
|
*
|
|
* Recognized:
|
|
* --quiet
|
|
* --progress-json
|
|
* --progress-interval=<ms>
|
|
* --progress-interval <ms> (space-separated form)
|
|
*
|
|
* Unknown flags are passed through unchanged — per-command parsers see them.
|
|
*/
|
|
export function parseGlobalFlags(argv: string[]): { cliOpts: CliOptions; rest: string[] } {
|
|
const cliOpts: CliOptions = { ...DEFAULT_CLI_OPTIONS };
|
|
const rest: string[] = [];
|
|
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--quiet') {
|
|
cliOpts.quiet = true;
|
|
continue;
|
|
}
|
|
if (a === '--progress-json') {
|
|
cliOpts.progressJson = true;
|
|
continue;
|
|
}
|
|
if (a === '--progress-interval' && i + 1 < argv.length) {
|
|
const next = argv[i + 1];
|
|
const parsed = parseInterval(next);
|
|
if (parsed !== null) {
|
|
cliOpts.progressInterval = parsed;
|
|
i++;
|
|
continue;
|
|
}
|
|
// not a number — let per-command parser handle; pass through
|
|
rest.push(a);
|
|
continue;
|
|
}
|
|
if (a.startsWith('--progress-interval=')) {
|
|
const val = a.slice('--progress-interval='.length);
|
|
const parsed = parseInterval(val);
|
|
if (parsed !== null) {
|
|
cliOpts.progressInterval = parsed;
|
|
continue;
|
|
}
|
|
rest.push(a);
|
|
continue;
|
|
}
|
|
// v0.31.1: --timeout=Ns or --timeout Ns. Accepts plain ms, "30s", "2m".
|
|
if (a === '--timeout' && i + 1 < argv.length) {
|
|
const next = argv[i + 1];
|
|
const parsed = parseTimeout(next);
|
|
if (parsed !== null) {
|
|
cliOpts.timeoutMs = parsed;
|
|
i++;
|
|
continue;
|
|
}
|
|
rest.push(a);
|
|
continue;
|
|
}
|
|
if (a.startsWith('--timeout=')) {
|
|
const val = a.slice('--timeout='.length);
|
|
const parsed = parseTimeout(val);
|
|
if (parsed !== null) {
|
|
cliOpts.timeoutMs = parsed;
|
|
continue;
|
|
}
|
|
rest.push(a);
|
|
continue;
|
|
}
|
|
rest.push(a);
|
|
}
|
|
|
|
return { cliOpts, rest };
|
|
}
|
|
|
|
/**
|
|
* v0.31.1: parse a timeout value. Accepts:
|
|
* "30000" / "30000ms" → 30000
|
|
* "30s" → 30000
|
|
* "2m" → 120000
|
|
* "1.5s" → 1500
|
|
* Returns null on parse failure (caller decides whether to error or fall through).
|
|
*/
|
|
export function parseTimeout(s: string): number | null {
|
|
const m = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m)?$/.exec(s.trim());
|
|
if (!m) return null;
|
|
const n = Number(m[1]);
|
|
if (!Number.isFinite(n) || n <= 0) return null;
|
|
const unit = m[2] ?? 'ms';
|
|
const ms = unit === 'ms' ? n : unit === 's' ? n * 1000 : n * 60_000;
|
|
return Math.floor(ms);
|
|
}
|
|
|
|
function parseInterval(s: string): number | null {
|
|
const n = Number(s);
|
|
if (!Number.isFinite(n) || n < 0) return null;
|
|
return Math.floor(n);
|
|
}
|
|
|
|
/**
|
|
* Map resolved CliOptions to ProgressOptions for createProgress().
|
|
*
|
|
* Mode resolution:
|
|
* --quiet → 'quiet'
|
|
* --progress-json → 'json'
|
|
* otherwise → 'auto' (TTY: human-\r, non-TTY: human-plain)
|
|
*
|
|
* Agents that want structured events on a non-TTY stream must pass
|
|
* --progress-json explicitly. Non-TTY default is plain human lines so
|
|
* shell pipelines don't suddenly see JSON noise.
|
|
*/
|
|
export function cliOptsToProgressOptions(cliOpts: CliOptions): ProgressOptions {
|
|
if (cliOpts.quiet) return { mode: 'quiet' };
|
|
if (cliOpts.progressJson) return { mode: 'json', minIntervalMs: cliOpts.progressInterval };
|
|
return { mode: 'auto', minIntervalMs: cliOpts.progressInterval };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module-level singleton (set once by cli.ts after parsing global flags; read
|
|
// by any bulk command that wants to construct a reporter). Same pattern as
|
|
// Commander's `program.opts()`. Also threaded into OperationContext for
|
|
// shared ops that run under the MCP server (which sets its own defaults).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let activeCliOptions: CliOptions = { ...DEFAULT_CLI_OPTIONS };
|
|
|
|
export function setCliOptions(opts: CliOptions): void {
|
|
activeCliOptions = { ...opts };
|
|
}
|
|
|
|
export function getCliOptions(): CliOptions {
|
|
return activeCliOptions;
|
|
}
|
|
|
|
/**
|
|
* Reset singleton to defaults. Only used by tests.
|
|
*/
|
|
export function _resetCliOptionsForTest(): void {
|
|
activeCliOptions = { ...DEFAULT_CLI_OPTIONS };
|
|
}
|
|
|
|
/**
|
|
* Build the global-flag suffix to append to child `gbrain …` subprocess
|
|
* commands so children inherit the parent's progress-mode.
|
|
*
|
|
* Returns a string ready to concat onto an execSync command string, with
|
|
* a leading space when non-empty. E.g. " --progress-json --quiet".
|
|
*
|
|
* Empty string when nothing to propagate (so the child's behavior is
|
|
* unchanged for the common no-flag case).
|
|
*/
|
|
export function childGlobalFlags(cliOpts?: CliOptions): string {
|
|
const opts = cliOpts ?? activeCliOptions;
|
|
const parts: string[] = [];
|
|
if (opts.quiet) parts.push('--quiet');
|
|
if (opts.progressJson) parts.push('--progress-json');
|
|
if (opts.progressInterval !== DEFAULT_CLI_OPTIONS.progressInterval) {
|
|
parts.push(`--progress-interval=${opts.progressInterval}`);
|
|
}
|
|
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
|
}
|
|
|
|
// ============================================================
|
|
// v0.36+ brain-health-100 wave: --background flag (D9 + T7)
|
|
//
|
|
// Per the locked decision: --background means submit-and-exit ALWAYS.
|
|
// Same semantics in TTY and cron. Composable in shell pipelines:
|
|
//
|
|
// JOB=$(gbrain embed --stale --background | grep -oE 'job_id=[0-9]+' | cut -d= -f2)
|
|
// gbrain jobs get $JOB
|
|
//
|
|
// `--background --follow` submits then execs `gbrain jobs follow <id>`
|
|
// so the user sees live stream while still getting durable queue
|
|
// semantics (worker survives if user disconnects).
|
|
//
|
|
// PGLite degrades to inline with a clear stderr note. NOT a no-op,
|
|
// NOT silent. Doc-stated semantic difference because PGLite has no
|
|
// worker daemon.
|
|
// ============================================================
|
|
|
|
import type { BrainEngine } from './engine.ts';
|
|
import { createHash } from 'crypto';
|
|
|
|
export interface MaybeBackgroundOpts {
|
|
engine: BrainEngine;
|
|
args: string[];
|
|
jobName: string;
|
|
paramBuilder: (args: string[]) => Record<string, unknown>;
|
|
/** Source id for the idempotency key namespace. Default 'cli'. */
|
|
source?: string;
|
|
}
|
|
|
|
/**
|
|
* If `--background` is in args, submit a Minion job and return true
|
|
* (caller should exit). Otherwise return false (caller does inline work).
|
|
*
|
|
* Strips `--background` and `--follow` from args before paramBuilder
|
|
* runs so the param shape stays clean. On submit failure, prints stderr
|
|
* + exits 1 (no orphan job; no silent fallthrough to inline).
|
|
*
|
|
* @returns true if backgrounded (caller MUST exit), false otherwise.
|
|
*/
|
|
export async function maybeBackground(opts: MaybeBackgroundOpts): Promise<boolean> {
|
|
if (!opts.args.includes('--background')) return false;
|
|
|
|
const filtered = opts.args.filter((a) => a !== '--background' && a !== '--follow');
|
|
const params = opts.paramBuilder(filtered);
|
|
const follow = opts.args.includes('--follow');
|
|
const source = opts.source ?? 'cli';
|
|
|
|
// PGLite has no worker daemon. Per the doc-stated semantics, degrade
|
|
// to inline with a clear stderr note rather than silently failing.
|
|
if (opts.engine.kind === 'pglite') {
|
|
process.stderr.write(
|
|
`[--background] PGLite has no worker daemon; running inline.\n`,
|
|
);
|
|
return false; // caller runs inline
|
|
}
|
|
|
|
// D9: content-hash idempotency key. No time-slot — same intent = same
|
|
// key. Failed-row replay is the doctor --remediate loop's job, not
|
|
// the CLI --background path's job.
|
|
const idempotency_key = `${source}:${opts.jobName}:${sha8(canonicalJson(params))}`;
|
|
|
|
try {
|
|
const { MinionQueue } = await import('./minions/queue.ts');
|
|
const queue = new MinionQueue(opts.engine);
|
|
const job = await queue.add(opts.jobName, params, {
|
|
queue: 'default',
|
|
idempotency_key,
|
|
max_attempts: 2,
|
|
});
|
|
process.stdout.write(`job_id=${job.id}\n`);
|
|
|
|
if (follow) {
|
|
// exec `gbrain jobs follow <id>` so the user sees live stream
|
|
// without losing the durable-queue submission.
|
|
const { spawn } = await import('child_process');
|
|
const cmd = process.argv[0] ?? 'bun';
|
|
const script = process.argv[1] ?? '';
|
|
const child = spawn(cmd, [script, 'jobs', 'follow', String(job.id)], {
|
|
stdio: 'inherit',
|
|
});
|
|
await new Promise<void>((resolve) => child.on('exit', () => resolve()));
|
|
}
|
|
return true; // caller exits
|
|
} catch (e) {
|
|
process.stderr.write(`[--background] submit failed: ${(e as Error).message}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function sha8(s: string): string {
|
|
return createHash('sha256').update(s).digest('hex').slice(0, 8);
|
|
}
|
|
|
|
function canonicalJson(value: unknown): string {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
}
|