open-multi-agent-kit 0.95.0 → 0.95.2
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/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/approvals/approval-receipt-store.d.ts +18 -0
- package/dist/approvals/approval-receipt-store.d.ts.map +1 -0
- package/dist/approvals/approval-receipt-store.js +169 -0
- package/dist/approvals/approval-receipt-store.js.map +1 -0
- package/dist/approvals/approval-receipt.d.ts +43 -0
- package/dist/approvals/approval-receipt.d.ts.map +1 -0
- package/dist/approvals/approval-receipt.js +208 -0
- package/dist/approvals/approval-receipt.js.map +1 -0
- package/dist/approvals/approval-verifier.d.ts +17 -0
- package/dist/approvals/approval-verifier.d.ts.map +1 -0
- package/dist/approvals/approval-verifier.js +23 -0
- package/dist/approvals/approval-verifier.js.map +1 -0
- package/dist/approvals/plannotator-approval-bridge.d.ts +58 -0
- package/dist/approvals/plannotator-approval-bridge.d.ts.map +1 -0
- package/dist/approvals/plannotator-approval-bridge.js +169 -0
- package/dist/approvals/plannotator-approval-bridge.js.map +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +2 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/commands/package-doctor-cli.d.ts +13 -0
- package/dist/commands/package-doctor-cli.d.ts.map +1 -0
- package/dist/commands/package-doctor-cli.js +80 -0
- package/dist/commands/package-doctor-cli.js.map +1 -0
- package/dist/commands/router-feedback-cli.d.ts +11 -0
- package/dist/commands/router-feedback-cli.d.ts.map +1 -0
- package/dist/commands/router-feedback-cli.js +99 -0
- package/dist/commands/router-feedback-cli.js.map +1 -0
- package/dist/commands/stats-cli.d.ts +20 -0
- package/dist/commands/stats-cli.d.ts.map +1 -0
- package/dist/commands/stats-cli.js +99 -0
- package/dist/commands/stats-cli.js.map +1 -0
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +4 -1
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/agent-session.d.ts +97 -29
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +583 -199
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +8 -2
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/compaction/transaction.d.ts +9 -0
- package/dist/core/compaction/transaction.d.ts.map +1 -1
- package/dist/core/compaction/transaction.js +17 -2
- package/dist/core/compaction/transaction.js.map +1 -1
- package/dist/core/context-budget-governor-v2.d.ts +1 -1
- package/dist/core/context-budget-governor-v2.d.ts.map +1 -1
- package/dist/core/context-budget-governor-v2.js +1 -1
- package/dist/core/context-budget-governor-v2.js.map +1 -1
- package/dist/core/context-budget-system-prompt.d.ts.map +1 -1
- package/dist/core/context-budget-system-prompt.js +18 -2
- package/dist/core/context-budget-system-prompt.js.map +1 -1
- package/dist/core/context-budget-v2-cache-disk.d.ts +109 -0
- package/dist/core/context-budget-v2-cache-disk.d.ts.map +1 -0
- package/dist/core/context-budget-v2-cache-disk.js +343 -0
- package/dist/core/context-budget-v2-cache-disk.js.map +1 -0
- package/dist/core/context-budget-v2-cache-keys.d.ts +22 -0
- package/dist/core/context-budget-v2-cache-keys.d.ts.map +1 -1
- package/dist/core/context-budget-v2-cache-keys.js +4 -4
- package/dist/core/context-budget-v2-cache-keys.js.map +1 -1
- package/dist/core/context-budget-v2-cache.d.ts +1 -0
- package/dist/core/context-budget-v2-cache.d.ts.map +1 -1
- package/dist/core/context-budget-v2-cache.js +1 -0
- package/dist/core/context-budget-v2-cache.js.map +1 -1
- package/dist/core/context-budget-v2-plan-cache-keys.d.ts.map +1 -1
- package/dist/core/context-budget-v2-plan-cache-keys.js +39 -5
- package/dist/core/context-budget-v2-plan-cache-keys.js.map +1 -1
- package/dist/core/context-budget-v2-planner.d.ts.map +1 -1
- package/dist/core/context-budget-v2-planner.js +17 -14
- package/dist/core/context-budget-v2-planner.js.map +1 -1
- package/dist/core/context-budget-v2-types.d.ts +1 -0
- package/dist/core/context-budget-v2-types.d.ts.map +1 -1
- package/dist/core/context-budget-v2-types.js.map +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +45 -22
- package/dist/core/extensions/loader.js.bak-sharedjiti-20260812-111246 +553 -0
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +4 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/headroom.d.ts.map +1 -1
- package/dist/core/headroom.js +7 -2
- package/dist/core/headroom.js.map +1 -1
- package/dist/core/mcp/client.d.ts +105 -0
- package/dist/core/mcp/client.d.ts.map +1 -0
- package/dist/core/mcp/client.js +198 -0
- package/dist/core/mcp/client.js.map +1 -0
- package/dist/core/mcp/config.d.ts +23 -0
- package/dist/core/mcp/config.d.ts.map +1 -0
- package/dist/core/mcp/config.js +89 -0
- package/dist/core/mcp/config.js.map +1 -0
- package/dist/core/mcp/index.d.ts +15 -0
- package/dist/core/mcp/index.d.ts.map +1 -0
- package/dist/core/mcp/index.js +15 -0
- package/dist/core/mcp/index.js.map +1 -0
- package/dist/core/mcp/manager.d.ts +84 -0
- package/dist/core/mcp/manager.d.ts.map +1 -0
- package/dist/core/mcp/manager.js +173 -0
- package/dist/core/mcp/manager.js.map +1 -0
- package/dist/core/mcp/protocol.d.ts +59 -0
- package/dist/core/mcp/protocol.d.ts.map +1 -0
- package/dist/core/mcp/protocol.js +103 -0
- package/dist/core/mcp/protocol.js.map +1 -0
- package/dist/core/mcp/stdio-transport.d.ts +63 -0
- package/dist/core/mcp/stdio-transport.d.ts.map +1 -0
- package/dist/core/mcp/stdio-transport.js +132 -0
- package/dist/core/mcp/stdio-transport.js.map +1 -0
- package/dist/core/mcp/tools.d.ts +47 -0
- package/dist/core/mcp/tools.d.ts.map +1 -0
- package/dist/core/mcp/tools.js +102 -0
- package/dist/core/mcp/tools.js.map +1 -0
- package/dist/core/package-archive.d.ts +3 -0
- package/dist/core/package-archive.d.ts.map +1 -0
- package/dist/core/package-archive.js +161 -0
- package/dist/core/package-archive.js.map +1 -0
- package/dist/core/package-doctor-source-scan.d.ts +13 -0
- package/dist/core/package-doctor-source-scan.d.ts.map +1 -0
- package/dist/core/package-doctor-source-scan.js +124 -0
- package/dist/core/package-doctor-source-scan.js.map +1 -0
- package/dist/core/package-doctor.d.ts +41 -0
- package/dist/core/package-doctor.d.ts.map +1 -0
- package/dist/core/package-doctor.js +145 -0
- package/dist/core/package-doctor.js.map +1 -0
- package/dist/core/package-manager.d.ts +10 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +175 -42
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/package-manifest.d.ts +26 -0
- package/dist/core/package-manifest.d.ts.map +1 -0
- package/dist/core/package-manifest.js +85 -0
- package/dist/core/package-manifest.js.map +1 -0
- package/dist/core/pi-compat.d.ts +5 -0
- package/dist/core/pi-compat.d.ts.map +1 -0
- package/dist/core/pi-compat.js +18 -0
- package/dist/core/pi-compat.js.map +1 -0
- package/dist/core/reasoning-router-bias.d.ts +10 -14
- package/dist/core/reasoning-router-bias.d.ts.map +1 -1
- package/dist/core/reasoning-router-bias.js +22 -15
- package/dist/core/reasoning-router-bias.js.map +1 -1
- package/dist/core/reasoning-router-v4.d.ts +9 -1
- package/dist/core/reasoning-router-v4.d.ts.map +1 -1
- package/dist/core/reasoning-router-v4.js +20 -5
- package/dist/core/reasoning-router-v4.js.map +1 -1
- package/dist/core/redaction.d.ts +2 -5
- package/dist/core/redaction.d.ts.map +1 -1
- package/dist/core/redaction.js +14 -1
- package/dist/core/redaction.js.map +1 -1
- package/dist/core/repository-learning-scope.d.ts +10 -0
- package/dist/core/repository-learning-scope.d.ts.map +1 -0
- package/dist/core/repository-learning-scope.js +42 -0
- package/dist/core/repository-learning-scope.js.map +1 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +16 -1
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/router-feedback-collector.d.ts +2 -1
- package/dist/core/router-feedback-collector.d.ts.map +1 -1
- package/dist/core/router-feedback-collector.js +2 -2
- package/dist/core/router-feedback-collector.js.map +1 -1
- package/dist/core/runtime-provenance.d.ts +54 -0
- package/dist/core/runtime-provenance.d.ts.map +1 -0
- package/dist/core/runtime-provenance.js +115 -0
- package/dist/core/runtime-provenance.js.map +1 -0
- package/dist/core/sandbox/backend.d.ts.map +1 -1
- package/dist/core/sandbox/backend.js +39 -21
- package/dist/core/sandbox/backend.js.map +1 -1
- package/dist/core/sandbox/default-policy.d.ts +4 -5
- package/dist/core/sandbox/default-policy.d.ts.map +1 -1
- package/dist/core/sandbox/default-policy.js +9 -11
- package/dist/core/sandbox/default-policy.js.map +1 -1
- package/dist/core/session-bash-runtime.d.ts +4 -4
- package/dist/core/session-bash-runtime.d.ts.map +1 -1
- package/dist/core/session-bash-runtime.js +6 -5
- package/dist/core/session-bash-runtime.js.map +1 -1
- package/dist/core/session-compaction-service.d.ts.map +1 -1
- package/dist/core/session-compaction-service.js +7 -7
- package/dist/core/session-compaction-service.js.map +1 -1
- package/dist/core/session-manager.d.ts +4 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +47 -7
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +7 -4
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +11 -3
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts +9 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +10 -4
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/bash.d.ts +2 -2
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +2 -1
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.js +15 -0
- package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
- package/dist/core/turn-metrics.d.ts +135 -0
- package/dist/core/turn-metrics.d.ts.map +1 -0
- package/dist/core/turn-metrics.js +261 -0
- package/dist/core/turn-metrics.js.map +1 -0
- package/dist/core/verified-bash-runtime.d.ts.map +1 -1
- package/dist/core/verified-bash-runtime.js +10 -4
- package/dist/core/verified-bash-runtime.js.map +1 -1
- package/dist/guardrails/replay-ledger-lock-owner.d.ts +7 -0
- package/dist/guardrails/replay-ledger-lock-owner.d.ts.map +1 -1
- package/dist/guardrails/replay-ledger-lock-owner.js +39 -0
- package/dist/guardrails/replay-ledger-lock-owner.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/control-panel-layout.d.ts.map +1 -1
- package/dist/modes/interactive/components/control-panel-layout.js +2 -2
- package/dist/modes/interactive/components/control-panel-layout.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +3 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +40 -12
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +10 -0
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/status-sidebar.d.ts +14 -2
- package/dist/modes/interactive/components/status-sidebar.d.ts.map +1 -1
- package/dist/modes/interactive/components/status-sidebar.js +106 -19
- package/dist/modes/interactive/components/status-sidebar.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +10 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +104 -17
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/dark.json +1 -1
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +1 -0
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +1 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/docs/compaction.md +25 -3
- package/docs/containerization.md +14 -2
- package/docs/docs.json +8 -0
- package/docs/environment-variables.md +1 -1
- package/docs/extensions.md +75 -16
- package/docs/mcp.md +97 -0
- package/docs/metrics.md +87 -0
- package/docs/packages.md +25 -4
- package/docs/providers.md +1 -1
- package/docs/sdk.md +25 -4
- package/docs/settings.md +8 -7
- package/docs/usage.md +13 -3
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/plannotator-approval-bridge.ts +83 -0
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/subagent/README.md +21 -11
- package/examples/extensions/subagent/adaptive-agent-runtime.ts +9 -3
- package/examples/extensions/subagent/agents/planner.md +1 -3
- package/examples/extensions/subagent/agents/reviewer.md +1 -3
- package/examples/extensions/subagent/agents/scout.md +1 -3
- package/examples/extensions/subagent/agents/worker.md +0 -1
- package/examples/extensions/subagent/agents.test.ts +9 -0
- package/examples/extensions/subagent/agents.ts +11 -4
- package/examples/extensions/subagent/deadline-budget.test.ts +32 -0
- package/examples/extensions/subagent/deadline-budget.ts +25 -1
- package/examples/extensions/subagent/fixtures/fake-omk-json.mjs +10 -0
- package/examples/extensions/subagent/index.ts +234 -55
- package/examples/extensions/subagent/subagent-execution-policy.test.ts +27 -0
- package/examples/extensions/subagent/subagent-extension-smoke.test.ts +167 -2
- package/examples/extensions/subagent/workflow-graph.test.ts +80 -0
- package/examples/extensions/subagent/workflow-graph.ts +89 -0
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +253 -439
- package/package.json +8 -8
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn runtime metrics.
|
|
3
|
+
*
|
|
4
|
+
* Before this, `telemetry.ts` covered install opt-in and nothing else, and
|
|
5
|
+
* `run-journal.ts` recorded a hash-chained *integrity* log (run started /
|
|
6
|
+
* finished / tool timeout). Neither could answer the questions that actually
|
|
7
|
+
* drive harness work: which tools fail, how long they take, what a turn costs,
|
|
8
|
+
* and whether the context cache is doing anything.
|
|
9
|
+
*
|
|
10
|
+
* Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.
|
|
11
|
+
* Append-only, bounded by size with a single rotation, and never load-bearing —
|
|
12
|
+
* a failed write is dropped, never surfaced as a turn error.
|
|
13
|
+
*
|
|
14
|
+
* Privacy: records counts, durations, ids, and error *classes*. It never
|
|
15
|
+
* records prompt text, tool arguments, tool output, or file contents. Tool
|
|
16
|
+
* error strings are truncated and kept only to distinguish failure modes.
|
|
17
|
+
*/
|
|
18
|
+
import { type RuntimeProvenance } from "./runtime-provenance.ts";
|
|
19
|
+
export declare const TURN_METRICS_SCHEMA_VERSION: "omk-turn-metrics-1";
|
|
20
|
+
/** Rotate once the active file passes this size. */
|
|
21
|
+
export declare const DEFAULT_MAX_METRICS_BYTES: number;
|
|
22
|
+
/** Cap on a retained error string. Enough to classify, too short to carry a payload. */
|
|
23
|
+
export declare const MAX_ERROR_CHARS = 200;
|
|
24
|
+
export interface ToolCallMetric {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly durationMs: number;
|
|
27
|
+
readonly ok: boolean;
|
|
28
|
+
/** Truncated failure text, present only when `ok` is false. */
|
|
29
|
+
readonly error?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface TurnUsageMetric {
|
|
32
|
+
readonly input: number;
|
|
33
|
+
readonly output: number;
|
|
34
|
+
readonly cacheRead: number;
|
|
35
|
+
readonly cacheWrite: number;
|
|
36
|
+
readonly costUsd: number;
|
|
37
|
+
}
|
|
38
|
+
export interface TurnMetricInput {
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
readonly turnIndex: number;
|
|
41
|
+
readonly provider?: string;
|
|
42
|
+
readonly model?: string;
|
|
43
|
+
readonly startedAtEpochMs: number;
|
|
44
|
+
readonly endedAtEpochMs: number;
|
|
45
|
+
/** Time to the first streamed assistant chunk, when observed. */
|
|
46
|
+
readonly timeToFirstChunkMs?: number;
|
|
47
|
+
readonly usage?: TurnUsageMetric;
|
|
48
|
+
readonly stopReason?: string;
|
|
49
|
+
readonly toolCalls?: readonly ToolCallMetric[];
|
|
50
|
+
/** Whether compaction ran during this turn. */
|
|
51
|
+
readonly compacted?: boolean;
|
|
52
|
+
/** Provider failover attempts made during this turn. */
|
|
53
|
+
readonly failovers?: number;
|
|
54
|
+
/** Context-budget cache outcome for the turn's prompt build. */
|
|
55
|
+
readonly contextCache?: {
|
|
56
|
+
readonly planHit: boolean;
|
|
57
|
+
readonly hits: number;
|
|
58
|
+
readonly misses: number;
|
|
59
|
+
};
|
|
60
|
+
/** Requested/selected/response model and thinking projection. Advisory metadata only. */
|
|
61
|
+
readonly runtimeProvenance?: RuntimeProvenance;
|
|
62
|
+
}
|
|
63
|
+
export interface TurnMetricRecord extends TurnMetricInput {
|
|
64
|
+
readonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;
|
|
65
|
+
readonly durationMs: number;
|
|
66
|
+
readonly toolCallCount: number;
|
|
67
|
+
readonly toolFailureCount: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the record written for one turn. Pure: no clock, no filesystem, so the
|
|
71
|
+
* derived fields can be asserted directly.
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord;
|
|
74
|
+
export interface TurnMetricsSinkOptions {
|
|
75
|
+
readonly dir: string;
|
|
76
|
+
readonly maxBytes?: number;
|
|
77
|
+
readonly fileName?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Append-only JSONL sink. Writes are synchronous and best-effort: metrics must
|
|
81
|
+
* never be the reason a turn fails, so every filesystem error is swallowed and
|
|
82
|
+
* counted instead of thrown.
|
|
83
|
+
*/
|
|
84
|
+
export declare class TurnMetricsSink {
|
|
85
|
+
private readonly filePath;
|
|
86
|
+
private readonly maxBytes;
|
|
87
|
+
private dropped;
|
|
88
|
+
private written;
|
|
89
|
+
constructor(options: TurnMetricsSinkOptions);
|
|
90
|
+
get path(): string;
|
|
91
|
+
/** Records written successfully this process. */
|
|
92
|
+
get writtenCount(): number;
|
|
93
|
+
/** Records lost to a filesystem error this process. */
|
|
94
|
+
get droppedCount(): number;
|
|
95
|
+
/** Append one turn. Returns `true` when the line reached disk. */
|
|
96
|
+
record(input: TurnMetricInput): boolean;
|
|
97
|
+
private rotateIfNeeded;
|
|
98
|
+
}
|
|
99
|
+
export interface ToolAggregate {
|
|
100
|
+
readonly name: string;
|
|
101
|
+
readonly calls: number;
|
|
102
|
+
readonly failures: number;
|
|
103
|
+
readonly failureRate: number;
|
|
104
|
+
readonly p50Ms: number;
|
|
105
|
+
readonly p95Ms: number;
|
|
106
|
+
readonly totalMs: number;
|
|
107
|
+
}
|
|
108
|
+
export interface TurnMetricsSummary {
|
|
109
|
+
readonly turns: number;
|
|
110
|
+
readonly sessions: number;
|
|
111
|
+
readonly models: readonly string[];
|
|
112
|
+
readonly totalCostUsd: number;
|
|
113
|
+
readonly totals: TurnUsageMetric;
|
|
114
|
+
readonly turnDurationP50Ms: number;
|
|
115
|
+
readonly turnDurationP95Ms: number;
|
|
116
|
+
readonly timeToFirstChunkP50Ms?: number;
|
|
117
|
+
readonly cacheReadShare: number;
|
|
118
|
+
readonly compactions: number;
|
|
119
|
+
readonly failovers: number;
|
|
120
|
+
readonly contextCachePlanHitRate?: number;
|
|
121
|
+
/** Turns where a valid projection's response model differs from the selected model. */
|
|
122
|
+
readonly provenanceMismatchTurns: number;
|
|
123
|
+
readonly tools: readonly ToolAggregate[];
|
|
124
|
+
/** Lines that could not be parsed as a metric record. */
|
|
125
|
+
readonly malformedLines: number;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Aggregate JSONL lines into a summary. Malformed lines are counted rather than
|
|
129
|
+
* discarded silently, so a truncated tail is visible instead of pretending the
|
|
130
|
+
* data is complete.
|
|
131
|
+
*/
|
|
132
|
+
export declare function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary;
|
|
133
|
+
/** Read and summarize a metrics file. A missing file summarizes as zero turns. */
|
|
134
|
+
export declare function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary;
|
|
135
|
+
//# sourceMappingURL=turn-metrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-metrics.d.ts","sourceRoot":"","sources":["../../src/core/turn-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-turn runtime metrics.\n *\n * Before this, `telemetry.ts` covered install opt-in and nothing else, and\n * `run-journal.ts` recorded a hash-chained *integrity* log (run started /\n * finished / tool timeout). Neither could answer the questions that actually\n * drive harness work: which tools fail, how long they take, what a turn costs,\n * and whether the context cache is doing anything.\n *\n * Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.\n * Append-only, bounded by size with a single rotation, and never load-bearing —\n * a failed write is dropped, never surfaced as a turn error.\n *\n * Privacy: records counts, durations, ids, and error *classes*. It never\n * records prompt text, tool arguments, tool output, or file contents. Tool\n * error strings are truncated and kept only to distinguish failure modes.\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { parseRuntimeProvenance, type RuntimeProvenance } from \"./runtime-provenance.ts\";\n\nexport const TURN_METRICS_SCHEMA_VERSION = \"omk-turn-metrics-1\" as const;\n/** Rotate once the active file passes this size. */\nexport const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;\n/** Cap on a retained error string. Enough to classify, too short to carry a payload. */\nexport const MAX_ERROR_CHARS = 200;\n\nexport interface ToolCallMetric {\n\treadonly name: string;\n\treadonly durationMs: number;\n\treadonly ok: boolean;\n\t/** Truncated failure text, present only when `ok` is false. */\n\treadonly error?: string;\n}\n\nexport interface TurnUsageMetric {\n\treadonly input: number;\n\treadonly output: number;\n\treadonly cacheRead: number;\n\treadonly cacheWrite: number;\n\treadonly costUsd: number;\n}\n\nexport interface TurnMetricInput {\n\treadonly sessionId: string;\n\treadonly turnIndex: number;\n\treadonly provider?: string;\n\treadonly model?: string;\n\treadonly startedAtEpochMs: number;\n\treadonly endedAtEpochMs: number;\n\t/** Time to the first streamed assistant chunk, when observed. */\n\treadonly timeToFirstChunkMs?: number;\n\treadonly usage?: TurnUsageMetric;\n\treadonly stopReason?: string;\n\treadonly toolCalls?: readonly ToolCallMetric[];\n\t/** Whether compaction ran during this turn. */\n\treadonly compacted?: boolean;\n\t/** Provider failover attempts made during this turn. */\n\treadonly failovers?: number;\n\t/** Context-budget cache outcome for the turn's prompt build. */\n\treadonly contextCache?: { readonly planHit: boolean; readonly hits: number; readonly misses: number };\n\t/** Requested/selected/response model and thinking projection. Advisory metadata only. */\n\treadonly runtimeProvenance?: RuntimeProvenance;\n}\n\nexport interface TurnMetricRecord extends TurnMetricInput {\n\treadonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;\n\treadonly durationMs: number;\n\treadonly toolCallCount: number;\n\treadonly toolFailureCount: number;\n}\n\nfunction truncateError(value: string | undefined): string | undefined {\n\tif (value === undefined) return undefined;\n\tconst collapsed = value.replace(/\\s+/gu, \" \").trim();\n\tif (collapsed.length === 0) return undefined;\n\treturn collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\\u2026` : collapsed;\n}\n\n/**\n * Build the record written for one turn. Pure: no clock, no filesystem, so the\n * derived fields can be asserted directly.\n */\nexport function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord {\n\tconst toolCalls = (input.toolCalls ?? []).map((call) => ({\n\t\tname: call.name,\n\t\tdurationMs: Math.max(0, Math.round(call.durationMs)),\n\t\tok: call.ok,\n\t\t...(call.ok ? {} : { error: truncateError(call.error) }),\n\t}));\n\tconst runtimeProvenance =\n\t\tinput.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);\n\t// Spread the raw input minus its projection: a validated projection is re-added\n\t// below, an invalid one is dropped rather than persisted.\n\tconst { runtimeProvenance: _rawProvenance, ...rest } = input;\n\treturn {\n\t\t...rest,\n\t\tschemaVersion: TURN_METRICS_SCHEMA_VERSION,\n\t\tdurationMs: Math.max(0, input.endedAtEpochMs - input.startedAtEpochMs),\n\t\ttoolCalls,\n\t\ttoolCallCount: toolCalls.length,\n\t\ttoolFailureCount: toolCalls.filter((call) => !call.ok).length,\n\t\t...(runtimeProvenance === null || runtimeProvenance === undefined ? {} : { runtimeProvenance }),\n\t};\n}\n\nexport interface TurnMetricsSinkOptions {\n\treadonly dir: string;\n\treadonly maxBytes?: number;\n\treadonly fileName?: string;\n}\n\n/**\n * Append-only JSONL sink. Writes are synchronous and best-effort: metrics must\n * never be the reason a turn fails, so every filesystem error is swallowed and\n * counted instead of thrown.\n */\nexport class TurnMetricsSink {\n\tprivate readonly filePath: string;\n\tprivate readonly maxBytes: number;\n\tprivate dropped = 0;\n\tprivate written = 0;\n\n\tconstructor(options: TurnMetricsSinkOptions) {\n\t\tthis.filePath = path.join(options.dir, options.fileName ?? \"turns.jsonl\");\n\t\tthis.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);\n\t}\n\n\tget path(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** Records written successfully this process. */\n\tget writtenCount(): number {\n\t\treturn this.written;\n\t}\n\n\t/** Records lost to a filesystem error this process. */\n\tget droppedCount(): number {\n\t\treturn this.dropped;\n\t}\n\n\t/** Append one turn. Returns `true` when the line reached disk. */\n\trecord(input: TurnMetricInput): boolean {\n\t\tlet line: string;\n\t\ttry {\n\t\t\tline = `${JSON.stringify(buildTurnMetricRecord(input))}\\n`;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tfs.mkdirSync(path.dirname(this.filePath), { recursive: true });\n\t\t\tthis.rotateIfNeeded(Buffer.byteLength(line, \"utf8\"));\n\t\t\tfs.appendFileSync(this.filePath, line, { encoding: \"utf8\", mode: 0o600 });\n\t\t\tthis.written++;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rotateIfNeeded(incomingBytes: number): void {\n\t\tlet size: number;\n\t\ttry {\n\t\t\tsize = fs.statSync(this.filePath).size;\n\t\t} catch {\n\t\t\treturn; // No file yet.\n\t\t}\n\t\tif (size + incomingBytes <= this.maxBytes) return;\n\t\ttry {\n\t\t\t// Single generation: the previous file is replaced, so disk use stays bounded at 2x.\n\t\t\tfs.renameSync(this.filePath, `${this.filePath}.1`);\n\t\t} catch {\n\t\t\t// Rotation failure is not fatal; the next append still succeeds.\n\t\t}\n\t}\n}\n\nexport interface ToolAggregate {\n\treadonly name: string;\n\treadonly calls: number;\n\treadonly failures: number;\n\treadonly failureRate: number;\n\treadonly p50Ms: number;\n\treadonly p95Ms: number;\n\treadonly totalMs: number;\n}\n\nexport interface TurnMetricsSummary {\n\treadonly turns: number;\n\treadonly sessions: number;\n\treadonly models: readonly string[];\n\treadonly totalCostUsd: number;\n\treadonly totals: TurnUsageMetric;\n\treadonly turnDurationP50Ms: number;\n\treadonly turnDurationP95Ms: number;\n\treadonly timeToFirstChunkP50Ms?: number;\n\treadonly cacheReadShare: number;\n\treadonly compactions: number;\n\treadonly failovers: number;\n\treadonly contextCachePlanHitRate?: number;\n\t/** Turns where a valid projection's response model differs from the selected model. */\n\treadonly provenanceMismatchTurns: number;\n\treadonly tools: readonly ToolAggregate[];\n\t/** Lines that could not be parsed as a metric record. */\n\treadonly malformedLines: number;\n}\n\nfunction percentile(sorted: readonly number[], fraction: number): number {\n\tif (sorted.length === 0) return 0;\n\tconst index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));\n\treturn sorted[index];\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction parseRecord(line: string): TurnMetricRecord | undefined {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(line);\n\t} catch {\n\t\treturn undefined;\n\t}\n\tif (!isRecord(parsed) || parsed.schemaVersion !== TURN_METRICS_SCHEMA_VERSION) return undefined;\n\tif (typeof parsed.turnIndex !== \"number\" || typeof parsed.durationMs !== \"number\") return undefined;\n\treturn parsed as unknown as TurnMetricRecord;\n}\n\n/**\n * Aggregate JSONL lines into a summary. Malformed lines are counted rather than\n * discarded silently, so a truncated tail is visible instead of pretending the\n * data is complete.\n */\nexport function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary {\n\tconst records: TurnMetricRecord[] = [];\n\tlet malformedLines = 0;\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (trimmed.length === 0) continue;\n\t\tconst record = parseRecord(trimmed);\n\t\tif (record) records.push(record);\n\t\telse malformedLines++;\n\t}\n\n\tconst sessions = new Set<string>();\n\tconst models = new Set<string>();\n\tconst totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };\n\tconst turnDurations: number[] = [];\n\tconst firstChunkTimes: number[] = [];\n\tconst toolStats = new Map<string, { durations: number[]; failures: number }>();\n\tlet compactions = 0;\n\tlet failovers = 0;\n\tlet planHits = 0;\n\tlet planObservations = 0;\n\tlet provenanceMismatchTurns = 0;\n\n\tfor (const record of records) {\n\t\tsessions.add(record.sessionId);\n\t\tif (record.model) models.add(record.provider ? `${record.provider}/${record.model}` : record.model);\n\t\tif (record.usage) {\n\t\t\ttotals.input += record.usage.input;\n\t\t\ttotals.output += record.usage.output;\n\t\t\ttotals.cacheRead += record.usage.cacheRead;\n\t\t\ttotals.cacheWrite += record.usage.cacheWrite;\n\t\t\ttotals.costUsd += record.usage.costUsd;\n\t\t}\n\t\tturnDurations.push(record.durationMs);\n\t\tif (typeof record.timeToFirstChunkMs === \"number\") firstChunkTimes.push(record.timeToFirstChunkMs);\n\t\tif (record.compacted) compactions++;\n\t\tfailovers += record.failovers ?? 0;\n\t\tif (record.contextCache) {\n\t\t\tplanObservations++;\n\t\t\tif (record.contextCache.planHit) planHits++;\n\t\t}\n\t\t// Re-validate provenance at read time: a tampered projection never counts.\n\t\tconst provenance =\n\t\t\trecord.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(record.runtimeProvenance);\n\t\tif (\n\t\t\tprovenance !== undefined &&\n\t\t\tprovenance !== null &&\n\t\t\tprovenance.responseModel !== null &&\n\t\t\tprovenance.responseModel !== provenance.selectedModel\n\t\t) {\n\t\t\tprovenanceMismatchTurns++;\n\t\t}\n\t\tfor (const call of record.toolCalls ?? []) {\n\t\t\tconst stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };\n\t\t\tstats.durations.push(call.durationMs);\n\t\t\tif (!call.ok) stats.failures++;\n\t\t\ttoolStats.set(call.name, stats);\n\t\t}\n\t}\n\n\tturnDurations.sort((a, b) => a - b);\n\tfirstChunkTimes.sort((a, b) => a - b);\n\n\tconst tools: ToolAggregate[] = [...toolStats.entries()]\n\t\t.map(([name, stats]) => {\n\t\t\tconst sorted = [...stats.durations].sort((a, b) => a - b);\n\t\t\treturn {\n\t\t\t\tname,\n\t\t\t\tcalls: sorted.length,\n\t\t\t\tfailures: stats.failures,\n\t\t\t\tfailureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,\n\t\t\t\tp50Ms: percentile(sorted, 0.5),\n\t\t\t\tp95Ms: percentile(sorted, 0.95),\n\t\t\t\ttotalMs: sorted.reduce((sum, value) => sum + value, 0),\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));\n\n\tconst promptSideTotal = totals.input + totals.cacheRead;\n\treturn {\n\t\tturns: records.length,\n\t\tsessions: sessions.size,\n\t\tmodels: [...models].sort(),\n\t\ttotalCostUsd: totals.costUsd,\n\t\ttotals,\n\t\tturnDurationP50Ms: percentile(turnDurations, 0.5),\n\t\tturnDurationP95Ms: percentile(turnDurations, 0.95),\n\t\ttimeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,\n\t\tcacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,\n\t\tcompactions,\n\t\tfailovers,\n\t\tcontextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,\n\t\tprovenanceMismatchTurns,\n\t\ttools,\n\t\tmalformedLines,\n\t};\n}\n\n/** Read and summarize a metrics file. A missing file summarizes as zero turns. */\nexport function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary {\n\tlet raw: string;\n\ttry {\n\t\traw = fs.readFileSync(filePath, \"utf8\");\n\t} catch {\n\t\treturn summarizeTurnMetrics([]);\n\t}\n\treturn summarizeTurnMetrics(raw.split(\"\\n\"));\n}\n"]}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn runtime metrics.
|
|
3
|
+
*
|
|
4
|
+
* Before this, `telemetry.ts` covered install opt-in and nothing else, and
|
|
5
|
+
* `run-journal.ts` recorded a hash-chained *integrity* log (run started /
|
|
6
|
+
* finished / tool timeout). Neither could answer the questions that actually
|
|
7
|
+
* drive harness work: which tools fail, how long they take, what a turn costs,
|
|
8
|
+
* and whether the context cache is doing anything.
|
|
9
|
+
*
|
|
10
|
+
* Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.
|
|
11
|
+
* Append-only, bounded by size with a single rotation, and never load-bearing —
|
|
12
|
+
* a failed write is dropped, never surfaced as a turn error.
|
|
13
|
+
*
|
|
14
|
+
* Privacy: records counts, durations, ids, and error *classes*. It never
|
|
15
|
+
* records prompt text, tool arguments, tool output, or file contents. Tool
|
|
16
|
+
* error strings are truncated and kept only to distinguish failure modes.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { parseRuntimeProvenance } from "./runtime-provenance.js";
|
|
21
|
+
export const TURN_METRICS_SCHEMA_VERSION = "omk-turn-metrics-1";
|
|
22
|
+
/** Rotate once the active file passes this size. */
|
|
23
|
+
export const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
/** Cap on a retained error string. Enough to classify, too short to carry a payload. */
|
|
25
|
+
export const MAX_ERROR_CHARS = 200;
|
|
26
|
+
function truncateError(value) {
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
30
|
+
if (collapsed.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
return collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\u2026` : collapsed;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the record written for one turn. Pure: no clock, no filesystem, so the
|
|
36
|
+
* derived fields can be asserted directly.
|
|
37
|
+
*/
|
|
38
|
+
export function buildTurnMetricRecord(input) {
|
|
39
|
+
const toolCalls = (input.toolCalls ?? []).map((call) => ({
|
|
40
|
+
name: call.name,
|
|
41
|
+
durationMs: Math.max(0, Math.round(call.durationMs)),
|
|
42
|
+
ok: call.ok,
|
|
43
|
+
...(call.ok ? {} : { error: truncateError(call.error) }),
|
|
44
|
+
}));
|
|
45
|
+
const runtimeProvenance = input.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);
|
|
46
|
+
// Spread the raw input minus its projection: a validated projection is re-added
|
|
47
|
+
// below, an invalid one is dropped rather than persisted.
|
|
48
|
+
const { runtimeProvenance: _rawProvenance, ...rest } = input;
|
|
49
|
+
return {
|
|
50
|
+
...rest,
|
|
51
|
+
schemaVersion: TURN_METRICS_SCHEMA_VERSION,
|
|
52
|
+
durationMs: Math.max(0, input.endedAtEpochMs - input.startedAtEpochMs),
|
|
53
|
+
toolCalls,
|
|
54
|
+
toolCallCount: toolCalls.length,
|
|
55
|
+
toolFailureCount: toolCalls.filter((call) => !call.ok).length,
|
|
56
|
+
...(runtimeProvenance === null || runtimeProvenance === undefined ? {} : { runtimeProvenance }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Append-only JSONL sink. Writes are synchronous and best-effort: metrics must
|
|
61
|
+
* never be the reason a turn fails, so every filesystem error is swallowed and
|
|
62
|
+
* counted instead of thrown.
|
|
63
|
+
*/
|
|
64
|
+
export class TurnMetricsSink {
|
|
65
|
+
filePath;
|
|
66
|
+
maxBytes;
|
|
67
|
+
dropped = 0;
|
|
68
|
+
written = 0;
|
|
69
|
+
constructor(options) {
|
|
70
|
+
this.filePath = path.join(options.dir, options.fileName ?? "turns.jsonl");
|
|
71
|
+
this.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);
|
|
72
|
+
}
|
|
73
|
+
get path() {
|
|
74
|
+
return this.filePath;
|
|
75
|
+
}
|
|
76
|
+
/** Records written successfully this process. */
|
|
77
|
+
get writtenCount() {
|
|
78
|
+
return this.written;
|
|
79
|
+
}
|
|
80
|
+
/** Records lost to a filesystem error this process. */
|
|
81
|
+
get droppedCount() {
|
|
82
|
+
return this.dropped;
|
|
83
|
+
}
|
|
84
|
+
/** Append one turn. Returns `true` when the line reached disk. */
|
|
85
|
+
record(input) {
|
|
86
|
+
let line;
|
|
87
|
+
try {
|
|
88
|
+
line = `${JSON.stringify(buildTurnMetricRecord(input))}\n`;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
this.dropped++;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
96
|
+
this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
97
|
+
fs.appendFileSync(this.filePath, line, { encoding: "utf8", mode: 0o600 });
|
|
98
|
+
this.written++;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.dropped++;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
rotateIfNeeded(incomingBytes) {
|
|
107
|
+
let size;
|
|
108
|
+
try {
|
|
109
|
+
size = fs.statSync(this.filePath).size;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return; // No file yet.
|
|
113
|
+
}
|
|
114
|
+
if (size + incomingBytes <= this.maxBytes)
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
// Single generation: the previous file is replaced, so disk use stays bounded at 2x.
|
|
118
|
+
fs.renameSync(this.filePath, `${this.filePath}.1`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Rotation failure is not fatal; the next append still succeeds.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function percentile(sorted, fraction) {
|
|
126
|
+
if (sorted.length === 0)
|
|
127
|
+
return 0;
|
|
128
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
|
|
129
|
+
return sorted[index];
|
|
130
|
+
}
|
|
131
|
+
function isRecord(value) {
|
|
132
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
function parseRecord(line) {
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(line);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (!isRecord(parsed) || parsed.schemaVersion !== TURN_METRICS_SCHEMA_VERSION)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (typeof parsed.turnIndex !== "number" || typeof parsed.durationMs !== "number")
|
|
145
|
+
return undefined;
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Aggregate JSONL lines into a summary. Malformed lines are counted rather than
|
|
150
|
+
* discarded silently, so a truncated tail is visible instead of pretending the
|
|
151
|
+
* data is complete.
|
|
152
|
+
*/
|
|
153
|
+
export function summarizeTurnMetrics(lines) {
|
|
154
|
+
const records = [];
|
|
155
|
+
let malformedLines = 0;
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (trimmed.length === 0)
|
|
159
|
+
continue;
|
|
160
|
+
const record = parseRecord(trimmed);
|
|
161
|
+
if (record)
|
|
162
|
+
records.push(record);
|
|
163
|
+
else
|
|
164
|
+
malformedLines++;
|
|
165
|
+
}
|
|
166
|
+
const sessions = new Set();
|
|
167
|
+
const models = new Set();
|
|
168
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
|
|
169
|
+
const turnDurations = [];
|
|
170
|
+
const firstChunkTimes = [];
|
|
171
|
+
const toolStats = new Map();
|
|
172
|
+
let compactions = 0;
|
|
173
|
+
let failovers = 0;
|
|
174
|
+
let planHits = 0;
|
|
175
|
+
let planObservations = 0;
|
|
176
|
+
let provenanceMismatchTurns = 0;
|
|
177
|
+
for (const record of records) {
|
|
178
|
+
sessions.add(record.sessionId);
|
|
179
|
+
if (record.model)
|
|
180
|
+
models.add(record.provider ? `${record.provider}/${record.model}` : record.model);
|
|
181
|
+
if (record.usage) {
|
|
182
|
+
totals.input += record.usage.input;
|
|
183
|
+
totals.output += record.usage.output;
|
|
184
|
+
totals.cacheRead += record.usage.cacheRead;
|
|
185
|
+
totals.cacheWrite += record.usage.cacheWrite;
|
|
186
|
+
totals.costUsd += record.usage.costUsd;
|
|
187
|
+
}
|
|
188
|
+
turnDurations.push(record.durationMs);
|
|
189
|
+
if (typeof record.timeToFirstChunkMs === "number")
|
|
190
|
+
firstChunkTimes.push(record.timeToFirstChunkMs);
|
|
191
|
+
if (record.compacted)
|
|
192
|
+
compactions++;
|
|
193
|
+
failovers += record.failovers ?? 0;
|
|
194
|
+
if (record.contextCache) {
|
|
195
|
+
planObservations++;
|
|
196
|
+
if (record.contextCache.planHit)
|
|
197
|
+
planHits++;
|
|
198
|
+
}
|
|
199
|
+
// Re-validate provenance at read time: a tampered projection never counts.
|
|
200
|
+
const provenance = record.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(record.runtimeProvenance);
|
|
201
|
+
if (provenance !== undefined &&
|
|
202
|
+
provenance !== null &&
|
|
203
|
+
provenance.responseModel !== null &&
|
|
204
|
+
provenance.responseModel !== provenance.selectedModel) {
|
|
205
|
+
provenanceMismatchTurns++;
|
|
206
|
+
}
|
|
207
|
+
for (const call of record.toolCalls ?? []) {
|
|
208
|
+
const stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };
|
|
209
|
+
stats.durations.push(call.durationMs);
|
|
210
|
+
if (!call.ok)
|
|
211
|
+
stats.failures++;
|
|
212
|
+
toolStats.set(call.name, stats);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
turnDurations.sort((a, b) => a - b);
|
|
216
|
+
firstChunkTimes.sort((a, b) => a - b);
|
|
217
|
+
const tools = [...toolStats.entries()]
|
|
218
|
+
.map(([name, stats]) => {
|
|
219
|
+
const sorted = [...stats.durations].sort((a, b) => a - b);
|
|
220
|
+
return {
|
|
221
|
+
name,
|
|
222
|
+
calls: sorted.length,
|
|
223
|
+
failures: stats.failures,
|
|
224
|
+
failureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,
|
|
225
|
+
p50Ms: percentile(sorted, 0.5),
|
|
226
|
+
p95Ms: percentile(sorted, 0.95),
|
|
227
|
+
totalMs: sorted.reduce((sum, value) => sum + value, 0),
|
|
228
|
+
};
|
|
229
|
+
})
|
|
230
|
+
.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));
|
|
231
|
+
const promptSideTotal = totals.input + totals.cacheRead;
|
|
232
|
+
return {
|
|
233
|
+
turns: records.length,
|
|
234
|
+
sessions: sessions.size,
|
|
235
|
+
models: [...models].sort(),
|
|
236
|
+
totalCostUsd: totals.costUsd,
|
|
237
|
+
totals,
|
|
238
|
+
turnDurationP50Ms: percentile(turnDurations, 0.5),
|
|
239
|
+
turnDurationP95Ms: percentile(turnDurations, 0.95),
|
|
240
|
+
timeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,
|
|
241
|
+
cacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,
|
|
242
|
+
compactions,
|
|
243
|
+
failovers,
|
|
244
|
+
contextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,
|
|
245
|
+
provenanceMismatchTurns,
|
|
246
|
+
tools,
|
|
247
|
+
malformedLines,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** Read and summarize a metrics file. A missing file summarizes as zero turns. */
|
|
251
|
+
export function summarizeTurnMetricsFile(filePath) {
|
|
252
|
+
let raw;
|
|
253
|
+
try {
|
|
254
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return summarizeTurnMetrics([]);
|
|
258
|
+
}
|
|
259
|
+
return summarizeTurnMetrics(raw.split("\n"));
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=turn-metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-metrics.js","sourceRoot":"","sources":["../../src/core/turn-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-turn runtime metrics.\n *\n * Before this, `telemetry.ts` covered install opt-in and nothing else, and\n * `run-journal.ts` recorded a hash-chained *integrity* log (run started /\n * finished / tool timeout). Neither could answer the questions that actually\n * drive harness work: which tools fail, how long they take, what a turn costs,\n * and whether the context cache is doing anything.\n *\n * Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.\n * Append-only, bounded by size with a single rotation, and never load-bearing —\n * a failed write is dropped, never surfaced as a turn error.\n *\n * Privacy: records counts, durations, ids, and error *classes*. It never\n * records prompt text, tool arguments, tool output, or file contents. Tool\n * error strings are truncated and kept only to distinguish failure modes.\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { parseRuntimeProvenance, type RuntimeProvenance } from \"./runtime-provenance.ts\";\n\nexport const TURN_METRICS_SCHEMA_VERSION = \"omk-turn-metrics-1\" as const;\n/** Rotate once the active file passes this size. */\nexport const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;\n/** Cap on a retained error string. Enough to classify, too short to carry a payload. */\nexport const MAX_ERROR_CHARS = 200;\n\nexport interface ToolCallMetric {\n\treadonly name: string;\n\treadonly durationMs: number;\n\treadonly ok: boolean;\n\t/** Truncated failure text, present only when `ok` is false. */\n\treadonly error?: string;\n}\n\nexport interface TurnUsageMetric {\n\treadonly input: number;\n\treadonly output: number;\n\treadonly cacheRead: number;\n\treadonly cacheWrite: number;\n\treadonly costUsd: number;\n}\n\nexport interface TurnMetricInput {\n\treadonly sessionId: string;\n\treadonly turnIndex: number;\n\treadonly provider?: string;\n\treadonly model?: string;\n\treadonly startedAtEpochMs: number;\n\treadonly endedAtEpochMs: number;\n\t/** Time to the first streamed assistant chunk, when observed. */\n\treadonly timeToFirstChunkMs?: number;\n\treadonly usage?: TurnUsageMetric;\n\treadonly stopReason?: string;\n\treadonly toolCalls?: readonly ToolCallMetric[];\n\t/** Whether compaction ran during this turn. */\n\treadonly compacted?: boolean;\n\t/** Provider failover attempts made during this turn. */\n\treadonly failovers?: number;\n\t/** Context-budget cache outcome for the turn's prompt build. */\n\treadonly contextCache?: { readonly planHit: boolean; readonly hits: number; readonly misses: number };\n\t/** Requested/selected/response model and thinking projection. Advisory metadata only. */\n\treadonly runtimeProvenance?: RuntimeProvenance;\n}\n\nexport interface TurnMetricRecord extends TurnMetricInput {\n\treadonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;\n\treadonly durationMs: number;\n\treadonly toolCallCount: number;\n\treadonly toolFailureCount: number;\n}\n\nfunction truncateError(value: string | undefined): string | undefined {\n\tif (value === undefined) return undefined;\n\tconst collapsed = value.replace(/\\s+/gu, \" \").trim();\n\tif (collapsed.length === 0) return undefined;\n\treturn collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\\u2026` : collapsed;\n}\n\n/**\n * Build the record written for one turn. Pure: no clock, no filesystem, so the\n * derived fields can be asserted directly.\n */\nexport function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord {\n\tconst toolCalls = (input.toolCalls ?? []).map((call) => ({\n\t\tname: call.name,\n\t\tdurationMs: Math.max(0, Math.round(call.durationMs)),\n\t\tok: call.ok,\n\t\t...(call.ok ? {} : { error: truncateError(call.error) }),\n\t}));\n\tconst runtimeProvenance =\n\t\tinput.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);\n\t// Spread the raw input minus its projection: a validated projection is re-added\n\t// below, an invalid one is dropped rather than persisted.\n\tconst { runtimeProvenance: _rawProvenance, ...rest } = input;\n\treturn {\n\t\t...rest,\n\t\tschemaVersion: TURN_METRICS_SCHEMA_VERSION,\n\t\tdurationMs: Math.max(0, input.endedAtEpochMs - input.startedAtEpochMs),\n\t\ttoolCalls,\n\t\ttoolCallCount: toolCalls.length,\n\t\ttoolFailureCount: toolCalls.filter((call) => !call.ok).length,\n\t\t...(runtimeProvenance === null || runtimeProvenance === undefined ? {} : { runtimeProvenance }),\n\t};\n}\n\nexport interface TurnMetricsSinkOptions {\n\treadonly dir: string;\n\treadonly maxBytes?: number;\n\treadonly fileName?: string;\n}\n\n/**\n * Append-only JSONL sink. Writes are synchronous and best-effort: metrics must\n * never be the reason a turn fails, so every filesystem error is swallowed and\n * counted instead of thrown.\n */\nexport class TurnMetricsSink {\n\tprivate readonly filePath: string;\n\tprivate readonly maxBytes: number;\n\tprivate dropped = 0;\n\tprivate written = 0;\n\n\tconstructor(options: TurnMetricsSinkOptions) {\n\t\tthis.filePath = path.join(options.dir, options.fileName ?? \"turns.jsonl\");\n\t\tthis.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);\n\t}\n\n\tget path(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** Records written successfully this process. */\n\tget writtenCount(): number {\n\t\treturn this.written;\n\t}\n\n\t/** Records lost to a filesystem error this process. */\n\tget droppedCount(): number {\n\t\treturn this.dropped;\n\t}\n\n\t/** Append one turn. Returns `true` when the line reached disk. */\n\trecord(input: TurnMetricInput): boolean {\n\t\tlet line: string;\n\t\ttry {\n\t\t\tline = `${JSON.stringify(buildTurnMetricRecord(input))}\\n`;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tfs.mkdirSync(path.dirname(this.filePath), { recursive: true });\n\t\t\tthis.rotateIfNeeded(Buffer.byteLength(line, \"utf8\"));\n\t\t\tfs.appendFileSync(this.filePath, line, { encoding: \"utf8\", mode: 0o600 });\n\t\t\tthis.written++;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rotateIfNeeded(incomingBytes: number): void {\n\t\tlet size: number;\n\t\ttry {\n\t\t\tsize = fs.statSync(this.filePath).size;\n\t\t} catch {\n\t\t\treturn; // No file yet.\n\t\t}\n\t\tif (size + incomingBytes <= this.maxBytes) return;\n\t\ttry {\n\t\t\t// Single generation: the previous file is replaced, so disk use stays bounded at 2x.\n\t\t\tfs.renameSync(this.filePath, `${this.filePath}.1`);\n\t\t} catch {\n\t\t\t// Rotation failure is not fatal; the next append still succeeds.\n\t\t}\n\t}\n}\n\nexport interface ToolAggregate {\n\treadonly name: string;\n\treadonly calls: number;\n\treadonly failures: number;\n\treadonly failureRate: number;\n\treadonly p50Ms: number;\n\treadonly p95Ms: number;\n\treadonly totalMs: number;\n}\n\nexport interface TurnMetricsSummary {\n\treadonly turns: number;\n\treadonly sessions: number;\n\treadonly models: readonly string[];\n\treadonly totalCostUsd: number;\n\treadonly totals: TurnUsageMetric;\n\treadonly turnDurationP50Ms: number;\n\treadonly turnDurationP95Ms: number;\n\treadonly timeToFirstChunkP50Ms?: number;\n\treadonly cacheReadShare: number;\n\treadonly compactions: number;\n\treadonly failovers: number;\n\treadonly contextCachePlanHitRate?: number;\n\t/** Turns where a valid projection's response model differs from the selected model. */\n\treadonly provenanceMismatchTurns: number;\n\treadonly tools: readonly ToolAggregate[];\n\t/** Lines that could not be parsed as a metric record. */\n\treadonly malformedLines: number;\n}\n\nfunction percentile(sorted: readonly number[], fraction: number): number {\n\tif (sorted.length === 0) return 0;\n\tconst index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));\n\treturn sorted[index];\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction parseRecord(line: string): TurnMetricRecord | undefined {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(line);\n\t} catch {\n\t\treturn undefined;\n\t}\n\tif (!isRecord(parsed) || parsed.schemaVersion !== TURN_METRICS_SCHEMA_VERSION) return undefined;\n\tif (typeof parsed.turnIndex !== \"number\" || typeof parsed.durationMs !== \"number\") return undefined;\n\treturn parsed as unknown as TurnMetricRecord;\n}\n\n/**\n * Aggregate JSONL lines into a summary. Malformed lines are counted rather than\n * discarded silently, so a truncated tail is visible instead of pretending the\n * data is complete.\n */\nexport function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary {\n\tconst records: TurnMetricRecord[] = [];\n\tlet malformedLines = 0;\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (trimmed.length === 0) continue;\n\t\tconst record = parseRecord(trimmed);\n\t\tif (record) records.push(record);\n\t\telse malformedLines++;\n\t}\n\n\tconst sessions = new Set<string>();\n\tconst models = new Set<string>();\n\tconst totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };\n\tconst turnDurations: number[] = [];\n\tconst firstChunkTimes: number[] = [];\n\tconst toolStats = new Map<string, { durations: number[]; failures: number }>();\n\tlet compactions = 0;\n\tlet failovers = 0;\n\tlet planHits = 0;\n\tlet planObservations = 0;\n\tlet provenanceMismatchTurns = 0;\n\n\tfor (const record of records) {\n\t\tsessions.add(record.sessionId);\n\t\tif (record.model) models.add(record.provider ? `${record.provider}/${record.model}` : record.model);\n\t\tif (record.usage) {\n\t\t\ttotals.input += record.usage.input;\n\t\t\ttotals.output += record.usage.output;\n\t\t\ttotals.cacheRead += record.usage.cacheRead;\n\t\t\ttotals.cacheWrite += record.usage.cacheWrite;\n\t\t\ttotals.costUsd += record.usage.costUsd;\n\t\t}\n\t\tturnDurations.push(record.durationMs);\n\t\tif (typeof record.timeToFirstChunkMs === \"number\") firstChunkTimes.push(record.timeToFirstChunkMs);\n\t\tif (record.compacted) compactions++;\n\t\tfailovers += record.failovers ?? 0;\n\t\tif (record.contextCache) {\n\t\t\tplanObservations++;\n\t\t\tif (record.contextCache.planHit) planHits++;\n\t\t}\n\t\t// Re-validate provenance at read time: a tampered projection never counts.\n\t\tconst provenance =\n\t\t\trecord.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(record.runtimeProvenance);\n\t\tif (\n\t\t\tprovenance !== undefined &&\n\t\t\tprovenance !== null &&\n\t\t\tprovenance.responseModel !== null &&\n\t\t\tprovenance.responseModel !== provenance.selectedModel\n\t\t) {\n\t\t\tprovenanceMismatchTurns++;\n\t\t}\n\t\tfor (const call of record.toolCalls ?? []) {\n\t\t\tconst stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };\n\t\t\tstats.durations.push(call.durationMs);\n\t\t\tif (!call.ok) stats.failures++;\n\t\t\ttoolStats.set(call.name, stats);\n\t\t}\n\t}\n\n\tturnDurations.sort((a, b) => a - b);\n\tfirstChunkTimes.sort((a, b) => a - b);\n\n\tconst tools: ToolAggregate[] = [...toolStats.entries()]\n\t\t.map(([name, stats]) => {\n\t\t\tconst sorted = [...stats.durations].sort((a, b) => a - b);\n\t\t\treturn {\n\t\t\t\tname,\n\t\t\t\tcalls: sorted.length,\n\t\t\t\tfailures: stats.failures,\n\t\t\t\tfailureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,\n\t\t\t\tp50Ms: percentile(sorted, 0.5),\n\t\t\t\tp95Ms: percentile(sorted, 0.95),\n\t\t\t\ttotalMs: sorted.reduce((sum, value) => sum + value, 0),\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));\n\n\tconst promptSideTotal = totals.input + totals.cacheRead;\n\treturn {\n\t\tturns: records.length,\n\t\tsessions: sessions.size,\n\t\tmodels: [...models].sort(),\n\t\ttotalCostUsd: totals.costUsd,\n\t\ttotals,\n\t\tturnDurationP50Ms: percentile(turnDurations, 0.5),\n\t\tturnDurationP95Ms: percentile(turnDurations, 0.95),\n\t\ttimeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,\n\t\tcacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,\n\t\tcompactions,\n\t\tfailovers,\n\t\tcontextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,\n\t\tprovenanceMismatchTurns,\n\t\ttools,\n\t\tmalformedLines,\n\t};\n}\n\n/** Read and summarize a metrics file. A missing file summarizes as zero turns. */\nexport function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary {\n\tlet raw: string;\n\ttry {\n\t\traw = fs.readFileSync(filePath, \"utf8\");\n\t} catch {\n\t\treturn summarizeTurnMetrics([]);\n\t}\n\treturn summarizeTurnMetrics(raw.split(\"\\n\"));\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verified-bash-runtime.d.ts","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAGvF;AAaD;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAUzG","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n\nconst scopeCache = new Map<string, { at: number; scope: WorkspaceScope }>();\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\tconst cached = scopeCache.get(cwd);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.scope;\n\tconst scope = computeSessionWorkspaceScope(cwd, options?.maxPaths ?? SCOPE_MAX_PATHS);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(cwd, { at: Date.now(), scope });\n\treturn scope;\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): WorkspaceScope {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return { root: cwd, artifactPaths: [] };\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return { root: cwd, artifactPaths: [] };\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=
|
|
1
|
+
{"version":3,"file":"verified-bash-runtime.d.ts","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAGvF;AAaD;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAUzG","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n\nconst scopeCache = new Map<string, { at: number; scope: WorkspaceScope }>();\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\tconst cached = scopeCache.get(cwd);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.scope;\n\tconst scope = computeSessionWorkspaceScope(cwd, options?.maxPaths ?? SCOPE_MAX_PATHS);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(cwd, { at: Date.now(), scope });\n\treturn scope;\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): WorkspaceScope {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return { root: cwd, artifactPaths: [] };\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return { root: cwd, artifactPaths: [] };\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return { root, artifactPaths: [] };\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths: string[] = [];\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Trailing-slash entries survive -uall only for untracked nested\n\t\t\t// repositories; their contents are outside the parent's dirty digest\n\t\t\t// and cannot be file-hashed, so exclude them from the scope.\n\t\t\tif (artifact.length > 0 && !artifact.endsWith(\"/\")) paths.push(artifact);\n\t\t}\n\t\tconst artifactPaths = [...new Set(paths)].sort().slice(0, maxPaths);\n\t\treturn { root, artifactPaths };\n\t} catch {\n\t\treturn { root: cwd, artifactPaths: [] };\n\t}\n}\n"]}
|
|
@@ -49,7 +49,10 @@ function computeSessionWorkspaceScope(cwd, maxPaths) {
|
|
|
49
49
|
const root = top.stdout.trim();
|
|
50
50
|
if (root.length === 0)
|
|
51
51
|
return { root: cwd, artifactPaths: [] };
|
|
52
|
-
|
|
52
|
+
// File-granular untracked listing: -unormal emits untracked directories as
|
|
53
|
+
// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream
|
|
54
|
+
// and kills every verified bash call in the session.
|
|
55
|
+
const status = spawnSync("git", ["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
53
56
|
timeout: GIT_TIMEOUT_MS,
|
|
54
57
|
maxBuffer: 4 * 1024 * 1024,
|
|
55
58
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -65,10 +68,13 @@ function computeSessionWorkspaceScope(cwd, maxPaths) {
|
|
|
65
68
|
const xy = field.slice(0, 2);
|
|
66
69
|
if (xy === "!!")
|
|
67
70
|
continue;
|
|
68
|
-
if (xy.includes("R"))
|
|
69
|
-
index++; // rename: consume the source path field too
|
|
71
|
+
if (xy.includes("R") || xy.includes("C"))
|
|
72
|
+
index++; // rename/copy: consume the source path field too
|
|
70
73
|
const artifact = field.slice(3);
|
|
71
|
-
|
|
74
|
+
// Trailing-slash entries survive -uall only for untracked nested
|
|
75
|
+
// repositories; their contents are outside the parent's dirty digest
|
|
76
|
+
// and cannot be file-hashed, so exclude them from the scope.
|
|
77
|
+
if (artifact.length > 0 && !artifact.endsWith("/"))
|
|
72
78
|
paths.push(artifact);
|
|
73
79
|
}
|
|
74
80
|
const artifactPaths = [...new Set(paths)].sort().slice(0, maxPaths);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verified-bash-runtime.js","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAG/C,MAAM,UAAU,qBAAqB,CAAC,GAAwC,EAAW;IACxF,MAAM,MAAM,GAAuC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC;IACtE,OAAO,MAAM,CAAC,iBAAiB,KAAK,GAAG,CAAC;AAAA,CACxC;AAED,8EAA8E;AAC9E,0DAA0D;AAC1D,8EAA8E;AAE9E,MAAM,YAAY,GAAG,KAAK,CAAC;AAC3B,MAAM,eAAe,GAAG,CAAC,CAAC;AAC1B,MAAM,eAAe,GAAG,EAAE,CAAC;AAC3B,MAAM,cAAc,GAAG,KAAK,CAAC;AAE7B,MAAM,UAAU,GAAG,IAAI,GAAG,EAAiD,CAAC;AAE5E;;;;;;GAMG;AACH,MAAM,UAAU,4BAA4B,CAAC,GAAW,EAAE,OAA+B,EAAkB;IAC1G,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACnC,IAAI,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,EAAE,GAAG,YAAY;QAAE,OAAO,MAAM,CAAC,KAAK,CAAC;IACzE,MAAM,KAAK,GAAG,4BAA4B,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,IAAI,eAAe,CAAC,CAAC;IACtF,IAAI,UAAU,CAAC,IAAI,IAAI,eAAe,EAAE,CAAC;QACxC,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC;QAC9C,IAAI,MAAM,KAAK,SAAS;YAAE,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACrD,CAAC;IACD,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;IAC/C,OAAO,KAAK,CAAC;AAAA,CACb;AAED,SAAS,4BAA4B,CAAC,GAAW,EAAE,QAAgB,EAAkB;IACpF,IAAI,CAAC;QACJ,MAAM,GAAG,GAAG,SAAS,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,EAAE,iBAAiB,CAAC,EAAE;YACzE,QAAQ,EAAE,MAAM;YAChB,OAAO,EAAE,cAAc;YACvB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;SACnC,CAAC,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ;YAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC;QAChG,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAC/B,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC;QAC/D,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,
|
|
1
|
+
{"version":3,"file":"verified-bash-runtime.js","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n\nconst scopeCache = new Map<string, { at: number; scope: WorkspaceScope }>();\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\tconst cached = scopeCache.get(cwd);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.scope;\n\tconst scope = computeSessionWorkspaceScope(cwd, options?.maxPaths ?? SCOPE_MAX_PATHS);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(cwd, { at: Date.now(), scope });\n\treturn scope;\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): WorkspaceScope {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return { root: cwd, artifactPaths: [] };\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return { root: cwd, artifactPaths: [] };\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return { root, artifactPaths: [] };\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths: string[] = [];\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Trailing-slash entries survive -uall only for untracked nested\n\t\t\t// repositories; their contents are outside the parent's dirty digest\n\t\t\t// and cannot be file-hashed, so exclude them from the scope.\n\t\t\tif (artifact.length > 0 && !artifact.endsWith(\"/\")) paths.push(artifact);\n\t\t}\n\t\tconst artifactPaths = [...new Set(paths)].sort().slice(0, maxPaths);\n\t\treturn { root, artifactPaths };\n\t} catch {\n\t\treturn { root: cwd, artifactPaths: [] };\n\t}\n}\n"]}
|
|
@@ -28,5 +28,12 @@ export declare function lockOwnersEqual(left: LockOwnerSnapshot, right: LockOwne
|
|
|
28
28
|
export declare function createLockOwner(processIdentity: ReplayLedgerProcessIdentityReader): LockOwner;
|
|
29
29
|
export declare function lockOwnerIsDead(owner: LockOwner, processIdentity: ReplayLedgerProcessIdentityReader): boolean;
|
|
30
30
|
export declare function parseLinuxProcessIdentityStat(stat: string): ReplayLedgerProcessIdentity;
|
|
31
|
+
/**
|
|
32
|
+
* Parses BSD `ps -o lstart=` output (e.g. `Wed Aug 12 14:30:00 2026`).
|
|
33
|
+
* lstart is fixed for the lifetime of a pid, so `darwin:<epochMs>` is a
|
|
34
|
+
* stable per-process-instance token accepted by PROCESS_START_TOKEN.
|
|
35
|
+
* Empty output means ps matched no process; anything unparseable fails closed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseDarwinProcessIdentityLstart(lstart: string): ReplayLedgerProcessIdentity;
|
|
31
38
|
export declare function inspectProcessIdentity(pid: number): ReplayLedgerProcessIdentity;
|
|
32
39
|
//# sourceMappingURL=replay-ledger-lock-owner.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay-ledger-lock-owner.d.ts","sourceRoot":"","sources":["../../src/guardrails/replay-ledger-lock-owner.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"replay-ledger-lock-owner.d.ts","sourceRoot":"","sources":["../../src/guardrails/replay-ledger-lock-owner.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,SAAS,GAAG;IACvB,QAAQ,CAAC,aAAa,EAAE,CAAC,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC/B,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GACpC;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAAE,CAAC;AAErC,MAAM,MAAM,iCAAiC,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,2BAA2B,CAAC;AAE7F,qBAAa,qBAAsB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,IAAI,2BAA2B;CACxC;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAG5D;AA6CD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,CAqB7D;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAS1F;AAED,wBAAgB,eAAe,CAAC,eAAe,EAAE,iCAAiC,GAAG,SAAS,CAY7F;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,iCAAiC,GAAG,OAAO,CAU7G;AAED,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,MAAM,GAAG,2BAA2B,CAavF;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAAC,MAAM,EAAE,MAAM,GAAG,2BAA2B,CAM5F;AAgBD,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,2BAA2B,CAS/E","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from \"node:fs\";\n\nconst MAX_LOCK_RECORD_BYTES = 512;\nconst MAX_CLOCK_SKEW_MS = 60_000;\n/** Hard bounds on what one `ps` child process may contribute on Darwin. */\nconst DARWIN_PS_TIMEOUT_MS = 5_000;\nconst DARWIN_PS_MAX_OUTPUT_BYTES = 4_096;\nconst MAX_DARWIN_LSTART_CHARS = 512;\nconst OWNER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nconst PROCESS_START_TOKEN = /^[A-Za-z0-9._:-]{1,128}$/;\n\nexport type LockOwner = {\n\treadonly schemaVersion: 2;\n\treadonly pid: number;\n\treadonly processStartToken: string | null;\n\treadonly acquiredAtMs: number;\n\treadonly ownerId: string;\n};\n\nexport type LockOwnerSnapshot = {\n\treadonly owner: LockOwner;\n\treadonly dev: string;\n\treadonly ino: string;\n};\n\nexport type ReplayLedgerProcessIdentity =\n\t| { readonly state: \"present\"; readonly startToken: string }\n\t| { readonly state: \"absent\" }\n\t| { readonly state: \"unavailable\" };\n\nexport type ReplayLedgerProcessIdentityReader = (pid: number) => ReplayLedgerProcessIdentity;\n\nexport class ReplayLedgerLockError extends Error {\n\treadonly name = \"ReplayLedgerLockError\";\n}\n\nexport function errorCode(error: unknown): string | undefined {\n\tif (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n\treturn typeof error.code === \"string\" ? error.code : undefined;\n}\n\nfunction ownerInteger(value: unknown): number {\n\tif (typeof value !== \"number\" || !Number.isSafeInteger(value) || value <= 0) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock owner record is invalid\");\n\t}\n\treturn value;\n}\n\nfunction parseOwner(bytes: Buffer): LockOwner {\n\tif (bytes.byteLength === 0 || bytes.byteLength > MAX_LOCK_RECORD_BYTES) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock owner record is outside its size bound\");\n\t}\n\tlet value: unknown;\n\ttry {\n\t\tvalue = JSON.parse(bytes.toString(\"utf8\"));\n\t} catch (error) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock owner record is malformed\", { cause: error });\n\t}\n\tif (\n\t\ttypeof value !== \"object\" ||\n\t\tvalue === null ||\n\t\tArray.isArray(value) ||\n\t\tObject.keys(value).sort().join(\",\") !== \"acquiredAtMs,ownerId,pid,processStartToken,schemaVersion\" ||\n\t\t!(\"schemaVersion\" in value) ||\n\t\tvalue.schemaVersion !== 2 ||\n\t\t!(\"pid\" in value) ||\n\t\t!(\"processStartToken\" in value) ||\n\t\t!(\"acquiredAtMs\" in value) ||\n\t\t!(\"ownerId\" in value) ||\n\t\ttypeof value.ownerId !== \"string\" ||\n\t\t!OWNER_ID.test(value.ownerId) ||\n\t\t(value.processStartToken !== null &&\n\t\t\t(typeof value.processStartToken !== \"string\" || !PROCESS_START_TOKEN.test(value.processStartToken)))\n\t) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock owner record is invalid\");\n\t}\n\tconst pid = ownerInteger(value.pid);\n\tconst acquiredAtMs = ownerInteger(value.acquiredAtMs);\n\tif (acquiredAtMs > Date.now() + MAX_CLOCK_SKEW_MS) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock owner record is invalid\");\n\t}\n\treturn { schemaVersion: 2, pid, processStartToken: value.processStartToken, acquiredAtMs, ownerId: value.ownerId };\n}\n\nexport function readLockOwner(path: string): LockOwnerSnapshot {\n\tconst fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));\n\ttry {\n\t\tconst before = fstatSync(fd, { bigint: true });\n\t\tif (!before.isFile() || before.size <= 0n || before.size > BigInt(MAX_LOCK_RECORD_BYTES)) {\n\t\t\tthrow new ReplayLedgerLockError(\"replay lock owner record is outside its size bound\");\n\t\t}\n\t\tconst bytes = readFileSync(fd);\n\t\tconst after = fstatSync(fd, { bigint: true });\n\t\tif (\n\t\t\tbefore.dev !== after.dev ||\n\t\t\tbefore.ino !== after.ino ||\n\t\t\tbefore.size !== after.size ||\n\t\t\tafter.size !== BigInt(bytes.length)\n\t\t) {\n\t\t\tthrow new ReplayLedgerLockError(\"replay lock owner record changed during inspection\");\n\t\t}\n\t\treturn { owner: parseOwner(bytes), dev: String(after.dev), ino: String(after.ino) };\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n}\n\nexport function lockOwnersEqual(left: LockOwnerSnapshot, right: LockOwnerSnapshot): boolean {\n\treturn (\n\t\tleft.dev === right.dev &&\n\t\tleft.ino === right.ino &&\n\t\tleft.owner.ownerId === right.owner.ownerId &&\n\t\tleft.owner.pid === right.owner.pid &&\n\t\tleft.owner.processStartToken === right.owner.processStartToken &&\n\t\tleft.owner.acquiredAtMs === right.owner.acquiredAtMs\n\t);\n}\n\nexport function createLockOwner(processIdentity: ReplayLedgerProcessIdentityReader): LockOwner {\n\tconst identity = processIdentity(process.pid);\n\tif (identity.state !== \"present\" || !PROCESS_START_TOKEN.test(identity.startToken)) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock acquisition requires process-start identity\");\n\t}\n\treturn {\n\t\tschemaVersion: 2,\n\t\tpid: process.pid,\n\t\tprocessStartToken: identity.startToken,\n\t\tacquiredAtMs: Date.now(),\n\t\townerId: randomUUID(),\n\t};\n}\n\nexport function lockOwnerIsDead(owner: LockOwner, processIdentity: ReplayLedgerProcessIdentityReader): boolean {\n\tconst current = processIdentity(owner.pid);\n\tif (current.state === \"unavailable\") {\n\t\tthrow new ReplayLedgerLockError(\"replay lock owner liveness could not be established\");\n\t}\n\tif (current.state === \"absent\") return true;\n\tif (owner.processStartToken === null) {\n\t\tthrow new ReplayLedgerLockError(\"replay lock recovery requires process-start identity\");\n\t}\n\treturn current.startToken !== owner.processStartToken;\n}\n\nexport function parseLinuxProcessIdentityStat(stat: string): ReplayLedgerProcessIdentity {\n\tif (stat.length > 4_096) return { state: \"unavailable\" };\n\tconst close = stat.lastIndexOf(\")\");\n\tif (close < 0) return { state: \"unavailable\" };\n\tconst fields = stat\n\t\t.slice(close + 2)\n\t\t.trim()\n\t\t.split(/\\s+/);\n\tif ([\"Z\", \"X\", \"x\"].includes(fields[0] ?? \"\")) return { state: \"absent\" };\n\tconst startTime = fields[19];\n\treturn startTime !== undefined && /^\\d+$/.test(startTime)\n\t\t? { state: \"present\", startToken: `linux:${startTime}` }\n\t\t: { state: \"unavailable\" };\n}\n\n/**\n * Parses BSD `ps -o lstart=` output (e.g. `Wed Aug 12 14:30:00 2026`).\n * lstart is fixed for the lifetime of a pid, so `darwin:<epochMs>` is a\n * stable per-process-instance token accepted by PROCESS_START_TOKEN.\n * Empty output means ps matched no process; anything unparseable fails closed.\n */\nexport function parseDarwinProcessIdentityLstart(lstart: string): ReplayLedgerProcessIdentity {\n\tconst trimmed = lstart.trim();\n\tif (trimmed === \"\") return { state: \"absent\" };\n\tif (trimmed.length > MAX_DARWIN_LSTART_CHARS) return { state: \"unavailable\" };\n\tconst epochMs = Date.parse(trimmed);\n\treturn Number.isFinite(epochMs) ? { state: \"present\", startToken: `darwin:${epochMs}` } : { state: \"unavailable\" };\n}\n\nfunction inspectDarwinProcessIdentity(pid: number): ReplayLedgerProcessIdentity {\n\tif (!Number.isSafeInteger(pid) || pid <= 0) return { state: \"unavailable\" };\n\tconst result = spawnSync(\"ps\", [\"-o\", \"lstart=\", \"-p\", String(pid)], {\n\t\tencoding: \"utf8\",\n\t\ttimeout: DARWIN_PS_TIMEOUT_MS,\n\t\tmaxBuffer: DARWIN_PS_MAX_OUTPUT_BYTES,\n\t});\n\tif (result.error) return { state: \"unavailable\" };\n\t// BSD ps exits 1 when no process matches the pid; other non-zero is fail-closed.\n\tif (result.status === 1) return { state: \"absent\" };\n\tif (result.status !== 0) return { state: \"unavailable\" };\n\treturn parseDarwinProcessIdentityLstart(result.stdout ?? \"\");\n}\n\nexport function inspectProcessIdentity(pid: number): ReplayLedgerProcessIdentity {\n\tif (process.platform === \"darwin\") return inspectDarwinProcessIdentity(pid);\n\tif (process.platform !== \"linux\") return { state: \"unavailable\" };\n\ttry {\n\t\treturn parseLinuxProcessIdentityStat(readFileSync(`/proc/${pid}/stat`, \"utf8\"));\n\t} catch (error) {\n\t\tif (!(error instanceof Error)) throw error;\n\t\treturn errorCode(error) === \"ENOENT\" ? { state: \"absent\" } : { state: \"unavailable\" };\n\t}\n}\n"]}
|