@fusengine/harness 0.1.80 → 0.1.83
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/dist/adapters/claude/index.d.mts +1 -1
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.d.mts +11 -12
- package/dist/adapters/codex/index.mjs +3 -3
- package/dist/adapters/cursor/index.d.mts +29 -29
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.d.mts +22 -23
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/adapters/kimi/index.d.mts +54 -46
- package/dist/adapters/kimi/index.mjs +2 -2
- package/dist/cache/index.d.mts +1 -1
- package/dist/{claude-51RSspJ1.mjs → claude-D93HY8dq.mjs} +2 -2
- package/dist/cli/bin.d.mts +1 -1
- package/dist/cli/bin.mjs +129 -38
- package/dist/cli/index.d.mts +3 -3
- package/dist/cli/index.mjs +1 -1
- package/dist/config/index.d.mts +2 -2
- package/dist/config/index.mjs +4 -4
- package/dist/detect/index.d.mts +2 -1
- package/dist/{doc-helpers-CEKzGg2u.d.mts → doc-helpers-BGCzP9VF.d.mts} +12 -12
- package/dist/{dotenv-B26N_uGr.mjs → dotenv-DoqNlALG.mjs} +29 -2
- package/dist/{evaluate-ClfbCDiY.mjs → evaluate-B4-MhOy_.mjs} +322 -32
- package/dist/freshness/index.d.mts +2 -2
- package/dist/{handle-B7YufFz9.mjs → handle-F35Gx-QS.mjs} +596 -174
- package/dist/{hermes-rTmVdhUL.mjs → hermes-1PUEoY8q.mjs} +2 -2
- package/dist/{home-state-BXf38Zi1.mjs → home-state-oUGFB4ds.mjs} +4 -2
- package/dist/index-84heAAm_.d.mts +12 -0
- package/dist/index-CaxrMD1A.d.mts +1 -0
- package/dist/index-CigBu4K9.d.mts +14 -0
- package/dist/{index-DGxNjXW7.d.mts → index-D4C2-IIc.d.mts} +6 -8
- package/dist/index-DBgnIZn9.d.mts +756 -0
- package/dist/{index-mmRF3KNp.d.mts → index-DEfz82BP.d.mts} +12 -13
- package/dist/{index-BUwEmIK-.d.mts → index-DhmV2MPh.d.mts} +31 -31
- package/dist/index-oXXPs2xl.d.mts +119 -0
- package/dist/index-p9fLrSOD.d.mts +27 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +8 -8
- package/dist/init/index.d.mts +8 -9
- package/dist/init/index.mjs +1 -1
- package/dist/{kimi-C7FGprQz.mjs → kimi-ChbcU2uI.mjs} +17 -3
- package/dist/{layout-C0jaaCQC.mjs → layout-KWoE_Mqn.mjs} +1 -0
- package/dist/limits-DG5pl-5s.mjs +59 -0
- package/dist/memory/index.mjs +1 -1
- package/dist/policy/index.d.mts +2 -3
- package/dist/policy/index.mjs +4 -4
- package/dist/prompt/index.d.mts +2 -1
- package/dist/refs/index.d.mts +1 -1
- package/dist/{registry-CymilZiZ.mjs → registry-IhHk2KlT.mjs} +1 -1
- package/dist/{run-3tyTW9Ud.mjs → run-D_WchZ3R.mjs} +1 -1
- package/dist/{run-Do2JltgU.mjs → run-TAXNRSpD.mjs} +1 -1
- package/dist/runtime/index.d.mts +453 -451
- package/dist/runtime/index.mjs +3 -3
- package/dist/{session-state-CY4iohn_.d.mts → session-state-D5gLr66m.d.mts} +29 -30
- package/dist/{skill-path-CDvR4bbF.mjs → skill-path-DhBGlQe3.mjs} +2 -2
- package/dist/store-Dw08Amqh.mjs +845 -0
- package/dist/tracking/index.d.mts +23 -13
- package/dist/tracking/index.mjs +2 -2
- package/dist/{types-DVbIl9md.d.mts → types-C0-igRKr.d.mts} +8 -8
- package/dist/types-DvXTI71X.d.mts +15 -0
- package/dist/util/index.d.mts +1 -1
- package/dist/{validate-CkmquRDV.mjs → validate-Bf-EsbDh.mjs} +279 -20
- package/package.json +163 -163
- package/dist/apex-Wdi1nq_w.d.mts +0 -73
- package/dist/harness-BQ5_Wnva.d.mts +0 -27
- package/dist/index-BEMumjOw.d.mts +0 -27
- package/dist/index-BIGVNQB8.d.mts +0 -576
- package/dist/index-BKZ67WMa.d.mts +0 -1
- package/dist/index-BOBXQ91y.d.mts +0 -12
- package/dist/index-BVVgDSdq.d.mts +0 -1
- package/dist/index-B_VHbsaJ.d.mts +0 -96
- package/dist/limits-CHn8AIL1.mjs +0 -31
- package/dist/store-nDZ3eLBQ.mjs +0 -349
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import { c as ttlLabel } from "./dotenv-DoqNlALG.mjs";
|
|
2
|
+
import { n as fuseHarnessHome } from "./home-state-oUGFB4ds.mjs";
|
|
3
|
+
import { a as writeJsonFile, i as readJsonFile, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
|
|
4
|
+
import { i as resolveSessions } from "./doc-helpers-CWZegVdR.mjs";
|
|
5
|
+
import { t as routeReferences } from "./router-PKVNBHge.mjs";
|
|
6
|
+
import { t as resolveSkillPath } from "./skill-path-DhBGlQe3.mjs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
10
|
+
//#region src/policy/skill-source.ts
|
|
11
|
+
/**
|
|
12
|
+
* Framework -> SKILL.md doc source for the APEX Check-1 non-routed deny —
|
|
13
|
+
* parity with the Python plugin's enforce-helpers.ts `getSkillSource()`
|
|
14
|
+
* (10-entry map, fallback "mcp__context7__query-docs"). The map is ported
|
|
15
|
+
* faithfully as skill NAMES only: disk paths are resolved DYNAMICALLY via
|
|
16
|
+
* {@link resolveSkillPath} (proprietary rule — no static path table; installed
|
|
17
|
+
* plugin dir names differ from upstream's `react-expert/...`, and a hardcoded
|
|
18
|
+
* table drifts from what is actually installed).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Fallback doc source when no SKILL.md can be promised: the consigne to query
|
|
22
|
+
* context7 (a TOOL name, not a path) — parity enforce-helpers.ts:51.
|
|
23
|
+
*/
|
|
24
|
+
const CONTEXT7_SOURCE = "mcp__context7__query-docs";
|
|
25
|
+
/**
|
|
26
|
+
* Framework -> sub-skill dir name, faithful to the Python
|
|
27
|
+
* `getSkillSource` map (enforce-helpers.ts:39-50, all 10 entries).
|
|
28
|
+
*/
|
|
29
|
+
const SKILL_NAMES = {
|
|
30
|
+
react: "react-19",
|
|
31
|
+
nextjs: "nextjs-16",
|
|
32
|
+
swift: "swiftui-components",
|
|
33
|
+
laravel: "laravel-eloquent",
|
|
34
|
+
tailwind: "tailwindcss-v4",
|
|
35
|
+
generic: "solid-generic",
|
|
36
|
+
java: "solid-java",
|
|
37
|
+
go: "solid-go",
|
|
38
|
+
ruby: "solid-ruby",
|
|
39
|
+
rust: "solid-rust"
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The doc source a Check-1 deny may promise for `framework` (parity
|
|
43
|
+
* `getSkillSource`): the REAL installed SKILL.md absolute path when it exists
|
|
44
|
+
* on disk, else {@link CONTEXT7_SOURCE}. `resolveSkillPath` returns a
|
|
45
|
+
* `/SKILL.md`-suffixed path ONLY on an `existsSync` hit; its
|
|
46
|
+
* `${PLUGINS_DIR}/<name>` not-found fallback (no marketplace installed, or a
|
|
47
|
+
* mapped skill absent on disk — e.g. upstream's `swiftui-components`) is
|
|
48
|
+
* converted to the context7 consigne here, because a deny must never promise
|
|
49
|
+
* a nonexistent path (every option a deny offers must actually unblock).
|
|
50
|
+
* @param framework - Detected framework (the `detectFramework()` domain).
|
|
51
|
+
* @param pluginsDirOverride - Marketplace plugins dir override (tests).
|
|
52
|
+
*/
|
|
53
|
+
function getSkillSource(framework, pluginsDirOverride) {
|
|
54
|
+
const name = SKILL_NAMES[framework];
|
|
55
|
+
if (!name) return CONTEXT7_SOURCE;
|
|
56
|
+
const path = resolveSkillPath(name, pluginsDirOverride);
|
|
57
|
+
return path.endsWith("/SKILL.md") ? path : CONTEXT7_SOURCE;
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/policy/apex-authorization.ts
|
|
61
|
+
/**
|
|
62
|
+
* Credit a doc consultation onto one framework's auth entry (parity
|
|
63
|
+
* track-doc-consultation.py): the once-per-session Check-2 fields
|
|
64
|
+
* (`doc_sessions` + `sources`) plus the per-framework Check-1 stamp
|
|
65
|
+
* (`sessions` + `doc_consulted`). Pure.
|
|
66
|
+
*/
|
|
67
|
+
function creditDocConsultation(prev, sessionId, source, nowIso) {
|
|
68
|
+
const p = prev ?? {};
|
|
69
|
+
const add = (list, v) => v && !list.includes(v) ? [...list, v] : list;
|
|
70
|
+
return {
|
|
71
|
+
...p,
|
|
72
|
+
sessions: add(resolveSessions(p), sessionId),
|
|
73
|
+
doc_sessions: add(p.doc_sessions ?? [], sessionId),
|
|
74
|
+
sources: add(p.sources ?? (p.source ? [p.source] : []), source),
|
|
75
|
+
doc_consulted: nowIso
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check 1 (parity enforce-apex-phases.ts isAuthorized): the framework's doc or
|
|
80
|
+
* skill was consulted in THIS session (resolveSessions) AND the stamp is
|
|
81
|
+
* younger than the TTL (FUSE_ENFORCE_TTL_SEC via src/config/ttl.ts).
|
|
82
|
+
*/
|
|
83
|
+
function isAuthorized(auth, sessionId, now, ttlMs) {
|
|
84
|
+
if (!auth?.doc_consulted || !resolveSessions(auth).includes(sessionId)) return false;
|
|
85
|
+
const readEpoch = Date.parse(auth.doc_consulted);
|
|
86
|
+
return !Number.isNaN(readEpoch) && now - readEpoch < ttlMs;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A fresh same-session `skills/**.md` read whose path names the framework also
|
|
90
|
+
* satisfies Check 1 (parity track-doc-consultation.py, which stamps
|
|
91
|
+
* `doc_consulted` on `skills/.*\.md` Reads; the harness records those Reads in
|
|
92
|
+
* `refsRead`/`refsReadAt` instead, and the track file is per-session).
|
|
93
|
+
*/
|
|
94
|
+
function skillReadAuthorized(ctx, now, ttlMs) {
|
|
95
|
+
const nameRe = new RegExp(`\\b(${{
|
|
96
|
+
laravel: "laravel|php",
|
|
97
|
+
tailwind: "tailwindcss?"
|
|
98
|
+
}[ctx.framework] ?? ctx.framework})\\b`, "i");
|
|
99
|
+
return (ctx.refsRead ?? []).some((p) => {
|
|
100
|
+
if (!/skills\/.*\.md$/i.test(p) || !nameRe.test(p)) return false;
|
|
101
|
+
const at = ctx.refsReadAt?.[p];
|
|
102
|
+
return at !== void 0 && now - at < ttlMs;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Gate (Check 1): deny until the framework's doc/skill is freshly consulted.
|
|
107
|
+
* The deny lists the routed SOLID refs when refs are loaded (parity
|
|
108
|
+
* formatRoutedDeny), else the framework's skill source (parity getSkillSource:
|
|
109
|
+
* a real installed SKILL.md path, or the context7 consigne). The RUNTIME
|
|
110
|
+
* caller persists `state.target` on deny (recordTarget, parity :80-81) so
|
|
111
|
+
* recordDoc can cross-credit this framework on the next doc consultation.
|
|
112
|
+
*/
|
|
113
|
+
const apexAuthorizationGate = (ctx) => {
|
|
114
|
+
const windowMs = ctx.windowMs ?? 120 * 1e3;
|
|
115
|
+
const now = ctx.now ?? Date.now();
|
|
116
|
+
if (isAuthorized(ctx.authorizations?.[ctx.framework], ctx.sessionId, now, windowMs)) return null;
|
|
117
|
+
if (skillReadAuthorized(ctx, now, windowMs)) return null;
|
|
118
|
+
const ttl = ttlLabel(Math.round(windowMs / 1e3));
|
|
119
|
+
const title = `APEX: ${ctx.framework} documentation required`;
|
|
120
|
+
const routed = ctx.refs?.length ? routeReferences(ctx.refs, ctx.filePath, ctx.content) : null;
|
|
121
|
+
if (!routed) {
|
|
122
|
+
const src = getSkillSource(ctx.framework);
|
|
123
|
+
return {
|
|
124
|
+
kind: "block",
|
|
125
|
+
title,
|
|
126
|
+
reason: `APEX: Read doc first (expires every ${ttl}) for ${ctx.framework}! Source: ${src}`,
|
|
127
|
+
actions: [src === "mcp__context7__query-docs" ? `Query ${CONTEXT7_SOURCE} for ${ctx.framework} docs` : `Read ${src}`]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const optional = routed.optional.map((r) => r.meta.filePath);
|
|
131
|
+
return {
|
|
132
|
+
kind: "block",
|
|
133
|
+
title,
|
|
134
|
+
reason: [
|
|
135
|
+
`APEX: Read ALL SOLID references below (including Optional), then retry (expires every ${ttl}) for ${ctx.framework}.`,
|
|
136
|
+
`Editing: ${ctx.filePath}`,
|
|
137
|
+
...optional.length ? [`Optional: ${optional.join(", ")}`] : [],
|
|
138
|
+
`Full skill: ${routed.skillPath}`
|
|
139
|
+
].join("\n"),
|
|
140
|
+
actions: routed.required.map((r) => r.meta.filePath)
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/tracking/session-state.ts
|
|
145
|
+
/** A fresh, empty track. */
|
|
146
|
+
function emptyTrack() {
|
|
147
|
+
return {
|
|
148
|
+
authorizations: {},
|
|
149
|
+
refsRead: [],
|
|
150
|
+
agents: [],
|
|
151
|
+
trivialEdits: []
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Record a doc consultation (Context7/Exa/web) for a framework in this session:
|
|
156
|
+
* the Check-2 credit (`doc_sessions` + `sources`) plus the Check-1 stamp
|
|
157
|
+
* (`sessions` + `doc_consulted` = ISO of `now`). PARITY track-doc-consultation.py
|
|
158
|
+
* :62-70 — a `target` left by a Check-1 deny (enforce-apex-phases.ts:80) is
|
|
159
|
+
* cross-credited on EVERY consultation, with NO TTL on `target.set_at`, and the
|
|
160
|
+
* target PERSISTS (only the next deny replaces it via {@link recordTarget});
|
|
161
|
+
* single-shot clearing or TTL-gating the target re-opened the deny loop when a
|
|
162
|
+
* consultation landed > TTL after the deny. Immutable.
|
|
163
|
+
*/
|
|
164
|
+
function recordDoc(track, framework, sessionId, source, now = Date.now()) {
|
|
165
|
+
const nowIso = new Date(now).toISOString();
|
|
166
|
+
const authorizations = {
|
|
167
|
+
...track.authorizations,
|
|
168
|
+
[framework]: creditDocConsultation(track.authorizations[framework], sessionId, source, nowIso)
|
|
169
|
+
};
|
|
170
|
+
const t = track.target;
|
|
171
|
+
if (t && t.framework !== framework) authorizations[t.framework] = creditDocConsultation(track.authorizations[t.framework], sessionId, source, nowIso);
|
|
172
|
+
return {
|
|
173
|
+
...track,
|
|
174
|
+
authorizations
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Set the pending doc-credit target (written by the runtime on a Check-1 deny). Immutable. */
|
|
178
|
+
function recordTarget(track, target) {
|
|
179
|
+
return {
|
|
180
|
+
...track,
|
|
181
|
+
target
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Record that a SOLID reference file was read (deduped). Immutable. When `now`
|
|
186
|
+
* (epoch ms — the tool event's own timestamp, never a fresh `Date.now()`) is
|
|
187
|
+
* supplied, the read is stamped in `refsReadAt`, refreshed on re-reads so the
|
|
188
|
+
* LATEST read drives the SOLID TTL (parity track-solid-reads.py, which appends
|
|
189
|
+
* a timestamped entry per read; require-solid-read.py checks the most recent).
|
|
190
|
+
* Callers that omit `now` keep the legacy untimestamped behavior.
|
|
191
|
+
*/
|
|
192
|
+
function recordRefRead(track, path, now) {
|
|
193
|
+
const refsRead = track.refsRead.includes(path) ? track.refsRead : [...track.refsRead, path];
|
|
194
|
+
if (now === void 0) return refsRead === track.refsRead ? track : {
|
|
195
|
+
...track,
|
|
196
|
+
refsRead
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
...track,
|
|
200
|
+
refsRead,
|
|
201
|
+
refsReadAt: {
|
|
202
|
+
...track.refsReadAt,
|
|
203
|
+
[path]: now
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Record an agent/tool call with a timestamp + optional quality. Immutable. */
|
|
208
|
+
function recordAgent(track, name, ts, quality) {
|
|
209
|
+
const entry = quality ? {
|
|
210
|
+
name,
|
|
211
|
+
ts,
|
|
212
|
+
quality
|
|
213
|
+
} : {
|
|
214
|
+
name,
|
|
215
|
+
ts
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
...track,
|
|
219
|
+
agents: [...track.agents, entry]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** True when ALL of `names` ran within `windowMs` with non-insufficient quality. */
|
|
223
|
+
function agentsFresh(track, names, windowMs, now) {
|
|
224
|
+
const cutoff = now - windowMs;
|
|
225
|
+
return names.every((n) => track.agents.some((a) => a.name === n && a.ts > cutoff && a.quality !== "insufficient"));
|
|
226
|
+
}
|
|
227
|
+
/** Record a trivial edit timestamp (sliding window; old evicted). Immutable. */
|
|
228
|
+
function recordTrivialEdit(track, ts, windowMs, now) {
|
|
229
|
+
const cutoff = now - windowMs;
|
|
230
|
+
return {
|
|
231
|
+
...track,
|
|
232
|
+
trivialEdits: [...(track.trivialEdits ?? []).filter((t) => t > cutoff), ts]
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Count trivial edits within the sliding window. */
|
|
236
|
+
function trivialCount(track, windowMs, now) {
|
|
237
|
+
const cutoff = now - windowMs;
|
|
238
|
+
return (track.trivialEdits ?? []).filter((t) => t > cutoff).length;
|
|
239
|
+
}
|
|
240
|
+
/** Set the brainstorm-required flag (from creation-intent detection). Immutable. */
|
|
241
|
+
function recordBrainstormRequired(track, required) {
|
|
242
|
+
return {
|
|
243
|
+
...track,
|
|
244
|
+
brainstormRequired: required
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/tracking/integrity.ts
|
|
249
|
+
/**
|
|
250
|
+
* @module integrity
|
|
251
|
+
* HMAC-SHA256 tamper-evident wrapping for {@link SessionTrack}.
|
|
252
|
+
*
|
|
253
|
+
* Failure policy: ONLY a MAC mismatch causes fail-closed (returns null).
|
|
254
|
+
* The nonce is included in the signed payload and written to disk for advisory
|
|
255
|
+
* diagnostics, but is NOT checked during verification — concurrent hook
|
|
256
|
+
* invocations (PostToolUse, SubagentStop, …) legitimately load the same
|
|
257
|
+
* envelope after the nonce watermark advances; a monotonic check would trigger
|
|
258
|
+
* spurious fail-closed behaviour mid-session.
|
|
259
|
+
*
|
|
260
|
+
* The machine key is readable by the same agent process, so this deters naive
|
|
261
|
+
* out-of-band tampering — not a determined re-sign. Item B (transcript-grounded
|
|
262
|
+
* freshness) is the primary guarantee.
|
|
263
|
+
* @packageDocumentation
|
|
264
|
+
*/
|
|
265
|
+
const HARNESS_DIR = fuseHarnessHome();
|
|
266
|
+
const KEY_PATH = join(HARNESS_DIR, ".key");
|
|
267
|
+
const NONCE_PATH = join(HARNESS_DIR, ".nonce");
|
|
268
|
+
/** Load (or create on first use) the per-machine HMAC key stored at mode 0600. */
|
|
269
|
+
function loadOrCreateKey() {
|
|
270
|
+
mkdirSync(HARNESS_DIR, { recursive: true });
|
|
271
|
+
if (existsSync(KEY_PATH)) return readFileSync(KEY_PATH, "utf8").trim();
|
|
272
|
+
const candidate = randomBytes(32).toString("hex");
|
|
273
|
+
try {
|
|
274
|
+
writeFileSync(KEY_PATH, candidate, {
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
mode: 384,
|
|
277
|
+
flag: "wx"
|
|
278
|
+
});
|
|
279
|
+
return candidate;
|
|
280
|
+
} catch (e) {
|
|
281
|
+
if (e.code !== "EEXIST") throw e;
|
|
282
|
+
}
|
|
283
|
+
for (let i = 0; i < 5; i++) {
|
|
284
|
+
const existing = readFileSync(KEY_PATH, "utf8").trim();
|
|
285
|
+
if (existing) return existing;
|
|
286
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
287
|
+
}
|
|
288
|
+
const key = readFileSync(KEY_PATH, "utf8").trim();
|
|
289
|
+
if (!key) throw new Error("fuse: empty .key after write contention");
|
|
290
|
+
return key;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Persist the last-seen nonce for advisory diagnostics (mode 0600).
|
|
294
|
+
* Never read during {@link verifyTrack} — see module JSDoc for rationale.
|
|
295
|
+
*/
|
|
296
|
+
function writeLastNonce(nonce) {
|
|
297
|
+
mkdirSync(HARNESS_DIR, { recursive: true });
|
|
298
|
+
writeFileSync(NONCE_PATH, String(nonce), {
|
|
299
|
+
encoding: "utf8",
|
|
300
|
+
mode: 384
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** Compute HMAC-SHA256 over `"${nonce}:${data}"` (nonce: envelope number or journal per-line string). */
|
|
304
|
+
function computeMac(key, data, nonce) {
|
|
305
|
+
return createHmac("sha256", key).update(`${nonce}:${data}`).digest("hex");
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Sign a {@link SessionTrack} into a tamper-evident envelope. The nonce
|
|
309
|
+
* (`Date.now()`) is embedded in the MAC to bind the timestamp to the payload.
|
|
310
|
+
*/
|
|
311
|
+
function signTrack(track) {
|
|
312
|
+
const key = loadOrCreateKey();
|
|
313
|
+
const data = JSON.stringify(track);
|
|
314
|
+
const nonce = Date.now();
|
|
315
|
+
return {
|
|
316
|
+
data,
|
|
317
|
+
nonce,
|
|
318
|
+
mac: computeMac(key, data, nonce)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Verify a {@link TrackEnvelope}. Returns the parsed {@link SessionTrack} on
|
|
323
|
+
* success, or `null` ONLY on MAC mismatch / parse failure (fail-closed on
|
|
324
|
+
* tampering). The nonce is NOT checked — see module JSDoc.
|
|
325
|
+
*/
|
|
326
|
+
function verifyTrack(envelope) {
|
|
327
|
+
try {
|
|
328
|
+
const key = loadOrCreateKey();
|
|
329
|
+
if (envelope.mac !== computeMac(key, envelope.data, envelope.nonce)) return null;
|
|
330
|
+
return JSON.parse(envelope.data);
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/tracking/track-lock.ts
|
|
337
|
+
/**
|
|
338
|
+
* Cross-process lock for session-track read-modify-write. The ~11-hook
|
|
339
|
+
* fan-out races plain `loadTrack → mutate → saveTrack` (writes silently
|
|
340
|
+
* lost — `refsRead` was the only mitigated channel, via its journal). This
|
|
341
|
+
* lockfile (`wx`, atomic create) serializes the whole RMW:
|
|
342
|
+
* - retry is bounded in TOTAL time (400 ms, far under any hook timeout);
|
|
343
|
+
* - after the budget, NEVER crash: stderr is logged and the caller skips the
|
|
344
|
+
* write — a named lost write beats a fail-open crash (hook fail-open
|
|
345
|
+
* semantics preserved);
|
|
346
|
+
* - an orphaned lockfile (dead process) is reclaimed after a 10 s TTL, so a
|
|
347
|
+
* crash mid-write never blocks a project forever.
|
|
348
|
+
*/
|
|
349
|
+
/** Orphaned lockfile lifetime before reclamation. */
|
|
350
|
+
const LOCK_TTL_MS = 1e4;
|
|
351
|
+
/** Delay between two lock acquisition attempts. */
|
|
352
|
+
const RETRY_STEP_MS = 8;
|
|
353
|
+
/** Sentinel returned by {@link withTrackLock} when the lock could not be taken. */
|
|
354
|
+
const LOCK_FAILED = Symbol("track-lock-failed");
|
|
355
|
+
/** True when the lockfile is older than the TTL (orphaned by a dead process). */
|
|
356
|
+
function isStale$1(lock) {
|
|
357
|
+
try {
|
|
358
|
+
return Date.now() - statSync(lock).mtimeMs > LOCK_TTL_MS;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
364
|
+
/**
|
|
365
|
+
* Run `fn` under the track lock of `dir`, or skip with a stderr note.
|
|
366
|
+
* @param dir - The track state directory (lockfile lives inside).
|
|
367
|
+
* @param fn - The protected read-modify-write.
|
|
368
|
+
* @returns `fn`'s result, or {@link LOCK_FAILED} when the lock stayed busy.
|
|
369
|
+
*/
|
|
370
|
+
async function withTrackLock(dir, fn) {
|
|
371
|
+
mkdirSync(dir, { recursive: true });
|
|
372
|
+
const lock = join(dir, "track.lock");
|
|
373
|
+
const deadline = Date.now() + 400;
|
|
374
|
+
for (;;) try {
|
|
375
|
+
closeSync(openSync(lock, "wx"));
|
|
376
|
+
break;
|
|
377
|
+
} catch {
|
|
378
|
+
if (isStale$1(lock)) {
|
|
379
|
+
try {
|
|
380
|
+
unlinkSync(lock);
|
|
381
|
+
} catch {}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (Date.now() >= deadline) {
|
|
385
|
+
process.stderr.write(`harness: track lock busy, write skipped (${lock})\n`);
|
|
386
|
+
return LOCK_FAILED;
|
|
387
|
+
}
|
|
388
|
+
await sleep(RETRY_STEP_MS);
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
return await fn();
|
|
392
|
+
} finally {
|
|
393
|
+
try {
|
|
394
|
+
unlinkSync(lock);
|
|
395
|
+
} catch {}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/tracking/track-lock-sync.ts
|
|
400
|
+
/**
|
|
401
|
+
* Synchronous twin of {@link withTrackLock} for lifecycle paths that CANNOT
|
|
402
|
+
* float async work (the SubagentStop dispatch runs synchronously in a
|
|
403
|
+
* short-lived hook process — an async write could be dropped before exit,
|
|
404
|
+
* see `evidence-harvest-io.ts`). Same semantics: `wx` lockfile, bounded total
|
|
405
|
+
* retry, named skipped write on contention, stale reclamation. The wait uses
|
|
406
|
+
* `Atomics.wait` (allowed on the main thread in both Node and Bun).
|
|
407
|
+
*/
|
|
408
|
+
/** Block the thread for `ms` (bounded by the caller's total budget). */
|
|
409
|
+
function sleepSync(ms) {
|
|
410
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
411
|
+
}
|
|
412
|
+
/** True when the lockfile is older than the TTL (orphaned by a dead process). */
|
|
413
|
+
function isStale(lock) {
|
|
414
|
+
try {
|
|
415
|
+
return Date.now() - statSync(lock).mtimeMs > LOCK_TTL_MS;
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Run `fn` under the track lock of `dir`, synchronously.
|
|
422
|
+
* @param dir - The track state directory (lockfile lives inside).
|
|
423
|
+
* @param fn - The protected read-modify-write.
|
|
424
|
+
* @returns `fn`'s result, or {@link LOCK_FAILED} when the lock stayed busy.
|
|
425
|
+
*/
|
|
426
|
+
function withTrackLockSync(dir, fn) {
|
|
427
|
+
mkdirSync(dir, { recursive: true });
|
|
428
|
+
const lock = join(dir, "track.lock");
|
|
429
|
+
const deadline = Date.now() + 400;
|
|
430
|
+
for (;;) try {
|
|
431
|
+
closeSync(openSync(lock, "wx"));
|
|
432
|
+
break;
|
|
433
|
+
} catch {
|
|
434
|
+
if (isStale(lock)) {
|
|
435
|
+
try {
|
|
436
|
+
unlinkSync(lock);
|
|
437
|
+
} catch {}
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (Date.now() >= deadline) {
|
|
441
|
+
process.stderr.write(`harness: track lock busy, write skipped (${lock})\n`);
|
|
442
|
+
return LOCK_FAILED;
|
|
443
|
+
}
|
|
444
|
+
sleepSync(8);
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
return fn();
|
|
448
|
+
} finally {
|
|
449
|
+
try {
|
|
450
|
+
unlinkSync(lock);
|
|
451
|
+
} catch {}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Blocking twin of {@link withTrackLockSync}: NEVER skips — spins (1 ms step)
|
|
456
|
+
* until the lock is acquired. Used by the journal append path (track-journal
|
|
457
|
+
* `appendEvent`): an append must wait out an in-flight compaction (same
|
|
458
|
+
* `track.lock`), never race its rename/fold/unlink, never be skipped — a lost
|
|
459
|
+
* write is not an option. The stale-lock TTL (dead-process reclamation) is the
|
|
460
|
+
* only anti-deadlock guard. Do NOT use on paths that may already hold the lock.
|
|
461
|
+
*/
|
|
462
|
+
function withTrackLockSyncBlocking(dir, fn) {
|
|
463
|
+
mkdirSync(dir, { recursive: true });
|
|
464
|
+
const lock = join(dir, "track.lock");
|
|
465
|
+
for (;;) try {
|
|
466
|
+
closeSync(openSync(lock, "wx"));
|
|
467
|
+
break;
|
|
468
|
+
} catch {
|
|
469
|
+
if (isStale(lock)) try {
|
|
470
|
+
unlinkSync(lock);
|
|
471
|
+
} catch {}
|
|
472
|
+
sleepSync(1);
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
return fn();
|
|
476
|
+
} finally {
|
|
477
|
+
try {
|
|
478
|
+
unlinkSync(lock);
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/tracking/track-journal.ts
|
|
484
|
+
/**
|
|
485
|
+
* @module track-journal
|
|
486
|
+
* Append-only, per-line-signed event journal for {@link SessionTrack} — the
|
|
487
|
+
* fan-out-immune replacement for the locked RMW (generalises the freshness/
|
|
488
|
+
* ref-journal.ts pattern to every field). Appends serialise on a SHORT
|
|
489
|
+
* BLOCKING track lock (never skipped, sub-ms wait — see appendEvent) so they
|
|
490
|
+
* can never race the rename-atomic compaction: zero lost write. Per-line HMAC
|
|
491
|
+
* (same key/scheme as integrity.ts): a tampered line is dropped, never the
|
|
492
|
+
* whole file (fail-closed PER LINE).
|
|
493
|
+
* @packageDocumentation
|
|
494
|
+
*/
|
|
495
|
+
/** Hard cap per line — short lines stay non-interleaved under O_APPEND. */
|
|
496
|
+
const MAX_LINE = 32 * 1024;
|
|
497
|
+
/** Trivial-edit sliding window for the fold (mirrors gate.ts DEFAULT_WINDOW_MS). */
|
|
498
|
+
const TRIVIAL_WINDOW_MS = 12e4;
|
|
499
|
+
/** Sign one event: an oversized STRING value is truncated then re-signed (never an unsigned partial line); an oversized non-string is dropped (null). */
|
|
500
|
+
function signEvent(field, op, value, ts) {
|
|
501
|
+
let data = JSON.stringify({
|
|
502
|
+
field,
|
|
503
|
+
op,
|
|
504
|
+
value,
|
|
505
|
+
ts
|
|
506
|
+
});
|
|
507
|
+
if (data.length > MAX_LINE - 256) {
|
|
508
|
+
if (typeof value !== "string") return null;
|
|
509
|
+
value = value.slice(0, Math.max(0, value.length - (data.length - (MAX_LINE - 256))));
|
|
510
|
+
data = JSON.stringify({
|
|
511
|
+
field,
|
|
512
|
+
op,
|
|
513
|
+
value,
|
|
514
|
+
ts
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
const nonce = randomBytes(8).toString("hex");
|
|
518
|
+
return {
|
|
519
|
+
v: 1,
|
|
520
|
+
field,
|
|
521
|
+
op,
|
|
522
|
+
value,
|
|
523
|
+
ts,
|
|
524
|
+
nonce,
|
|
525
|
+
mac: computeMac(loadOrCreateKey(), data, nonce)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/** Append one signed event line under the BLOCKING track lock (same `track.lock` as the compaction — an append can never straddle rename/fold/unlink; never skipped). Fail-open on I/O error. */
|
|
529
|
+
function appendEvent(logPath, field, op, value, ts) {
|
|
530
|
+
try {
|
|
531
|
+
const ev = signEvent(field, op, value, ts);
|
|
532
|
+
if (!ev) return false;
|
|
533
|
+
withTrackLockSyncBlocking(dirname(logPath), () => appendFileSync(logPath, JSON.stringify(ev) + "\n", "utf8"));
|
|
534
|
+
return true;
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const union = (a, b) => [.../* @__PURE__ */ new Set([...a ?? [], ...b ?? []])];
|
|
540
|
+
/** Merge one auth entry: non-empty list fields union, scalars of the later-folded (newer) event win. */
|
|
541
|
+
function mergeAuth(p, e) {
|
|
542
|
+
const b = p ?? {}, out = {
|
|
543
|
+
...b,
|
|
544
|
+
...e
|
|
545
|
+
};
|
|
546
|
+
const sessions = union(b.sessions, e.sessions);
|
|
547
|
+
if (sessions.length) out.sessions = sessions;
|
|
548
|
+
const docSessions = union(b.doc_sessions, e.doc_sessions);
|
|
549
|
+
if (docSessions.length) out.doc_sessions = docSessions;
|
|
550
|
+
const sources = union(b.sources ?? (b.source ? [b.source] : []), e.sources ?? (e.source ? [e.source] : []));
|
|
551
|
+
if (sources.length) out.sources = sources;
|
|
552
|
+
const readPaths = union(b.read_paths, e.read_paths);
|
|
553
|
+
if (readPaths.length) out.read_paths = readPaths;
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Replay events over `base` (the legacy snapshot — emptyTrack when absent).
|
|
558
|
+
* Deterministic CRDT fold with a STABLE sort on ts (log order breaks ties):
|
|
559
|
+
* refsRead = dedup union; refsReadAt = per-key max; agents/receipts =
|
|
560
|
+
* ts-ordered append, never deduped; authorizations = per-key merge (list
|
|
561
|
+
* unions); trivialEdits = union re-filtered by the sliding window;
|
|
562
|
+
* target/brainstormRequired = last-writer-wins on ts.
|
|
563
|
+
*/
|
|
564
|
+
function foldEvents(events, base = emptyTrack()) {
|
|
565
|
+
const t = {
|
|
566
|
+
...base,
|
|
567
|
+
refsRead: [...base.refsRead],
|
|
568
|
+
agents: [...base.agents],
|
|
569
|
+
authorizations: { ...base.authorizations },
|
|
570
|
+
trivialEdits: []
|
|
571
|
+
};
|
|
572
|
+
const at = { ...base.refsReadAt };
|
|
573
|
+
const receipts = [...base.receipts ?? []];
|
|
574
|
+
const trivial = new Set(base.trivialEdits ?? []);
|
|
575
|
+
let targetTs = base.target ? Date.parse(base.target.set_at) || 0 : 0, brainstormTs = 0;
|
|
576
|
+
for (const ev of [...events].sort((a, b) => a.ts - b.ts)) if (ev.field === "refsRead") {
|
|
577
|
+
const p = String(ev.value);
|
|
578
|
+
if (!t.refsRead.includes(p)) t.refsRead.push(p);
|
|
579
|
+
} else if (ev.field === "refsReadAt") {
|
|
580
|
+
const [p, ts] = ev.value;
|
|
581
|
+
if ((at[p] ?? -1) < ts) at[p] = ts;
|
|
582
|
+
} else if (ev.field === "agents") t.agents.push(ev.value);
|
|
583
|
+
else if (ev.field === "receipts") receipts.push(ev.value);
|
|
584
|
+
else if (ev.field === "authorizations") {
|
|
585
|
+
const m = ev.value;
|
|
586
|
+
t.authorizations[m.key] = mergeAuth(t.authorizations[m.key], m.entry);
|
|
587
|
+
} else if (ev.field === "trivialEdits") trivial.add(Number(ev.value));
|
|
588
|
+
else if (ev.field === "target" && ev.ts >= targetTs) {
|
|
589
|
+
t.target = ev.value;
|
|
590
|
+
targetTs = ev.ts;
|
|
591
|
+
} else if (ev.field === "brainstormRequired" && ev.ts >= brainstormTs) {
|
|
592
|
+
t.brainstormRequired = Boolean(ev.value);
|
|
593
|
+
brainstormTs = ev.ts;
|
|
594
|
+
}
|
|
595
|
+
if (Object.keys(at).length) t.refsReadAt = at;
|
|
596
|
+
if (receipts.length) t.receipts = receipts;
|
|
597
|
+
const maxT = Math.max(0, ...trivial);
|
|
598
|
+
t.trivialEdits = [...trivial].filter((x) => x > maxT - TRIVIAL_WINDOW_MS).sort((a, b) => a - b);
|
|
599
|
+
return t;
|
|
600
|
+
}
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/tracking/track-diff.ts
|
|
603
|
+
/** Elements of `next` beyond the longest prefix shared with `prev` (append-only arrays). */
|
|
604
|
+
function tail(prev, next) {
|
|
605
|
+
let i = 0;
|
|
606
|
+
while (i < prev.length && i < next.length && JSON.stringify(prev[i]) === JSON.stringify(next[i])) i++;
|
|
607
|
+
return next.slice(i);
|
|
608
|
+
}
|
|
609
|
+
/** Multiset of added numbers (next counts minus prev counts). */
|
|
610
|
+
function added(xs, ys) {
|
|
611
|
+
const left = /* @__PURE__ */ new Map();
|
|
612
|
+
for (const x of xs) left.set(x, (left.get(x) ?? 0) + 1);
|
|
613
|
+
const out = [];
|
|
614
|
+
for (const y of ys) {
|
|
615
|
+
const n = left.get(y) ?? 0;
|
|
616
|
+
if (n > 0) left.set(y, n - 1);
|
|
617
|
+
else out.push(y);
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
/** Events replaying `next` over `prev` (empty when the mutation was a no-op). */
|
|
622
|
+
function diffTrackEvents(prev, next, now) {
|
|
623
|
+
if (prev === next) return [];
|
|
624
|
+
const out = [];
|
|
625
|
+
for (const p of next.refsRead) if (!prev.refsRead.includes(p)) out.push({
|
|
626
|
+
field: "refsRead",
|
|
627
|
+
op: "add",
|
|
628
|
+
value: p,
|
|
629
|
+
ts: next.refsReadAt?.[p] ?? now
|
|
630
|
+
});
|
|
631
|
+
for (const [p, ts] of Object.entries(next.refsReadAt ?? {})) if (ts !== prev.refsReadAt?.[p]) out.push({
|
|
632
|
+
field: "refsReadAt",
|
|
633
|
+
op: "max",
|
|
634
|
+
value: [p, ts],
|
|
635
|
+
ts
|
|
636
|
+
});
|
|
637
|
+
for (const a of tail(prev.agents, next.agents)) out.push({
|
|
638
|
+
field: "agents",
|
|
639
|
+
op: "append",
|
|
640
|
+
value: a,
|
|
641
|
+
ts: a.ts ?? now
|
|
642
|
+
});
|
|
643
|
+
for (const r of tail(prev.receipts ?? [], next.receipts ?? [])) out.push({
|
|
644
|
+
field: "receipts",
|
|
645
|
+
op: "append",
|
|
646
|
+
value: r,
|
|
647
|
+
ts: r.ts ?? now
|
|
648
|
+
});
|
|
649
|
+
for (const [key, entry] of Object.entries(next.authorizations)) if (JSON.stringify(entry) !== JSON.stringify(prev.authorizations[key])) out.push({
|
|
650
|
+
field: "authorizations",
|
|
651
|
+
op: "merge",
|
|
652
|
+
value: {
|
|
653
|
+
key,
|
|
654
|
+
entry
|
|
655
|
+
},
|
|
656
|
+
ts: Date.parse(entry.doc_consulted ?? "") || now
|
|
657
|
+
});
|
|
658
|
+
for (const ts of added(prev.trivialEdits ?? [], next.trivialEdits ?? [])) out.push({
|
|
659
|
+
field: "trivialEdits",
|
|
660
|
+
op: "add",
|
|
661
|
+
value: ts,
|
|
662
|
+
ts
|
|
663
|
+
});
|
|
664
|
+
if (next.target && JSON.stringify(next.target) !== JSON.stringify(prev.target)) out.push({
|
|
665
|
+
field: "target",
|
|
666
|
+
op: "set",
|
|
667
|
+
value: next.target,
|
|
668
|
+
ts: Date.parse(next.target.set_at) || now
|
|
669
|
+
});
|
|
670
|
+
if (next.brainstormRequired !== prev.brainstormRequired) out.push({
|
|
671
|
+
field: "brainstormRequired",
|
|
672
|
+
op: "set",
|
|
673
|
+
value: next.brainstormRequired,
|
|
674
|
+
ts: now
|
|
675
|
+
});
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
/** The journal log path twin of a track snapshot path. */
|
|
679
|
+
function journalLogPath(trackPath) {
|
|
680
|
+
return trackPath.replace(/\.json$/, ".log");
|
|
681
|
+
}
|
|
682
|
+
/** Parse & MAC-verify journal text; malformed or tampered lines are skipped. */
|
|
683
|
+
function parseEvents(text) {
|
|
684
|
+
const key = loadOrCreateKey(), out = [];
|
|
685
|
+
for (const line of text.split("\n")) {
|
|
686
|
+
if (!line) continue;
|
|
687
|
+
try {
|
|
688
|
+
const ev = JSON.parse(line);
|
|
689
|
+
if (ev?.v !== 1 || typeof ev.nonce !== "string" || typeof ev.ts !== "number") continue;
|
|
690
|
+
if (ev.mac !== computeMac(key, JSON.stringify({
|
|
691
|
+
field: ev.field,
|
|
692
|
+
op: ev.op,
|
|
693
|
+
value: ev.value,
|
|
694
|
+
ts: ev.ts
|
|
695
|
+
}), ev.nonce)) continue;
|
|
696
|
+
out.push(ev);
|
|
697
|
+
} catch {}
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
/** Read & verify every event of a log; absent/unreadable → [] (fail-open read). */
|
|
702
|
+
function readEvents(logPath) {
|
|
703
|
+
try {
|
|
704
|
+
return parseEvents(readFileSync(logPath, "utf8"));
|
|
705
|
+
} catch {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Verified legacy snapshot only (fail-closed → emptyTrack), sync. */
|
|
710
|
+
function readSnapshotSync(file) {
|
|
711
|
+
try {
|
|
712
|
+
return verifyTrack(JSON.parse(readFileSync(file, "utf8"))) ?? emptyTrack();
|
|
713
|
+
} catch {
|
|
714
|
+
return emptyTrack();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Sync snapshot ⊕ journal read for the sync gates; `journal=false` = legacy kill-switch path. */
|
|
718
|
+
function readTrackSync(file, journal) {
|
|
719
|
+
const base = readSnapshotSync(file);
|
|
720
|
+
return journal ? foldEvents(readEvents(journalLogPath(file)), base) : base;
|
|
721
|
+
}
|
|
722
|
+
/** Fold a whole log file into the signed snapshot (signTrack + nonce, unchanged). */
|
|
723
|
+
function foldIntoSnapshot(file, logPath) {
|
|
724
|
+
const envelope = signTrack(foldEvents(readEvents(logPath), readSnapshotSync(file)));
|
|
725
|
+
atomicWrite(file, JSON.stringify(envelope, null, 2));
|
|
726
|
+
writeLastNonce(envelope.nonce);
|
|
727
|
+
}
|
|
728
|
+
/** Rename-atomic compaction: rename captures the WHOLE log, fold, unlink. A crash leaves `.folding` (recovered first next run); on fold failure BEFORE commit the log is renamed back — never a lost event, and never an event in BOTH snapshot and log (no double-count). */
|
|
729
|
+
function compactSync(file) {
|
|
730
|
+
const log = journalLogPath(file), folding = `${log}.folding`;
|
|
731
|
+
if (existsSync(folding)) {
|
|
732
|
+
foldIntoSnapshot(file, folding);
|
|
733
|
+
unlinkSync(folding);
|
|
734
|
+
}
|
|
735
|
+
renameSync(log, folding);
|
|
736
|
+
let committed = false;
|
|
737
|
+
try {
|
|
738
|
+
foldIntoSnapshot(file, folding);
|
|
739
|
+
committed = true;
|
|
740
|
+
unlinkSync(folding);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (!committed) try {
|
|
743
|
+
renameSync(folding, log);
|
|
744
|
+
} catch {}
|
|
745
|
+
else try {
|
|
746
|
+
unlinkSync(folding);
|
|
747
|
+
} catch {}
|
|
748
|
+
throw err;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/** Trigger compaction past the cap (`FUSE_TRACK_COMPACT_BYTES` overrides {@link COMPACT_BYTES}), under the existing lock (skipped on contention). */
|
|
752
|
+
async function maybeCompactJournal(file) {
|
|
753
|
+
try {
|
|
754
|
+
const cap = Number(process.env.FUSE_TRACK_COMPACT_BYTES) || 131072;
|
|
755
|
+
if (statSync(journalLogPath(file)).size < cap) return;
|
|
756
|
+
} catch {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
await withTrackLock(dirname(file), async () => {
|
|
760
|
+
try {
|
|
761
|
+
compactSync(file);
|
|
762
|
+
} catch {}
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/tracking/store.ts
|
|
767
|
+
/**
|
|
768
|
+
* @module store
|
|
769
|
+
* Session-track persistence — TWO modes behind an env kill-switch:
|
|
770
|
+
* - JOURNAL (default): writes append signed event lines to `track-<sid>.log`
|
|
771
|
+
* (O_APPEND, lock-free — see track-journal.ts), reads fold the log over the
|
|
772
|
+
* legacy `track-<sid>.json` snapshot. No lock on the hot path ⇒ the fan-out
|
|
773
|
+
* can no longer lose a write to "track lock busy, write skipped" — compaction
|
|
774
|
+
* included (it is rename-atomic, see track-compact.ts).
|
|
775
|
+
* - LEGACY (`FUSE_TRACK_JOURNAL=0`): the original locked read-modify-write,
|
|
776
|
+
* kept fully intact below as the instant rollback (G1) — no redeploy needed.
|
|
777
|
+
* NOTE: =0 does NOT honour the uncompacted journal (the track reads as its
|
|
778
|
+
* last snapshot, possibly EMPTY); the events are recoverable by switching
|
|
779
|
+
* back to journal mode — never delete a `.log` during a =0 rollback.
|
|
780
|
+
* The flag is read at CALL time so the switch is live.
|
|
781
|
+
* @packageDocumentation
|
|
782
|
+
*/
|
|
783
|
+
/** Journal mode is the default; `FUSE_TRACK_JOURNAL=0` reverts to the legacy locked RMW (see the module NOTE: =0 ignores the uncompacted journal — recoverable, keep the `.log`). */
|
|
784
|
+
function trackJournalEnabled() {
|
|
785
|
+
return process.env.FUSE_TRACK_JOURNAL !== "0";
|
|
786
|
+
}
|
|
787
|
+
/** Verified legacy snapshot (fail closed: absent/corrupt/tampered → emptyTrack). */
|
|
788
|
+
async function loadSnapshot(file) {
|
|
789
|
+
const envelope = await readJsonFile(file);
|
|
790
|
+
if (!envelope) return emptyTrack();
|
|
791
|
+
return verifyTrack(envelope) ?? emptyTrack();
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Load a session track: the verified legacy snapshot (the migration base — an
|
|
795
|
+
* old `track-<sid>.json` with no log reads exactly as before) with the journal
|
|
796
|
+
* folded on top. Fail closed on tampering (envelope MAC, per-line MAC).
|
|
797
|
+
*/
|
|
798
|
+
async function loadTrack(file) {
|
|
799
|
+
const base = await loadSnapshot(file);
|
|
800
|
+
if (!trackJournalEnabled()) return base;
|
|
801
|
+
return foldEvents(readEvents(journalLogPath(file)), base);
|
|
802
|
+
}
|
|
803
|
+
/** Journal-mode write: append the mutation's events, then maybe compact. */
|
|
804
|
+
async function appendDiff(file, prev, next) {
|
|
805
|
+
const log = journalLogPath(file);
|
|
806
|
+
let ok = true;
|
|
807
|
+
for (const ev of diffTrackEvents(prev, next, Date.now())) ok = appendEvent(log, ev.field, ev.op, ev.value, ev.ts) && ok;
|
|
808
|
+
await maybeCompactJournal(file);
|
|
809
|
+
return ok;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Persist a session track. Journal mode appends the diff as signed events;
|
|
813
|
+
* legacy mode signs and persists the tamper-evident envelope, then writes the
|
|
814
|
+
* advisory nonce watermark.
|
|
815
|
+
*/
|
|
816
|
+
async function saveTrack(file, track) {
|
|
817
|
+
if (trackJournalEnabled()) {
|
|
818
|
+
await appendDiff(file, await loadTrack(file), track);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const envelope = signTrack(track);
|
|
822
|
+
await writeJsonFile(file, envelope);
|
|
823
|
+
writeLastNonce(envelope.nonce);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Mutate a session track. Journal mode: load (snapshot ⊕ log), run `mutate`,
|
|
827
|
+
* append the resulting events — lock-free, so a write is only ever lost to a
|
|
828
|
+
* genuine I/O error (reported as `false`). Legacy mode: locked RMW; on lock
|
|
829
|
+
* contention the write is SKIPPED (fail-open, stderr logged) and `false` is
|
|
830
|
+
* returned — a named lost write, never a crash.
|
|
831
|
+
* @param file - The session track file.
|
|
832
|
+
* @param mutate - Pure mutation over the freshly loaded track.
|
|
833
|
+
* @returns True when the write landed.
|
|
834
|
+
*/
|
|
835
|
+
async function withTrack(file, mutate) {
|
|
836
|
+
if (trackJournalEnabled()) {
|
|
837
|
+
const track = await loadTrack(file);
|
|
838
|
+
return appendDiff(file, track, await mutate(track));
|
|
839
|
+
}
|
|
840
|
+
return await withTrackLock(dirname(file), async () => {
|
|
841
|
+
await saveTrack(file, await mutate(await loadTrack(file)));
|
|
842
|
+
}) !== LOCK_FAILED;
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
export { apexAuthorizationGate as C, trivialCount as S, recordBrainstormRequired as _, journalLogPath as a, recordTarget as b, appendEvent as c, signTrack as d, verifyTrack as f, recordAgent as g, emptyTrack as h, withTrack as i, withTrackLockSync as l, agentsFresh as m, saveTrack as n, readTrackSync as o, writeLastNonce as p, trackJournalEnabled as r, diffTrackEvents as s, loadTrack as t, LOCK_FAILED as u, recordDoc as v, recordTrivialEdit as x, recordRefRead as y };
|