faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exactly-once ledger: for every provider invocation, an intent written
|
|
3
|
+
* before the process is released and a settlement merged after it closes.
|
|
4
|
+
*
|
|
5
|
+
* The distinction the rest of the system depends on is `settled` versus
|
|
6
|
+
* `unknown_effect`. A settled operation has a known provider outcome. An
|
|
7
|
+
* unknown effect means the request may have run without leaving proof, and it is
|
|
8
|
+
* explicitly NOT permission to retry -- `replayPolicy` and a clean persisted
|
|
9
|
+
* scope decide that, elsewhere.
|
|
10
|
+
*/
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
import { readBoundedTail } from "../engine/process.mjs";
|
|
16
|
+
import { readJson, writeJsonAtomic } from "./store.mjs";
|
|
17
|
+
import { runtimeSnapshot } from "../engine/failover.mjs";
|
|
18
|
+
import { normalizeProviderResult } from "../harnesses/index.mjs";
|
|
19
|
+
|
|
20
|
+
/** @typedef {import("../engine/process.mjs").Invocation} Invocation */
|
|
21
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
22
|
+
/** @typedef {import("../contract/index.mjs").Usage} Usage */
|
|
23
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
24
|
+
|
|
25
|
+
const OPERATIONS_SCHEMA_VERSION = 1;
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} runDir
|
|
28
|
+
* @param {string} invocationId
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function operationIntentPath(runDir, invocationId) {
|
|
32
|
+
return join(runDir, "operations", `${invocationId}.intent.json`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} runDir
|
|
36
|
+
* @param {string} invocationId
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function operationSettlementPath(runDir, invocationId) {
|
|
40
|
+
return join(runDir, "operations", `${invocationId}.settlement.json`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reserve the operation durably before the gate releases the provider process.
|
|
44
|
+
* The invocation identity is written before spawn and is the only identity
|
|
45
|
+
* later accepted for settlement or recovery.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} runDir
|
|
48
|
+
* @param {Invocation} invocation
|
|
49
|
+
* @param {{nodeId: string, role: "worker"|"judge", attempt: number, runtimeFingerprint: string, prompt: string}} context
|
|
50
|
+
*/
|
|
51
|
+
export function persistInvocationIntent(runDir, invocation, context) {
|
|
52
|
+
const promptFingerprint = createHash("sha256").update(context.prompt, "utf8").digest("hex");
|
|
53
|
+
writeJsonAtomic(operationIntentPath(runDir, invocation.id), {
|
|
54
|
+
schemaVersion: OPERATIONS_SCHEMA_VERSION,
|
|
55
|
+
operationId: invocation.id,
|
|
56
|
+
invocationId: invocation.id,
|
|
57
|
+
runId: invocation.runId ?? basename(runDir),
|
|
58
|
+
campaignId: invocation.campaignId ?? null,
|
|
59
|
+
nodeId: context.nodeId,
|
|
60
|
+
role: context.role,
|
|
61
|
+
phase: invocation.phase,
|
|
62
|
+
planPhase: invocation.planPhase ?? null,
|
|
63
|
+
attempt: context.attempt,
|
|
64
|
+
runtimeId: invocation.runtimeId ?? null,
|
|
65
|
+
runtimeFingerprint: context.runtimeFingerprint,
|
|
66
|
+
promptFingerprint,
|
|
67
|
+
promptHash: promptFingerprint,
|
|
68
|
+
scopeSnapshotPath: context.role === "worker" ? invocation.snapshotPath ?? null : null,
|
|
69
|
+
scopeSnapshotRef: context.role === "worker" ? invocation.snapshotPath ?? null : null,
|
|
70
|
+
startedAt: invocation.startedAt,
|
|
71
|
+
intentAt: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read an operation record without allowing a malformed or mismatched record
|
|
76
|
+
* to become recovery evidence.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} runDir
|
|
79
|
+
* @param {string} invocationId
|
|
80
|
+
* @returns {Record<string, unknown>|null}
|
|
81
|
+
*/
|
|
82
|
+
export function readOperationSettlement(runDir, invocationId) {
|
|
83
|
+
try {
|
|
84
|
+
const record = readJson(operationSettlementPath(runDir, invocationId));
|
|
85
|
+
return record.operationId === invocationId ? record : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} runDir
|
|
92
|
+
* @param {string} invocationId
|
|
93
|
+
* @returns {Record<string, unknown>|null}
|
|
94
|
+
*/
|
|
95
|
+
function readOperationIntent(runDir, invocationId) {
|
|
96
|
+
try {
|
|
97
|
+
const record = readJson(operationIntentPath(runDir, invocationId));
|
|
98
|
+
return record.operationId === invocationId ? record : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A preliminary close observation is not a terminal settlement. It can be
|
|
105
|
+
* replaced by the final envelope outcome, while a resolved outcome is never
|
|
106
|
+
* downgraded by a later controller pass.
|
|
107
|
+
*/
|
|
108
|
+
const UNRESOLVED_OPERATION_STATUSES = new Set(["closed", "unknown_effect"]);
|
|
109
|
+
const RESOLVED_OPERATION_STATUSES = new Set(["done", "failed", "exhausted", "stalled", "canceled", "adopted", "rejudge", "restarted", "safe_replay", "reconciled"]);
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} runDir
|
|
112
|
+
* @param {string} invocationId
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function operationNeedsRecovery(runDir, invocationId) {
|
|
116
|
+
const settlement = readOperationSettlement(runDir, invocationId);
|
|
117
|
+
return !settlement || UNRESOLVED_OPERATION_STATUSES.has(String(settlement.status));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* @param {Invocation|undefined|string} invocationOrId
|
|
121
|
+
* @param {unknown} supplied
|
|
122
|
+
* @param {unknown[]} existing
|
|
123
|
+
* @returns {Record<string, string>[]}
|
|
124
|
+
*/
|
|
125
|
+
function operationReceipts(invocationOrId, supplied, existing = []) {
|
|
126
|
+
/** @type {Record<string, string>[]} */
|
|
127
|
+
const receipts = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
/** @type {(kind: string, ref: unknown) => void} */
|
|
130
|
+
const add = (kind, ref) => {
|
|
131
|
+
if (typeof ref !== "string" || ref.length === 0) return;
|
|
132
|
+
const key = `${kind}\u0000${ref}`;
|
|
133
|
+
if (seen.has(key)) return;
|
|
134
|
+
seen.add(key);
|
|
135
|
+
receipts.push({ kind, ref });
|
|
136
|
+
};
|
|
137
|
+
for (const receipt of existing) {
|
|
138
|
+
if (receipt && typeof receipt === "object") {
|
|
139
|
+
const record = /** @type {Record<string, unknown>} */ (receipt);
|
|
140
|
+
add(String(record.kind ?? "operation"), String(record.ref ?? ""));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(supplied)) {
|
|
144
|
+
for (const receipt of supplied) {
|
|
145
|
+
if (typeof receipt === "string") add("provider", receipt);
|
|
146
|
+
else if (receipt && typeof receipt === "object") {
|
|
147
|
+
const record = /** @type {Record<string, unknown>} */ (receipt);
|
|
148
|
+
add(String(record.kind ?? "provider"), String(record.ref ?? ""));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const invocation = invocationOrId && typeof invocationOrId === "object" ? invocationOrId : null;
|
|
153
|
+
if (invocation) {
|
|
154
|
+
add("prompt", invocation.promptPath);
|
|
155
|
+
add("stdout", invocation.stdoutPath);
|
|
156
|
+
add("stderr", invocation.stderrPath);
|
|
157
|
+
if (invocation.phase === "worker") add("scope_snapshot", invocation.snapshotPath);
|
|
158
|
+
add("provider", invocation.continuationId);
|
|
159
|
+
}
|
|
160
|
+
return receipts;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Provider-side evidence from the close path. The continuation identity the
|
|
164
|
+
* provider returned (thread/session) is a durable receipt for the invocation's
|
|
165
|
+
* external effect; the first terminal settlement must persist it.
|
|
166
|
+
*
|
|
167
|
+
* @param {{continuationId?: string|null}|null|undefined} envelope
|
|
168
|
+
* @returns {Record<string, string>[]}
|
|
169
|
+
*/
|
|
170
|
+
export function providerReceipts(envelope) {
|
|
171
|
+
const ref = envelope?.continuationId;
|
|
172
|
+
return typeof ref === "string" && ref.length > 0 ? [{ kind: "provider", ref }] : [];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Provider receipts still recoverable from an invocation's surviving stream
|
|
176
|
+
* tail. A controller-loss window's first terminal settlement must persist
|
|
177
|
+
* them so repeated settlement/recovery stays idempotent and exact-once.
|
|
178
|
+
*
|
|
179
|
+
* @param {ValidatedContract} contract
|
|
180
|
+
* @param {Invocation|undefined} invocation
|
|
181
|
+
* @returns {Record<string, string>[]}
|
|
182
|
+
*/
|
|
183
|
+
export function providerReceiptsFromInvocationTail(contract, invocation) {
|
|
184
|
+
if (!invocation?.stdoutPath) return [];
|
|
185
|
+
try {
|
|
186
|
+
const runtime = typeof invocation.runtimeId === "string"
|
|
187
|
+
? runtimeSnapshot(contract, invocation.runtimeId)
|
|
188
|
+
: null;
|
|
189
|
+
if (!runtime) return [];
|
|
190
|
+
const envelope = normalizeProviderResult(
|
|
191
|
+
runtime,
|
|
192
|
+
readBoundedTail(invocation.stdoutPath),
|
|
193
|
+
invocation.exitCode ?? null,
|
|
194
|
+
invocation.signal ?? null,
|
|
195
|
+
);
|
|
196
|
+
return providerReceipts(envelope);
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** @param {unknown} value @returns {unknown|null} */
|
|
202
|
+
function boundedSettlementResult(value) {
|
|
203
|
+
if (value === undefined || value === null) return null;
|
|
204
|
+
try {
|
|
205
|
+
const serialized = typeof value === "string" ? value : JSON.stringify(value);
|
|
206
|
+
if (typeof serialized !== "string" || Buffer.byteLength(serialized, "utf8") > 64 * 1024) return null;
|
|
207
|
+
return value;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** @param {NodeSnapshot} state @returns {Record<string, unknown>} */
|
|
213
|
+
export function operationNextState(state) {
|
|
214
|
+
return {
|
|
215
|
+
status: state.status,
|
|
216
|
+
phase: state.phase,
|
|
217
|
+
attempt: state.attempt,
|
|
218
|
+
revisions: state.revisions,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Persist a settlement as an idempotent operation record. The operation keeps
|
|
223
|
+
* its first settledAt timestamp, merges receipts, and retains unknown-effect
|
|
224
|
+
* classification while the controller resolves it.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} runDir
|
|
227
|
+
* @param {Invocation|string} invocationOrId
|
|
228
|
+
* @param {{status?: string, usage?: import("../contract/index.mjs").Usage|null, costUsd?: number|null, structuredResult?: boolean|null, result?: unknown, receipts?: unknown, nextState?: unknown, terminalOutcome?: unknown, unknownEffect?: boolean, classification?: string, reason?: string, error?: unknown}} settlement
|
|
229
|
+
*/
|
|
230
|
+
export function settleInvocation(runDir, invocationOrId, settlement) {
|
|
231
|
+
const invocation = typeof invocationOrId === "object" ? invocationOrId : undefined;
|
|
232
|
+
const invocationId = typeof invocationOrId === "string" ? invocationOrId : invocation?.id;
|
|
233
|
+
if (!invocationId) throw new TypeError("settlement requires an invocation identity");
|
|
234
|
+
const previous = readOperationSettlement(runDir, invocationId) ?? {};
|
|
235
|
+
const intent = readOperationIntent(runDir, invocationId) ?? {};
|
|
236
|
+
const requestedStatus = settlement.status ?? String(previous.status ?? "unknown_effect");
|
|
237
|
+
const previousStatus = String(previous.status ?? "");
|
|
238
|
+
const status = RESOLVED_OPERATION_STATUSES.has(previousStatus)
|
|
239
|
+
&& ["adopted", "rejudge", "restarted"].includes(requestedStatus)
|
|
240
|
+
? previousStatus
|
|
241
|
+
: requestedStatus;
|
|
242
|
+
const receipts = operationReceipts(invocationOrId, settlement.receipts, Array.isArray(previous.receipts) ? previous.receipts : []);
|
|
243
|
+
const result = settlement.result !== undefined
|
|
244
|
+
? boundedSettlementResult(settlement.result)
|
|
245
|
+
: previous.result ?? null;
|
|
246
|
+
const record = {
|
|
247
|
+
...previous,
|
|
248
|
+
schemaVersion: OPERATIONS_SCHEMA_VERSION,
|
|
249
|
+
operationId: invocationId,
|
|
250
|
+
invocationId,
|
|
251
|
+
runId: invocation?.runId ?? intent.runId ?? basename(runDir),
|
|
252
|
+
campaignId: invocation?.campaignId ?? intent.campaignId ?? null,
|
|
253
|
+
nodeId: intent.nodeId ?? null,
|
|
254
|
+
role: invocation?.role ?? intent.role ?? null,
|
|
255
|
+
status,
|
|
256
|
+
terminalOutcome: settlement.terminalOutcome ?? previous.terminalOutcome ?? status,
|
|
257
|
+
usage: settlement.usage !== undefined ? settlement.usage : previous.usage ?? null,
|
|
258
|
+
costUsd: settlement.costUsd !== undefined ? settlement.costUsd : previous.costUsd ?? null,
|
|
259
|
+
receipts,
|
|
260
|
+
nextState: settlement.nextState !== undefined ? settlement.nextState : previous.nextState ?? null,
|
|
261
|
+
structuredResult: settlement.structuredResult !== undefined ? settlement.structuredResult : previous.structuredResult ?? null,
|
|
262
|
+
result,
|
|
263
|
+
unknownEffect: settlement.unknownEffect ?? previous.unknownEffect ?? status === "unknown_effect",
|
|
264
|
+
classification: settlement.classification ?? previous.classification ?? null,
|
|
265
|
+
reason: settlement.reason ?? previous.reason ?? null,
|
|
266
|
+
error: settlement.error ?? previous.error ?? null,
|
|
267
|
+
settledAt: previous.settledAt ?? new Date().toISOString(),
|
|
268
|
+
};
|
|
269
|
+
writeJsonAtomic(operationSettlementPath(runDir, invocationId), record);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* @param {string} runDir
|
|
273
|
+
* @param {string} invocationId
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*/
|
|
276
|
+
export function hasOperationIntent(runDir, invocationId) {
|
|
277
|
+
return existsSync(operationIntentPath(runDir, invocationId));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* @param {string} runDir
|
|
281
|
+
* @param {string} invocationId
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
export function hasOperationSettlement(runDir, invocationId) {
|
|
285
|
+
return existsSync(operationSettlementPath(runDir, invocationId));
|
|
286
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
ftruncateSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
statSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { errorCode } from "../util.mjs";
|
|
18
|
+
|
|
19
|
+
const BOOTSTRAP_FILE = "bootstrap.json";
|
|
20
|
+
const JSONL_RECOVERY_TAIL_BYTES = 64 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} path
|
|
24
|
+
* @returns {Record<string, unknown>}
|
|
25
|
+
*/
|
|
26
|
+
export function readJson(path) {
|
|
27
|
+
return /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} path
|
|
32
|
+
* @param {unknown} value
|
|
33
|
+
*/
|
|
34
|
+
export function writeJsonAtomic(path, value) {
|
|
35
|
+
writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} path
|
|
40
|
+
* @param {string} text
|
|
41
|
+
*/
|
|
42
|
+
export function writeTextAtomic(path, text) {
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
45
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
46
|
+
let committed = false;
|
|
47
|
+
try {
|
|
48
|
+
try {
|
|
49
|
+
writeSync(fd, text, 0, "utf8");
|
|
50
|
+
fsyncSync(fd);
|
|
51
|
+
} finally {
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
renameSync(temporary, path);
|
|
55
|
+
fsyncDirectory(dirname(path));
|
|
56
|
+
committed = true;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
try { unlinkSync(temporary); } catch (cleanupError) {
|
|
59
|
+
if (errorCode(cleanupError) !== "ENOENT") throw cleanupError;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
} finally {
|
|
63
|
+
if (!committed) {
|
|
64
|
+
try { unlinkSync(temporary); } catch (error) {
|
|
65
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} path
|
|
73
|
+
* @param {unknown} value
|
|
74
|
+
*/
|
|
75
|
+
export function appendJsonl(path, value) {
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
recoverPartialJsonl(path);
|
|
78
|
+
const fd = openSync(path, "a", 0o600);
|
|
79
|
+
try {
|
|
80
|
+
writeSync(fd, Buffer.from(`${JSON.stringify(value)}\n`, "utf8"));
|
|
81
|
+
fsyncSync(fd);
|
|
82
|
+
} finally {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} path
|
|
89
|
+
*/
|
|
90
|
+
function recoverPartialJsonl(path) {
|
|
91
|
+
let size;
|
|
92
|
+
try {
|
|
93
|
+
size = statSync(path).size;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (errorCode(error) === "ENOENT") return;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
if (size === 0) return;
|
|
99
|
+
const window = Math.min(size, JSONL_RECOVERY_TAIL_BYTES);
|
|
100
|
+
const fd = openSync(path, "r+");
|
|
101
|
+
try {
|
|
102
|
+
const buffer = Buffer.alloc(window);
|
|
103
|
+
readSync(fd, buffer, 0, window, size - window);
|
|
104
|
+
const tail = buffer.toString("utf8");
|
|
105
|
+
if (tail.endsWith("\n")) return;
|
|
106
|
+
const newline = tail.lastIndexOf("\n");
|
|
107
|
+
const completeBytes = newline >= 0
|
|
108
|
+
? size - window + newline + 1
|
|
109
|
+
: size <= JSONL_RECOVERY_TAIL_BYTES ? 0 : size - window;
|
|
110
|
+
ftruncateSync(fd, completeBytes);
|
|
111
|
+
fsyncSync(fd);
|
|
112
|
+
} finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} path
|
|
119
|
+
*/
|
|
120
|
+
export function fsyncDirectory(path) {
|
|
121
|
+
try {
|
|
122
|
+
const fd = openSync(path, "r");
|
|
123
|
+
try {
|
|
124
|
+
fsyncSync(fd);
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!(["EINVAL", "EPERM", "EISDIR"].includes(/** @type {string} */ (errorCode(error))))) throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} runDir
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function bootstrapPath(runDir) {
|
|
138
|
+
return join(runDir, BOOTSTRAP_FILE);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} runDir
|
|
143
|
+
* @param {string} nonce
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function bootstrapAttemptPath(runDir, nonce) {
|
|
147
|
+
return join(runDir, `${BOOTSTRAP_FILE}.${nonce}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {string} runDir
|
|
152
|
+
* @param {string} nonce
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
export function bootstrapAckPath(runDir, nonce) {
|
|
156
|
+
return join(runDir, `${BOOTSTRAP_FILE}.${nonce}.ack`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} runDir
|
|
161
|
+
* @param {string|null} keepNonce
|
|
162
|
+
*/
|
|
163
|
+
export function cleanupBootstrapAttempts(runDir, keepNonce = null) {
|
|
164
|
+
const prefix = `${BOOTSTRAP_FILE}.`;
|
|
165
|
+
let names;
|
|
166
|
+
try { names = readdirSync(runDir); } catch (error) {
|
|
167
|
+
if (errorCode(error) === "ENOENT") return;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
if (!name.startsWith(prefix) || !isBootstrapAttemptName(name)) continue;
|
|
172
|
+
if (keepNonce && (name === `${prefix}${keepNonce}` || name === `${prefix}${keepNonce}.ack`)) continue;
|
|
173
|
+
try { unlinkSync(join(runDir, name)); } catch (error) {
|
|
174
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} name
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
function isBootstrapAttemptName(name) {
|
|
184
|
+
const rest = name.slice(`${BOOTSTRAP_FILE}.`.length);
|
|
185
|
+
return /^[A-Za-z0-9-]{16,64}$/u.test(rest) || /^[A-Za-z0-9-]{16,64}\.ack$/u.test(rest);
|
|
186
|
+
}
|
|
187
|
+
|