@manyoars/oars 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -28
- package/dist/api.js +1 -1
- package/dist/bin.js +6 -29
- package/dist/cli.d.ts +10 -4
- package/dist/cli.js +440 -91
- package/dist/commands/keys.d.ts +31 -0
- package/dist/commands/keys.js +65 -0
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +3 -3
- package/dist/errors.d.ts +13 -13
- package/dist/errors.js +28 -11
- package/dist/gate/checks.d.ts +68 -0
- package/dist/gate/checks.js +584 -0
- package/dist/gate/command.d.ts +14 -0
- package/dist/gate/command.js +61 -0
- package/dist/gate/hook-fill-main.d.ts +1 -0
- package/dist/gate/hook-fill-main.js +16 -0
- package/dist/gate/hook-fill.d.ts +38 -0
- package/dist/gate/hook-fill.js +94 -0
- package/dist/gate/hook-main.d.ts +1 -0
- package/dist/gate/hook-main.js +47 -0
- package/dist/gate/hook-process.d.ts +24 -0
- package/dist/gate/hook-process.js +112 -0
- package/dist/gate/hook-record.d.ts +3 -0
- package/dist/gate/hook-record.js +8 -0
- package/dist/gate/hook.d.ts +48 -0
- package/dist/gate/hook.js +259 -0
- package/dist/gate/install.d.ts +25 -0
- package/dist/gate/install.js +126 -0
- package/dist/gate/local-bin.d.ts +8 -0
- package/dist/gate/local-bin.js +36 -0
- package/dist/gate/notes.d.ts +19 -0
- package/dist/gate/notes.js +27 -0
- package/dist/gate/novelty.d.ts +35 -0
- package/dist/gate/novelty.js +99 -0
- package/dist/gate/policy.d.ts +25 -0
- package/dist/gate/policy.js +73 -0
- package/dist/gate/record.d.ts +120 -0
- package/dist/gate/record.js +125 -0
- package/dist/gate/registry-state.d.ts +10 -0
- package/dist/gate/registry-state.js +30 -0
- package/dist/gate/registry.d.ts +56 -0
- package/dist/gate/registry.js +94 -0
- package/dist/gate/replay.d.ts +20 -0
- package/dist/gate/replay.js +254 -0
- package/dist/gate/report.d.ts +50 -0
- package/dist/gate/report.js +89 -0
- package/dist/gate/scope.d.ts +7 -0
- package/dist/gate/scope.js +27 -0
- package/dist/gate/transcripts.d.ts +68 -0
- package/dist/gate/transcripts.js +232 -0
- package/dist/hook-bin.d.ts +1 -0
- package/dist/hook-bin.js +50 -0
- package/dist/issuer.d.ts +10 -6
- package/dist/issuer.js +19 -24
- package/dist/main.d.ts +1 -0
- package/dist/main.js +67 -0
- package/dist/oidc.js +1 -1
- package/dist/sessions/actions.d.ts +35 -0
- package/dist/sessions/actions.js +429 -0
- package/dist/sessions/allow.d.ts +23 -0
- package/dist/sessions/allow.js +71 -0
- package/dist/sessions/archive.d.ts +11 -0
- package/dist/sessions/archive.js +38 -0
- package/dist/sessions/audit.d.ts +42 -0
- package/dist/sessions/audit.js +104 -0
- package/dist/sessions/baseline.d.ts +62 -0
- package/dist/sessions/baseline.js +190 -0
- package/dist/sessions/call-shape.d.ts +19 -0
- package/dist/sessions/call-shape.js +85 -0
- package/dist/sessions/capture-runtime.d.ts +10 -0
- package/dist/sessions/capture-runtime.js +12 -0
- package/dist/sessions/cell-increments.d.ts +47 -0
- package/dist/sessions/cell-increments.js +243 -0
- package/dist/sessions/cells.d.ts +109 -0
- package/dist/sessions/cells.js +261 -0
- package/dist/sessions/codex.d.ts +53 -0
- package/dist/sessions/codex.js +745 -0
- package/dist/sessions/command.d.ts +127 -0
- package/dist/sessions/command.js +419 -0
- package/dist/sessions/copilot.d.ts +40 -0
- package/dist/sessions/copilot.js +432 -0
- package/dist/sessions/coverage.d.ts +19 -0
- package/dist/sessions/coverage.js +56 -0
- package/dist/sessions/cursor.d.ts +24 -0
- package/dist/sessions/cursor.js +324 -0
- package/dist/sessions/derive.d.ts +57 -0
- package/dist/sessions/derive.js +386 -0
- package/dist/sessions/exclusion.d.ts +87 -0
- package/dist/sessions/exclusion.js +361 -0
- package/dist/sessions/extract.d.ts +181 -0
- package/dist/sessions/extract.js +1806 -0
- package/dist/sessions/fold.d.ts +17 -0
- package/dist/sessions/fold.js +71 -0
- package/dist/sessions/frame.d.ts +24 -0
- package/dist/sessions/frame.js +95 -0
- package/dist/sessions/gemini.d.ts +37 -0
- package/dist/sessions/gemini.js +613 -0
- package/dist/sessions/gold-arms.d.ts +67 -0
- package/dist/sessions/gold-arms.js +172 -0
- package/dist/sessions/gold.d.ts +197 -0
- package/dist/sessions/gold.js +463 -0
- package/dist/sessions/graph.d.ts +12 -0
- package/dist/sessions/graph.js +34 -0
- package/dist/sessions/grok.d.ts +36 -0
- package/dist/sessions/grok.js +421 -0
- package/dist/sessions/hook-calls.d.ts +3 -0
- package/dist/sessions/hook-calls.js +47 -0
- package/dist/sessions/hook-index.d.ts +66 -0
- package/dist/sessions/hook-index.js +164 -0
- package/dist/sessions/hooks.d.ts +17 -0
- package/dist/sessions/hooks.js +281 -0
- package/dist/sessions/identity.d.ts +27 -0
- package/dist/sessions/identity.js +131 -0
- package/dist/sessions/install.d.ts +70 -0
- package/dist/sessions/install.js +246 -0
- package/dist/sessions/lineage.d.ts +19 -0
- package/dist/sessions/lineage.js +118 -0
- package/dist/sessions/mcp.d.ts +7 -0
- package/dist/sessions/mcp.js +157 -0
- package/dist/sessions/merkle.d.ts +7 -0
- package/dist/sessions/merkle.js +28 -0
- package/dist/sessions/motif.d.ts +44 -0
- package/dist/sessions/motif.js +59 -0
- package/dist/sessions/opencode.d.ts +33 -0
- package/dist/sessions/opencode.js +319 -0
- package/dist/sessions/otel.d.ts +60 -0
- package/dist/sessions/otel.js +494 -0
- package/dist/sessions/package-books.d.ts +48 -0
- package/dist/sessions/package-books.js +120 -0
- package/dist/sessions/package-resolve.d.ts +32 -0
- package/dist/sessions/package-resolve.js +185 -0
- package/dist/sessions/pending.d.ts +34 -0
- package/dist/sessions/pending.js +142 -0
- package/dist/sessions/pi.d.ts +36 -0
- package/dist/sessions/pi.js +353 -0
- package/dist/sessions/pool-books.d.ts +21 -0
- package/dist/sessions/pool-books.js +75 -0
- package/dist/sessions/pool-choices.d.ts +5 -0
- package/dist/sessions/pool-choices.js +11 -0
- package/dist/sessions/pool-command.d.ts +115 -0
- package/dist/sessions/pool-command.js +669 -0
- package/dist/sessions/pool-delta.d.ts +54 -0
- package/dist/sessions/pool-delta.js +190 -0
- package/dist/sessions/pool-nightly-deps.d.ts +3 -0
- package/dist/sessions/pool-nightly-deps.js +11 -0
- package/dist/sessions/pool-nightly.d.ts +10 -0
- package/dist/sessions/pool-nightly.js +85 -0
- package/dist/sessions/pool-outbox.d.ts +76 -0
- package/dist/sessions/pool-outbox.js +313 -0
- package/dist/sessions/pool-print.d.ts +26 -0
- package/dist/sessions/pool-print.js +68 -0
- package/dist/sessions/pool-raw.d.ts +27 -0
- package/dist/sessions/pool-raw.js +68 -0
- package/dist/sessions/pool-replica.d.ts +35 -0
- package/dist/sessions/pool-replica.js +91 -0
- package/dist/sessions/pool-send-lock.d.ts +20 -0
- package/dist/sessions/pool-send-lock.js +87 -0
- package/dist/sessions/pool-sent.d.ts +4 -0
- package/dist/sessions/pool-sent.js +26 -0
- package/dist/sessions/pool-totals.d.ts +10 -0
- package/dist/sessions/pool-totals.js +21 -0
- package/dist/sessions/pool-wire.d.ts +435 -0
- package/dist/sessions/pool-wire.js +1100 -0
- package/dist/sessions/pool.d.ts +32 -0
- package/dist/sessions/pool.js +164 -0
- package/dist/sessions/projection.d.ts +75 -0
- package/dist/sessions/projection.js +165 -0
- package/dist/sessions/public-vocabulary.d.ts +72 -0
- package/dist/sessions/public-vocabulary.js +295 -0
- package/dist/sessions/query.d.ts +123 -0
- package/dist/sessions/query.js +268 -0
- package/dist/sessions/read-adoption.d.ts +2 -0
- package/dist/sessions/read-adoption.js +27 -0
- package/dist/sessions/read-call-shape.d.ts +2 -0
- package/dist/sessions/read-call-shape.js +25 -0
- package/dist/sessions/read-change.d.ts +2 -0
- package/dist/sessions/read-change.js +30 -0
- package/dist/sessions/read-cost.d.ts +4 -0
- package/dist/sessions/read-cost.js +48 -0
- package/dist/sessions/read-day-move.d.ts +3 -0
- package/dist/sessions/read-day-move.js +65 -0
- package/dist/sessions/read-fit.d.ts +7 -0
- package/dist/sessions/read-fit.js +41 -0
- package/dist/sessions/read-interaction.d.ts +5 -0
- package/dist/sessions/read-interaction.js +62 -0
- package/dist/sessions/read-lead-lag.d.ts +15 -0
- package/dist/sessions/read-lead-lag.js +81 -0
- package/dist/sessions/read-live.d.ts +6 -0
- package/dist/sessions/read-live.js +38 -0
- package/dist/sessions/read-method.d.ts +2 -0
- package/dist/sessions/read-method.js +29 -0
- package/dist/sessions/read-next-step.d.ts +2 -0
- package/dist/sessions/read-next-step.js +35 -0
- package/dist/sessions/read-order.d.ts +54 -0
- package/dist/sessions/read-order.js +62 -0
- package/dist/sessions/read-readable.d.ts +8 -0
- package/dist/sessions/read-readable.js +24 -0
- package/dist/sessions/read-recovery-edge.d.ts +2 -0
- package/dist/sessions/read-recovery-edge.js +26 -0
- package/dist/sessions/read-recovery.d.ts +20 -0
- package/dist/sessions/read-recovery.js +97 -0
- package/dist/sessions/read-rows.d.ts +13 -0
- package/dist/sessions/read-rows.js +47 -0
- package/dist/sessions/read-substitution.d.ts +5 -0
- package/dist/sessions/read-substitution.js +51 -0
- package/dist/sessions/read-support.d.ts +48 -0
- package/dist/sessions/read-support.js +73 -0
- package/dist/sessions/read-tool-day.d.ts +2 -0
- package/dist/sessions/read-tool-day.js +26 -0
- package/dist/sessions/read-version-boundary.d.ts +31 -0
- package/dist/sessions/read-version-boundary.js +95 -0
- package/dist/sessions/read.d.ts +86 -0
- package/dist/sessions/read.js +87 -0
- package/dist/sessions/recall-bundle.d.ts +12 -0
- package/dist/sessions/recall-bundle.js +33 -0
- package/dist/sessions/recall-consequence.d.ts +45 -0
- package/dist/sessions/recall-consequence.js +179 -0
- package/dist/sessions/recall-frames.d.ts +18 -0
- package/dist/sessions/recall-frames.js +44 -0
- package/dist/sessions/recall-next.d.ts +145 -0
- package/dist/sessions/recall-next.js +241 -0
- package/dist/sessions/recall-object.d.ts +139 -0
- package/dist/sessions/recall-object.js +78 -0
- package/dist/sessions/recall-pool.d.ts +19 -0
- package/dist/sessions/recall-pool.js +96 -0
- package/dist/sessions/recall-push.d.ts +26 -0
- package/dist/sessions/recall-push.js +47 -0
- package/dist/sessions/recall-reads.d.ts +124 -0
- package/dist/sessions/recall-reads.js +417 -0
- package/dist/sessions/recall-render.d.ts +19 -0
- package/dist/sessions/recall-render.js +168 -0
- package/dist/sessions/recovery-calls.d.ts +21 -0
- package/dist/sessions/recovery-calls.js +25 -0
- package/dist/sessions/redact.d.ts +17 -0
- package/dist/sessions/redact.js +113 -0
- package/dist/sessions/repo-root.d.ts +10 -0
- package/dist/sessions/repo-root.js +101 -0
- package/dist/sessions/schema.d.ts +694 -0
- package/dist/sessions/schema.js +167 -0
- package/dist/sessions/session-shape.d.ts +76 -0
- package/dist/sessions/session-shape.js +355 -0
- package/dist/sessions/sources.d.ts +50 -0
- package/dist/sessions/sources.js +233 -0
- package/dist/sessions/steps.d.ts +105 -0
- package/dist/sessions/steps.js +250 -0
- package/dist/sessions/store.d.ts +309 -0
- package/dist/sessions/store.js +1498 -0
- package/dist/sessions/strata.d.ts +26 -0
- package/dist/sessions/strata.js +77 -0
- package/dist/sessions/survival.d.ts +27 -0
- package/dist/sessions/survival.js +230 -0
- package/dist/sessions/transcript-tail.d.ts +14 -0
- package/dist/sessions/transcript-tail.js +64 -0
- package/dist/sessions/verify.d.ts +53 -0
- package/dist/sessions/verify.js +286 -0
- package/dist/sessions/vocab-load.d.ts +26 -0
- package/dist/sessions/vocab-load.js +61 -0
- package/dist/sessions/vscode-copilot.d.ts +38 -0
- package/dist/sessions/vscode-copilot.js +494 -0
- package/dist/telemetry/command.d.ts +8 -0
- package/dist/telemetry/command.js +85 -0
- package/dist/telemetry/consent.d.ts +17 -0
- package/dist/telemetry/consent.js +63 -0
- package/dist/telemetry/crash.d.ts +8 -0
- package/dist/telemetry/crash.js +52 -0
- package/dist/telemetry/emit.d.ts +31 -0
- package/dist/telemetry/emit.js +42 -0
- package/dist/telemetry/fields.d.ts +16 -0
- package/dist/telemetry/fields.js +26 -0
- package/dist/telemetry/health.d.ts +21 -0
- package/dist/telemetry/health.js +108 -0
- package/dist/telemetry/hook-emit.d.ts +4 -0
- package/dist/telemetry/hook-emit.js +18 -0
- package/dist/telemetry/measures.d.ts +32 -0
- package/dist/telemetry/measures.js +128 -0
- package/dist/telemetry/observe.d.ts +13 -0
- package/dist/telemetry/observe.js +51 -0
- package/dist/telemetry/read-events.d.ts +9 -0
- package/dist/telemetry/read-events.js +17 -0
- package/dist/telemetry/record.d.ts +17 -0
- package/dist/telemetry/record.js +26 -0
- package/dist/telemetry/report.d.ts +77 -0
- package/dist/telemetry/report.js +188 -0
- package/dist/telemetry/schema.d.ts +40 -0
- package/dist/telemetry/schema.js +187 -0
- package/dist/telemetry/send-main.d.ts +1 -0
- package/dist/telemetry/send-main.js +11 -0
- package/dist/telemetry/send.d.ts +36 -0
- package/dist/telemetry/send.js +172 -0
- package/dist/telemetry/spool.d.ts +17 -0
- package/dist/telemetry/spool.js +70 -0
- package/dist/telemetry/wire.d.ts +25 -0
- package/dist/telemetry/wire.js +76 -0
- package/package.json +4 -5
- package/vocab/crates.json +1 -0
- package/vocab/go.json +1 -0
- package/vocab/harness.json +1 -0
- package/vocab/homebrew.json +1 -0
- package/vocab/manifest.json +1 -0
- package/vocab/maven.json +1 -0
- package/vocab/models.json +1 -0
- package/vocab/npm.json +1 -0
- package/vocab/programs.json +1 -0
- package/vocab/pypi.json +1 -0
- package/vocab/rubygems.json +1 -0
- package/dist/commands/deploy-input.d.ts +0 -38
- package/dist/commands/deploy-input.js +0 -150
- package/dist/commands/deploy.d.ts +0 -22
- package/dist/commands/deploy.js +0 -124
- package/dist/commands/status.d.ts +0 -7
- package/dist/commands/status.js +0 -18
- package/dist/commands/templates.d.ts +0 -11
- package/dist/commands/templates.js +0 -32
- package/dist/commands/test-charge.d.ts +0 -11
- package/dist/commands/test-charge.js +0 -34
- package/dist/journal.d.ts +0 -26
- package/dist/journal.js +0 -58
- package/parity/deploy-plans/paid-api.json +0 -34
- package/parity/deploy-plans/per-task-agent.json +0 -34
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
import { appendFile, mkdir, open, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
7
|
+
import { compileAllow, mergeAllow, runAllow, slugOf } from './allow.js';
|
|
8
|
+
import { SOURCES } from './sources.js';
|
|
9
|
+
import { buildIndex, indexPath } from './hook-index.js';
|
|
10
|
+
import { archiveRaw } from './archive.js';
|
|
11
|
+
import { deferSession, failPending, listPending, PENDING_HOOK_CAP, PENDING_MAX_FAILURES, removePending, sweepPendingTemps } from './pending.js';
|
|
12
|
+
import { deriveSession } from './derive.js';
|
|
13
|
+
import { captureRuntime } from './capture-runtime.js';
|
|
14
|
+
import { callPositions, captureSession } from './extract.js';
|
|
15
|
+
import { foldToolRows, withoutReplays } from './fold.js';
|
|
16
|
+
import { asRepoSize } from './public-vocabulary.js';
|
|
17
|
+
import { makeRepoIdentity, makeRepoRoot } from './repo-root.js';
|
|
18
|
+
import { makeRedactor } from './redact.js';
|
|
19
|
+
import { CAPTURE_VERSION, EXTRACTOR_VERSION, OLDEST_READABLE_CAPTURE, } from './schema.js';
|
|
20
|
+
const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
21
|
+
export const paths = (storeDir) => ({
|
|
22
|
+
observations: join(storeDir, 'observations.jsonl'),
|
|
23
|
+
state: join(storeDir, 'state.json'),
|
|
24
|
+
derived: join(storeDir, 'derived.json'),
|
|
25
|
+
skeletons: join(storeDir, 'skeletons'),
|
|
26
|
+
calls: join(storeDir, 'mcp-calls.jsonl'),
|
|
27
|
+
hookCalls: join(storeDir, 'hook-calls.jsonl'),
|
|
28
|
+
pipelineRuns: join(storeDir, 'pipeline-runs.jsonl'),
|
|
29
|
+
salt: join(storeDir, 'salt'),
|
|
30
|
+
raw: join(storeDir, 'raw'),
|
|
31
|
+
});
|
|
32
|
+
export const sidOf = (salt) => createHash('sha256').update(salt).digest('hex').slice(0, 8);
|
|
33
|
+
/**
|
|
34
|
+
* This machine's salt, created on first use: path targets hashed with it join inside this store only. This is the one seam where the
|
|
35
|
+
* account's salt replaces the machine's (`SessionsRuntime.accountSalt`, Open question M4): a store whose `sid` then changes captures its
|
|
36
|
+
* files again from raw at the next deposit, and nothing else moves.
|
|
37
|
+
*/
|
|
38
|
+
export const localSalt = async (storeDir) => {
|
|
39
|
+
const file = paths(storeDir).salt;
|
|
40
|
+
let salt;
|
|
41
|
+
try {
|
|
42
|
+
salt = (await readFile(file, 'utf8')).trim();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!isMissing(error))
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (salt === undefined) {
|
|
49
|
+
salt = randomBytes(16).toString('hex');
|
|
50
|
+
await writeFile(file, salt, { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
return { salt, sid: sidOf(salt) };
|
|
53
|
+
};
|
|
54
|
+
const sameSet = (a, b) => a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
55
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
export const readState = async (storeDir) => {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(await readFile(paths(storeDir).state, 'utf8'));
|
|
59
|
+
if (isRecord(parsed) && isRecord(parsed.files)) {
|
|
60
|
+
const files = {};
|
|
61
|
+
for (const [key, value] of Object.entries(parsed.files)) {
|
|
62
|
+
if (isRecord(value) && typeof value.size === 'number' && typeof value.mtimeMs === 'number' && typeof value.rev === 'number') {
|
|
63
|
+
const repoSize = asRepoSize(value.repoSize);
|
|
64
|
+
files[key] = {
|
|
65
|
+
size: value.size,
|
|
66
|
+
mtimeMs: value.mtimeMs,
|
|
67
|
+
rev: value.rev,
|
|
68
|
+
v: typeof value.v === 'string' ? value.v : '',
|
|
69
|
+
...(typeof value.s === 'string' ? { s: value.s } : {}),
|
|
70
|
+
d: Array.isArray(value.d) ? value.d.filter((entry) => typeof entry === 'string') : [],
|
|
71
|
+
...(repoSize === undefined ? {} : { repoSize }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const domains = Array.isArray(parsed.redactDomains) ? parsed.redactDomains.filter((entry) => typeof entry === 'string') : [];
|
|
76
|
+
const allow = Array.isArray(parsed.allow) ? parsed.allow.filter((entry) => typeof entry === 'string') : [];
|
|
77
|
+
const pool = isRecord(parsed.pool) ? parsed.pool : {};
|
|
78
|
+
const folders = {};
|
|
79
|
+
if (isRecord(pool.folders))
|
|
80
|
+
for (const [entry, mode] of Object.entries(pool.folders))
|
|
81
|
+
if (mode === 'skeleton' || mode === 'off')
|
|
82
|
+
folders[entry] = mode;
|
|
83
|
+
return { files, redactDomains: domains, allow, pool: { rawConsent: pool.rawConsent === 'granted' || pool.rawConsent === 'declined' ? pool.rawConsent : null, folders } };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!isMissing(error) && !(error instanceof SyntaxError))
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, folders: {} } };
|
|
91
|
+
};
|
|
92
|
+
/** Changes the pool choices under the store lock, so a deposit running at the same time keeps them (it writes the state it read). */
|
|
93
|
+
export const updatePoolChoices = (storeDir, change) => withLock(storeDir, async (held) => {
|
|
94
|
+
const state = await readState(storeDir);
|
|
95
|
+
change(state.pool);
|
|
96
|
+
if (!(await held()))
|
|
97
|
+
throw new StoreLockLostError();
|
|
98
|
+
await writeAtomic(paths(storeDir).state, JSON.stringify(state));
|
|
99
|
+
return state.pool;
|
|
100
|
+
});
|
|
101
|
+
export const readRedactDomains = async (storeDir) => (await readState(storeDir)).redactDomains;
|
|
102
|
+
/** The `--allow` entries and `--redact-domain` set earlier deposits used; the nightly run repeats them. */
|
|
103
|
+
export const readSaved = async (storeDir) => {
|
|
104
|
+
const { allow, redactDomains } = await readState(storeDir);
|
|
105
|
+
return { allow, redactDomains };
|
|
106
|
+
};
|
|
107
|
+
export const writeAtomic = async (file, text) => {
|
|
108
|
+
await mkdir(join(file, '..'), { recursive: true });
|
|
109
|
+
const temp = `${file}.${String(process.pid)}.tmp`;
|
|
110
|
+
await writeFile(temp, text, { mode: 0o600 });
|
|
111
|
+
await rename(temp, file);
|
|
112
|
+
};
|
|
113
|
+
export { slugOf };
|
|
114
|
+
const projectFolders = async (projectsRoot) => {
|
|
115
|
+
try {
|
|
116
|
+
return (await readdir(projectsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (isMissing(error))
|
|
120
|
+
return [];
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* The scan rule is the allowlist (`depositLocked`, `depositSessionLocked`): a folder no entry names is not opened, by design, so
|
|
126
|
+
* a worktree folder of an allowed repository is skipped when only the repository's own folder is named. This reads folder listings
|
|
127
|
+
* only, never a transcript, so `verify` and `books` can say what the store does not hold.
|
|
128
|
+
*/
|
|
129
|
+
export const skippedFolders = async (runtime) => {
|
|
130
|
+
const allow = compileAllow((await readState(runtime.storeDir)).allow);
|
|
131
|
+
const names = [];
|
|
132
|
+
let transcripts = 0;
|
|
133
|
+
for (const name of (await projectFolders(runtime.projectsRoot)).filter((folder) => !allow.allows(folder))) {
|
|
134
|
+
const folder = join(runtime.projectsRoot, name);
|
|
135
|
+
let held = 0;
|
|
136
|
+
let anyTranscript;
|
|
137
|
+
try {
|
|
138
|
+
held = (await listDir(folder)).filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')).length;
|
|
139
|
+
// A deposit would also take the subagent transcripts of a session folder whose main transcript is gone (`depositLocked`).
|
|
140
|
+
anyTranscript = held > 0 || (await subagentFiles(folder, await sessionFolders(folder))).length > 0;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Scope is not a failure: a folder this process cannot list is named with no count rather than failing `verify`.
|
|
144
|
+
anyTranscript = true;
|
|
145
|
+
}
|
|
146
|
+
if (!anyTranscript)
|
|
147
|
+
continue;
|
|
148
|
+
names.push(name);
|
|
149
|
+
transcripts += held;
|
|
150
|
+
}
|
|
151
|
+
return { folders: names.length, transcripts, names };
|
|
152
|
+
};
|
|
153
|
+
const contextFor = async (runtime, state, now, held) => {
|
|
154
|
+
// Each file records the set it was deposited under. A file whose set differs from the current one is read again, so a domain
|
|
155
|
+
// added while other folders were allowed still masks those folders later. Lines already written stay in the observations file
|
|
156
|
+
// until it is compacted; readers use only the highest rev of each session.
|
|
157
|
+
await mkdir(paths(runtime.storeDir).skeletons, { recursive: true });
|
|
158
|
+
const salt = await (runtime.accountSalt ?? localSalt)(runtime.storeDir);
|
|
159
|
+
let logged;
|
|
160
|
+
return {
|
|
161
|
+
held,
|
|
162
|
+
runtime,
|
|
163
|
+
state,
|
|
164
|
+
domains: [...state.redactDomains].sort(),
|
|
165
|
+
redact: makeRedactor(state.redactDomains),
|
|
166
|
+
salt,
|
|
167
|
+
repoRoot: makeRepoRoot(),
|
|
168
|
+
repoIdentity: makeRepoIdentity(),
|
|
169
|
+
captureRuntime: captureRuntime(),
|
|
170
|
+
logged: () => (logged ??= loggedSessions(runtime.storeDir)),
|
|
171
|
+
stateLost: Object.keys(state.files).length === 0,
|
|
172
|
+
parentCalls: new Map(),
|
|
173
|
+
siblingCalls: new Map(),
|
|
174
|
+
at: new Date(now()).toISOString(),
|
|
175
|
+
now,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
const mainTranscript = (folder, name) => ({ file: join(folder, name), session: name.slice(0, -'.jsonl'.length), relative: name });
|
|
179
|
+
export const listDir = async (dir) => {
|
|
180
|
+
try {
|
|
181
|
+
return (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (isMissing(error))
|
|
185
|
+
return [];
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
/** The subagent's type and the parent's spawning tool call, from its `.meta.json`; null when the file is missing or says nothing. */
|
|
190
|
+
const readMeta = async (file) => {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
193
|
+
if (isRecord(parsed)) {
|
|
194
|
+
return { agentType: typeof parsed.agentType === 'string' ? parsed.agentType : null, toolUseId: typeof parsed.toolUseId === 'string' ? parsed.toolUseId : null };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (!isMissing(error) && !(error instanceof SyntaxError))
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
return { agentType: null, toolUseId: null };
|
|
202
|
+
};
|
|
203
|
+
// The subagent transcript files of the given sessions, from folder listings alone:
|
|
204
|
+
// <session>/subagents/agent-*.jsonl and <session>/subagents/workflows/wf_*/agent-*.jsonl. No file is opened, so `skippedFolders` can use it on a folder no allow entry names.
|
|
205
|
+
export const subagentFiles = async (folder, sessions) => {
|
|
206
|
+
const found = [];
|
|
207
|
+
for (const parent of sessions) {
|
|
208
|
+
const root = join(folder, parent, 'subagents');
|
|
209
|
+
const dirs = [{ dir: root, path: [] }];
|
|
210
|
+
for (const entry of await listDir(join(root, 'workflows'))) {
|
|
211
|
+
if (entry.isDirectory() && entry.name.startsWith('wf_'))
|
|
212
|
+
dirs.push({ dir: join(root, 'workflows', entry.name), path: ['workflows', entry.name] });
|
|
213
|
+
}
|
|
214
|
+
for (const { dir, path } of dirs) {
|
|
215
|
+
for (const entry of await listDir(dir)) {
|
|
216
|
+
if (entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl'))
|
|
217
|
+
found.push({ dir, path, parent, name: entry.name });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
};
|
|
223
|
+
// The subagent transcripts of the given sessions, each with its `.meta.json` read.
|
|
224
|
+
const subagentTranscripts = async (folder, sessions) => {
|
|
225
|
+
const found = [];
|
|
226
|
+
for (const { dir, path, parent, name } of await subagentFiles(folder, sessions)) {
|
|
227
|
+
const agent = name.slice(0, -'.jsonl'.length);
|
|
228
|
+
const meta = await readMeta(join(dir, `${agent}.meta.json`));
|
|
229
|
+
found.push({
|
|
230
|
+
file: join(dir, name),
|
|
231
|
+
session: [parent, ...path.filter((part) => part !== 'workflows'), agent].join('~'),
|
|
232
|
+
relative: [parent, 'subagents', ...path, name].join('/'),
|
|
233
|
+
subagent: { parent, agentType: meta.agentType, spawnedBy: meta.toolUseId },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return found;
|
|
237
|
+
};
|
|
238
|
+
/** The session folders under a project folder: each may hold subagent transcripts, even when its main transcript is gone. */
|
|
239
|
+
export const sessionFolders = async (folder) => (await listDir(folder)).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
240
|
+
/** Refuses a state file recorded at a newer capture version before anything is appended, so a refused run leaves the log as it was. */
|
|
241
|
+
const refuseNewer = (state) => {
|
|
242
|
+
const newer = Object.entries(state.files).find(([, entry]) => Number(entry.v) > Number(CAPTURE_VERSION));
|
|
243
|
+
if (newer !== undefined) {
|
|
244
|
+
throw new CliError(`${newer[0]} was captured by a newer oars (capture version ${newer[1].v}, this is ${CAPTURE_VERSION}); upgrade oars.`, ExitCode.UNEXPECTED, 'unexpected');
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
/** The parent transcript's call positions, from the harness's copy or else the archive; empty when neither holds it. */
|
|
248
|
+
const readParentCalls = async (candidates) => {
|
|
249
|
+
for (const candidate of candidates) {
|
|
250
|
+
try {
|
|
251
|
+
return callPositions(await readFile(candidate, 'utf8'));
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (!isMissing(error))
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return new Map();
|
|
259
|
+
};
|
|
260
|
+
/** The transcripts under one main session, each with its call positions: from the harness's folder, then whatever only the archive still holds. */
|
|
261
|
+
const readSiblings = async (folders, parent) => {
|
|
262
|
+
const siblings = new Map();
|
|
263
|
+
for (const folder of folders) {
|
|
264
|
+
for (const { session, file } of await subagentTranscripts(folder, [parent])) {
|
|
265
|
+
if (!siblings.has(session))
|
|
266
|
+
siblings.set(session, { session, calls: await readParentCalls([file]) });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Sorted, so which copy of a repeated call wins does not depend on the order a directory lists in.
|
|
270
|
+
return [...siblings.values()].sort((one, two) => one.session.localeCompare(two.session));
|
|
271
|
+
};
|
|
272
|
+
/**
|
|
273
|
+
* The call that spawned a subagent, in its main transcript or else in a sibling subagent's (a subagent can spawn another); null when its
|
|
274
|
+
* origin names no call or no transcript holds that call. A sibling is read only on a miss, so the common case costs one parent read.
|
|
275
|
+
*/
|
|
276
|
+
const spawnPointOf = async (context, project, transcript, origin) => {
|
|
277
|
+
const id = origin.spawnedBy;
|
|
278
|
+
if (id === null)
|
|
279
|
+
return null;
|
|
280
|
+
const name = `${origin.parent}.jsonl`;
|
|
281
|
+
const key = `${project}/${origin.parent}`;
|
|
282
|
+
// The project folder holds the transcript at `relative`, whether that folder is the harness's or the archive's.
|
|
283
|
+
const folder = resolve(transcript.file, ...transcript.relative.split('/').map(() => '..'));
|
|
284
|
+
const archive = join(paths(context.runtime.storeDir).raw, project);
|
|
285
|
+
const calls = context.parentCalls.get(key) ?? readParentCalls([join(folder, name), join(archive, name)]);
|
|
286
|
+
context.parentCalls.set(key, calls);
|
|
287
|
+
const inParent = (await calls).get(id);
|
|
288
|
+
if (inParent !== undefined)
|
|
289
|
+
return { n: inParent, session: origin.parent };
|
|
290
|
+
const siblings = context.siblingCalls.get(key) ?? readSiblings([folder, archive], origin.parent);
|
|
291
|
+
context.siblingCalls.set(key, siblings);
|
|
292
|
+
for (const { session, calls: held } of await siblings) {
|
|
293
|
+
const n = session === transcript.session ? undefined : held.get(id);
|
|
294
|
+
if (n !== undefined)
|
|
295
|
+
return { n, session };
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
};
|
|
299
|
+
/** A subagent transcript's origin with where it was spawned, as far as the transcripts in reach say. */
|
|
300
|
+
const originOf = async (context, project, transcript) => {
|
|
301
|
+
const { subagent } = transcript;
|
|
302
|
+
if (subagent === undefined)
|
|
303
|
+
return undefined;
|
|
304
|
+
const point = await spawnPointOf(context, project, transcript, subagent);
|
|
305
|
+
return { ...subagent, spawnedAt: point?.n ?? null, spawnedIn: point?.session ?? null };
|
|
306
|
+
};
|
|
307
|
+
/** The size bucket an earlier capture of this transcript read; undefined when there was none, so this capture reads the repository. */
|
|
308
|
+
const earlierRepoSize = async (context, previous, session) => {
|
|
309
|
+
if (previous !== undefined)
|
|
310
|
+
return previous.repoSize;
|
|
311
|
+
return context.stateLost ? (await context.logged()).repoSizes.get(session) : undefined;
|
|
312
|
+
};
|
|
313
|
+
/** Deposits one transcript unless its size, mtime, capture version, salt, and domain set are unchanged; returns what it wrote, or undefined when skipped. */
|
|
314
|
+
const depositFile = async (context, project, transcript) => {
|
|
315
|
+
const { runtime, state } = context;
|
|
316
|
+
const { file, session } = transcript;
|
|
317
|
+
const info = await stat(file);
|
|
318
|
+
// Archived before the unchanged check, so a store deposited before the archive existed fills it on its next run.
|
|
319
|
+
if (transcript.fromRaw !== true) {
|
|
320
|
+
await requireHold(context.held);
|
|
321
|
+
await archiveRaw(file, join(paths(runtime.storeDir).raw, project, ...transcript.relative.split('/')));
|
|
322
|
+
}
|
|
323
|
+
const key = `${project}/${transcript.relative}`;
|
|
324
|
+
const previous = state.files[key];
|
|
325
|
+
if (previous !== undefined &&
|
|
326
|
+
previous.size === info.size &&
|
|
327
|
+
previous.mtimeMs === info.mtimeMs &&
|
|
328
|
+
previous.v === CAPTURE_VERSION &&
|
|
329
|
+
previous.s === context.salt.sid &&
|
|
330
|
+
sameSet(previous.d, context.domains)) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
const rev = Math.max((previous?.rev ?? 0) + 1, context.now());
|
|
334
|
+
const extract = captureSession({
|
|
335
|
+
text: await readFile(file, 'utf8'),
|
|
336
|
+
session,
|
|
337
|
+
project,
|
|
338
|
+
rev,
|
|
339
|
+
at: context.at,
|
|
340
|
+
bytes: info.size,
|
|
341
|
+
redact: context.redact,
|
|
342
|
+
salt: context.salt.salt,
|
|
343
|
+
sid: context.salt.sid,
|
|
344
|
+
repoRoot: context.repoRoot,
|
|
345
|
+
repoIdentity: context.repoIdentity,
|
|
346
|
+
runtime: context.captureRuntime,
|
|
347
|
+
repoSize: await earlierRepoSize(context, previous, session),
|
|
348
|
+
subagent: await originOf(context, project, transcript),
|
|
349
|
+
});
|
|
350
|
+
await commitCapture(context, key, extract, { size: info.size, mtimeMs: info.mtimeMs, rev });
|
|
351
|
+
return extract;
|
|
352
|
+
};
|
|
353
|
+
export const newTrace = () => ({ kinds: {} });
|
|
354
|
+
// A subagent transcript's prompts, tools, and error lines are counted under the same kinds as a main session's; only the transcript itself
|
|
355
|
+
// is counted apart (`subagent`). Spec 3.2's "by kind, main and subagent" is read as that split of the transcripts.
|
|
356
|
+
const tally = (trace, extract, subagent) => {
|
|
357
|
+
if (trace === undefined)
|
|
358
|
+
return;
|
|
359
|
+
const add = (kind, count) => {
|
|
360
|
+
trace.kinds[kind] = (trace.kinds[kind] ?? 0) + count;
|
|
361
|
+
};
|
|
362
|
+
add(subagent ? 'subagent' : 'session', 1);
|
|
363
|
+
add('user-prompt', extract.prompts.length);
|
|
364
|
+
add('tool', extract.tools.length);
|
|
365
|
+
add('error-line', extract.errorLines.length);
|
|
366
|
+
};
|
|
367
|
+
export const digest = (parts) => createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
|
|
368
|
+
/**
|
|
369
|
+
* Rewrites `derived.json` (the derive step), then the hook index (the index step), from the whole store. With a trace, a failure of
|
|
370
|
+
* either is recorded on it instead of thrown, so the caller can log the deposit that already wrote its records, then the failed step
|
|
371
|
+
* (spec 3.1: one row per step, a failed derive leaving the index skipped), and rethrow.
|
|
372
|
+
*/
|
|
373
|
+
export const refresh = async (runtime, redactDomains, at, trace, held = () => Promise.resolve(true)) => {
|
|
374
|
+
const started = performance.now();
|
|
375
|
+
// The extractor version alone until the store is read: a derive that fails before the high-water mark is known still has a key (spec 4.4).
|
|
376
|
+
let key = digest([EXTRACTOR_VERSION]);
|
|
377
|
+
let store;
|
|
378
|
+
try {
|
|
379
|
+
store = await loadStore(runtime.storeDir);
|
|
380
|
+
// The key is the account's capture high-water mark (the highest rev of each session) and `ev` (spec 4.2); the index shares it.
|
|
381
|
+
const highWater = [...store.sessions, ...store.subagents].map((session) => `${session.session}:${String(session.rev)}`).sort();
|
|
382
|
+
key = digest([EXTRACTOR_VERSION, ...highWater]);
|
|
383
|
+
const derived = derive(store, at);
|
|
384
|
+
await requireHold(held);
|
|
385
|
+
await writeAtomic(paths(runtime.storeDir).derived, JSON.stringify(derived));
|
|
386
|
+
if (trace !== undefined) {
|
|
387
|
+
trace.derive = {
|
|
388
|
+
ms: Math.round(performance.now() - started),
|
|
389
|
+
in: { session: store.sessions.length, subagent: store.subagents.length, 'user-prompt': store.prompts.length, tool: store.tools.length },
|
|
390
|
+
out: { signature: derived.signatures.length, summary: derived.summaries.length },
|
|
391
|
+
key,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (error instanceof StoreLockLostError || trace === undefined)
|
|
397
|
+
throw error;
|
|
398
|
+
trace.deriveFailure = { error, key, ms: Math.round(performance.now() - started) };
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// The pool is derived for the index's fit reads only: one that cannot be read or written costs the index those, never the index itself (`currentPool` says so on stderr). Loaded here, not at the top: the pool imports every read family, and a lookup hook never reaches this step. It runs before the clock starts, so the index's `ms` is the index and not the derive (the first pool run takes 12 to 17 s on a store copy).
|
|
402
|
+
// A module that will not load costs the index the same fit reads, with one line on stderr, as `currentPool` does when the pool cannot be built.
|
|
403
|
+
const current = await import('./pool.js').then(async ({ currentPool }) => currentPool(runtime.storeDir, store), (error) => {
|
|
404
|
+
process.stderr.write(`oars: the read pool module could not be loaded, and the index keeps no fit reads: ${error instanceof Error ? error.message : String(error)}
|
|
405
|
+
`);
|
|
406
|
+
return undefined;
|
|
407
|
+
});
|
|
408
|
+
const reads = current?.reads ?? [];
|
|
409
|
+
const indexStarted = performance.now();
|
|
410
|
+
try {
|
|
411
|
+
// The next-node reads for each harness and version, derived over the same pool; a deposit that has no pool leaves the index without them.
|
|
412
|
+
const next = current === undefined ? undefined : (await import('./recall-frames.js')).nextByFrame(current.pool, store);
|
|
413
|
+
const index = buildIndex(store, redactDomains, at, reads, next);
|
|
414
|
+
await requireHold(held);
|
|
415
|
+
await writeAtomic(indexPath(runtime.storeDir), JSON.stringify(index));
|
|
416
|
+
if (trace !== undefined) {
|
|
417
|
+
trace.index = {
|
|
418
|
+
ms: Math.round(performance.now() - indexStarted),
|
|
419
|
+
in: { session: store.sessions.length, subagent: store.subagents.length, error: store.errors.length },
|
|
420
|
+
out: { signature: Object.keys(index.signatures).length, project: Object.keys(index.projects).length },
|
|
421
|
+
key,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (error instanceof StoreLockLostError || trace === undefined)
|
|
427
|
+
throw error;
|
|
428
|
+
trace.indexFailure = { error, key, ms: Math.round(performance.now() - indexStarted) };
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
/** Appends a capture's records, writes its skeleton, and records the cursor in the state map (the caller writes `state.json`). */
|
|
432
|
+
const commitCapture = async (context, key, extract, cursor) => {
|
|
433
|
+
const files = paths(context.runtime.storeDir);
|
|
434
|
+
const batch = [extract.session, ...extract.prompts, ...extract.tools, ...extract.errorLines];
|
|
435
|
+
await mkdir(context.runtime.storeDir, { recursive: true });
|
|
436
|
+
await requireHold(context.held);
|
|
437
|
+
await appendFile(files.observations, batch.map((observation) => `${JSON.stringify(observation)}\n`).join(''), { mode: 0o600 });
|
|
438
|
+
await requireHold(context.held);
|
|
439
|
+
await writeAtomic(join(files.skeletons, `${extract.session.session}.json`), JSON.stringify(extract.skeleton));
|
|
440
|
+
context.state.files[key] = { ...cursor, v: CAPTURE_VERSION, s: context.salt.sid, d: context.domains, repoSize: extract.session.frame?.stack.size ?? null };
|
|
441
|
+
};
|
|
442
|
+
/** Another deposit holds the store lock and the wait ran out: the one failure the Stop hook defers instead of counting as a failed run. */
|
|
443
|
+
export class StoreLockedError extends CliError {
|
|
444
|
+
constructor() {
|
|
445
|
+
super('The store is locked by another deposit; try again shortly.', ExitCode.UNEXPECTED, 'unexpected');
|
|
446
|
+
this.name = 'StoreLockedError';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** The store lock was broken and taken by another run while this one worked: it writes nothing more. */
|
|
450
|
+
export class StoreLockLostError extends CliError {
|
|
451
|
+
constructor() {
|
|
452
|
+
super('This run lost the store lock to another deposit while it worked, so it wrote no more; its captures are redone by the next deposit. Rerun it.', ExitCode.UNEXPECTED, 'unexpected');
|
|
453
|
+
this.name = 'StoreLockLostError';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
457
|
+
const isCode = (error, ...codes) => error instanceof Error && 'code' in error && typeof error.code === 'string' && codes.includes(error.code);
|
|
458
|
+
/**
|
|
459
|
+
* Creates `file` only if absent. False when it exists; EPERM or EACCES is Windows saying the previous holder's delete has not
|
|
460
|
+
* finished, so it is returned (not thrown) and `withLock` rethrows it when the wait runs out with no lock file standing.
|
|
461
|
+
*/
|
|
462
|
+
const claim = async (file, token = String(process.pid)) => {
|
|
463
|
+
try {
|
|
464
|
+
await writeFile(file, token, { flag: 'wx', mode: 0o600 });
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (isCode(error, 'EEXIST'))
|
|
469
|
+
return false;
|
|
470
|
+
if (isCode(error, 'EPERM', 'EACCES') && error instanceof Error)
|
|
471
|
+
return error;
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Breaks `lock` when it is older than `staleMs`. Only one waiter at a time may judge and remove it (a second `store.lock.break` claim),
|
|
477
|
+
* and the age is read again inside that claim, so a waiter that lost the race finds the winner's fresh lock and leaves it.
|
|
478
|
+
*/
|
|
479
|
+
const breakStale = async (lock, staleMs) => {
|
|
480
|
+
const guard = `${lock}.break`;
|
|
481
|
+
if ((await claim(guard)) !== true) {
|
|
482
|
+
const guardAge = await stat(guard).then((info) => Date.now() - info.mtimeMs, () => 0);
|
|
483
|
+
if (guardAge > staleMs)
|
|
484
|
+
await rm(guard, { force: true });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
|
|
489
|
+
if (age > staleMs) {
|
|
490
|
+
const holder = ((await readFile(lock, 'utf8').catch(() => '')).trim().split(' ')[0]) ?? '';
|
|
491
|
+
await rm(lock, { force: true });
|
|
492
|
+
process.stderr.write(`oars: broke the store lock held by pid ${holder === '' ? 'unknown' : holder}, not refreshed for over ${String(staleMs)} ms\n`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
await rm(guard, { force: true });
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
/**
|
|
500
|
+
* Runs `work` as the only writer of `state.json` and the observations file. The SessionEnd hooks of parallel sessions and the nightly
|
|
501
|
+
* run each read, extract, and rewrite `state.json`; without this one's `files` and `allow` entries are lost to another's write.
|
|
502
|
+
*/
|
|
503
|
+
export const withLock = async (storeDir, work, options = { waitMs: 10_000, staleMs: 600_000 }) => {
|
|
504
|
+
const lock = join(storeDir, 'store.lock');
|
|
505
|
+
await mkdir(storeDir, { recursive: true });
|
|
506
|
+
const started = Date.now();
|
|
507
|
+
// The token names this claim, so a holder whose lock was broken cannot delete the next holder's lock when it ends.
|
|
508
|
+
const token = `${String(process.pid)} ${randomUUID()}`;
|
|
509
|
+
let claimed = await claim(lock, token);
|
|
510
|
+
while (claimed !== true) {
|
|
511
|
+
await breakStale(lock, options.staleMs);
|
|
512
|
+
if (Date.now() - started >= options.waitMs) {
|
|
513
|
+
if (claimed instanceof Error && !(await stat(lock).then(() => true, (error) => !isCode(error, 'ENOENT'))))
|
|
514
|
+
throw claimed;
|
|
515
|
+
throw new StoreLockedError();
|
|
516
|
+
}
|
|
517
|
+
await sleep(25);
|
|
518
|
+
claimed = await claim(lock, token);
|
|
519
|
+
}
|
|
520
|
+
// A waiter breaks a lock older than `staleMs`, and a full re-capture runs longer than that: the holder keeps its lock young, or a hook
|
|
521
|
+
// would break it and append during the compaction that ends the run.
|
|
522
|
+
const holdMs = options.holdMs ?? options.staleMs * 6;
|
|
523
|
+
// Beats are counted, not timed: a suspended machine fires one tick on wake, and elapsed time would read that as a hung holder.
|
|
524
|
+
let beats = 0;
|
|
525
|
+
const heartbeat = setInterval(() => {
|
|
526
|
+
if (++beats * (options.staleMs / 4) >= holdMs) {
|
|
527
|
+
clearInterval(heartbeat);
|
|
528
|
+
process.stderr.write(`oars: this run held the store lock past ${String(holdMs)} ms and may lose it; rerun it when it ends\n`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const now = new Date();
|
|
532
|
+
void utimes(lock, now, now).catch(() => undefined);
|
|
533
|
+
}, options.staleMs / 4);
|
|
534
|
+
heartbeat.unref();
|
|
535
|
+
// The heartbeat stops at the hold cap but the work does not, so a waiter can break the lock and a second run take it while this one is still
|
|
536
|
+
// going. This run then must not write what it read at the start (`state.json`'s `allow` and `redactDomains`, the log's rename) over the second
|
|
537
|
+
// run's. A refused read counts as ours, as it does in the release below.
|
|
538
|
+
const held = async () => (await readFile(lock, 'utf8').catch((error) => (isCode(error, 'ENOENT') ? '' : token))) === token;
|
|
539
|
+
try {
|
|
540
|
+
return await work(held);
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
clearInterval(heartbeat);
|
|
544
|
+
// A refused read (an antivirus share violation) counts as ours, so the lock is not left standing for the stale window; `rm` retries a share violation, and a refusal that outlasts the retries is swallowed so the finished work's result is not lost (the stale window then clears the lock).
|
|
545
|
+
if ((await readFile(lock, 'utf8').catch(() => token)) === token)
|
|
546
|
+
await rm(lock, { force: true, recursive: true, maxRetries: 3, retryDelay: 50 }).catch(() => undefined);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
export const deposit = (runtime, options, trace) => withLock(runtime.storeDir, (holds) => depositLocked(runtime, options, holds, trace));
|
|
550
|
+
/** Throws `StoreLockLostError` when this run's token no longer stands. */
|
|
551
|
+
const requireHold = async (held) => {
|
|
552
|
+
if (!(await held()))
|
|
553
|
+
throw new StoreLockLostError();
|
|
554
|
+
};
|
|
555
|
+
/** Writes `state.json` only while this run holds the lock; a run that lost it throws before the write, so the holder that took over keeps its entries. */
|
|
556
|
+
const writeStateHeld = async (runtime, state, held) => {
|
|
557
|
+
await requireHold(held);
|
|
558
|
+
await writeAtomic(paths(runtime.storeDir).state, JSON.stringify(state));
|
|
559
|
+
};
|
|
560
|
+
const depositLocked = async (runtime, options, holds, trace) => {
|
|
561
|
+
const state = await readState(runtime.storeDir);
|
|
562
|
+
const entries = runAllow(state.allow, options.allow);
|
|
563
|
+
const allow = compileAllow(entries);
|
|
564
|
+
const folders = await projectFolders(runtime.projectsRoot);
|
|
565
|
+
const allowed = folders.filter((name) => allow.allows(name));
|
|
566
|
+
const roots = sourceRoots(runtime);
|
|
567
|
+
const table = sourcesOf(runtime);
|
|
568
|
+
const missing = allow.unmatched([...folders, ...table.filter((source) => roots.has(source.name)).map((source) => source.name)]);
|
|
569
|
+
refuseNewer(state);
|
|
570
|
+
// A log written by a newer oars is refused even when state.json is older or lost; the Stop-hook path reads only state.json.
|
|
571
|
+
const held = (await scanLog(paths(runtime.storeDir).observations)).newerCaptures;
|
|
572
|
+
if (held > 0)
|
|
573
|
+
throw new CliError(`The log holds captures by a newer oars (sessions: ${String(held)}); upgrade oars.`, ExitCode.UNEXPECTED, 'unexpected');
|
|
574
|
+
state.redactDomains = [...new Set([...state.redactDomains, ...options.redactDomains])];
|
|
575
|
+
state.allow = mergeAllow(state.allow, options.allow);
|
|
576
|
+
const context = await contextFor(runtime, state, options.now, holds);
|
|
577
|
+
const result = {
|
|
578
|
+
sessions: 0,
|
|
579
|
+
unchanged: 0,
|
|
580
|
+
lines: 0,
|
|
581
|
+
badLines: 0,
|
|
582
|
+
observations: 0,
|
|
583
|
+
subagents: 0,
|
|
584
|
+
sources: {},
|
|
585
|
+
cursor: 0,
|
|
586
|
+
cursorUnreadable: [],
|
|
587
|
+
cursorUnreadableLogged: [],
|
|
588
|
+
cursorRejected: 0,
|
|
589
|
+
skippedFolders: folders.length - allowed.length,
|
|
590
|
+
folders: allowed,
|
|
591
|
+
missing,
|
|
592
|
+
};
|
|
593
|
+
const record = (extract, subagent) => {
|
|
594
|
+
if (subagent)
|
|
595
|
+
result.subagents += 1;
|
|
596
|
+
else
|
|
597
|
+
result.sessions += 1;
|
|
598
|
+
result.lines += extract.session.lines;
|
|
599
|
+
result.badLines += extract.session.badLines;
|
|
600
|
+
result.observations += 1 + extract.prompts.length + extract.tools.length + extract.errorLines.length;
|
|
601
|
+
};
|
|
602
|
+
// Sessions a Stop hook deferred come first, then this run's own walk; their entries go once `state.json` is written below. What the drain
|
|
603
|
+
// deposited is in the result, and the walk finds those files current without counting them as unchanged.
|
|
604
|
+
const drainedKeys = new Set();
|
|
605
|
+
const drained = await drainPending(context, state, runtime.storeDir, Number.POSITIVE_INFINITY, trace, (extract, subagent, key) => {
|
|
606
|
+
drainedKeys.add(key);
|
|
607
|
+
record(extract, subagent);
|
|
608
|
+
});
|
|
609
|
+
await requireHold(context.held);
|
|
610
|
+
await sweepPendingTemps(runtime.storeDir, HOOK_LOCK.staleMs);
|
|
611
|
+
// A project the harness cleaned up entirely is still visited when the state names it, so its archived transcripts are captured again.
|
|
612
|
+
const stateProjects = Object.keys(state.files).map((key) => key.slice(0, key.indexOf('/'))).filter((name) => !isSource(table, name) && allow.allows(name));
|
|
613
|
+
// The same holds for a lost state.json: raw/ still names the projects whose transcripts can be captured again.
|
|
614
|
+
const rawProjects = (await listDir(paths(runtime.storeDir).raw)).filter((entry) => entry.isDirectory() && !isSource(table, entry.name) && allow.allows(entry.name)).map((entry) => entry.name);
|
|
615
|
+
for (const project of [...new Set([...allowed, ...stateProjects, ...rawProjects])].sort()) {
|
|
616
|
+
const folder = join(runtime.projectsRoot, project);
|
|
617
|
+
const entries = (await listDir(folder)).filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')).map((entry) => entry.name).sort();
|
|
618
|
+
const onDisk = [...entries.map((name) => mainTranscript(folder, name)), ...(await subagentTranscripts(folder, await sessionFolders(folder)))];
|
|
619
|
+
const transcripts = [...onDisk, ...(await rawOnlyTranscripts(context, project, new Set(onDisk.map((transcript) => `${project}/${transcript.relative}`))))];
|
|
620
|
+
for (const transcript of transcripts) {
|
|
621
|
+
const extract = await depositFile(context, project, transcript);
|
|
622
|
+
if (extract === undefined) {
|
|
623
|
+
if (!drainedKeys.has(`${project}/${transcript.relative}`))
|
|
624
|
+
result.unchanged += 1;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
record(extract, transcript.subagent !== undefined);
|
|
628
|
+
tally(trace, extract, transcript.subagent !== undefined);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const source of table) {
|
|
632
|
+
const root = roots.get(source.name);
|
|
633
|
+
if (root !== undefined && allow.allows(source.name))
|
|
634
|
+
await readSource(context, source, root, result, trace);
|
|
635
|
+
}
|
|
636
|
+
const cursor = result.sources.cursor;
|
|
637
|
+
if (cursor !== undefined) {
|
|
638
|
+
result.cursor = cursor.read;
|
|
639
|
+
result.cursorUnreadable = cursor.unreadable;
|
|
640
|
+
result.cursorUnreadableLogged = cursor.unreadableLogged;
|
|
641
|
+
result.cursorRejected = cursor.rejected;
|
|
642
|
+
}
|
|
643
|
+
await writeStateHeld(runtime, state, holds);
|
|
644
|
+
await requireHold(context.held);
|
|
645
|
+
await removePending(runtime.storeDir, drained.settled);
|
|
646
|
+
// Decided by what the log holds, not by what this run captured: a run that died after its re-capture left the copies for this one. The hook
|
|
647
|
+
// path never compacts, since it has seconds. The records are safe by now and an uncompacted log still loads, so a failure here does not fail the deposit.
|
|
648
|
+
await compactLog(runtime.storeDir, LOG_RENAME_WAIT_MS, holds).catch((error) => {
|
|
649
|
+
if (error instanceof StoreLockLostError)
|
|
650
|
+
throw error;
|
|
651
|
+
process.stderr.write(`oars: the session log was not compacted, and the next deposit tries again: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
652
|
+
});
|
|
653
|
+
await refresh(runtime, state.redactDomains, context.at, trace, holds);
|
|
654
|
+
return result;
|
|
655
|
+
};
|
|
656
|
+
/** The table in force: the runtime's own (a test's), else every source. */
|
|
657
|
+
const sourcesOf = (runtime) => runtime.sources ?? SOURCES;
|
|
658
|
+
/** A source's name is the first `state.json` key segment, and no project folder uses it, so the Claude Code walk skips it. */
|
|
659
|
+
const isSource = (table, name) => table.some((source) => source.name === name);
|
|
660
|
+
/** The roots the runtime names, by source name; `cursorRoot` stays the alias of the Cursor entry. */
|
|
661
|
+
const sourceRoots = (runtime) => new Map(Object.entries({ ...(runtime.cursorRoot === undefined ? {} : { cursor: runtime.cursorRoot }), ...runtime.sourceRoots }));
|
|
662
|
+
/** Reports an unreadable source store or file, once per reason: the full path for the terminal, the base name for the hook-call record. */
|
|
663
|
+
const noteReason = (counts, file, reason) => {
|
|
664
|
+
const line = `${file}: ${reason}`;
|
|
665
|
+
if (counts.unreadable.includes(line))
|
|
666
|
+
return;
|
|
667
|
+
counts.unreadable.push(line);
|
|
668
|
+
counts.unreadableLogged.push(`${basename(file)}: ${reason}`);
|
|
669
|
+
};
|
|
670
|
+
const noteUnreadable = (counts, file, error) => {
|
|
671
|
+
noteReason(counts, file, error instanceof Error ? error.message : String(error));
|
|
672
|
+
};
|
|
673
|
+
/**
|
|
674
|
+
* Deposits the sessions of one non-Claude source under `root`. A session's cursor is its size and
|
|
675
|
+
* mtime (for Cursor, its message count and `lastUpdatedAt`), kept in `state.json` like a transcript file's, so a second deposit adds nothing
|
|
676
|
+
* until the harness writes the session again. The store is opened read-only and a missing one is skipped silently. Nothing is archived: a
|
|
677
|
+
* source keeps its own history, and this store never copies it.
|
|
678
|
+
*/
|
|
679
|
+
const readSource = async (context, source, root, result, trace) => {
|
|
680
|
+
const counts = { read: 0, unreadable: [], unreadableLogged: [], rejected: 0 };
|
|
681
|
+
// A source fails open on its own: only its read is guarded, and the store's write failures (a full disk) propagate as they do on the
|
|
682
|
+
// Claude Code path instead of blaming the source's file.
|
|
683
|
+
let store;
|
|
684
|
+
try {
|
|
685
|
+
store = await source.open(root);
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
noteUnreadable(counts, source.storeFile(root), error);
|
|
689
|
+
result.sources[source.name] = counts;
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (store === undefined)
|
|
693
|
+
return;
|
|
694
|
+
result.sources[source.name] = counts;
|
|
695
|
+
counts.rejected += store.rejected;
|
|
696
|
+
for (const entry of store.unreadable)
|
|
697
|
+
noteReason(counts, entry.file, entry.reason);
|
|
698
|
+
try {
|
|
699
|
+
for (const session of store.sessions) {
|
|
700
|
+
const key = `${source.name}/${session.id}`;
|
|
701
|
+
const previous = context.state.files[key];
|
|
702
|
+
if (previous !== undefined &&
|
|
703
|
+
previous.size === session.size &&
|
|
704
|
+
previous.mtimeMs === session.mtimeMs &&
|
|
705
|
+
previous.v === CAPTURE_VERSION &&
|
|
706
|
+
previous.s === context.salt.sid &&
|
|
707
|
+
sameSet(previous.d, context.domains)) {
|
|
708
|
+
result.unchanged += 1;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
let text;
|
|
712
|
+
try {
|
|
713
|
+
text = await store.transcript(session);
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
noteUnreadable(counts, store.where(session), error);
|
|
717
|
+
if (source.stopOnTranscriptError === true)
|
|
718
|
+
break;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const rev = Math.max((previous?.rev ?? 0) + 1, context.now());
|
|
722
|
+
const id = `${source.name}-${session.id}`;
|
|
723
|
+
const extract = captureSession({
|
|
724
|
+
text,
|
|
725
|
+
session: id,
|
|
726
|
+
project: session.folder === null ? source.name : slugOf(session.folder),
|
|
727
|
+
rev,
|
|
728
|
+
at: context.at,
|
|
729
|
+
bytes: Buffer.byteLength(text),
|
|
730
|
+
redact: context.redact,
|
|
731
|
+
salt: context.salt.salt,
|
|
732
|
+
sid: context.salt.sid,
|
|
733
|
+
repoRoot: context.repoRoot,
|
|
734
|
+
repoIdentity: context.repoIdentity,
|
|
735
|
+
runtime: context.captureRuntime,
|
|
736
|
+
subagent: session.subagent,
|
|
737
|
+
repoSize: await earlierRepoSize(context, previous, id),
|
|
738
|
+
harness: source.harness,
|
|
739
|
+
});
|
|
740
|
+
// The input names the frame and the action table; the session record carries the harness for `harnessOf`, `books --by harness`, and `measure`.
|
|
741
|
+
extract.session.harness = source.harness;
|
|
742
|
+
await commitCapture(context, key, extract, { size: session.size, mtimeMs: session.mtimeMs, rev });
|
|
743
|
+
tally(trace, extract, session.subagent !== undefined);
|
|
744
|
+
if (session.subagent === undefined) {
|
|
745
|
+
result.sessions += 1;
|
|
746
|
+
counts.read += 1;
|
|
747
|
+
}
|
|
748
|
+
else
|
|
749
|
+
result.subagents += 1;
|
|
750
|
+
result.lines += extract.session.lines;
|
|
751
|
+
result.badLines += extract.session.badLines;
|
|
752
|
+
result.observations += 1 + extract.prompts.length + extract.tools.length + extract.errorLines.length;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
store.close();
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
/**
|
|
760
|
+
* The log's lines, a chunk at a time: the log can pass the longest string Node can hold (536,870,888 characters), so it is never one string.
|
|
761
|
+
* It splits on the newline alone, not through `readline`, which also ends a line at U+2028 and U+2029: `JSON.stringify` leaves those raw inside a
|
|
762
|
+
* value. A missing log has no lines, and a last line with no newline is yielded as it stands, for the reader to parse or skip.
|
|
763
|
+
*/
|
|
764
|
+
export const logLines = async function* (file, held) {
|
|
765
|
+
let rest = '';
|
|
766
|
+
try {
|
|
767
|
+
// From a handle, each call reads from byte 0 of the same open file, which a compaction's rename leaves as it was; `autoClose` is off so the caller closes it.
|
|
768
|
+
const stream = held === undefined ? createReadStream(file, { encoding: 'utf8', highWaterMark: 1 << 20 }) : held.createReadStream({ encoding: 'utf8', highWaterMark: 1 << 20, start: 0, autoClose: false });
|
|
769
|
+
for await (const chunk of stream) {
|
|
770
|
+
const lines = (rest + String(chunk)).split('\n');
|
|
771
|
+
rest = lines.pop() ?? '';
|
|
772
|
+
yield* lines;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
if (!isMissing(error))
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
yield rest;
|
|
780
|
+
};
|
|
781
|
+
/** The log opened for reading, or undefined when there is none. A reader that makes more than one pass holds this open across them (`loadStore`). */
|
|
782
|
+
const openLog = async (file) => {
|
|
783
|
+
try {
|
|
784
|
+
return await open(file, 'r');
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
if (!isMissing(error))
|
|
788
|
+
throw error;
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
const loggedSessions = async (storeDir) => {
|
|
793
|
+
const origins = new Map();
|
|
794
|
+
const repoSizes = new Map();
|
|
795
|
+
for await (const line of logLines(paths(storeDir).observations)) {
|
|
796
|
+
if (!line.includes('"kind":"session"'))
|
|
797
|
+
continue;
|
|
798
|
+
try {
|
|
799
|
+
const parsed = JSON.parse(line);
|
|
800
|
+
if (!isRecord(parsed) || parsed.kind !== 'session' || typeof parsed.session !== 'string')
|
|
801
|
+
continue;
|
|
802
|
+
if (typeof parsed.parent === 'string') {
|
|
803
|
+
origins.set(parsed.session, {
|
|
804
|
+
parent: parsed.parent,
|
|
805
|
+
agentType: typeof parsed.agentType === 'string' ? parsed.agentType : null,
|
|
806
|
+
spawnedBy: typeof parsed.spawnedBy === 'string' ? parsed.spawnedBy : null,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
const size = isRecord(parsed.frame) && isRecord(parsed.frame.stack) ? asRepoSize(parsed.frame.stack.size) : undefined;
|
|
810
|
+
if (size !== undefined && !repoSizes.has(parsed.session))
|
|
811
|
+
repoSizes.set(parsed.session, size);
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
// A torn line is skipped.
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return { origins, repoSizes };
|
|
818
|
+
};
|
|
819
|
+
/** The session id of a transcript's path under its project folder: `<id>.jsonl`, or `<parent>~<path>~agent-x` for a subagent transcript. */
|
|
820
|
+
const sessionOfRelative = (relative) => {
|
|
821
|
+
const parts = relative.split('/');
|
|
822
|
+
const agent = (parts[parts.length - 1] ?? '').slice(0, -'.jsonl'.length);
|
|
823
|
+
return parts.length === 1 ? agent : [parts[0] ?? '', ...parts.slice(2, -1).filter((part) => part !== 'workflows'), agent].join('~');
|
|
824
|
+
};
|
|
825
|
+
/** The inverse of `sessionOfRelative`: `<id>.jsonl`, or `<parent>/subagents/[workflows/<wf>/]<agent>.jsonl` for a `<parent>~[<wf>~]agent-x` id. */
|
|
826
|
+
export const relativeOfSession = (session) => {
|
|
827
|
+
const [parent = '', ...rest] = session.split('~');
|
|
828
|
+
if (rest.length === 0)
|
|
829
|
+
return [`${session}.jsonl`];
|
|
830
|
+
const agent = rest[rest.length - 1] ?? '';
|
|
831
|
+
const workflow = rest.slice(0, -1);
|
|
832
|
+
return [parent, 'subagents', ...(workflow.length === 0 ? [] : ['workflows', ...workflow]), `${agent}.jsonl`];
|
|
833
|
+
};
|
|
834
|
+
/**
|
|
835
|
+
* The sessions of `behind` (captured before the current version) whose source is gone (the transcript from both the harness and `raw/`, or the
|
|
836
|
+
* session from its source's store, such as a Cursor composer): they can never be captured again, so `verify` counts them `cv-frozen` instead of failing `--strict` over them.
|
|
837
|
+
*/
|
|
838
|
+
export const frozenSessions = async (runtime, behind) => {
|
|
839
|
+
const wanted = new Set(behind.map((entry) => `${entry.project}/${entry.session}`));
|
|
840
|
+
if (wanted.size === 0)
|
|
841
|
+
return new Set();
|
|
842
|
+
const exists = (file) => stat(file).then(() => true, () => false);
|
|
843
|
+
const alive = new Set();
|
|
844
|
+
for (const key of Object.keys((await readState(runtime.storeDir)).files)) {
|
|
845
|
+
const [project = '', ...rest] = key.split('/');
|
|
846
|
+
const id = `${project}/${sessionOfRelative(rest.join('/'))}`;
|
|
847
|
+
if (wanted.has(id) && !alive.has(id) && ((await exists(join(runtime.projectsRoot, key))) || (await exists(join(paths(runtime.storeDir).raw, key)))))
|
|
848
|
+
alive.add(id);
|
|
849
|
+
}
|
|
850
|
+
// A lost or unreadable state.json lists no files, so a transcript is also looked up by the path its session id implies.
|
|
851
|
+
for (const entry of behind) {
|
|
852
|
+
const id = `${entry.project}/${entry.session}`;
|
|
853
|
+
const relative = join(entry.project, ...relativeOfSession(entry.session));
|
|
854
|
+
if (!alive.has(id) && ((await exists(join(runtime.projectsRoot, relative))) || (await exists(join(paths(runtime.storeDir).raw, relative)))))
|
|
855
|
+
alive.add(id);
|
|
856
|
+
}
|
|
857
|
+
const roots = sourceRoots(runtime);
|
|
858
|
+
for (const source of sourcesOf(runtime)) {
|
|
859
|
+
const prefix = `${source.name}-`;
|
|
860
|
+
const pending = behind.filter((entry) => entry.session.startsWith(prefix) && !alive.has(`${entry.project}/${entry.session}`));
|
|
861
|
+
const root = roots.get(source.name);
|
|
862
|
+
if (pending.length === 0 || root === undefined)
|
|
863
|
+
continue;
|
|
864
|
+
// A source session has no transcript file here: its origin is the source's own store, which the next deposit reads again while the
|
|
865
|
+
// harness still lists it. An unreadable store counts as alive too, since it may read again once the harness releases it.
|
|
866
|
+
let listed;
|
|
867
|
+
try {
|
|
868
|
+
const store = await source.open(root);
|
|
869
|
+
listed = store !== undefined && store.unreadable.length > 0 ? undefined : new Set((store?.sessions ?? []).map((session) => `${prefix}${session.id}`));
|
|
870
|
+
store?.close();
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
listed = undefined;
|
|
874
|
+
}
|
|
875
|
+
for (const entry of pending)
|
|
876
|
+
if (listed === undefined || listed.has(entry.session))
|
|
877
|
+
alive.add(`${entry.project}/${entry.session}`);
|
|
878
|
+
}
|
|
879
|
+
return new Set(behind.filter((entry) => !alive.has(`${entry.project}/${entry.session}`)).map((entry) => entry.session));
|
|
880
|
+
};
|
|
881
|
+
/**
|
|
882
|
+
* Transcripts the harness no longer has but the raw archive does, whose last capture is behind this code or used another salt: they are
|
|
883
|
+
* captured again from `<store>/raw/` (spec Section 4, re-capture source). One with neither stays as logged, read through the compatibility reader.
|
|
884
|
+
*/
|
|
885
|
+
const rawOnlyTranscripts = async (context, project, onDisk) => {
|
|
886
|
+
const { storeDir } = context.runtime;
|
|
887
|
+
const prefix = `${project}/`;
|
|
888
|
+
const root = join(paths(storeDir).raw, project);
|
|
889
|
+
// Listed from raw/ itself, so a lost state.json (no entries to name the files) still finds them; a file with no entry counts as behind.
|
|
890
|
+
const behind = (await rawRelatives(root, [])).filter((relative) => {
|
|
891
|
+
const entry = context.state.files[`${prefix}${relative}`];
|
|
892
|
+
// A domain named after the capture changes what the redactor masks, so a file recorded under another domain set is behind too.
|
|
893
|
+
return !onDisk.has(`${prefix}${relative}`) && (entry === undefined || entry.v !== CAPTURE_VERSION || entry.s !== context.salt.sid || !sameSet(entry.d, context.domains));
|
|
894
|
+
});
|
|
895
|
+
const found = [];
|
|
896
|
+
for (const relative of behind) {
|
|
897
|
+
const file = join(root, ...relative.split('/'));
|
|
898
|
+
const parts = relative.split('/');
|
|
899
|
+
if (parts.length === 1) {
|
|
900
|
+
found.push({ ...mainTranscript(dirname(file), relative), fromRaw: true });
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const { origins } = await context.logged();
|
|
904
|
+
const session = sessionOfRelative(relative);
|
|
905
|
+
const origin = origins.get(session) ?? { parent: parts[0] ?? '', agentType: null, spawnedBy: null };
|
|
906
|
+
found.push({ file, session, relative, subagent: origin, fromRaw: true });
|
|
907
|
+
}
|
|
908
|
+
return found;
|
|
909
|
+
};
|
|
910
|
+
/** The `.jsonl` files under a `raw/<project>/` folder, as paths relative to it. */
|
|
911
|
+
const rawRelatives = async (dir, path) => {
|
|
912
|
+
const found = [];
|
|
913
|
+
for (const entry of await listDir(dir)) {
|
|
914
|
+
if (entry.isDirectory())
|
|
915
|
+
found.push(...(await rawRelatives(join(dir, entry.name), [...path, entry.name])));
|
|
916
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
917
|
+
found.push([...path, entry.name].join('/'));
|
|
918
|
+
}
|
|
919
|
+
return found.sort();
|
|
920
|
+
};
|
|
921
|
+
const HOOK_LOCK = { waitMs: 3_000, staleMs: 600_000 };
|
|
922
|
+
/**
|
|
923
|
+
* Deposits one transcript, as the SessionEnd hook does: only from a folder that earlier `--allow` entries name, and idempotent by size and
|
|
924
|
+
* mtime. It does not rebuild `derived.json` or the hook index (that is a whole-store pass; the nightly run does it), so it stays fast.
|
|
925
|
+
* Under the lock it first takes up to `PENDING_HOOK_CAP` sessions an earlier hook deferred, then its own. If the lock is still held after the
|
|
926
|
+
* wait it records the session in the pending list, which needs no lock, and returns `deferred`: the next deposit of any kind takes it.
|
|
927
|
+
*/
|
|
928
|
+
export const depositSession = async (runtime, transcript, now, trace) => {
|
|
929
|
+
try {
|
|
930
|
+
// The hook path waits 3 s for the lock, well under the harness's 30 s ceiling on the hook (install.ts), so a SessionEnd behind the nightly deposit cannot hold session exit.
|
|
931
|
+
return await withLock(runtime.storeDir, (held) => depositSessionLocked(runtime, transcript, now, held, trace), HOOK_LOCK);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
if (!(error instanceof StoreLockedError))
|
|
935
|
+
throw error;
|
|
936
|
+
try {
|
|
937
|
+
// The allowlist holds without the lock: a session no `--allow` names writes nothing, not even a pending entry.
|
|
938
|
+
if (!compileAllow((await readState(runtime.storeDir)).allow).allows(basename(dirname(transcript))))
|
|
939
|
+
return 'not-allowed';
|
|
940
|
+
await deferSession(runtime.storeDir, transcript);
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
// A pending entry that cannot be written (or a state that cannot be read) leaves the refusal what it was before: a failed deposit, with the lock's message.
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
if (trace !== undefined)
|
|
947
|
+
trace.deferred = error.message;
|
|
948
|
+
return 'deferred';
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/** Deposits one transcript and its subagents into `state` in memory; the caller writes `state.json`. */
|
|
952
|
+
const depositSessionInto = async (context, state, transcript, trace, taken) => {
|
|
953
|
+
const project = basename(dirname(transcript));
|
|
954
|
+
if (!compileAllow(state.allow).allows(project))
|
|
955
|
+
return { result: 'not-allowed', wrote: false };
|
|
956
|
+
const folder = dirname(transcript);
|
|
957
|
+
const main = mainTranscript(folder, basename(transcript));
|
|
958
|
+
let wrote;
|
|
959
|
+
try {
|
|
960
|
+
const extract = await depositFile(context, project, main);
|
|
961
|
+
wrote = extract !== undefined;
|
|
962
|
+
if (extract !== undefined) {
|
|
963
|
+
tally(trace, extract, false);
|
|
964
|
+
taken?.(extract, false, `${project}/${main.relative}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
if (isMissing(error))
|
|
969
|
+
return { result: 'missing', wrote: false };
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
// The session's subagents finished before it did, so the Stop deposit takes them too (A24).
|
|
973
|
+
for (const subagent of await subagentTranscripts(folder, [main.session])) {
|
|
974
|
+
const extract = await depositFile(context, project, subagent);
|
|
975
|
+
if (extract !== undefined) {
|
|
976
|
+
tally(trace, extract, true);
|
|
977
|
+
taken?.(extract, true, `${project}/${subagent.relative}`);
|
|
978
|
+
}
|
|
979
|
+
wrote = extract !== undefined || wrote;
|
|
980
|
+
}
|
|
981
|
+
return { result: wrote ? 'deposited' : 'unchanged', wrote };
|
|
982
|
+
};
|
|
983
|
+
const bump = (trace, kind) => {
|
|
984
|
+
if (trace === undefined)
|
|
985
|
+
return;
|
|
986
|
+
trace.pending ??= {};
|
|
987
|
+
trace.pending[kind] = (trace.pending[kind] ?? 0) + 1;
|
|
988
|
+
};
|
|
989
|
+
/**
|
|
990
|
+
* Deposits up to `limit` pending sessions into `state`, before the caller's own work, under the store lock. An entry is settled (and removed by the
|
|
991
|
+
* caller after `state.json` is written, so a crash leaves it for the next run) when its transcript was deposited, was already current, is gone, or
|
|
992
|
+
* is in a folder no `--allow` names now; one that fails to deposit stays, is counted, and does not stop the rest.
|
|
993
|
+
*/
|
|
994
|
+
const drainPending = async (context, state, storeDir, limit, trace, taken) => {
|
|
995
|
+
const drained = { settled: [], wrote: false };
|
|
996
|
+
for (const entry of await listPending(storeDir, limit)) {
|
|
997
|
+
bump(trace, 'pending');
|
|
998
|
+
if (entry.unreadable) {
|
|
999
|
+
bump(trace, 'pendingKept');
|
|
1000
|
+
process.stderr.write('oars: a pending entry could not be read and stays where it is\n');
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
const into = entry.transcript === undefined ? undefined : await depositSessionInto(context, state, entry.transcript, trace, taken);
|
|
1005
|
+
drained.wrote ||= into?.wrote === true;
|
|
1006
|
+
drained.settled.push(entry);
|
|
1007
|
+
if (into === undefined || into.result === 'missing' || into.result === 'not-allowed')
|
|
1008
|
+
bump(trace, 'pendingDropped');
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
if (error instanceof StoreLockLostError)
|
|
1012
|
+
throw error;
|
|
1013
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1014
|
+
// An entry that keeps failing is not retried forever: a whole-store walk still reaches any transcript on the allowlist, so dropping it loses nothing.
|
|
1015
|
+
if (entry.failures + 1 >= PENDING_MAX_FAILURES) {
|
|
1016
|
+
drained.settled.push(entry);
|
|
1017
|
+
bump(trace, 'pendingDropped');
|
|
1018
|
+
process.stderr.write(`oars: a pending session failed ${String(PENDING_MAX_FAILURES)} times and was dropped: ${message}\n`);
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
await requireHold(context.held);
|
|
1022
|
+
await failPending(storeDir, entry);
|
|
1023
|
+
bump(trace, 'pendingKept');
|
|
1024
|
+
process.stderr.write(`oars: a pending session was not deposited and stays pending: ${message}\n`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return drained;
|
|
1029
|
+
};
|
|
1030
|
+
const depositSessionLocked = async (runtime, transcript, now, held, trace) => {
|
|
1031
|
+
const state = await readState(runtime.storeDir);
|
|
1032
|
+
refuseNewer(state);
|
|
1033
|
+
// A session no `--allow` names costs nothing when nothing is pending: no salt, no skeletons folder, no context.
|
|
1034
|
+
if (!compileAllow(state.allow).allows(basename(dirname(transcript))) && (await listPending(runtime.storeDir, 1)).length === 0)
|
|
1035
|
+
return 'not-allowed';
|
|
1036
|
+
const context = await contextFor(runtime, state, now, held);
|
|
1037
|
+
const drained = await drainPending(context, state, runtime.storeDir, PENDING_HOOK_CAP, trace);
|
|
1038
|
+
const own = await depositSessionInto(context, state, transcript, trace);
|
|
1039
|
+
if (drained.wrote || own.wrote)
|
|
1040
|
+
await writeStateHeld(runtime, state, held);
|
|
1041
|
+
await requireHold(context.held);
|
|
1042
|
+
await removePending(runtime.storeDir, drained.settled);
|
|
1043
|
+
return own.result;
|
|
1044
|
+
};
|
|
1045
|
+
export const scanLog = async (file, held) => {
|
|
1046
|
+
const latest = new Map();
|
|
1047
|
+
const cvAt = new Map();
|
|
1048
|
+
let superseded = false;
|
|
1049
|
+
for await (const line of logLines(file, held)) {
|
|
1050
|
+
const observation = observationOf(line);
|
|
1051
|
+
if (observation === undefined)
|
|
1052
|
+
continue;
|
|
1053
|
+
const seen = latest.get(observation.session);
|
|
1054
|
+
superseded ||= seen !== undefined && seen !== observation.rev;
|
|
1055
|
+
latest.set(observation.session, Math.max(seen ?? 0, observation.rev));
|
|
1056
|
+
// The last session record at the highest rev is the one `loadStore` reads.
|
|
1057
|
+
if (observation.kind === 'session' && observation.rev >= (cvAt.get(observation.session)?.rev ?? 0))
|
|
1058
|
+
cvAt.set(observation.session, { rev: observation.rev, cv: observation.cv });
|
|
1059
|
+
}
|
|
1060
|
+
let newerCaptures = 0;
|
|
1061
|
+
for (const [session, held] of cvAt)
|
|
1062
|
+
if (held.rev === latest.get(session) && Number(held.cv) > Number(CAPTURE_VERSION))
|
|
1063
|
+
newerCaptures += 1;
|
|
1064
|
+
return { latest, superseded, newerCaptures };
|
|
1065
|
+
};
|
|
1066
|
+
/** Returns the first copy it was given of each string, so the strings a log repeats are held once. */
|
|
1067
|
+
const interner = () => {
|
|
1068
|
+
const seen = new Map();
|
|
1069
|
+
return (value) => {
|
|
1070
|
+
const held = seen.get(value);
|
|
1071
|
+
if (held !== undefined)
|
|
1072
|
+
return held;
|
|
1073
|
+
seen.set(value, value);
|
|
1074
|
+
return value;
|
|
1075
|
+
};
|
|
1076
|
+
};
|
|
1077
|
+
/**
|
|
1078
|
+
* Every tool row carries the session id, the tool name, and its key names, and `JSON.parse` makes a new string of each: a log of a million rows
|
|
1079
|
+
* held a million copies of a few thousand strings. The row is the same value with the strings shared.
|
|
1080
|
+
*/
|
|
1081
|
+
const internTool = (tool, intern) => ({
|
|
1082
|
+
...tool,
|
|
1083
|
+
cv: intern(tool.cv),
|
|
1084
|
+
session: intern(tool.session),
|
|
1085
|
+
parent: intern(tool.parent),
|
|
1086
|
+
tool: intern(tool.tool),
|
|
1087
|
+
keys: tool.keys.map(intern),
|
|
1088
|
+
sid: tool.sid === null ? null : intern(tool.sid),
|
|
1089
|
+
agentType: tool.agentType === null ? null : intern(tool.agentType),
|
|
1090
|
+
harnessVersion: tool.harnessVersion === null ? null : intern(tool.harnessVersion),
|
|
1091
|
+
target: tool.target === null ? null : intern(tool.target),
|
|
1092
|
+
...(tool.segments === undefined ? {} : { segments: tool.segments.map((segment) => ({ ...segment, run: intern(segment.run) })) }),
|
|
1093
|
+
});
|
|
1094
|
+
/**
|
|
1095
|
+
* The store as a reader sees it: for each session only the highest `rev`. A version-3 or later session is derived here from its capture records
|
|
1096
|
+
* (EXTRACTOR_VERSION rules); a session whose last deposit was version 2 keeps the prompt and error lines that deposit wrote.
|
|
1097
|
+
* `afterFirstPass` runs between the two passes over the log: a seam for the test that compacts the log there.
|
|
1098
|
+
*/
|
|
1099
|
+
export const loadStore = async (storeDir, afterFirstPass) => {
|
|
1100
|
+
// Two passes, so a superseded record is never held: the first keeps one number per session, the second keeps only the records at that rev.
|
|
1101
|
+
// A re-capture leaves the old copies in the log until a compaction, and holding them as well doubled the heap of every read.
|
|
1102
|
+
// Both passes read one open file: a lock-free reader can meet a compaction between them, and a second open would see the compacted log, where
|
|
1103
|
+
// a session recaptured in that window fails the rev match and drops out of this read.
|
|
1104
|
+
const file = paths(storeDir).observations;
|
|
1105
|
+
const store = { sessions: [], prompts: [], errors: [], subagents: [], tools: [], newerCaptures: 0 };
|
|
1106
|
+
const intern = interner();
|
|
1107
|
+
const captured = new Map();
|
|
1108
|
+
const of = (session) => {
|
|
1109
|
+
const found = captured.get(session) ?? { prompts: [], tools: [], errorLines: [] };
|
|
1110
|
+
captured.set(session, found);
|
|
1111
|
+
return found;
|
|
1112
|
+
};
|
|
1113
|
+
const held = await openLog(file);
|
|
1114
|
+
try {
|
|
1115
|
+
const { latest } = await scanLog(file, held);
|
|
1116
|
+
await afterFirstPass?.();
|
|
1117
|
+
for await (const line of logLines(file, held)) {
|
|
1118
|
+
const observation = observationOf(line);
|
|
1119
|
+
if (observation === undefined || observation.rev !== latest.get(observation.session))
|
|
1120
|
+
continue;
|
|
1121
|
+
if (observation.kind === 'session')
|
|
1122
|
+
of(observation.session).session = observation;
|
|
1123
|
+
else if (observation.kind === 'user-prompt')
|
|
1124
|
+
of(observation.session).prompts.push(observation);
|
|
1125
|
+
else if (observation.kind === 'tool')
|
|
1126
|
+
of(observation.session).tools.push(internTool(observation, intern));
|
|
1127
|
+
else if (observation.kind === 'error-line')
|
|
1128
|
+
of(observation.session).errorLines.push(observation);
|
|
1129
|
+
else if (observation.kind === 'prompt')
|
|
1130
|
+
store.prompts.push(observation);
|
|
1131
|
+
else
|
|
1132
|
+
store.errors.push(observation);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
finally {
|
|
1136
|
+
await held?.close();
|
|
1137
|
+
}
|
|
1138
|
+
const redact = makeRedactor((await readState(storeDir)).redactDomains);
|
|
1139
|
+
const agents = [];
|
|
1140
|
+
for (const { session: written, prompts, tools: writtenTools, errorLines: writtenErrorLines } of captured.values()) {
|
|
1141
|
+
if (written === undefined)
|
|
1142
|
+
continue;
|
|
1143
|
+
// One tool call is one record: a replay an older capture wrote beside the call is folded here.
|
|
1144
|
+
const folded = foldToolRows(writtenTools, writtenErrorLines);
|
|
1145
|
+
const session = withoutReplays(written, folded.dropped);
|
|
1146
|
+
const { tools, errorLines } = folded;
|
|
1147
|
+
if (Number(session.cv) > Number(CAPTURE_VERSION))
|
|
1148
|
+
store.newerCaptures += 1;
|
|
1149
|
+
store.tools.push(...tools);
|
|
1150
|
+
if (session.parent !== null) {
|
|
1151
|
+
store.subagents.push(session);
|
|
1152
|
+
if (isReadable(session.cv))
|
|
1153
|
+
agents.push({ session, prompts, tools, errorLines, parent: session.parent });
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
store.sessions.push(session);
|
|
1157
|
+
if (!isReadable(session.cv))
|
|
1158
|
+
continue;
|
|
1159
|
+
const derived = deriveSession({ session, prompts, tools, errorLines }, redact);
|
|
1160
|
+
store.prompts.push(...derived.prompts);
|
|
1161
|
+
store.errors.push(...derived.errors);
|
|
1162
|
+
}
|
|
1163
|
+
store.errors.push(...agentErrors(store, agents, redact));
|
|
1164
|
+
return store;
|
|
1165
|
+
};
|
|
1166
|
+
/**
|
|
1167
|
+
* A subagent's errors are the fleet's work too (A24), so they join `store.errors` under the main session that spawned the agent,
|
|
1168
|
+
* with the agent's transcript in `agent`: every count by session (signature sessions, measure's recurrence, the hook index) then
|
|
1169
|
+
* counts the parent once, and G1's denominators stay main sessions. `prompt` and `cls` are the spawning prompt's (null when the
|
|
1170
|
+
* spawning call is not in the parent), as books attributes the agent's tokens.
|
|
1171
|
+
*/
|
|
1172
|
+
const agentErrors = (store, agents, redact) => {
|
|
1173
|
+
const classOf = new Map(store.prompts.map((prompt) => [`${prompt.session}#${String(prompt.index)}`, prompt.cls]));
|
|
1174
|
+
const promptOf = new Map();
|
|
1175
|
+
for (const tool of store.tools)
|
|
1176
|
+
if (tool.toolUseId !== null && tool.prompt !== null)
|
|
1177
|
+
promptOf.set(`${tool.session} ${tool.toolUseId}`, tool.prompt);
|
|
1178
|
+
const errors = [];
|
|
1179
|
+
for (const capture of agents) {
|
|
1180
|
+
const { parent } = capture;
|
|
1181
|
+
const prompt = promptOf.get(`${parent} ${String(capture.session.spawnedBy)}`) ?? null;
|
|
1182
|
+
const cls = prompt === null ? null : (classOf.get(`${parent}#${String(prompt)}`) ?? null);
|
|
1183
|
+
for (const error of deriveSession(capture, redact).errors)
|
|
1184
|
+
errors.push({ ...error, session: parent, agent: capture.session.session, prompt, cls });
|
|
1185
|
+
}
|
|
1186
|
+
return errors;
|
|
1187
|
+
};
|
|
1188
|
+
/** How long a compaction waits for a reader to let go of the log; Windows refuses to rename over a file another process has open. */
|
|
1189
|
+
const LOG_RENAME_WAIT_MS = 30_000;
|
|
1190
|
+
const renameWhenFree = async (from, to, waitMs) => {
|
|
1191
|
+
const started = Date.now();
|
|
1192
|
+
for (;;) {
|
|
1193
|
+
try {
|
|
1194
|
+
await rename(from, to);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
catch (error) {
|
|
1198
|
+
if (!isCode(error, 'EPERM', 'EACCES', 'EBUSY') || Date.now() - started >= waitMs)
|
|
1199
|
+
throw error;
|
|
1200
|
+
await sleep(100);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
/** Writes to `temp` every line of the log but the superseded ones and blanks, flushed to disk so a rename can never expose an unwritten file. */
|
|
1205
|
+
const writeLive = async (file, temp, latest) => {
|
|
1206
|
+
const out = await open(temp, 'w', 0o600);
|
|
1207
|
+
try {
|
|
1208
|
+
let batch = '';
|
|
1209
|
+
for await (const line of logLines(file)) {
|
|
1210
|
+
if (line === '')
|
|
1211
|
+
continue;
|
|
1212
|
+
const observation = observationOf(line);
|
|
1213
|
+
if (observation !== undefined && observation.rev !== latest.get(observation.session))
|
|
1214
|
+
continue;
|
|
1215
|
+
batch += `${line}\n`;
|
|
1216
|
+
if (batch.length < 1 << 20)
|
|
1217
|
+
continue;
|
|
1218
|
+
await out.appendFile(batch);
|
|
1219
|
+
batch = '';
|
|
1220
|
+
}
|
|
1221
|
+
await out.appendFile(batch);
|
|
1222
|
+
await out.sync();
|
|
1223
|
+
}
|
|
1224
|
+
finally {
|
|
1225
|
+
await out.close();
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
/**
|
|
1229
|
+
* Drops the superseded revs from the log, so a re-capture does not leave a full copy of every session behind. The caller holds the store lock,
|
|
1230
|
+
* the same one every append takes. Only a line a higher rev of its session replaces goes: a line this version cannot read stays, since a later
|
|
1231
|
+
* oars may read it, and a kept line is written back byte for byte. A crash before the rename leaves the old log whole and a temp file that no
|
|
1232
|
+
* reader opens and the next compaction removes. Throws, with the log as it was, when the rewrite cannot finish.
|
|
1233
|
+
*/
|
|
1234
|
+
export const compactLog = async (storeDir, renameWaitMs = LOG_RENAME_WAIT_MS, held = () => Promise.resolve(true)) => {
|
|
1235
|
+
const file = paths(storeDir).observations;
|
|
1236
|
+
// A run that lost the lock must not sweep a temp file the later holder is writing.
|
|
1237
|
+
await requireHold(held);
|
|
1238
|
+
// Under the lock no other compaction is running, so any temp file here is one a crash left.
|
|
1239
|
+
for (const entry of await listDir(storeDir)) {
|
|
1240
|
+
if (entry.name.startsWith(`${basename(file)}.`) && entry.name.endsWith('.tmp'))
|
|
1241
|
+
await rm(join(storeDir, entry.name), { force: true });
|
|
1242
|
+
}
|
|
1243
|
+
const size = await stat(file).then((info) => info.size, () => 0);
|
|
1244
|
+
const { latest, superseded } = await scanLog(file);
|
|
1245
|
+
if (!superseded)
|
|
1246
|
+
return;
|
|
1247
|
+
const temp = `${file}.${String(process.pid)}.tmp`;
|
|
1248
|
+
try {
|
|
1249
|
+
await writeLive(file, temp, latest);
|
|
1250
|
+
// The lock holds appends off, so this fires only when a waiter broke the lock (say across a sleeping laptop): the temp file lacks what it wrote.
|
|
1251
|
+
if ((await stat(file)).size !== size)
|
|
1252
|
+
throw new Error('the log changed while it was being rewritten');
|
|
1253
|
+
// The size check cannot see a run that took a broken lock and has not appended yet; its token can.
|
|
1254
|
+
await requireHold(held);
|
|
1255
|
+
await renameWhenFree(temp, file, renameWaitMs);
|
|
1256
|
+
}
|
|
1257
|
+
finally {
|
|
1258
|
+
await rm(temp, { force: true });
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
/** A capture record this code derives from: version 3 (upgraded on read) through the current one, and newer ones for the fields known here. */
|
|
1262
|
+
const isReadable = (cv) => Number(cv) >= OLDEST_READABLE_CAPTURE;
|
|
1263
|
+
/** A log line as a record, or undefined when a reader skips it: a blank, torn, or unparsable line, or one of no known shape. */
|
|
1264
|
+
const observationOf = (line) => {
|
|
1265
|
+
try {
|
|
1266
|
+
return asObservation(JSON.parse(line));
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
// The store is ours, so a line with a known `kind`, a session, and a rev is trusted as the type it says.
|
|
1273
|
+
const asObservation = (value) => {
|
|
1274
|
+
if (!isRecord(value) || typeof value.session !== 'string' || typeof value.rev !== 'number')
|
|
1275
|
+
return undefined;
|
|
1276
|
+
if (value.kind === 'session')
|
|
1277
|
+
return isSession(value) ? upgradeSession(asSession(value)) : undefined;
|
|
1278
|
+
if (value.kind === 'prompt')
|
|
1279
|
+
return isPrompt(value) ? value : undefined;
|
|
1280
|
+
if (value.kind === 'error')
|
|
1281
|
+
return isError(value) ? value : undefined;
|
|
1282
|
+
if (value.kind === 'user-prompt')
|
|
1283
|
+
return isUserPrompt(value) ? upgradeUserPrompt(value) : undefined;
|
|
1284
|
+
if (value.kind === 'tool')
|
|
1285
|
+
return isTool(value) ? upgradeTool(value) : undefined;
|
|
1286
|
+
if (value.kind === 'error-line')
|
|
1287
|
+
return isErrorLine(value) ? upgradeErrorLine(value) : undefined;
|
|
1288
|
+
return undefined;
|
|
1289
|
+
};
|
|
1290
|
+
// The compatibility reader for version-3 (and version-2 session) lines: a field CAPTURE 4 added reads as unknown, and
|
|
1291
|
+
// `contextFingerprint` reads as `promptFingerprint`. Nothing is rewritten; the next deposit captures the transcript again at the current capture version.
|
|
1292
|
+
const orNull = (value) => (typeof value === 'string' ? value : null);
|
|
1293
|
+
/** A version-3 `contextFingerprint` is `<prompt|->.<environment|->`: only the prompt half survives, and `-` reads as none. */
|
|
1294
|
+
export const promptHalf = (contextFingerprint) => {
|
|
1295
|
+
const first = orNull(contextFingerprint)?.split('.')[0];
|
|
1296
|
+
return first === undefined || first === '-' || first === '' ? null : first;
|
|
1297
|
+
};
|
|
1298
|
+
const upgradeSession = (value) => {
|
|
1299
|
+
const { contextFingerprint, ...rest } = value;
|
|
1300
|
+
return {
|
|
1301
|
+
...rest,
|
|
1302
|
+
sid: orNull(value.sid),
|
|
1303
|
+
repo: orNull(value.repo),
|
|
1304
|
+
fileHashes: Array.isArray(value.fileHashes) ? value.fileHashes : [],
|
|
1305
|
+
promptFingerprint: value.promptFingerprint ?? promptHalf(contextFingerprint),
|
|
1306
|
+
env: isRecord(value.env) ? value.env : { harness: null, oars: 'unknown', node: 'unknown', os: 'unknown' },
|
|
1307
|
+
};
|
|
1308
|
+
};
|
|
1309
|
+
const upgradeUserPrompt = (value) => {
|
|
1310
|
+
const { contextFingerprint, ...rest } = value;
|
|
1311
|
+
return { ...rest, sid: orNull(value.sid), promptFingerprint: value.promptFingerprint ?? promptHalf(contextFingerprint) };
|
|
1312
|
+
};
|
|
1313
|
+
/**
|
|
1314
|
+
* `isTool` admits a row by its tool name and position alone, and derive reads `keys` and `segments` as lists: a row whose `keys` is not a list reads
|
|
1315
|
+
* as having none, a `segments` that is not a list reads as a record captured before `segments`, and a segment without a text `run` is dropped.
|
|
1316
|
+
* Capture never writes these shapes; one hand-edited or foreign row must not abort every reader.
|
|
1317
|
+
*/
|
|
1318
|
+
const upgradeTool = (value) => {
|
|
1319
|
+
const { segments, ...rest } = value;
|
|
1320
|
+
const readable = segments;
|
|
1321
|
+
return {
|
|
1322
|
+
...rest,
|
|
1323
|
+
sid: orNull(value.sid),
|
|
1324
|
+
targetBase: value.targetBase ?? null,
|
|
1325
|
+
keys: Array.isArray(value.keys) ? value.keys : [],
|
|
1326
|
+
...(Array.isArray(readable) ? { segments: readable.filter(isSegment) } : {}),
|
|
1327
|
+
};
|
|
1328
|
+
};
|
|
1329
|
+
const isSegment = (value) => isRecord(value) && typeof value.run === 'string';
|
|
1330
|
+
const upgradeErrorLine = (value) => ({ ...value, sid: orNull(value.sid) });
|
|
1331
|
+
/** A version-2 session line has `v` and no `cv`, `parent`, or agent fields: it reads as a main transcript of that version. */
|
|
1332
|
+
const asSession = (value) => ({
|
|
1333
|
+
...value,
|
|
1334
|
+
cv: typeof value.cv === 'string' ? value.cv : String(value.v),
|
|
1335
|
+
parent: typeof value.parent === 'string' ? value.parent : null,
|
|
1336
|
+
agentType: typeof value.agentType === 'string' ? value.agentType : null,
|
|
1337
|
+
spawnedBy: typeof value.spawnedBy === 'string' ? value.spawnedBy : null,
|
|
1338
|
+
spawnedIn: typeof value.spawnedIn === 'string' ? value.spawnedIn : null,
|
|
1339
|
+
});
|
|
1340
|
+
const isSession = (value) => Array.isArray(value.files) && isRecord(value.usage);
|
|
1341
|
+
const isPrompt = (value) => Array.isArray(value.tools) && isRecord(value.usage);
|
|
1342
|
+
const isError = (value) => typeof value.sig === 'string' && Array.isArray(value.next);
|
|
1343
|
+
const isUserPrompt = (value) => typeof value.index === 'number' && isRecord(value.usage);
|
|
1344
|
+
const isTool = (value) => typeof value.tool === 'string' && typeof value.n === 'number';
|
|
1345
|
+
const isErrorLine = (value) => typeof value.line === 'string' && typeof value.after === 'number';
|
|
1346
|
+
export const readCalls = async (storeDir) => {
|
|
1347
|
+
let text = '';
|
|
1348
|
+
try {
|
|
1349
|
+
text = await readFile(paths(storeDir).calls, 'utf8');
|
|
1350
|
+
}
|
|
1351
|
+
catch (error) {
|
|
1352
|
+
if (!isMissing(error))
|
|
1353
|
+
throw error;
|
|
1354
|
+
}
|
|
1355
|
+
const calls = [];
|
|
1356
|
+
for (const line of text.split('\n')) {
|
|
1357
|
+
if (line === '')
|
|
1358
|
+
continue;
|
|
1359
|
+
try {
|
|
1360
|
+
const parsed = JSON.parse(line);
|
|
1361
|
+
if (isRecord(parsed) && typeof parsed.at === 'string' && typeof parsed.query === 'string' && typeof parsed.matches === 'number') {
|
|
1362
|
+
calls.push({ at: parsed.at, query: parsed.query, matches: parsed.matches, ...(typeof parsed.cwd === 'string' ? { cwd: parsed.cwd } : {}) });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
// A torn last line is skipped.
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return calls;
|
|
1370
|
+
};
|
|
1371
|
+
const isSkeleton = (value) => isRecord(value) && Array.isArray(value.lines) && typeof value.merkleRoot === 'string';
|
|
1372
|
+
const USAGE_KEYS = ['input', 'cacheWrite', 'cacheRead', 'output'];
|
|
1373
|
+
/** A `models` entry written as a bare name (before the keys were recorded) reads with no keys, so its excess falls under `other`. */
|
|
1374
|
+
const costModelOf = (entry) => {
|
|
1375
|
+
if (typeof entry === 'string')
|
|
1376
|
+
return [{ model: entry, keys: [] }];
|
|
1377
|
+
if (!isRecord(entry) || typeof entry.model !== 'string')
|
|
1378
|
+
return [];
|
|
1379
|
+
const reported = entry.keys;
|
|
1380
|
+
const keys = Array.isArray(reported) ? USAGE_KEYS.filter((key) => reported.includes(key)) : [];
|
|
1381
|
+
return [{ model: entry.model, keys }];
|
|
1382
|
+
};
|
|
1383
|
+
/** A skeleton written before `since` (version 2), `models` (early version 3), or the model keys existed reads with them empty, as the check expects. */
|
|
1384
|
+
const withCostFields = (skeleton, lean) => ({
|
|
1385
|
+
...skeleton,
|
|
1386
|
+
lines: skeleton.lines.map((read) => {
|
|
1387
|
+
// The content hashes are a skeleton's bulk (a 64-character string per block) and no invariant reads them, so a lean read does not keep them.
|
|
1388
|
+
const line = lean ? { ...read, hashes: [] } : read;
|
|
1389
|
+
const state = line.costState;
|
|
1390
|
+
if (line.costState === null || !isRecord(state))
|
|
1391
|
+
return line;
|
|
1392
|
+
const since = typeof state.since === 'string' ? state.since : null;
|
|
1393
|
+
const models = Array.isArray(state.models) ? state.models.flatMap(costModelOf) : [];
|
|
1394
|
+
return { ...line, costState: { ...line.costState, since, models } };
|
|
1395
|
+
}),
|
|
1396
|
+
});
|
|
1397
|
+
/**
|
|
1398
|
+
* `unreadable` counts skeleton files that are torn, corrupted, or not a skeleton, so verify reports them instead of aborting. With `lean`, each
|
|
1399
|
+
* line's `hashes` read as empty: `verify` holds every skeleton at once and checks structure, never content.
|
|
1400
|
+
*/
|
|
1401
|
+
export const readSkeletons = async (storeDir, only, lean = false) => {
|
|
1402
|
+
const folder = paths(storeDir).skeletons;
|
|
1403
|
+
let names;
|
|
1404
|
+
try {
|
|
1405
|
+
names = (await readdir(folder)).filter((name) => name.endsWith('.json')).sort();
|
|
1406
|
+
}
|
|
1407
|
+
catch (error) {
|
|
1408
|
+
if (isMissing(error))
|
|
1409
|
+
return { skeletons: [], unreadable: 0 };
|
|
1410
|
+
throw error;
|
|
1411
|
+
}
|
|
1412
|
+
const skeletons = [];
|
|
1413
|
+
let unreadable = 0;
|
|
1414
|
+
for (const name of names) {
|
|
1415
|
+
if (only !== undefined && name !== `${only}.json`)
|
|
1416
|
+
continue;
|
|
1417
|
+
let parsed;
|
|
1418
|
+
try {
|
|
1419
|
+
parsed = JSON.parse(await readFile(join(folder, name), 'utf8'));
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
if (!(error instanceof SyntaxError))
|
|
1423
|
+
throw error;
|
|
1424
|
+
}
|
|
1425
|
+
if (isSkeleton(parsed))
|
|
1426
|
+
skeletons.push(withCostFields(parsed, lean));
|
|
1427
|
+
else
|
|
1428
|
+
unreadable += 1;
|
|
1429
|
+
}
|
|
1430
|
+
return { skeletons, unreadable };
|
|
1431
|
+
};
|
|
1432
|
+
/** The derived records, a pure function of the store. */
|
|
1433
|
+
export const derive = (store, at) => {
|
|
1434
|
+
const bySig = new Map();
|
|
1435
|
+
for (const error of store.errors)
|
|
1436
|
+
bySig.set(error.sig, [...(bySig.get(error.sig) ?? []), error]);
|
|
1437
|
+
const signatures = [...bySig.entries()].map(([sig, list]) => signatureRecord(sig, list));
|
|
1438
|
+
signatures.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.sig.localeCompare(b.sig));
|
|
1439
|
+
const bigrams = {};
|
|
1440
|
+
const recovery = {};
|
|
1441
|
+
const classes = {};
|
|
1442
|
+
const ratio = new Map();
|
|
1443
|
+
for (const prompt of store.prompts) {
|
|
1444
|
+
for (const [pair, count] of Object.entries(prompt.bigrams))
|
|
1445
|
+
bigrams[pair] = (bigrams[pair] ?? 0) + count;
|
|
1446
|
+
if (prompt.recovery.length > 0) {
|
|
1447
|
+
const pair = prompt.recovery.slice(0, 2).join('>');
|
|
1448
|
+
recovery[pair] = (recovery[pair] ?? 0) + 1;
|
|
1449
|
+
}
|
|
1450
|
+
const entry = ratio.get(prompt.cls) ?? { explore: 0, act: 0 };
|
|
1451
|
+
entry.explore += prompt.explore;
|
|
1452
|
+
entry.act += prompt.act;
|
|
1453
|
+
ratio.set(prompt.cls, entry);
|
|
1454
|
+
classes[prompt.cls] = { prompts: (classes[prompt.cls]?.prompts ?? 0) + 1, exploreToAct: 0 };
|
|
1455
|
+
}
|
|
1456
|
+
for (const [cls, entry] of ratio) {
|
|
1457
|
+
const row = classes[cls];
|
|
1458
|
+
if (row !== undefined)
|
|
1459
|
+
row.exploreToAct = Math.round((entry.explore / Math.max(entry.act, 1)) * 100) / 100;
|
|
1460
|
+
}
|
|
1461
|
+
const promptCount = new Map();
|
|
1462
|
+
for (const prompt of store.prompts)
|
|
1463
|
+
promptCount.set(prompt.session, (promptCount.get(prompt.session) ?? 0) + 1);
|
|
1464
|
+
const errorCount = new Map();
|
|
1465
|
+
for (const error of store.errors)
|
|
1466
|
+
errorCount.set(error.session, (errorCount.get(error.session) ?? 0) + 1);
|
|
1467
|
+
const summaries = store.sessions.map((session) => ({
|
|
1468
|
+
session: session.session,
|
|
1469
|
+
project: session.project,
|
|
1470
|
+
prompts: promptCount.get(session.session) ?? 0,
|
|
1471
|
+
errors: errorCount.get(session.session) ?? 0,
|
|
1472
|
+
tools: Object.values(session.tools).reduce((sum, count) => sum + count, 0),
|
|
1473
|
+
tsFirst: session.tsFirst,
|
|
1474
|
+
tsLast: session.tsLast,
|
|
1475
|
+
tldrs: session.tldrs.length,
|
|
1476
|
+
}));
|
|
1477
|
+
return { v: EXTRACTOR_VERSION, at, sessions: store.sessions.length, signatures, bigrams, recovery, classes, summaries };
|
|
1478
|
+
};
|
|
1479
|
+
export const signatureRecord = (sig, list) => {
|
|
1480
|
+
const stamps = list.map((error) => error.ts).filter((ts) => ts !== null).sort();
|
|
1481
|
+
const following = {};
|
|
1482
|
+
for (const error of list)
|
|
1483
|
+
for (const tool of error.next)
|
|
1484
|
+
following[tool] = (following[tool] ?? 0) + 1;
|
|
1485
|
+
return {
|
|
1486
|
+
v: EXTRACTOR_VERSION,
|
|
1487
|
+
sig,
|
|
1488
|
+
tool: list[0]?.tool ?? '?',
|
|
1489
|
+
example: list[0]?.example ?? '',
|
|
1490
|
+
sessions: new Set(list.map((error) => error.session)).size,
|
|
1491
|
+
count: list.length,
|
|
1492
|
+
firstSeen: stamps[0] ?? null,
|
|
1493
|
+
lastSeen: stamps[stamps.length - 1] ?? null,
|
|
1494
|
+
harnessVersions: [...new Set(list.map((error) => error.harnessVersion).filter((version) => version !== null))].sort(),
|
|
1495
|
+
following,
|
|
1496
|
+
editFollowed: list.filter((error) => error.editFollowed).length,
|
|
1497
|
+
};
|
|
1498
|
+
};
|