@kodrunhq/opencode-autopilot 1.16.0 → 1.18.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/oc-doctor.md +17 -0
- package/bin/configure-tui.ts +1 -1
- package/bin/inspect.ts +2 -2
- package/package.json +1 -1
- package/src/config/index.ts +29 -0
- package/src/config/migrations.ts +196 -0
- package/src/config/v7.ts +45 -0
- package/src/config.ts +108 -24
- package/src/health/checks.ts +165 -0
- package/src/health/runner.ts +8 -2
- package/src/health/types.ts +1 -1
- package/src/index.ts +25 -2
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +1 -2
- package/src/logging/domains.ts +39 -0
- package/src/logging/forensic-writer.ts +177 -0
- package/src/logging/index.ts +4 -0
- package/src/logging/logger.ts +44 -0
- package/src/logging/performance.ts +59 -0
- package/src/logging/rotation.ts +261 -0
- package/src/logging/types.ts +33 -0
- package/src/memory/capture-utils.ts +149 -0
- package/src/memory/capture.ts +16 -197
- package/src/memory/decay.ts +11 -2
- package/src/memory/injector.ts +4 -1
- package/src/memory/lessons.ts +85 -0
- package/src/memory/observations.ts +177 -0
- package/src/memory/preferences.ts +718 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +46 -1001
- package/src/memory/retrieval.ts +5 -1
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +44 -6
- package/src/observability/forensic-log.ts +10 -2
- package/src/observability/forensic-schemas.ts +9 -1
- package/src/observability/log-reader.ts +20 -1
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +13 -148
- package/src/orchestrator/handlers/retrospective.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +7 -2
- package/src/orchestrator/orchestration-logger.ts +46 -31
- package/src/orchestrator/progress.ts +63 -0
- package/src/review/memory.ts +11 -3
- package/src/review/parse-findings.ts +116 -0
- package/src/review/pipeline.ts +3 -107
- package/src/review/selection.ts +38 -4
- package/src/scoring/time-provider.ts +23 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/doctor.ts +2 -2
- package/src/tools/logs.ts +32 -6
- package/src/tools/orchestrate.ts +11 -9
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +8 -2
- package/src/tools/summary.ts +43 -0
- package/src/types/background.ts +51 -0
- package/src/types/mcp.ts +27 -0
- package/src/types/recovery.ts +39 -0
- package/src/types/routing.ts +39 -0
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
package/src/health/checks.ts
CHANGED
|
@@ -44,6 +44,171 @@ export async function configHealthCheck(configPath?: string): Promise<HealthResu
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const LATEST_CONFIG_VERSION = 7;
|
|
48
|
+
|
|
49
|
+
export async function configVersionCheck(configPath?: string): Promise<HealthResult> {
|
|
50
|
+
try {
|
|
51
|
+
const config = await loadConfig(configPath);
|
|
52
|
+
if (config === null) {
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
name: "config-version",
|
|
55
|
+
status: "fail" as const,
|
|
56
|
+
message: "Config file not found",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (config.version < LATEST_CONFIG_VERSION) {
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
name: "config-version",
|
|
62
|
+
status: "warn" as const,
|
|
63
|
+
message: `Config v${config.version} is outdated (latest: v${LATEST_CONFIG_VERSION}). Auto-migration will upgrade on next load.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
name: "config-version",
|
|
68
|
+
status: "pass" as const,
|
|
69
|
+
message: `Config is on latest version (v${config.version})`,
|
|
70
|
+
});
|
|
71
|
+
} catch (error: unknown) {
|
|
72
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
name: "config-version",
|
|
75
|
+
status: "fail" as const,
|
|
76
|
+
message: `Config version check failed: ${msg}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const REQUIRED_GROUPS: readonly string[] = Object.freeze([
|
|
82
|
+
"architects",
|
|
83
|
+
"challengers",
|
|
84
|
+
"builders",
|
|
85
|
+
"reviewers",
|
|
86
|
+
"red-team",
|
|
87
|
+
"researchers",
|
|
88
|
+
"communicators",
|
|
89
|
+
"utilities",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
export async function configGroupsCheck(configPath?: string): Promise<HealthResult> {
|
|
93
|
+
try {
|
|
94
|
+
const config = await loadConfig(configPath);
|
|
95
|
+
if (config === null) {
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
name: "config-groups",
|
|
98
|
+
status: "fail" as const,
|
|
99
|
+
message: "Config file not found",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const assignedGroups = Object.keys(config.groups);
|
|
104
|
+
const missingGroups = REQUIRED_GROUPS.filter((g) => !assignedGroups.includes(g));
|
|
105
|
+
|
|
106
|
+
if (missingGroups.length > 0) {
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
name: "config-groups",
|
|
109
|
+
status: "warn" as const,
|
|
110
|
+
message: `Missing model assignments for groups: ${missingGroups.join(", ")}`,
|
|
111
|
+
details: Object.freeze(missingGroups),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const groupsWithoutPrimary = assignedGroups.filter((g) => {
|
|
116
|
+
const group = config.groups[g];
|
|
117
|
+
return !group?.primary;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (groupsWithoutPrimary.length > 0) {
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
name: "config-groups",
|
|
123
|
+
status: "warn" as const,
|
|
124
|
+
message: `Groups without primary model: ${groupsWithoutPrimary.join(", ")}`,
|
|
125
|
+
details: Object.freeze(groupsWithoutPrimary),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
name: "config-groups",
|
|
131
|
+
status: "pass" as const,
|
|
132
|
+
message: `All ${REQUIRED_GROUPS.length} required groups have primary models assigned`,
|
|
133
|
+
});
|
|
134
|
+
} catch (error: unknown) {
|
|
135
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
136
|
+
return Object.freeze({
|
|
137
|
+
name: "config-groups",
|
|
138
|
+
status: "fail" as const,
|
|
139
|
+
message: `Config groups check failed: ${msg}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** v7 config fields that must be present on a v7 config. */
|
|
145
|
+
const V7_REQUIRED_FIELDS: readonly string[] = Object.freeze([
|
|
146
|
+
"background",
|
|
147
|
+
"routing",
|
|
148
|
+
"recovery",
|
|
149
|
+
"mcp",
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check that v7 configs contain all four new top-level fields introduced in v7:
|
|
154
|
+
* background, routing, recovery, and mcp.
|
|
155
|
+
* Inspects the raw on-disk JSON so that Zod default-filling does not mask
|
|
156
|
+
* actually-missing fields. Pre-v7 configs receive a pass with a migration notice.
|
|
157
|
+
*/
|
|
158
|
+
export async function configV7FieldsCheck(configPath?: string): Promise<HealthResult> {
|
|
159
|
+
const resolvedPath = configPath ?? join(getGlobalConfigDir(), "opencode-autopilot.json");
|
|
160
|
+
try {
|
|
161
|
+
let raw: Record<string, unknown>;
|
|
162
|
+
try {
|
|
163
|
+
const content = await readFile(resolvedPath, "utf-8");
|
|
164
|
+
raw = JSON.parse(content) as Record<string, unknown>;
|
|
165
|
+
} catch (error: unknown) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
name: "config-v7-fields",
|
|
169
|
+
status: "fail" as const,
|
|
170
|
+
message: "Config file not found",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const version = typeof raw.version === "number" ? raw.version : 0;
|
|
177
|
+
|
|
178
|
+
if (version < 7) {
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
name: "config-v7-fields",
|
|
181
|
+
status: "pass" as const,
|
|
182
|
+
message: `Config v${version} will gain v7 fields (background, routing, recovery, mcp) on next load`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const missingFields = V7_REQUIRED_FIELDS.filter((field) => !(field in raw));
|
|
187
|
+
|
|
188
|
+
if (missingFields.length > 0) {
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
name: "config-v7-fields",
|
|
191
|
+
status: "fail" as const,
|
|
192
|
+
message: `Config v7 is missing required fields: ${missingFields.join(", ")}`,
|
|
193
|
+
details: Object.freeze(missingFields),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return Object.freeze({
|
|
198
|
+
name: "config-v7-fields",
|
|
199
|
+
status: "pass" as const,
|
|
200
|
+
message: `Config v7 fields present: ${V7_REQUIRED_FIELDS.join(", ")}`,
|
|
201
|
+
});
|
|
202
|
+
} catch (error: unknown) {
|
|
203
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
name: "config-v7-fields",
|
|
206
|
+
status: "fail" as const,
|
|
207
|
+
message: `Config v7 fields check failed: ${msg}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
47
212
|
/** Standard agent names, derived from the agents barrel export. */
|
|
48
213
|
const STANDARD_AGENT_NAMES: readonly string[] = Object.freeze([
|
|
49
214
|
"researcher",
|
package/src/health/runner.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
assetHealthCheck,
|
|
5
5
|
commandHealthCheck,
|
|
6
6
|
configHealthCheck,
|
|
7
|
+
configV7FieldsCheck,
|
|
7
8
|
memoryHealthCheck,
|
|
8
9
|
nativeAgentSuppressionHealthCheck,
|
|
9
10
|
skillHealthCheck,
|
|
@@ -43,16 +44,20 @@ export async function runHealthChecks(options?: {
|
|
|
43
44
|
}): Promise<HealthReport> {
|
|
44
45
|
const start = Date.now();
|
|
45
46
|
|
|
47
|
+
const configOutcome = await Promise.allSettled([configHealthCheck(options?.configPath)]);
|
|
48
|
+
|
|
46
49
|
const settled = await Promise.allSettled([
|
|
47
|
-
configHealthCheck(options?.configPath),
|
|
48
50
|
agentHealthCheck(options?.openCodeConfig ?? null),
|
|
49
51
|
nativeAgentSuppressionHealthCheck(options?.openCodeConfig ?? null),
|
|
50
52
|
assetHealthCheck(options?.assetsDir, options?.targetDir),
|
|
51
53
|
skillHealthCheck(options?.projectRoot ?? process.cwd()),
|
|
52
54
|
memoryHealthCheck(options?.targetDir),
|
|
53
55
|
commandHealthCheck(options?.targetDir),
|
|
56
|
+
configV7FieldsCheck(options?.configPath),
|
|
54
57
|
]);
|
|
55
58
|
|
|
59
|
+
const allSettled = [...configOutcome, ...settled];
|
|
60
|
+
|
|
56
61
|
const fallbackNames = [
|
|
57
62
|
"config-validity",
|
|
58
63
|
"agent-injection",
|
|
@@ -61,9 +66,10 @@ export async function runHealthChecks(options?: {
|
|
|
61
66
|
"skill-loading",
|
|
62
67
|
"memory-db",
|
|
63
68
|
"command-accessibility",
|
|
69
|
+
"config-v7-fields",
|
|
64
70
|
];
|
|
65
71
|
const results: readonly HealthResult[] = Object.freeze(
|
|
66
|
-
|
|
72
|
+
allSettled.map((outcome, i) => settledToResult(outcome, fallbackNames[i])),
|
|
67
73
|
);
|
|
68
74
|
|
|
69
75
|
const allPassed = results.every((r) => r.status === "pass");
|
package/src/health/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isFirstLoad, loadConfig } from "./config";
|
|
|
4
4
|
import { runHealthChecks } from "./health/runner";
|
|
5
5
|
import { createAntiSlopHandler } from "./hooks/anti-slop";
|
|
6
6
|
import { installAssets } from "./installer";
|
|
7
|
+
import { getLogger, initLoggers } from "./logging/domains";
|
|
7
8
|
import {
|
|
8
9
|
createMemoryCaptureHandler,
|
|
9
10
|
createMemoryChatMessageHandler,
|
|
@@ -55,17 +56,36 @@ import { ocReview } from "./tools/review";
|
|
|
55
56
|
import { ocSessionStats } from "./tools/session-stats";
|
|
56
57
|
import { ocState } from "./tools/state";
|
|
57
58
|
import { ocStocktake } from "./tools/stocktake";
|
|
59
|
+
import { ocSummary } from "./tools/summary";
|
|
58
60
|
import { ocUpdateDocs } from "./tools/update-docs";
|
|
59
61
|
|
|
60
62
|
let openCodeConfig: Config | null = null;
|
|
61
63
|
|
|
64
|
+
let processHandlersRegistered = false;
|
|
65
|
+
function registerProcessHandlers() {
|
|
66
|
+
if (processHandlersRegistered) return;
|
|
67
|
+
processHandlersRegistered = true;
|
|
68
|
+
process.on("uncaughtException", (error) => {
|
|
69
|
+
getLogger("system").error("Uncaught exception", {
|
|
70
|
+
error: error instanceof Error ? error.stack : String(error),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
process.on("unhandledRejection", (reason) => {
|
|
74
|
+
getLogger("system").error("Unhandled rejection", {
|
|
75
|
+
reason: reason instanceof Error ? reason.stack : String(reason),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
const plugin: Plugin = async (input) => {
|
|
63
81
|
const client = input.client;
|
|
82
|
+
initLoggers(process.cwd());
|
|
83
|
+
registerProcessHandlers();
|
|
64
84
|
|
|
65
85
|
// Self-healing asset installation on every load
|
|
66
86
|
const installResult = await installAssets();
|
|
67
87
|
if (installResult.errors.length > 0) {
|
|
68
|
-
|
|
88
|
+
getLogger("system").warn("Asset installation errors", { errors: installResult.errors });
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
// Discover available providers/models in the background (non-blocking).
|
|
@@ -102,7 +122,9 @@ const plugin: Plugin = async (input) => {
|
|
|
102
122
|
|
|
103
123
|
// Retention pruning on load (non-blocking per D-14)
|
|
104
124
|
pruneOldLogs().catch((err) => {
|
|
105
|
-
|
|
125
|
+
getLogger("system").error("Log retention pruning failed", {
|
|
126
|
+
error: err instanceof Error ? err.stack : String(err),
|
|
127
|
+
});
|
|
106
128
|
});
|
|
107
129
|
|
|
108
130
|
// --- Fallback subsystem initialization ---
|
|
@@ -307,6 +329,7 @@ const plugin: Plugin = async (input) => {
|
|
|
307
329
|
oc_logs: ocLogs,
|
|
308
330
|
oc_session_stats: ocSessionStats,
|
|
309
331
|
oc_pipeline_report: ocPipelineReport,
|
|
332
|
+
oc_summary: ocSummary,
|
|
310
333
|
oc_mock_fallback: ocMockFallback,
|
|
311
334
|
oc_stocktake: ocStocktake,
|
|
312
335
|
oc_update_docs: ocUpdateDocs,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export interface TransactionOptions {
|
|
4
|
+
maxRetries?: number;
|
|
5
|
+
backoffMs?: number;
|
|
6
|
+
useImmediate?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function withTransaction<T>(db: Database, fn: () => T, options: TransactionOptions = {}): T {
|
|
10
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
11
|
+
const backoffMs = options.backoffMs ?? 100;
|
|
12
|
+
const useImmediate = options.useImmediate ?? true;
|
|
13
|
+
|
|
14
|
+
let attempts = 0;
|
|
15
|
+
while (true) {
|
|
16
|
+
try {
|
|
17
|
+
if (useImmediate) {
|
|
18
|
+
db.run("BEGIN IMMEDIATE");
|
|
19
|
+
try {
|
|
20
|
+
const result = fn();
|
|
21
|
+
db.run("COMMIT");
|
|
22
|
+
return result;
|
|
23
|
+
} catch (innerError) {
|
|
24
|
+
db.run("ROLLBACK");
|
|
25
|
+
throw innerError;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const transaction = db.transaction(fn);
|
|
30
|
+
return transaction();
|
|
31
|
+
} catch (error: unknown) {
|
|
32
|
+
const e = error as Error;
|
|
33
|
+
const isBusyError =
|
|
34
|
+
e.message &&
|
|
35
|
+
(e.message.includes("database is locked") ||
|
|
36
|
+
e.message.includes("SQLITE_BUSY") ||
|
|
37
|
+
e.message.includes("database table is locked"));
|
|
38
|
+
|
|
39
|
+
if (isBusyError && attempts < maxRetries) {
|
|
40
|
+
attempts++;
|
|
41
|
+
const waitTime = backoffMs * attempts;
|
|
42
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitTime);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/kernel/types.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ForensicEvent } from "../observability/forensic-types";
|
|
2
|
-
import type { LessonMemory } from "../orchestrator/lesson-types";
|
|
3
2
|
import type { PipelineState } from "../orchestrator/types";
|
|
4
|
-
import type {
|
|
3
|
+
import type { ReviewState } from "../review/types";
|
|
5
4
|
|
|
6
5
|
export const KERNEL_STATE_CONFLICT_CODE = "E_STATE_CONFLICT";
|
|
7
6
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createForensicSink } from "./forensic-writer";
|
|
2
|
+
import { BaseLogger } from "./logger";
|
|
3
|
+
import type { LogEntry, Logger, LogMetadata, LogSink } from "./types";
|
|
4
|
+
|
|
5
|
+
export class MultiplexSink implements LogSink {
|
|
6
|
+
constructor(private readonly sinks: readonly LogSink[]) {}
|
|
7
|
+
|
|
8
|
+
write(entry: LogEntry): void {
|
|
9
|
+
for (const sink of this.sinks) {
|
|
10
|
+
sink.write(entry);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let rootLogger: Logger | null = null;
|
|
16
|
+
|
|
17
|
+
export function initLoggers(projectRoot: string, sinks?: readonly LogSink[]): void {
|
|
18
|
+
const resolvedSinks = sinks ?? [createForensicSink(projectRoot)];
|
|
19
|
+
rootLogger = new BaseLogger(new MultiplexSink(resolvedSinks), { domain: "system" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getLogger(domain: string, subsystem?: string): Logger {
|
|
23
|
+
if (!rootLogger) {
|
|
24
|
+
return new BaseLogger(
|
|
25
|
+
{
|
|
26
|
+
write(entry: LogEntry): void {
|
|
27
|
+
console.log(entry.level, entry.message);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
compactMetadata(domain, subsystem),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return rootLogger.child(compactMetadata(domain, subsystem));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compactMetadata(domain: string, subsystem?: string): LogMetadata {
|
|
38
|
+
return subsystem ? { domain, subsystem } : { domain };
|
|
39
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendForensicEvent,
|
|
3
|
+
appendForensicEventForArtifactDir,
|
|
4
|
+
} from "../observability/forensic-log";
|
|
5
|
+
import type { ForensicEventDomain, ForensicEventType } from "../observability/forensic-types";
|
|
6
|
+
import type { LogEntry, LogSink } from "./types";
|
|
7
|
+
|
|
8
|
+
export function createForensicSinkForArtifactDir(artifactDir: string): LogSink {
|
|
9
|
+
return {
|
|
10
|
+
write(entry: LogEntry): void {
|
|
11
|
+
const {
|
|
12
|
+
domain,
|
|
13
|
+
operation,
|
|
14
|
+
runId,
|
|
15
|
+
sessionId,
|
|
16
|
+
parentSessionId,
|
|
17
|
+
phase,
|
|
18
|
+
dispatchId,
|
|
19
|
+
taskId,
|
|
20
|
+
agent,
|
|
21
|
+
code,
|
|
22
|
+
subsystem,
|
|
23
|
+
...payload
|
|
24
|
+
} = entry.metadata;
|
|
25
|
+
|
|
26
|
+
let forensicDomain: ForensicEventDomain = "system";
|
|
27
|
+
if (
|
|
28
|
+
domain === "session" ||
|
|
29
|
+
domain === "orchestrator" ||
|
|
30
|
+
domain === "contract" ||
|
|
31
|
+
domain === "system" ||
|
|
32
|
+
domain === "review"
|
|
33
|
+
) {
|
|
34
|
+
forensicDomain = domain;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let forensicType: ForensicEventType = "info";
|
|
38
|
+
|
|
39
|
+
if (operation && isValidForensicType(operation as string)) {
|
|
40
|
+
forensicType = operation as ForensicEventType;
|
|
41
|
+
} else {
|
|
42
|
+
switch (entry.level) {
|
|
43
|
+
case "ERROR":
|
|
44
|
+
forensicType = "error";
|
|
45
|
+
break;
|
|
46
|
+
case "WARN":
|
|
47
|
+
forensicType = "warning";
|
|
48
|
+
break;
|
|
49
|
+
case "INFO":
|
|
50
|
+
forensicType = "info";
|
|
51
|
+
break;
|
|
52
|
+
case "DEBUG":
|
|
53
|
+
forensicType = "debug";
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
appendForensicEventForArtifactDir(artifactDir, {
|
|
59
|
+
timestamp: entry.timestamp,
|
|
60
|
+
domain: forensicDomain,
|
|
61
|
+
runId: (runId as string) ?? null,
|
|
62
|
+
sessionId: (sessionId as string) ?? null,
|
|
63
|
+
parentSessionId: (parentSessionId as string) ?? null,
|
|
64
|
+
phase: (phase as string) ?? null,
|
|
65
|
+
dispatchId: (dispatchId as string) ?? null,
|
|
66
|
+
taskId: (taskId as number) ?? null,
|
|
67
|
+
agent: (agent as string) ?? null,
|
|
68
|
+
type: forensicType,
|
|
69
|
+
code: (code as string) ?? null,
|
|
70
|
+
message: entry.message,
|
|
71
|
+
payload: {
|
|
72
|
+
...payload,
|
|
73
|
+
...(subsystem ? { subsystem } : {}),
|
|
74
|
+
} as Record<string, string | number | boolean | object | readonly unknown[] | null>,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createForensicSink(projectRoot: string): LogSink {
|
|
81
|
+
return {
|
|
82
|
+
write(entry: LogEntry): void {
|
|
83
|
+
const {
|
|
84
|
+
domain,
|
|
85
|
+
operation,
|
|
86
|
+
runId,
|
|
87
|
+
sessionId,
|
|
88
|
+
parentSessionId,
|
|
89
|
+
phase,
|
|
90
|
+
dispatchId,
|
|
91
|
+
taskId,
|
|
92
|
+
agent,
|
|
93
|
+
code,
|
|
94
|
+
subsystem,
|
|
95
|
+
...payload
|
|
96
|
+
} = entry.metadata;
|
|
97
|
+
|
|
98
|
+
let forensicDomain: ForensicEventDomain = "system";
|
|
99
|
+
if (
|
|
100
|
+
domain === "session" ||
|
|
101
|
+
domain === "orchestrator" ||
|
|
102
|
+
domain === "contract" ||
|
|
103
|
+
domain === "system" ||
|
|
104
|
+
domain === "review"
|
|
105
|
+
) {
|
|
106
|
+
forensicDomain = domain;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let forensicType: ForensicEventType = "info";
|
|
110
|
+
|
|
111
|
+
if (operation && isValidForensicType(operation as string)) {
|
|
112
|
+
forensicType = operation as ForensicEventType;
|
|
113
|
+
} else {
|
|
114
|
+
switch (entry.level) {
|
|
115
|
+
case "ERROR":
|
|
116
|
+
forensicType = "error";
|
|
117
|
+
break;
|
|
118
|
+
case "WARN":
|
|
119
|
+
forensicType = "warning";
|
|
120
|
+
break;
|
|
121
|
+
case "INFO":
|
|
122
|
+
forensicType = "info";
|
|
123
|
+
break;
|
|
124
|
+
case "DEBUG":
|
|
125
|
+
forensicType = "debug";
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
appendForensicEvent(projectRoot, {
|
|
131
|
+
timestamp: entry.timestamp,
|
|
132
|
+
projectRoot,
|
|
133
|
+
domain: forensicDomain,
|
|
134
|
+
runId: (runId as string) ?? null,
|
|
135
|
+
sessionId: (sessionId as string) ?? null,
|
|
136
|
+
parentSessionId: (parentSessionId as string) ?? null,
|
|
137
|
+
phase: (phase as string) ?? null,
|
|
138
|
+
dispatchId: (dispatchId as string) ?? null,
|
|
139
|
+
taskId: (taskId as number) ?? null,
|
|
140
|
+
agent: (agent as string) ?? null,
|
|
141
|
+
type: forensicType,
|
|
142
|
+
code: (code as string) ?? null,
|
|
143
|
+
message: entry.message,
|
|
144
|
+
payload: {
|
|
145
|
+
...payload,
|
|
146
|
+
...(subsystem ? { subsystem } : {}),
|
|
147
|
+
} as Record<string, string | number | boolean | object | readonly unknown[] | null>,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isValidForensicType(type: string): boolean {
|
|
154
|
+
const validTypes = [
|
|
155
|
+
"run_started",
|
|
156
|
+
"dispatch",
|
|
157
|
+
"dispatch_multi",
|
|
158
|
+
"result_applied",
|
|
159
|
+
"phase_transition",
|
|
160
|
+
"complete",
|
|
161
|
+
"decision",
|
|
162
|
+
"error",
|
|
163
|
+
"loop_detected",
|
|
164
|
+
"failure_recorded",
|
|
165
|
+
"warning",
|
|
166
|
+
"session_start",
|
|
167
|
+
"session_end",
|
|
168
|
+
"fallback",
|
|
169
|
+
"model_switch",
|
|
170
|
+
"context_warning",
|
|
171
|
+
"tool_complete",
|
|
172
|
+
"compacted",
|
|
173
|
+
"info",
|
|
174
|
+
"debug",
|
|
175
|
+
];
|
|
176
|
+
return validTypes.includes(type);
|
|
177
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LogEntry, Logger, LogLevel, LogMetadata, LogSink } from "./types";
|
|
2
|
+
|
|
3
|
+
export class BaseLogger implements Logger {
|
|
4
|
+
constructor(
|
|
5
|
+
private readonly sink: LogSink,
|
|
6
|
+
private readonly baseMetadata: LogMetadata,
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
debug(message: string, metadata?: Partial<LogMetadata>): void {
|
|
10
|
+
this.log("DEBUG", message, metadata);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
info(message: string, metadata?: Partial<LogMetadata>): void {
|
|
14
|
+
this.log("INFO", message, metadata);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
warn(message: string, metadata?: Partial<LogMetadata>): void {
|
|
18
|
+
this.log("WARN", message, metadata);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
error(message: string, metadata?: Partial<LogMetadata>): void {
|
|
22
|
+
this.log("ERROR", message, metadata);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
child(metadata: Partial<LogMetadata>): Logger {
|
|
26
|
+
return new BaseLogger(this.sink, {
|
|
27
|
+
...this.baseMetadata,
|
|
28
|
+
...metadata,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private log(level: LogLevel, message: string, metadata?: Partial<LogMetadata>): void {
|
|
33
|
+
const entry: LogEntry = {
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
level,
|
|
36
|
+
message,
|
|
37
|
+
metadata: {
|
|
38
|
+
...this.baseMetadata,
|
|
39
|
+
...metadata,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
this.sink.write(Object.freeze(entry));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getLogger } from "./domains";
|
|
2
|
+
|
|
3
|
+
function log() {
|
|
4
|
+
return getLogger("system", "performance");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface MemorySnapshot {
|
|
8
|
+
readonly rss: number;
|
|
9
|
+
readonly heapTotal: number;
|
|
10
|
+
readonly heapUsed: number;
|
|
11
|
+
readonly external: number;
|
|
12
|
+
readonly arrayBuffers: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimerHandle {
|
|
16
|
+
stop(metadata?: Record<string, unknown>): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recordMemoryUsage(): void {
|
|
20
|
+
const mem = process.memoryUsage();
|
|
21
|
+
|
|
22
|
+
const snapshot: MemorySnapshot = {
|
|
23
|
+
rss: mem.rss,
|
|
24
|
+
heapTotal: mem.heapTotal,
|
|
25
|
+
heapUsed: mem.heapUsed,
|
|
26
|
+
external: mem.external,
|
|
27
|
+
arrayBuffers: mem.arrayBuffers,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
log().info("memory usage", {
|
|
31
|
+
operation: "memory_snapshot",
|
|
32
|
+
...snapshot,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function startTimer(operation: string): TimerHandle {
|
|
37
|
+
// performance.now() is monotonic and unaffected by system-clock adjustments
|
|
38
|
+
const startMs = performance.now();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
stop(metadata?: Record<string, unknown>): void {
|
|
42
|
+
const durationMs = performance.now() - startMs;
|
|
43
|
+
|
|
44
|
+
log().info("operation completed", {
|
|
45
|
+
operation,
|
|
46
|
+
durationMs,
|
|
47
|
+
...metadata,
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recordAgentResponseTime(agent: string, durationMs: number): void {
|
|
54
|
+
log().info("agent response time", {
|
|
55
|
+
operation: "agent_response_time",
|
|
56
|
+
agent,
|
|
57
|
+
durationMs,
|
|
58
|
+
});
|
|
59
|
+
}
|