feat(gsd-uok): unify audit envelopes across logger metrics and activity
This commit is contained in:
parent
d6c93ef07f
commit
558ac1067b
7 changed files with 213 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
101
src/resources/extensions/gsd/tests/uok-audit-unified.test.ts
Normal file
101
src/resources/extensions/gsd/tests/uok-audit-unified.test.ts
Normal file
|
|
@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
9
src/resources/extensions/gsd/uok/audit-toggle.ts
Normal file
9
src/resources/extensions/gsd/uok/audit-toggle.ts
Normal file
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue