From 558ac1067bc01a08f28464f0d501cedf7c0c9295 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:38 -0500 Subject: [PATCH] feat(gsd-uok): unify audit envelopes across logger metrics and activity --- src/resources/extensions/gsd/activity-log.ts | 21 ++++ src/resources/extensions/gsd/journal.ts | 30 ++++++ src/resources/extensions/gsd/metrics.ts | 26 +++++ .../gsd/tests/uok-audit-unified.test.ts | 101 ++++++++++++++++++ .../extensions/gsd/uok/audit-toggle.ts | 9 ++ src/resources/extensions/gsd/uok/kernel.ts | 2 + .../extensions/gsd/workflow-logger.ts | 24 +++++ 7 files changed, 213 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/uok-audit-unified.test.ts create mode 100644 src/resources/extensions/gsd/uok/audit-toggle.ts diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts index 82896ea5b..5e93c0240 100644 --- a/src/resources/extensions/gsd/activity-log.ts +++ b/src/resources/extensions/gsd/activity-log.ts @@ -16,6 +16,8 @@ import { GSDError, GSD_IO_ERROR } from "./errors.js"; const SEQ_PREFIX_RE = /^(\d+)-/; import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { gsdRoot } from "./paths.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; interface ActivityLogState { nextSeq: number; @@ -132,6 +134,25 @@ export function saveActivityLog( } state.nextSeq += 1; state.lastSnapshotKeyByUnit.set(unitKey, key); + + if (isUnifiedAuditEnabled()) { + emitUokAuditEvent( + basePath, + buildAuditEnvelope({ + traceId: `activity:${unitType}:${unitId}`, + turnId: unitId, + category: "execution", + type: "activity-log-saved", + payload: { + unitType, + unitId, + filePath, + entryCount: entries.length, + }, + }), + ); + } + return filePath; } catch (e) { // Don't let logging failures break auto-mode diff --git a/src/resources/extensions/gsd/journal.ts b/src/resources/extensions/gsd/journal.ts index 5b7003781..3d635b5ce 100644 --- a/src/resources/extensions/gsd/journal.ts +++ b/src/resources/extensions/gsd/journal.ts @@ -15,6 +15,8 @@ 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 { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -90,6 +92,34 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void { } catch { // Silent failure — journal must never break auto-mode } + + if (!isUnifiedAuditEnabled()) 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 ──────────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 85f3484bb..0bccfe52d 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -19,6 +19,8 @@ import { gsdRoot } from "./paths.js"; import { getAndClearSkills } from "./skill-telemetry.js"; import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; import { parseUnitId } from "./unit-id.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; // Re-export from shared — import directly from format-utils to avoid pulling // in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded @@ -143,6 +145,9 @@ export function snapshotUnitMetrics( promptCharCount?: number; baselineCharCount?: number; autoSessionKey?: string; + traceId?: string; + turnId?: string; + causedBy?: string; }, ): UnitMetrics | null { if (!ledger) return null; @@ -235,6 +240,27 @@ export function snapshotUnitMetrics( } saveLedger(basePath, ledger); + if (isUnifiedAuditEnabled()) { + emitUokAuditEvent( + basePath, + buildAuditEnvelope({ + traceId: opts?.traceId ?? `metrics:${unitType}:${unitId}`, + turnId: opts?.turnId, + causedBy: opts?.causedBy, + category: "metrics", + type: "unit-metrics-snapshot", + payload: { + unitType, + unitId, + model, + tokens: unit.tokens, + cost: unit.cost, + toolCalls: unit.toolCalls, + }, + }), + ); + } + return unit; } diff --git a/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts b/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts new file mode 100644 index 000000000..884b88115 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { emitJournalEvent } from "../journal.ts"; +import { saveActivityLog } from "../activity-log.ts"; +import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts"; +import { setLogBasePath, logWarning } from "../workflow-logger.ts"; +import { setUnifiedAuditEnabled } from "../uok/audit-toggle.ts"; + +function readAuditEvents(basePath: string): Array> { + const file = join(basePath, ".gsd", "audit", "events.jsonl"); + if (!existsSync(file)) return []; + const raw = readFileSync(file, "utf-8"); + return raw + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +function makeMockContext(entries: unknown[]): any { + return { + sessionManager: { + getEntries: () => entries, + }, + }; +} + +test("unified audit plane bridges journal/activity/metrics/workflow logger into audit envelope log", () => { + const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-")); + setUnifiedAuditEnabled(true); + try { + emitJournalEvent(basePath, { + ts: new Date().toISOString(), + flowId: "trace-123", + seq: 1, + eventType: "iteration-start", + data: { turnId: "turn-123", unitId: "M001/S01/T01" }, + }); + + const activityCtx = makeMockContext([ + { type: "message", message: { role: "assistant", content: [{ type: "text", text: "hello" }] } }, + ]); + const activityPath = saveActivityLog(activityCtx, basePath, "execute-task", "M001/S01/T01"); + assert.ok(activityPath); + + initMetrics(basePath); + const metricsCtx = makeMockContext([ + { + type: "message", + message: { + role: "assistant", + usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15, cost: 0.01 }, + content: [], + }, + }, + ]); + const unit = snapshotUnitMetrics( + metricsCtx, + "execute-task", + "M001/S01/T01", + Date.now() - 1000, + "openai/gpt-5.4", + { traceId: "trace-123", turnId: "turn-123" }, + ); + assert.ok(unit); + resetMetrics(); + + setLogBasePath(basePath); + logWarning("engine", "audit bridge check", { id: "turn-123" }); + + const events = readAuditEvents(basePath); + const types = new Set(events.map((event) => String(event.type ?? ""))); + assert.ok(types.has("journal-iteration-start")); + assert.ok(types.has("activity-log-saved")); + assert.ok(types.has("unit-metrics-snapshot")); + assert.ok(types.has("workflow-log-warn")); + } finally { + setUnifiedAuditEnabled(false); + resetMetrics(); + rmSync(basePath, { recursive: true, force: true }); + } +}); + +test("unified audit bridge is disabled when toggle is off", () => { + const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-")); + setUnifiedAuditEnabled(false); + try { + emitJournalEvent(basePath, { + ts: new Date().toISOString(), + flowId: "trace-off", + seq: 1, + eventType: "iteration-start", + }); + const events = readAuditEvents(basePath); + assert.equal(events.length, 0); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/uok/audit-toggle.ts b/src/resources/extensions/gsd/uok/audit-toggle.ts new file mode 100644 index 000000000..688c5c53e --- /dev/null +++ b/src/resources/extensions/gsd/uok/audit-toggle.ts @@ -0,0 +1,9 @@ +const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED"; + +export function setUnifiedAuditEnabled(enabled: boolean): void { + process.env[AUDIT_ENV_KEY] = enabled ? "1" : "0"; +} + +export function isUnifiedAuditEnabled(): boolean { + return process.env[AUDIT_ENV_KEY] === "1"; +} diff --git a/src/resources/extensions/gsd/uok/kernel.ts b/src/resources/extensions/gsd/uok/kernel.ts index 6c5d60f13..656c6db92 100644 --- a/src/resources/extensions/gsd/uok/kernel.ts +++ b/src/resources/extensions/gsd/uok/kernel.ts @@ -6,6 +6,7 @@ import type { AutoSession } from "../auto/session.js"; import type { LoopDeps } from "../auto/loop-deps.js"; import { gsdRoot } from "../paths.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; +import { setUnifiedAuditEnabled } from "./audit-toggle.js"; import { resolveUokFlags } from "./flags.js"; import { createTurnObserver } from "./loop-adapter.js"; @@ -39,6 +40,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< const { ctx, pi, s, deps, runLegacyLoop } = args; const prefs = deps.loadEffectiveGSDPreferences()?.preferences; const flags = resolveUokFlags(prefs); + setUnifiedAuditEnabled(flags.auditUnified); writeParityEvent(s.basePath, { ts: new Date().toISOString(), diff --git a/src/resources/extensions/gsd/workflow-logger.ts b/src/resources/extensions/gsd/workflow-logger.ts index cdff396a3..996bed98b 100644 --- a/src/resources/extensions/gsd/workflow-logger.ts +++ b/src/resources/extensions/gsd/workflow-logger.ts @@ -20,6 +20,8 @@ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { appendNotification } from "./notification-store.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; // ─── Types ────────────────────────────────────────────────────────────── @@ -275,6 +277,28 @@ function _push( _buffer.shift(); } + if (_auditBasePath && isUnifiedAuditEnabled()) { + try { + emitUokAuditEvent( + _auditBasePath, + buildAuditEnvelope({ + traceId: `workflow-log:${component}`, + turnId: context?.id, + causedBy: context?.fn ?? context?.tool, + category: "orchestration", + type: severity === "error" ? "workflow-log-error" : "workflow-log-warn", + payload: { + component, + message, + context: context ?? {}, + }, + }), + ); + } catch { + // Best-effort: unified audit projection must never block workflow logger. + } + } + // Persist errors to .gsd/audit-log.jsonl so they survive context resets. // Only error-severity entries are persisted — warnings are ephemeral (stderr + buffer) // to avoid log amplification from expected-control-flow catch paths.