singularity-forge/src/resources/extensions/gsd/journal.ts
ace-pm 172753c3b2 refactor(forge): complete gsd → forge rebrand across native, logging, and build system
- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep
- Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts
- Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests)
- Rename log prefix "gsd-db:" → "forge-db:" in template literals
- Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds
- Update CI workflow artifact names (build-native.yml)
- Verify only packages/native/* touched (no upstream pi-* packages renamed)

Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is
sf-run-specific; all gsd-prefixed logging and crate names must align with new identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:11:45 +02:00

169 lines
5.8 KiB
TypeScript

/**
* GSD Event Journal — structured JSONL event log for auto-mode iterations.
*
* Writes daily-rotated JSONL files to `.gsd/journal/YYYY-MM-DD.jsonl`.
* Zero imports from `auto/` — depends only on node:fs, node:path, and paths.ts.
*
* Observability:
* - Each line in the JSONL file is a self-contained JournalEntry
* - Events are grouped by flowId (one per iteration) with monotonic seq numbers
* - causedBy references enable causal chain reconstruction
* - queryJournal() enables programmatic filtering by flowId, eventType, unitId, time range
* - Silent failure: journal writes never throw — absence of events is the failure signal
*/
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
// ─── Types ────────────────────────────────────────────────────────────────────
/** Event types emitted by the auto-mode loop and phases. */
export type JournalEventType =
| "iteration-start"
| "dispatch-match"
| "dispatch-stop"
| "pre-dispatch-hook"
| "unit-start"
| "unit-end"
| "post-unit-hook"
| "terminal"
| "guard-block"
| "milestone-transition"
| "stuck-detected"
| "sidecar-dequeue"
| "iteration-end"
| "worktree-enter"
| "worktree-create-failed"
| "worktree-skip"
| "worktree-merge-start"
| "worktree-merge-failed";
/** A single structured event in the journal. */
export interface JournalEntry {
/** ISO-8601 timestamp */
ts: string;
/** UUID grouping all events from one iteration */
flowId: string;
/** Monotonically increasing sequence number within a flow */
seq: number;
/** The kind of event */
eventType: JournalEventType;
/** Name of the matched rule (from the unified registry), if applicable */
rule?: string;
/** Causal reference to a prior event in this or another flow */
causedBy?: { flowId: string; seq: number };
/** Arbitrary structured payload (e.g. unitId, status, action details) */
data?: Record<string, unknown>;
}
/** Filters for querying journal entries. */
export interface JournalQueryFilters {
flowId?: string;
eventType?: string;
unitId?: string;
/** Filter by the rule name that produced the event */
rule?: string;
/** ISO-8601 lower bound (inclusive) */
after?: string;
/** ISO-8601 upper bound (inclusive) */
before?: string;
}
// ─── Emit ─────────────────────────────────────────────────────────────────────
/**
* Append a journal event to the daily JSONL file.
*
* File path: `<gsdRoot>/journal/<YYYY-MM-DD>.jsonl`
* where the date is extracted from `entry.ts.slice(0, 10)`.
*
* Never throws — all errors are silently caught.
*/
export function emitJournalEvent(basePath: string, entry: JournalEntry): void {
try {
const journalDir = join(gsdRoot(basePath), "journal");
mkdirSync(journalDir, { recursive: true });
const dateStr = entry.ts.slice(0, 10);
const filePath = join(journalDir, `${dateStr}.jsonl`);
appendFileSync(filePath, JSON.stringify(entry) + "\n");
} catch {
// Silent failure — journal must never break auto-mode
}
if (!isAuditEnvelopeEnabled()) return;
try {
const causedBy = entry.causedBy
? `${entry.causedBy.flowId}:${entry.causedBy.seq}`
: undefined;
const turnId =
typeof entry.data?.turnId === "string"
? entry.data.turnId
: undefined;
emitUokAuditEvent(
basePath,
buildAuditEnvelope({
traceId: entry.flowId,
turnId,
causedBy,
category: "orchestration",
type: `journal-${entry.eventType}`,
payload: {
seq: entry.seq,
rule: entry.rule,
data: entry.data ?? {},
},
}),
);
} catch {
// Best-effort: audit projection must never block journal writes.
}
}
// ─── Query ────────────────────────────────────────────────────────────────────
/**
* Read and filter journal entries from all daily JSONL files.
*
* Returns an empty array on any error (missing directory, corrupt files, etc.).
*/
export function queryJournal(
basePath: string,
filters?: JournalQueryFilters,
): JournalEntry[] {
try {
const journalDir = join(gsdRoot(basePath), "journal");
const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort();
const entries: JournalEntry[] = [];
for (const file of files) {
const raw = readFileSync(join(journalDir, file), "utf-8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as JournalEntry;
entries.push(entry);
} catch {
// Skip malformed lines
}
}
}
if (!filters) return entries;
return entries.filter(e => {
if (filters.flowId && e.flowId !== filters.flowId) return false;
if (filters.eventType && e.eventType !== filters.eventType) return false;
if (filters.rule && e.rule !== filters.rule) return false;
if (filters.unitId && (e.data as Record<string, unknown> | undefined)?.unitId !== filters.unitId) return false;
if (filters.after && e.ts < filters.after) return false;
if (filters.before && e.ts > filters.before) return false;
return true;
});
} catch {
// Missing directory, permission errors, etc. — return empty
return [];
}
}