@kodrunhq/opencode-autopilot 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/commands/quick.md +7 -0
- package/package.json +1 -1
- package/src/health/checks.ts +125 -0
- package/src/health/index.ts +3 -0
- package/src/health/runner.ts +56 -0
- package/src/health/types.ts +20 -0
- package/src/index.ts +78 -1
- package/src/observability/context-monitor.ts +102 -0
- package/src/observability/event-emitter.ts +136 -0
- package/src/observability/event-handlers.ts +322 -0
- package/src/observability/event-store.ts +226 -0
- package/src/observability/index.ts +53 -0
- package/src/observability/log-reader.ts +152 -0
- package/src/observability/log-writer.ts +93 -0
- package/src/observability/mock/mock-provider.ts +72 -0
- package/src/observability/mock/types.ts +31 -0
- package/src/observability/retention.ts +57 -0
- package/src/observability/schemas.ts +83 -0
- package/src/observability/session-logger.ts +63 -0
- package/src/observability/summary-generator.ts +209 -0
- package/src/observability/token-tracker.ts +97 -0
- package/src/observability/types.ts +24 -0
- package/src/tools/configure.ts +13 -3
- package/src/tools/doctor.ts +111 -0
- package/src/tools/logs.ts +178 -0
- package/src/tools/mock-fallback.ts +100 -0
- package/src/tools/pipeline-report.ts +148 -0
- package/src/tools/quick.ts +126 -0
- package/src/tools/session-stats.ts +185 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session log reading, listing, searching, and filtering.
|
|
3
|
+
*
|
|
4
|
+
* Provides query capabilities over persisted session logs:
|
|
5
|
+
* - Read a specific session by ID
|
|
6
|
+
* - List all sessions sorted by startedAt (newest first)
|
|
7
|
+
* - Read the most recent session
|
|
8
|
+
* - Search/filter events within a session by type and time range
|
|
9
|
+
*
|
|
10
|
+
* All functions handle missing directories gracefully (D-16).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { isEnoentError } from "../utils/fs-helpers";
|
|
16
|
+
import { getLogsDir } from "./log-writer";
|
|
17
|
+
import { sessionLogSchema } from "./schemas";
|
|
18
|
+
import type { SessionEvent, SessionLog } from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Summary entry for session listing (lightweight, no full event data).
|
|
22
|
+
*/
|
|
23
|
+
export interface SessionLogEntry {
|
|
24
|
+
readonly sessionId: string;
|
|
25
|
+
readonly startedAt: string;
|
|
26
|
+
readonly endedAt: string | null;
|
|
27
|
+
readonly eventCount: number;
|
|
28
|
+
readonly decisionCount: number;
|
|
29
|
+
readonly errorCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Filters for searching events within a session log.
|
|
34
|
+
*/
|
|
35
|
+
export interface EventSearchFilters {
|
|
36
|
+
readonly type?: string;
|
|
37
|
+
readonly after?: string;
|
|
38
|
+
readonly before?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads and parses a specific session log by ID.
|
|
43
|
+
*
|
|
44
|
+
* Returns null on: non-existent file, malformed JSON, invalid schema.
|
|
45
|
+
* Never throws for expected failure modes.
|
|
46
|
+
*/
|
|
47
|
+
export async function readSessionLog(
|
|
48
|
+
sessionId: string,
|
|
49
|
+
logsDir?: string,
|
|
50
|
+
): Promise<SessionLog | null> {
|
|
51
|
+
const dir = logsDir ?? getLogsDir();
|
|
52
|
+
const logPath = join(dir, `${sessionId}.json`);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(logPath, "utf-8");
|
|
56
|
+
const parsed = JSON.parse(content);
|
|
57
|
+
const result = sessionLogSchema.safeParse(parsed);
|
|
58
|
+
return result.success ? result.data : null;
|
|
59
|
+
} catch (error: unknown) {
|
|
60
|
+
if (isEnoentError(error)) return null;
|
|
61
|
+
// Recover from malformed JSON
|
|
62
|
+
if (error instanceof SyntaxError) return null;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lists all session logs sorted by startedAt descending (newest first).
|
|
69
|
+
*
|
|
70
|
+
* Returns a lightweight SessionLogEntry for each log (no full event data).
|
|
71
|
+
* Skips non-JSON files and malformed logs.
|
|
72
|
+
* Returns empty array for missing or empty directories.
|
|
73
|
+
*/
|
|
74
|
+
export async function listSessionLogs(logsDir?: string): Promise<readonly SessionLogEntry[]> {
|
|
75
|
+
const dir = logsDir ?? getLogsDir();
|
|
76
|
+
|
|
77
|
+
let files: string[];
|
|
78
|
+
try {
|
|
79
|
+
files = await readdir(dir);
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
if (isEnoentError(error)) return [];
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
86
|
+
const entries: SessionLogEntry[] = [];
|
|
87
|
+
|
|
88
|
+
for (const file of jsonFiles) {
|
|
89
|
+
try {
|
|
90
|
+
const content = await readFile(join(dir, file), "utf-8");
|
|
91
|
+
const parsed = JSON.parse(content);
|
|
92
|
+
const result = sessionLogSchema.safeParse(parsed);
|
|
93
|
+
|
|
94
|
+
if (result.success) {
|
|
95
|
+
const log = result.data;
|
|
96
|
+
const errorCount = log.events.filter((e) => e.type === "error").length;
|
|
97
|
+
|
|
98
|
+
entries.push({
|
|
99
|
+
sessionId: log.sessionId,
|
|
100
|
+
startedAt: log.startedAt,
|
|
101
|
+
endedAt: log.endedAt,
|
|
102
|
+
eventCount: log.events.length,
|
|
103
|
+
decisionCount: log.decisions.length,
|
|
104
|
+
errorCount,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} catch (innerError: unknown) {
|
|
108
|
+
// SyntaxError = malformed JSON, expected and skipped.
|
|
109
|
+
// I/O errors (permissions, etc.) are unexpected — log them.
|
|
110
|
+
if (!(innerError instanceof SyntaxError) && !isEnoentError(innerError)) {
|
|
111
|
+
console.error("[opencode-autopilot] Unexpected error reading log file:", file, innerError);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sort by startedAt descending (newest first)
|
|
117
|
+
entries.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
118
|
+
|
|
119
|
+
return entries;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the most recent session log (by startedAt).
|
|
124
|
+
*
|
|
125
|
+
* Returns null when no valid logs exist.
|
|
126
|
+
*/
|
|
127
|
+
export async function readLatestSessionLog(logsDir?: string): Promise<SessionLog | null> {
|
|
128
|
+
const entries = await listSessionLogs(logsDir);
|
|
129
|
+
|
|
130
|
+
if (entries.length === 0) return null;
|
|
131
|
+
|
|
132
|
+
// Entries are already sorted newest-first
|
|
133
|
+
return readSessionLog(entries[0].sessionId, logsDir);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Filters events by type and/or time range.
|
|
138
|
+
*
|
|
139
|
+
* Pure function (no I/O, no side effects).
|
|
140
|
+
* Accepts a readonly array of events and returns a filtered copy.
|
|
141
|
+
*/
|
|
142
|
+
export function searchEvents(
|
|
143
|
+
events: readonly SessionEvent[],
|
|
144
|
+
filters: EventSearchFilters,
|
|
145
|
+
): readonly SessionEvent[] {
|
|
146
|
+
return events.filter((event) => {
|
|
147
|
+
if (filters.type && event.type !== filters.type) return false;
|
|
148
|
+
if (filters.after && event.timestamp <= filters.after) return false;
|
|
149
|
+
if (filters.before && event.timestamp >= filters.before) return false;
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session log persistence layer.
|
|
3
|
+
*
|
|
4
|
+
* Writes complete session logs as JSON files with atomic write pattern
|
|
5
|
+
* (temp file + rename) to prevent corruption. Logs are stored in
|
|
6
|
+
* ~/.config/opencode/logs/ (user-scoped, not project-scoped per D-08, D-09).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { ensureDir } from "../utils/fs-helpers";
|
|
13
|
+
import { getGlobalConfigDir } from "../utils/paths";
|
|
14
|
+
import { sessionLogSchema } from "./schemas";
|
|
15
|
+
import type { SessionEvent, SessionLog } from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns the global logs directory path.
|
|
19
|
+
* Logs are user-scoped, not project-scoped (D-08, D-09).
|
|
20
|
+
*/
|
|
21
|
+
export function getLogsDir(): string {
|
|
22
|
+
return join(getGlobalConfigDir(), "logs");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Input shape for writeSessionLog -- the in-memory data to persist.
|
|
27
|
+
*/
|
|
28
|
+
interface WriteSessionInput {
|
|
29
|
+
readonly sessionId: string;
|
|
30
|
+
readonly startedAt: string;
|
|
31
|
+
readonly events: readonly SessionEvent[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Converts in-memory session events to the persisted SessionLog format.
|
|
36
|
+
*
|
|
37
|
+
* Extracts decisions from decision-type events.
|
|
38
|
+
* Builds errorSummary by counting error events by errorType.
|
|
39
|
+
*/
|
|
40
|
+
export function convertToSessionLog(input: WriteSessionInput): SessionLog {
|
|
41
|
+
const decisions = input.events
|
|
42
|
+
.filter((e): e is SessionEvent & { type: "decision" } => e.type === "decision")
|
|
43
|
+
.map((e) => ({
|
|
44
|
+
timestamp: e.timestamp,
|
|
45
|
+
phase: e.phase,
|
|
46
|
+
agent: e.agent,
|
|
47
|
+
decision: e.decision,
|
|
48
|
+
rationale: e.rationale,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const errorSummary: Record<string, number> = {};
|
|
52
|
+
for (const e of input.events) {
|
|
53
|
+
if (e.type === "error") {
|
|
54
|
+
errorSummary[e.errorType] = (errorSummary[e.errorType] ?? 0) + 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
schemaVersion: 1 as const,
|
|
60
|
+
sessionId: input.sessionId,
|
|
61
|
+
startedAt: input.startedAt,
|
|
62
|
+
endedAt: new Date().toISOString(),
|
|
63
|
+
events: [...input.events],
|
|
64
|
+
decisions,
|
|
65
|
+
errorSummary,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Persists a session log to disk as a JSON file.
|
|
71
|
+
*
|
|
72
|
+
* Uses atomic write pattern: write to temp file, then rename.
|
|
73
|
+
* Validates through sessionLogSchema before writing (defensive).
|
|
74
|
+
* Creates the logs directory if it does not exist.
|
|
75
|
+
*
|
|
76
|
+
* @param input - Session data to persist
|
|
77
|
+
* @param logsDir - Optional override for logs directory (for testing)
|
|
78
|
+
*/
|
|
79
|
+
export async function writeSessionLog(input: WriteSessionInput, logsDir?: string): Promise<void> {
|
|
80
|
+
const raw = convertToSessionLog(input);
|
|
81
|
+
|
|
82
|
+
// Validate and sanitize — use parsed result (strips unknown keys, applies defaults)
|
|
83
|
+
const log = sessionLogSchema.parse(raw);
|
|
84
|
+
|
|
85
|
+
const dir = logsDir ?? getLogsDir();
|
|
86
|
+
await ensureDir(dir);
|
|
87
|
+
|
|
88
|
+
const finalPath = join(dir, `${log.sessionId}.json`);
|
|
89
|
+
const tmpPath = `${finalPath}.tmp.${randomBytes(8).toString("hex")}`;
|
|
90
|
+
|
|
91
|
+
await writeFile(tmpPath, JSON.stringify(log, null, 2), "utf-8");
|
|
92
|
+
await rename(tmpPath, finalPath);
|
|
93
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { MockFailureMode } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default error messages for each failure mode.
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_MESSAGES: Readonly<Record<MockFailureMode, string>> = Object.freeze({
|
|
7
|
+
rate_limit: "Rate limit exceeded",
|
|
8
|
+
quota_exceeded: "Quota exceeded",
|
|
9
|
+
timeout: "Request timeout — service unavailable (504)",
|
|
10
|
+
malformed: "Malformed response from model",
|
|
11
|
+
service_unavailable: "Service unavailable",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a deterministic error object for the given failure mode.
|
|
16
|
+
* Generated errors match the shapes consumed by classifyErrorType in
|
|
17
|
+
* src/orchestrator/fallback/error-classifier.ts.
|
|
18
|
+
*
|
|
19
|
+
* This is a test utility -- not a real provider registration.
|
|
20
|
+
* Each returned error object is frozen for immutability.
|
|
21
|
+
*
|
|
22
|
+
* @param mode - The failure mode to simulate
|
|
23
|
+
* @param customMessage - Optional override for the default error message
|
|
24
|
+
* @returns A frozen error-like object matching SDK error shapes
|
|
25
|
+
*/
|
|
26
|
+
export function createMockError(mode: MockFailureMode, customMessage?: string): unknown {
|
|
27
|
+
const message = customMessage ?? DEFAULT_MESSAGES[mode];
|
|
28
|
+
|
|
29
|
+
switch (mode) {
|
|
30
|
+
case "rate_limit":
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
name: "APIError",
|
|
33
|
+
status: 429,
|
|
34
|
+
statusCode: 429,
|
|
35
|
+
message,
|
|
36
|
+
error: Object.freeze({ message }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
case "quota_exceeded":
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
name: "APIError",
|
|
42
|
+
status: 402,
|
|
43
|
+
statusCode: 402,
|
|
44
|
+
message,
|
|
45
|
+
error: Object.freeze({ message }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
case "timeout":
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
name: "APIError",
|
|
51
|
+
status: 504,
|
|
52
|
+
statusCode: 504,
|
|
53
|
+
message,
|
|
54
|
+
error: Object.freeze({ message }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
case "malformed":
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
name: "UnknownError",
|
|
60
|
+
message,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
case "service_unavailable":
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
name: "APIError",
|
|
66
|
+
status: 503,
|
|
67
|
+
statusCode: 503,
|
|
68
|
+
message,
|
|
69
|
+
error: Object.freeze({ message }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock failure modes for fallback chain testing.
|
|
3
|
+
* Each mode generates a deterministic error object that feeds into the existing
|
|
4
|
+
* error classifier (src/orchestrator/fallback/error-classifier.ts).
|
|
5
|
+
*/
|
|
6
|
+
export type MockFailureMode =
|
|
7
|
+
| "rate_limit"
|
|
8
|
+
| "quota_exceeded"
|
|
9
|
+
| "timeout"
|
|
10
|
+
| "malformed"
|
|
11
|
+
| "service_unavailable";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* All valid failure modes as a frozen readonly array.
|
|
15
|
+
*/
|
|
16
|
+
export const FAILURE_MODES: readonly MockFailureMode[] = Object.freeze([
|
|
17
|
+
"rate_limit",
|
|
18
|
+
"quota_exceeded",
|
|
19
|
+
"timeout",
|
|
20
|
+
"malformed",
|
|
21
|
+
"service_unavailable",
|
|
22
|
+
] as const);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Configuration for a mock error provider instance.
|
|
26
|
+
*/
|
|
27
|
+
export interface MockProviderConfig {
|
|
28
|
+
readonly mode: MockFailureMode;
|
|
29
|
+
readonly delayMs?: number;
|
|
30
|
+
readonly customMessage?: string;
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isEnoentError } from "../utils/fs-helpers";
|
|
4
|
+
import { getLogsDir } from "./session-logger";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RETENTION_DAYS = 30;
|
|
7
|
+
|
|
8
|
+
interface PruneOptions {
|
|
9
|
+
readonly logsDir?: string;
|
|
10
|
+
readonly retentionDays?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PruneResult {
|
|
14
|
+
readonly pruned: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Removes log files older than the configured retention period.
|
|
19
|
+
* Defaults to 30 days if no retention period is specified (D-12).
|
|
20
|
+
*
|
|
21
|
+
* Runs non-blocking on plugin load (D-14).
|
|
22
|
+
* Handles missing or empty directories gracefully.
|
|
23
|
+
*
|
|
24
|
+
* @param options - Optional logs directory and retention period
|
|
25
|
+
* @returns Count of pruned files
|
|
26
|
+
*/
|
|
27
|
+
export async function pruneOldLogs(options?: PruneOptions): Promise<PruneResult> {
|
|
28
|
+
const logsDir = options?.logsDir ?? getLogsDir();
|
|
29
|
+
const retentionDays = options?.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
30
|
+
const threshold = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
let entries: string[];
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(logsDir);
|
|
35
|
+
} catch (error: unknown) {
|
|
36
|
+
if (isEnoentError(error)) return { pruned: 0 };
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let pruned = 0;
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const filePath = join(logsDir, entry);
|
|
44
|
+
try {
|
|
45
|
+
const fileStat = await stat(filePath);
|
|
46
|
+
if (fileStat.isFile() && fileStat.mtimeMs < threshold) {
|
|
47
|
+
await unlink(filePath);
|
|
48
|
+
pruned++;
|
|
49
|
+
}
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
// Skip files that disappear between readdir and stat (race condition safety)
|
|
52
|
+
if (!isEnoentError(error)) throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { pruned };
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const baseEventSchema = z.object({
|
|
4
|
+
timestamp: z.string().max(128),
|
|
5
|
+
sessionId: z
|
|
6
|
+
.string()
|
|
7
|
+
.max(256)
|
|
8
|
+
.regex(/^[a-zA-Z0-9_-]{1,256}$/, "Invalid session ID"),
|
|
9
|
+
type: z.enum(["fallback", "error", "decision", "model_switch"]),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const fallbackEventSchema = baseEventSchema.extend({
|
|
13
|
+
type: z.literal("fallback"),
|
|
14
|
+
failedModel: z.string().max(256),
|
|
15
|
+
nextModel: z.string().max(256),
|
|
16
|
+
reason: z.string().max(1024),
|
|
17
|
+
success: z.boolean(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const errorEventSchema = baseEventSchema.extend({
|
|
21
|
+
type: z.literal("error"),
|
|
22
|
+
errorType: z.enum([
|
|
23
|
+
"rate_limit",
|
|
24
|
+
"quota_exceeded",
|
|
25
|
+
"service_unavailable",
|
|
26
|
+
"missing_api_key",
|
|
27
|
+
"model_not_found",
|
|
28
|
+
"content_filter",
|
|
29
|
+
"context_length",
|
|
30
|
+
"unknown",
|
|
31
|
+
]),
|
|
32
|
+
model: z.string().max(256),
|
|
33
|
+
message: z.string().max(4096),
|
|
34
|
+
statusCode: z.number().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const decisionEventSchema = baseEventSchema.extend({
|
|
38
|
+
type: z.literal("decision"),
|
|
39
|
+
phase: z.string().max(128),
|
|
40
|
+
agent: z.string().max(128),
|
|
41
|
+
decision: z.string().max(2048),
|
|
42
|
+
rationale: z.string().max(2048),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const modelSwitchEventSchema = baseEventSchema.extend({
|
|
46
|
+
type: z.literal("model_switch"),
|
|
47
|
+
fromModel: z.string().max(256),
|
|
48
|
+
toModel: z.string().max(256),
|
|
49
|
+
trigger: z.enum(["fallback", "config", "user"]),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const sessionEventSchema = z.discriminatedUnion("type", [
|
|
53
|
+
fallbackEventSchema,
|
|
54
|
+
errorEventSchema,
|
|
55
|
+
decisionEventSchema,
|
|
56
|
+
modelSwitchEventSchema,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export const loggingConfigSchema = z.object({
|
|
60
|
+
enabled: z.boolean().default(true),
|
|
61
|
+
retentionDays: z.number().min(1).max(365).default(30),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const sessionDecisionSchema = z.object({
|
|
65
|
+
timestamp: z.string().optional(),
|
|
66
|
+
phase: z.string(),
|
|
67
|
+
agent: z.string(),
|
|
68
|
+
decision: z.string(),
|
|
69
|
+
rationale: z.string(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const sessionLogSchema = z.object({
|
|
73
|
+
schemaVersion: z.number().default(1),
|
|
74
|
+
sessionId: z.string().regex(/^[a-zA-Z0-9_-]{1,256}$/, "Invalid session ID"),
|
|
75
|
+
startedAt: z.string(),
|
|
76
|
+
endedAt: z.string().nullable().default(null),
|
|
77
|
+
events: z.array(sessionEventSchema),
|
|
78
|
+
decisions: z.array(sessionDecisionSchema).default([]),
|
|
79
|
+
errorSummary: z.record(z.string(), z.number()).default({}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Pre-compute defaults for Zod v4 nested default compatibility
|
|
83
|
+
export const loggingDefaults = loggingConfigSchema.parse({});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureDir, isEnoentError } from "../utils/fs-helpers";
|
|
4
|
+
import { getGlobalConfigDir } from "../utils/paths";
|
|
5
|
+
import { sessionEventSchema } from "./schemas";
|
|
6
|
+
import type { SessionEvent } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the global logs directory path.
|
|
10
|
+
* Logs are user-scoped, not project-scoped (D-08, D-09).
|
|
11
|
+
*/
|
|
12
|
+
export function getLogsDir(): string {
|
|
13
|
+
return join(getGlobalConfigDir(), "logs");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the JSONL log file path for a given session ID.
|
|
18
|
+
*/
|
|
19
|
+
function getSessionLogPath(sessionId: string, logsDir?: string): string {
|
|
20
|
+
return join(logsDir ?? getLogsDir(), `${sessionId}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Logs a structured event to the session's JSONL log file.
|
|
25
|
+
* Validates the event against the Zod schema before writing.
|
|
26
|
+
* Creates the logs directory if it does not exist.
|
|
27
|
+
*
|
|
28
|
+
* @param event - The session event to log (D-02, D-04-D-07)
|
|
29
|
+
* @param logsDir - Optional override for the logs directory (for testing)
|
|
30
|
+
*/
|
|
31
|
+
export async function logEvent(event: SessionEvent, logsDir?: string): Promise<void> {
|
|
32
|
+
// Validate against schema -- throws on invalid events
|
|
33
|
+
const validated = sessionEventSchema.parse(event);
|
|
34
|
+
|
|
35
|
+
const dir = logsDir ?? getLogsDir();
|
|
36
|
+
await ensureDir(dir);
|
|
37
|
+
|
|
38
|
+
const logPath = getSessionLogPath(validated.sessionId, dir);
|
|
39
|
+
const line = `${JSON.stringify(validated)}\n`;
|
|
40
|
+
|
|
41
|
+
await appendFile(logPath, line, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads and parses all events from a session's JSONL log file.
|
|
46
|
+
* Returns an empty array if the session log does not exist.
|
|
47
|
+
*
|
|
48
|
+
* @param sessionId - The session ID to read logs for
|
|
49
|
+
* @param logsDir - Optional override for the logs directory (for testing)
|
|
50
|
+
*/
|
|
51
|
+
export async function getSessionLog(sessionId: string, logsDir?: string): Promise<SessionEvent[]> {
|
|
52
|
+
const logPath = getSessionLogPath(sessionId, logsDir);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(logPath, "utf-8");
|
|
56
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
57
|
+
|
|
58
|
+
return lines.map((line) => sessionEventSchema.parse(JSON.parse(line)));
|
|
59
|
+
} catch (error: unknown) {
|
|
60
|
+
if (isEnoentError(error)) return [];
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|