@kodrunhq/opencode-autopilot 1.3.0 → 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/package.json +1 -1
- package/src/index.ts +68 -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/logs.ts +178 -0
- package/src/tools/mock-fallback.ts +100 -0
- package/src/tools/pipeline-report.ts +148 -0
- package/src/tools/session-stats.ts +185 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory session event store for observability data.
|
|
3
|
+
*
|
|
4
|
+
* Accumulates events, tokens, and tool metrics per session.
|
|
5
|
+
* Data is flushed to disk by the event handler layer on session end.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TokenAggregate } from "./token-tracker";
|
|
11
|
+
import { accumulateTokens, createEmptyTokenAggregate } from "./token-tracker";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Unified event type for the in-memory store.
|
|
15
|
+
* Extends the Plan 01 SessionEvent types with additional observability events.
|
|
16
|
+
*/
|
|
17
|
+
export type ObservabilityEvent =
|
|
18
|
+
| {
|
|
19
|
+
readonly type: "fallback";
|
|
20
|
+
readonly timestamp: string;
|
|
21
|
+
readonly sessionId: string;
|
|
22
|
+
readonly failedModel: string;
|
|
23
|
+
readonly nextModel: string;
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
readonly success: boolean;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly type: "error";
|
|
29
|
+
readonly timestamp: string;
|
|
30
|
+
readonly sessionId: string;
|
|
31
|
+
readonly errorType:
|
|
32
|
+
| "rate_limit"
|
|
33
|
+
| "quota_exceeded"
|
|
34
|
+
| "service_unavailable"
|
|
35
|
+
| "missing_api_key"
|
|
36
|
+
| "model_not_found"
|
|
37
|
+
| "content_filter"
|
|
38
|
+
| "context_length"
|
|
39
|
+
| "unknown";
|
|
40
|
+
readonly message: string;
|
|
41
|
+
readonly model: string;
|
|
42
|
+
readonly statusCode?: number;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
readonly type: "decision";
|
|
46
|
+
readonly timestamp: string;
|
|
47
|
+
readonly sessionId: string;
|
|
48
|
+
readonly phase: string;
|
|
49
|
+
readonly agent: string;
|
|
50
|
+
readonly decision: string;
|
|
51
|
+
readonly rationale: string;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
readonly type: "model_switch";
|
|
55
|
+
readonly timestamp: string;
|
|
56
|
+
readonly sessionId: string;
|
|
57
|
+
readonly fromModel: string;
|
|
58
|
+
readonly toModel: string;
|
|
59
|
+
readonly trigger: "fallback" | "config" | "user";
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
readonly type: "context_warning";
|
|
63
|
+
readonly timestamp: string;
|
|
64
|
+
readonly sessionId: string;
|
|
65
|
+
readonly utilization: number;
|
|
66
|
+
readonly contextLimit: number;
|
|
67
|
+
readonly inputTokens: number;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
readonly type: "tool_complete";
|
|
71
|
+
readonly timestamp: string;
|
|
72
|
+
readonly sessionId: string;
|
|
73
|
+
readonly tool: string;
|
|
74
|
+
readonly durationMs: number;
|
|
75
|
+
readonly success: boolean;
|
|
76
|
+
}
|
|
77
|
+
| {
|
|
78
|
+
readonly type: "phase_transition";
|
|
79
|
+
readonly timestamp: string;
|
|
80
|
+
readonly sessionId: string;
|
|
81
|
+
readonly fromPhase: string;
|
|
82
|
+
readonly toPhase: string;
|
|
83
|
+
}
|
|
84
|
+
| { readonly type: "session_start"; readonly timestamp: string; readonly sessionId: string }
|
|
85
|
+
| {
|
|
86
|
+
readonly type: "session_end";
|
|
87
|
+
readonly timestamp: string;
|
|
88
|
+
readonly sessionId: string;
|
|
89
|
+
readonly durationMs: number;
|
|
90
|
+
readonly totalCost: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Per-tool execution metrics accumulated during a session.
|
|
95
|
+
*/
|
|
96
|
+
export interface ToolMetrics {
|
|
97
|
+
readonly invocations: number;
|
|
98
|
+
readonly totalDurationMs: number;
|
|
99
|
+
readonly successes: number;
|
|
100
|
+
readonly failures: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* All collected data for a single session.
|
|
105
|
+
*/
|
|
106
|
+
export interface SessionEvents {
|
|
107
|
+
readonly sessionId: string;
|
|
108
|
+
readonly events: readonly ObservabilityEvent[];
|
|
109
|
+
readonly tokens: TokenAggregate;
|
|
110
|
+
readonly toolMetrics: ReadonlyMap<string, ToolMetrics>;
|
|
111
|
+
readonly currentPhase: string | null;
|
|
112
|
+
readonly startedAt: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mutable internal state for a session (used within the store only).
|
|
117
|
+
*/
|
|
118
|
+
interface MutableSessionData {
|
|
119
|
+
sessionId: string;
|
|
120
|
+
events: ObservabilityEvent[];
|
|
121
|
+
tokens: TokenAggregate;
|
|
122
|
+
toolMetrics: Map<string, ToolMetrics>;
|
|
123
|
+
currentPhase: string | null;
|
|
124
|
+
startedAt: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* In-memory store for session observability events.
|
|
129
|
+
* Accumulates events, tokens, and tool metrics per session.
|
|
130
|
+
*/
|
|
131
|
+
export class SessionEventStore {
|
|
132
|
+
private readonly sessions: Map<string, MutableSessionData> = new Map();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Initializes a new session in the store.
|
|
136
|
+
*/
|
|
137
|
+
initSession(sessionId: string): void {
|
|
138
|
+
this.sessions.set(sessionId, {
|
|
139
|
+
sessionId,
|
|
140
|
+
events: [],
|
|
141
|
+
tokens: createEmptyTokenAggregate(),
|
|
142
|
+
toolMetrics: new Map(),
|
|
143
|
+
currentPhase: null,
|
|
144
|
+
startedAt: new Date().toISOString(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Appends an event to a session. Silently returns if session is not initialized.
|
|
150
|
+
*/
|
|
151
|
+
appendEvent(sessionId: string, event: ObservabilityEvent): void {
|
|
152
|
+
const session = this.sessions.get(sessionId);
|
|
153
|
+
if (!session) return;
|
|
154
|
+
session.events.push(event);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Accumulates token data for a session.
|
|
159
|
+
*/
|
|
160
|
+
accumulateTokens(sessionId: string, tokens: Partial<TokenAggregate>): void {
|
|
161
|
+
const session = this.sessions.get(sessionId);
|
|
162
|
+
if (!session) return;
|
|
163
|
+
session.tokens = accumulateTokens(session.tokens, tokens);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Records a tool execution metric.
|
|
168
|
+
*/
|
|
169
|
+
recordToolExecution(sessionId: string, tool: string, durationMs: number, success: boolean): void {
|
|
170
|
+
const session = this.sessions.get(sessionId);
|
|
171
|
+
if (!session) return;
|
|
172
|
+
|
|
173
|
+
const existing = session.toolMetrics.get(tool);
|
|
174
|
+
const updated: ToolMetrics = {
|
|
175
|
+
invocations: (existing?.invocations ?? 0) + 1,
|
|
176
|
+
totalDurationMs: (existing?.totalDurationMs ?? 0) + durationMs,
|
|
177
|
+
successes: (existing?.successes ?? 0) + (success ? 1 : 0),
|
|
178
|
+
failures: (existing?.failures ?? 0) + (success ? 0 : 1),
|
|
179
|
+
};
|
|
180
|
+
session.toolMetrics.set(tool, updated);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sets the current pipeline phase for a session.
|
|
185
|
+
*/
|
|
186
|
+
setCurrentPhase(sessionId: string, phase: string): void {
|
|
187
|
+
const session = this.sessions.get(sessionId);
|
|
188
|
+
if (!session) return;
|
|
189
|
+
session.currentPhase = phase;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Gets the current pipeline phase for a session.
|
|
194
|
+
*/
|
|
195
|
+
getCurrentPhase(sessionId: string): string | null {
|
|
196
|
+
return this.sessions.get(sessionId)?.currentPhase ?? null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Returns a snapshot of session data without removing it.
|
|
201
|
+
*/
|
|
202
|
+
getSession(sessionId: string): SessionEvents | undefined {
|
|
203
|
+
const session = this.sessions.get(sessionId);
|
|
204
|
+
if (!session) return undefined;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
sessionId: session.sessionId,
|
|
208
|
+
events: [...session.events],
|
|
209
|
+
tokens: session.tokens,
|
|
210
|
+
toolMetrics: new Map(session.toolMetrics),
|
|
211
|
+
currentPhase: session.currentPhase,
|
|
212
|
+
startedAt: session.startedAt,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Returns session data and removes it from the store (for disk flush).
|
|
218
|
+
*/
|
|
219
|
+
flush(sessionId: string): SessionEvents | undefined {
|
|
220
|
+
const session = this.getSession(sessionId);
|
|
221
|
+
if (session) {
|
|
222
|
+
this.sessions.delete(sessionId);
|
|
223
|
+
}
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability module barrel export.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all public APIs from the session observability system:
|
|
5
|
+
* - Schemas and types for structured events
|
|
6
|
+
* - Session logger for event capture (JSONL append)
|
|
7
|
+
* - Log writer for complete session persistence (atomic JSON)
|
|
8
|
+
* - Log reader for querying persisted sessions
|
|
9
|
+
* - Summary generator for human-readable markdown reports
|
|
10
|
+
* - Retention for time-based log pruning
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type { EventSearchFilters, SessionLogEntry } from "./log-reader";
|
|
14
|
+
export {
|
|
15
|
+
listSessionLogs,
|
|
16
|
+
readLatestSessionLog,
|
|
17
|
+
readSessionLog,
|
|
18
|
+
searchEvents,
|
|
19
|
+
} from "./log-reader";
|
|
20
|
+
export { convertToSessionLog, getLogsDir, writeSessionLog } from "./log-writer";
|
|
21
|
+
export { pruneOldLogs } from "./retention";
|
|
22
|
+
export {
|
|
23
|
+
baseEventSchema,
|
|
24
|
+
decisionEventSchema,
|
|
25
|
+
errorEventSchema,
|
|
26
|
+
fallbackEventSchema,
|
|
27
|
+
loggingConfigSchema,
|
|
28
|
+
loggingDefaults,
|
|
29
|
+
modelSwitchEventSchema,
|
|
30
|
+
sessionDecisionSchema,
|
|
31
|
+
sessionEventSchema,
|
|
32
|
+
sessionLogSchema,
|
|
33
|
+
} from "./schemas";
|
|
34
|
+
export { getLogsDir as getEventLogsDir, getSessionLog, logEvent } from "./session-logger";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
computeDuration,
|
|
38
|
+
formatCost,
|
|
39
|
+
formatDuration,
|
|
40
|
+
generateSessionSummary,
|
|
41
|
+
} from "./summary-generator";
|
|
42
|
+
export type {
|
|
43
|
+
BaseEvent,
|
|
44
|
+
DecisionEvent,
|
|
45
|
+
ErrorEvent,
|
|
46
|
+
FallbackEvent,
|
|
47
|
+
LoggingConfig,
|
|
48
|
+
ModelSwitchEvent,
|
|
49
|
+
SessionDecision,
|
|
50
|
+
SessionEvent,
|
|
51
|
+
SessionEventType,
|
|
52
|
+
SessionLog,
|
|
53
|
+
} from "./types";
|
|
@@ -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
|
+
}
|