@aeondave/pi-persona 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace work ledger — pure state transitions plus a bounded, locked JSONL store.
|
|
3
|
+
* Chat `message` never mutates it.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { closeSync, constants, fsyncSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { attributePeer } from "../core/fence.ts";
|
|
10
|
+
import { findWriteSetOverlaps, writeSetPathError } from "../core/ownership.ts";
|
|
11
|
+
import { isSemanticFrame, truncateForInject, type ExocomAsk, type ExocomClaim, type ExocomSemanticFrame } from "./envelope.ts";
|
|
12
|
+
|
|
13
|
+
export type LedgerEvent = ExocomSemanticFrame;
|
|
14
|
+
|
|
15
|
+
export interface LedgerClaim {
|
|
16
|
+
work_key: string; from_session: string; from_name: string; write_set: string[]; slice: string; msg_id: string; ts: string;
|
|
17
|
+
}
|
|
18
|
+
export interface LedgerAsk {
|
|
19
|
+
ask_id: string; work_key: string; from_session: string; from_name: string; to_session: string; question: string; msg_id: string; ts: string;
|
|
20
|
+
}
|
|
21
|
+
/** Durable answer/decline receipt. `ok: false` is a normal decline outcome. */
|
|
22
|
+
export interface LedgerAnswer {
|
|
23
|
+
ask_id: string; work_key: string;
|
|
24
|
+
requester_session: string;
|
|
25
|
+
/** Present on receipts written by this kernel; optional for backwards-compatible callers. */
|
|
26
|
+
requester_name?: string; to_session?: string; question?: string; ask_msg_id?: string; ask_ts?: string;
|
|
27
|
+
from_session: string; from_name?: string; ok: boolean; evidence: string; msg_id: string; ts: string;
|
|
28
|
+
}
|
|
29
|
+
export interface LedgerState {
|
|
30
|
+
claims: LedgerClaim[];
|
|
31
|
+
asks: LedgerAsk[];
|
|
32
|
+
answers: LedgerAnswer[];
|
|
33
|
+
/** Accepted ask ids, including completed asks, so ids cannot be recycled in this retained ledger window. */
|
|
34
|
+
askIds: string[];
|
|
35
|
+
seen: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ApplyResult =
|
|
39
|
+
| { ok: true; state: LedgerState; duplicate?: true }
|
|
40
|
+
| { ok: false; error: string };
|
|
41
|
+
|
|
42
|
+
/** Explicit in-memory and on-disk bounds. The store fails closed rather than silently dropping a transition. */
|
|
43
|
+
export const LEDGER_LIMITS = {
|
|
44
|
+
maxBytes: 1_048_576,
|
|
45
|
+
maxClaims: 128,
|
|
46
|
+
maxAsks: 128,
|
|
47
|
+
maxResults: 256,
|
|
48
|
+
maxAskIds: 4_096,
|
|
49
|
+
maxSeen: 4_096,
|
|
50
|
+
/** Pending asks from one session to another — stops a peer from hostage-taking a constrained turn. */
|
|
51
|
+
maxAsksFromTo: 3,
|
|
52
|
+
lockAttempts: 24,
|
|
53
|
+
lockBackoffMs: 20,
|
|
54
|
+
lockBackoffMaxMs: 80,
|
|
55
|
+
staleLockMs: 120_000,
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export function emptyLedger(): LedgerState {
|
|
59
|
+
return { claims: [], asks: [], answers: [], askIds: [], seen: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function remember(state: LedgerState, msgId: string): LedgerState {
|
|
63
|
+
const seen = state.seen.includes(msgId) ? state.seen : [...state.seen, msgId];
|
|
64
|
+
return { ...state, seen: seen.length > LEDGER_LIMITS.maxSeen ? seen.slice(-LEDGER_LIMITS.maxSeen) : seen };
|
|
65
|
+
}
|
|
66
|
+
function claimOf(event: ExocomClaim): LedgerClaim {
|
|
67
|
+
return { work_key: event.work_key, from_session: event.from_session, from_name: event.from_name, write_set: [...event.write_set], slice: event.slice, msg_id: event.msg_id, ts: event.ts };
|
|
68
|
+
}
|
|
69
|
+
function askOf(event: ExocomAsk): LedgerAsk {
|
|
70
|
+
return { ask_id: event.ask_id, work_key: event.work_key, from_session: event.from_session, from_name: event.from_name, to_session: event.to_session, question: event.question, msg_id: event.msg_id, ts: event.ts };
|
|
71
|
+
}
|
|
72
|
+
function answerOf(event: Extract<LedgerEvent, { kind: "answer" }>, ask: LedgerAsk): LedgerAnswer {
|
|
73
|
+
return {
|
|
74
|
+
ask_id: event.ask_id,
|
|
75
|
+
work_key: event.work_key,
|
|
76
|
+
requester_session: ask.from_session,
|
|
77
|
+
requester_name: ask.from_name,
|
|
78
|
+
to_session: ask.to_session,
|
|
79
|
+
question: ask.question,
|
|
80
|
+
ask_msg_id: ask.msg_id,
|
|
81
|
+
ask_ts: ask.ts,
|
|
82
|
+
from_session: event.from_session,
|
|
83
|
+
from_name: event.from_name,
|
|
84
|
+
ok: event.ok,
|
|
85
|
+
evidence: event.evidence,
|
|
86
|
+
msg_id: event.msg_id,
|
|
87
|
+
ts: event.ts,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Apply one signed-or-local semantic event. Fail-closed; idempotent on msg_id. */
|
|
92
|
+
export function applyLedgerEvent(state: LedgerState, event: LedgerEvent): ApplyResult {
|
|
93
|
+
if (!isSemanticFrame(event)) {
|
|
94
|
+
const raw = event && typeof event === "object"
|
|
95
|
+
? event as Partial<Extract<LedgerEvent, { kind: "claim" }>>
|
|
96
|
+
: undefined;
|
|
97
|
+
if (raw?.kind === "claim" && Array.isArray(raw.write_set) && raw.write_set.length === 0) {
|
|
98
|
+
return { ok: false, error: "claim write_set is empty" };
|
|
99
|
+
}
|
|
100
|
+
return { ok: false, error: "malformed ledger event" };
|
|
101
|
+
}
|
|
102
|
+
if (state.seen.includes(event.msg_id)) return { ok: true, state, duplicate: true };
|
|
103
|
+
switch (event.kind) {
|
|
104
|
+
case "claim": {
|
|
105
|
+
if (state.claims.length >= LEDGER_LIMITS.maxClaims) return { ok: false, error: "ledger claim limit reached" };
|
|
106
|
+
for (const path of event.write_set) {
|
|
107
|
+
const err = writeSetPathError(path);
|
|
108
|
+
if (err) return { ok: false, error: `claim write_set path "${path}" is ${err}` };
|
|
109
|
+
}
|
|
110
|
+
const owners = [...state.claims.map((claim) => ({ agent: claim.from_session, writeSet: claim.write_set })), { agent: event.from_session, writeSet: event.write_set }];
|
|
111
|
+
const overlap = findWriteSetOverlaps(owners)[0];
|
|
112
|
+
if (overlap) {
|
|
113
|
+
const other = state.claims[overlap.firstIndex];
|
|
114
|
+
return { ok: false, error: `claim overlaps open claim by "${other?.from_session ?? "?"}" on "${overlap.firstPath}" / "${overlap.secondPath}"` };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, state: remember({ ...state, claims: [...state.claims, claimOf(event)] }, event.msg_id) };
|
|
117
|
+
}
|
|
118
|
+
case "ask": {
|
|
119
|
+
if (state.askIds.includes(event.ask_id)) return { ok: false, error: `ask_id "${event.ask_id}" was already used` };
|
|
120
|
+
if (state.askIds.length >= LEDGER_LIMITS.maxAskIds) return { ok: false, error: "ledger ask id history limit reached" };
|
|
121
|
+
if (state.asks.length >= LEDGER_LIMITS.maxAsks) return { ok: false, error: "ledger ask limit reached" };
|
|
122
|
+
if (!event.to_session || event.to_session === "*") return { ok: false, error: "ask to_session is missing" };
|
|
123
|
+
if (event.to_session === event.from_session) return { ok: false, error: "ask to_session cannot be self" };
|
|
124
|
+
const fromTo = state.asks.filter((ask) => ask.from_session === event.from_session && ask.to_session === event.to_session).length;
|
|
125
|
+
if (fromTo >= LEDGER_LIMITS.maxAsksFromTo) return { ok: false, error: `ask limit from "${event.from_session}" to "${event.to_session}" reached` };
|
|
126
|
+
if (state.asks.some((ask) => ask.work_key === event.work_key && ask.to_session === event.to_session)) return { ok: false, error: `ask already pending for (${event.work_key}, ${event.to_session})` };
|
|
127
|
+
return { ok: true, state: remember({ ...state, asks: [...state.asks, askOf(event)], askIds: [...state.askIds, event.ask_id] }, event.msg_id) };
|
|
128
|
+
}
|
|
129
|
+
case "answer": {
|
|
130
|
+
const ask = state.asks.find((candidate) => candidate.ask_id === event.ask_id);
|
|
131
|
+
if (!ask) return { ok: false, error: `answer for unknown ask_id "${event.ask_id}"` };
|
|
132
|
+
if (ask.work_key !== event.work_key) return { ok: false, error: "answer work_key does not match the ask" };
|
|
133
|
+
if (event.from_session !== ask.to_session) return { ok: false, error: "answer from_session is not the ask's to_session" };
|
|
134
|
+
const asks = state.asks.filter((candidate) => candidate.ask_id !== event.ask_id);
|
|
135
|
+
const answers = [...state.answers, answerOf(event, ask)].slice(-LEDGER_LIMITS.maxResults);
|
|
136
|
+
return { ok: true, state: remember({ ...state, asks, answers }, event.msg_id) };
|
|
137
|
+
}
|
|
138
|
+
case "release": {
|
|
139
|
+
const owned = state.claims.filter((claim) => claim.work_key === event.work_key && claim.from_session === event.from_session);
|
|
140
|
+
const outbound = state.asks.filter((ask) => ask.work_key === event.work_key && ask.from_session === event.from_session);
|
|
141
|
+
if (owned.length === 0 && outbound.length === 0) return { ok: false, error: "release: no owned claim or outbound ask on that work_key" };
|
|
142
|
+
const claims = state.claims.filter((claim) => !(claim.work_key === event.work_key && claim.from_session === event.from_session));
|
|
143
|
+
const asks = state.asks.filter((ask) => !(ask.work_key === event.work_key && ask.from_session === event.from_session));
|
|
144
|
+
return { ok: true, state: remember({ ...state, claims, asks }, event.msg_id) };
|
|
145
|
+
}
|
|
146
|
+
case "progress": return { ok: true, state: remember(state, event.msg_id) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface LedgerSnapshot {
|
|
151
|
+
kind: "ledger_snapshot_v1";
|
|
152
|
+
claims: LedgerClaim[];
|
|
153
|
+
asks: LedgerAsk[];
|
|
154
|
+
answers: LedgerAnswer[];
|
|
155
|
+
ask_ids: string[];
|
|
156
|
+
seen: string[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function token(value: unknown): value is string {
|
|
160
|
+
return typeof value === "string" && value.length > 0 && value.length <= 128 && /^[A-Za-z0-9._:-]+$/.test(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A snapshot is accepted only by replaying every durable semantic fact through the ordinary reducer. */
|
|
164
|
+
function parseSnapshot(raw: unknown): LedgerState | undefined {
|
|
165
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
166
|
+
const snapshot = raw as Partial<LedgerSnapshot>;
|
|
167
|
+
if (snapshot.kind !== "ledger_snapshot_v1" || !Array.isArray(snapshot.claims) || !Array.isArray(snapshot.asks)
|
|
168
|
+
|| !Array.isArray(snapshot.answers) || !Array.isArray(snapshot.ask_ids) || !Array.isArray(snapshot.seen)
|
|
169
|
+
|| snapshot.claims.length > LEDGER_LIMITS.maxClaims || snapshot.asks.length > LEDGER_LIMITS.maxAsks
|
|
170
|
+
|| snapshot.answers.length > LEDGER_LIMITS.maxResults || snapshot.ask_ids.length > LEDGER_LIMITS.maxAskIds
|
|
171
|
+
|| snapshot.seen.length > LEDGER_LIMITS.maxSeen || !snapshot.ask_ids.every(token) || !snapshot.seen.every(token)
|
|
172
|
+
|| new Set(snapshot.ask_ids).size !== snapshot.ask_ids.length || new Set(snapshot.seen).size !== snapshot.seen.length) return undefined;
|
|
173
|
+
let state = emptyLedger();
|
|
174
|
+
for (const value of snapshot.answers) {
|
|
175
|
+
if (!value || typeof value !== "object") return undefined;
|
|
176
|
+
const answer = value as LedgerAnswer;
|
|
177
|
+
const reopened: unknown = {
|
|
178
|
+
kind: "ask", ask_id: answer.ask_id, work_key: answer.work_key, from_session: answer.requester_session,
|
|
179
|
+
from_name: answer.requester_name, to_session: answer.to_session, question: answer.question,
|
|
180
|
+
msg_id: answer.ask_msg_id, ts: answer.ask_ts,
|
|
181
|
+
};
|
|
182
|
+
const receipt: unknown = {
|
|
183
|
+
kind: "answer", ask_id: answer.ask_id, work_key: answer.work_key, from_session: answer.from_session,
|
|
184
|
+
from_name: answer.from_name, ok: answer.ok, evidence: answer.evidence, msg_id: answer.msg_id, ts: answer.ts,
|
|
185
|
+
};
|
|
186
|
+
if (!isSemanticFrame(reopened) || reopened.kind !== "ask" || !isSemanticFrame(receipt) || receipt.kind !== "answer") return undefined;
|
|
187
|
+
const opened = applyLedgerEvent(state, reopened);
|
|
188
|
+
if (!opened.ok) return undefined;
|
|
189
|
+
const closed = applyLedgerEvent(opened.state, receipt);
|
|
190
|
+
if (!closed.ok) return undefined;
|
|
191
|
+
state = closed.state;
|
|
192
|
+
}
|
|
193
|
+
for (const value of snapshot.claims) {
|
|
194
|
+
if (!value || typeof value !== "object") return undefined;
|
|
195
|
+
const claim = value as LedgerClaim;
|
|
196
|
+
const frame: unknown = {
|
|
197
|
+
kind: "claim", work_key: claim.work_key, from_session: claim.from_session, from_name: claim.from_name,
|
|
198
|
+
write_set: claim.write_set, slice: claim.slice, msg_id: claim.msg_id, ts: claim.ts,
|
|
199
|
+
};
|
|
200
|
+
if (!isSemanticFrame(frame) || frame.kind !== "claim") return undefined;
|
|
201
|
+
const applied = applyLedgerEvent(state, frame);
|
|
202
|
+
if (!applied.ok) return undefined;
|
|
203
|
+
state = applied.state;
|
|
204
|
+
}
|
|
205
|
+
for (const value of snapshot.asks) {
|
|
206
|
+
if (!value || typeof value !== "object") return undefined;
|
|
207
|
+
const ask = value as LedgerAsk;
|
|
208
|
+
const frame: unknown = {
|
|
209
|
+
kind: "ask", ask_id: ask.ask_id, work_key: ask.work_key, from_session: ask.from_session,
|
|
210
|
+
from_name: ask.from_name, to_session: ask.to_session, question: ask.question, msg_id: ask.msg_id, ts: ask.ts,
|
|
211
|
+
};
|
|
212
|
+
if (!isSemanticFrame(frame) || frame.kind !== "ask") return undefined;
|
|
213
|
+
const applied = applyLedgerEvent(state, frame);
|
|
214
|
+
if (!applied.ok) return undefined;
|
|
215
|
+
state = applied.state;
|
|
216
|
+
}
|
|
217
|
+
const requiredAskIds = new Set(state.askIds);
|
|
218
|
+
if (![...requiredAskIds].every((askId) => snapshot.ask_ids!.includes(askId))) return undefined;
|
|
219
|
+
return { ...state, askIds: [...snapshot.ask_ids], seen: [...snapshot.seen] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function snapshotOf(state: LedgerState): LedgerSnapshot {
|
|
223
|
+
return {
|
|
224
|
+
kind: "ledger_snapshot_v1",
|
|
225
|
+
claims: state.claims,
|
|
226
|
+
asks: state.asks,
|
|
227
|
+
answers: state.answers,
|
|
228
|
+
ask_ids: state.askIds,
|
|
229
|
+
seen: state.seen,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Reconstruct state from JSONL. Junk events are ignored; a corrupt authoritative snapshot fails closed. */
|
|
234
|
+
export function parseLedger(text: string): LedgerState {
|
|
235
|
+
if (Buffer.byteLength(text, "utf8") > LEDGER_LIMITS.maxBytes) throw new Error("ledger exceeds bounded size");
|
|
236
|
+
let state = emptyLedger();
|
|
237
|
+
for (const line of text.split(/\r?\n/)) {
|
|
238
|
+
const trimmed = line.trim();
|
|
239
|
+
if (!trimmed) continue;
|
|
240
|
+
let raw: unknown;
|
|
241
|
+
try { raw = JSON.parse(trimmed); }
|
|
242
|
+
catch { continue; }
|
|
243
|
+
if (raw && typeof raw === "object" && (raw as { kind?: unknown }).kind === "ledger_snapshot_v1") {
|
|
244
|
+
const restored = parseSnapshot(raw);
|
|
245
|
+
if (!restored) throw new Error("malformed ledger snapshot");
|
|
246
|
+
state = restored;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!isSemanticFrame(raw)) continue;
|
|
250
|
+
const result = applyLedgerEvent(state, raw);
|
|
251
|
+
if (result.ok) state = result.state;
|
|
252
|
+
}
|
|
253
|
+
return state;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function pendingAsksTo(state: LedgerState, sessionId: string): LedgerAsk[] {
|
|
257
|
+
return state.asks.filter((ask) => ask.to_session === sessionId);
|
|
258
|
+
}
|
|
259
|
+
/** Return the retained terminal receipt for an ask, including declines. */
|
|
260
|
+
export function answerFor(state: LedgerState, askId: string, requesterSession?: string): LedgerAnswer | undefined {
|
|
261
|
+
return state.answers.find((answer) => answer.ask_id === askId && (requesterSession === undefined || answer.requester_session === requesterSession));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface LedgerPruneOptions { now: number; ttlMs: number; liveSessions: ReadonlySet<string>; }
|
|
265
|
+
function isFresh(ts: string, now: number, ttlMs: number): boolean {
|
|
266
|
+
const at = Date.parse(ts);
|
|
267
|
+
return Number.isFinite(at) && now - at <= ttlMs;
|
|
268
|
+
}
|
|
269
|
+
/** Pure stale-state reconciliation for a future registry/heartbeat integration layer. */
|
|
270
|
+
export function pruneLedger(state: LedgerState, options: LedgerPruneOptions): LedgerState {
|
|
271
|
+
const now = Number.isFinite(options.now) ? options.now : 0;
|
|
272
|
+
const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs >= 0 ? options.ttlMs : 0;
|
|
273
|
+
// The registry heartbeat is the ownership lease. Event age must not evict a healthy long-running
|
|
274
|
+
// claim or question; only retained terminal receipts use a storage TTL.
|
|
275
|
+
const claims = state.claims.filter((claim) => options.liveSessions.has(claim.from_session));
|
|
276
|
+
const asks = state.asks.filter((ask) => options.liveSessions.has(ask.from_session) && options.liveSessions.has(ask.to_session));
|
|
277
|
+
const answers = state.answers.filter((answer) => isFresh(answer.ts, now, ttlMs)).slice(-LEDGER_LIMITS.maxResults);
|
|
278
|
+
const askIds = [...new Set([...asks.map((ask) => ask.ask_id), ...answers.map((answer) => answer.ask_id)])];
|
|
279
|
+
return { ...state, claims, asks, answers, askIds };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The pending asks a prune dropped (a party left the pool) — the ids a waiter must be woken for. */
|
|
283
|
+
export function droppedAskIds(before: LedgerState, after: LedgerState): string[] {
|
|
284
|
+
const kept = new Set(after.asks.map((a) => a.ask_id));
|
|
285
|
+
return before.asks.filter((a) => !kept.has(a.ask_id)).map((a) => a.ask_id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// `question` is capped at 4,096 UTF-16 code units on the wire. Three UTF-8 bytes per
|
|
289
|
+
// code unit covers its worst valid encoding, so the one ask we expose remains complete.
|
|
290
|
+
const PENDING_ASK_QUESTION_MAX_BYTES = 12_288;
|
|
291
|
+
|
|
292
|
+
/** Short machine-readable pending-ask block. Shows one resolvable ask at a time so a peer cannot consume a turn. */
|
|
293
|
+
export function pendingAskBlock(
|
|
294
|
+
asks: readonly LedgerAsk[],
|
|
295
|
+
resolvePeerName: (sessionId: string) => string | undefined = (sessionId) => sessionId,
|
|
296
|
+
): string | undefined {
|
|
297
|
+
if (asks.length === 0) return undefined;
|
|
298
|
+
const ask = asks[0]!;
|
|
299
|
+
const lines = ["[exocom-pending-ask]"];
|
|
300
|
+
lines.push(`ask_id=${ask.ask_id} work_key=${ask.work_key} from_session=${ask.from_session}`);
|
|
301
|
+
lines.push(attributePeer(resolvePeerName(ask.from_session) ?? ask.from_session, truncateForInject(ask.question, PENDING_ASK_QUESTION_MAX_BYTES).text));
|
|
302
|
+
if (asks.length > 1) lines.push(`omitted=${asks.length - 1}`);
|
|
303
|
+
lines.push("[/exocom-pending-ask]");
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function sleepSync(ms: number): void {
|
|
308
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
309
|
+
}
|
|
310
|
+
type LedgerWriter = (fd: number, payload: Buffer, offset: number, length: number) => number;
|
|
311
|
+
|
|
312
|
+
function writeAllSync(fd: number, payload: Buffer, writer: LedgerWriter = writeSync): void {
|
|
313
|
+
let offset = 0;
|
|
314
|
+
while (offset < payload.length) {
|
|
315
|
+
const written = writer(fd, payload, offset, payload.length - offset);
|
|
316
|
+
if (written <= 0) throw new Error("ledger short write");
|
|
317
|
+
offset += written;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function unsafe(path: string): Error { return new Error(`ledger unsafe file: ${path}`); }
|
|
321
|
+
function assertSafeStat(path: string, stat: { isFile(): boolean; nlink: number }): void {
|
|
322
|
+
if (!stat.isFile() || stat.nlink > 1) throw unsafe(path);
|
|
323
|
+
}
|
|
324
|
+
function noFollowFlag(): number { return constants.O_NOFOLLOW ?? 0; }
|
|
325
|
+
|
|
326
|
+
/** Open a ledger only after link/inode checks; returns undefined for a genuinely absent file. */
|
|
327
|
+
function openLedger(path: string, append: boolean): number | undefined {
|
|
328
|
+
let before: ReturnType<typeof lstatSync>;
|
|
329
|
+
try { before = lstatSync(path); } catch (error) {
|
|
330
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
331
|
+
if (!append) return undefined;
|
|
332
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
333
|
+
try { assertSafeStat(path, fstatSync(fd)); return fd; } catch (err) { closeSync(fd); throw err; }
|
|
334
|
+
}
|
|
335
|
+
if (before.isSymbolicLink()) throw unsafe(path);
|
|
336
|
+
assertSafeStat(path, before);
|
|
337
|
+
const fd = openSync(path, constants.O_RDWR | (append ? constants.O_APPEND : 0) | noFollowFlag());
|
|
338
|
+
try {
|
|
339
|
+
const opened = fstatSync(fd);
|
|
340
|
+
assertSafeStat(path, opened);
|
|
341
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino) throw unsafe(path);
|
|
342
|
+
return fd;
|
|
343
|
+
} catch (err) { closeSync(fd); throw err; }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function backoffMs(attempt: number): number {
|
|
347
|
+
return Math.min(LEDGER_LIMITS.lockBackoffMs * attempt, LEDGER_LIMITS.lockBackoffMaxMs);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Same liveness probe the registry uses: ESRCH is dead, EPERM is a live process we cannot signal. */
|
|
351
|
+
function pidAlive(pid: number): boolean {
|
|
352
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
353
|
+
try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function lockOwnerPid(lock: string): number | undefined {
|
|
357
|
+
try {
|
|
358
|
+
const first = readFileSync(lock, "utf8").trim().split(/\s+/)[0];
|
|
359
|
+
const pid = Number(first);
|
|
360
|
+
if (Number.isInteger(pid) && pid > 0) return pid;
|
|
361
|
+
} catch { /* unreadable lock is pid-unknown */ }
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sharingViolation(error: unknown): boolean {
|
|
366
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
367
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "EAGAIN";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Release only while the sidecar still carries our token — inode is 0 on Windows, so path+payload is the identity. */
|
|
371
|
+
function releaseLockFile(lock: string, payload: string, held: { dev: number; ino: number }): void {
|
|
372
|
+
try {
|
|
373
|
+
const current = lstatSync(lock);
|
|
374
|
+
if (current.isSymbolicLink() || !current.isFile() || current.nlink > 1) return;
|
|
375
|
+
if (held.ino !== 0 && (current.dev !== held.dev || current.ino !== held.ino)) return;
|
|
376
|
+
if (readFileSync(lock, "utf8") !== payload) return;
|
|
377
|
+
unlinkSync(lock);
|
|
378
|
+
} catch { /* successor already replaced this sidecar */ }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** A short `wx` sidecar lock serializes transactions on POSIX and Windows.
|
|
382
|
+
* Live owners are never stolen from mtime alone; a dead pid is reclaimed immediately; release is token-checked. */
|
|
383
|
+
function acquireLedgerLock(path: string): () => void {
|
|
384
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
385
|
+
const lock = `${path}.lock`;
|
|
386
|
+
for (let attempt = 1; attempt <= LEDGER_LIMITS.lockAttempts; attempt++) {
|
|
387
|
+
try {
|
|
388
|
+
const fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
389
|
+
const payload = `${process.pid} ${randomUUID()}\n`;
|
|
390
|
+
try {
|
|
391
|
+
writeSync(fd, payload, null, "utf8");
|
|
392
|
+
fsyncSync(fd);
|
|
393
|
+
const held = fstatSync(fd);
|
|
394
|
+
assertSafeStat(lock, held);
|
|
395
|
+
return () => { try { closeSync(fd); } finally { releaseLockFile(lock, payload, held); } };
|
|
396
|
+
} catch (error) {
|
|
397
|
+
closeSync(fd); try { unlinkSync(lock); } catch { /* best-effort cleanup */ } throw error;
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
401
|
+
let steal = false;
|
|
402
|
+
try {
|
|
403
|
+
const held = lstatSync(lock);
|
|
404
|
+
if (held.isSymbolicLink() || !held.isFile() || held.nlink > 1) throw unsafe(lock);
|
|
405
|
+
const pid = lockOwnerPid(lock);
|
|
406
|
+
if (pid !== undefined && pid !== process.pid && pidAlive(pid)) steal = false;
|
|
407
|
+
else if (pid !== undefined && (pid === process.pid || !pidAlive(pid))) steal = true;
|
|
408
|
+
else steal = Date.now() - held.mtimeMs > LEDGER_LIMITS.staleLockMs;
|
|
409
|
+
if (steal) unlinkSync(lock);
|
|
410
|
+
} catch (lockError) {
|
|
411
|
+
if ((lockError as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
412
|
+
throw lockError;
|
|
413
|
+
}
|
|
414
|
+
if (steal) continue;
|
|
415
|
+
if (attempt < LEDGER_LIMITS.lockAttempts) sleepSync(backoffMs(attempt));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`ledger lock unavailable: ${lock}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Strict read: absent is empty; linked, oversized, or unreadable files fail closed.
|
|
422
|
+
* Sharing violations (Windows rename vs reader) retry; a corrupt snapshot still fails closed. */
|
|
423
|
+
export function loadLedger(path: string): LedgerState {
|
|
424
|
+
let last: unknown;
|
|
425
|
+
for (let attempt = 1; attempt <= LEDGER_LIMITS.lockAttempts; attempt++) {
|
|
426
|
+
try {
|
|
427
|
+
const fd = openLedger(path, false);
|
|
428
|
+
if (fd === undefined) return emptyLedger();
|
|
429
|
+
try {
|
|
430
|
+
if (fstatSync(fd).size > LEDGER_LIMITS.maxBytes) throw new Error("ledger exceeds bounded size");
|
|
431
|
+
return parseLedger(readFileSync(fd, "utf8"));
|
|
432
|
+
} finally { closeSync(fd); }
|
|
433
|
+
} catch (error) {
|
|
434
|
+
last = error;
|
|
435
|
+
if (attempt < LEDGER_LIMITS.lockAttempts && sharingViolation(error)) {
|
|
436
|
+
sleepSync(backoffMs(attempt));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
443
|
+
}
|
|
444
|
+
function appendLocked(path: string, event: LedgerEvent, io?: LedgerFileIO): void {
|
|
445
|
+
const payload = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
|
|
446
|
+
const fd = openLedger(path, true);
|
|
447
|
+
if (fd === undefined) throw new Error("ledger unavailable");
|
|
448
|
+
let originalSize: number | undefined;
|
|
449
|
+
let originalIdentity: { dev: number; ino: number } | undefined;
|
|
450
|
+
try {
|
|
451
|
+
const original = fstatSync(fd);
|
|
452
|
+
originalSize = original.size;
|
|
453
|
+
originalIdentity = { dev: original.dev, ino: original.ino };
|
|
454
|
+
if (originalSize + payload.length > LEDGER_LIMITS.maxBytes) throw new Error("ledger bounded size reached");
|
|
455
|
+
writeAllSync(fd, payload, io?.write);
|
|
456
|
+
fsyncSync(fd);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
try {
|
|
459
|
+
if (originalSize !== undefined && originalIdentity !== undefined) {
|
|
460
|
+
// Windows cannot reliably truncate the O_APPEND descriptor. Reopen with the normal
|
|
461
|
+
// safety checks, then additionally prove it is still the exact file we appended to.
|
|
462
|
+
const rollbackFd = openLedger(path, false);
|
|
463
|
+
if (rollbackFd === undefined) throw new Error("ledger disappeared during append rollback");
|
|
464
|
+
try {
|
|
465
|
+
const current = fstatSync(rollbackFd);
|
|
466
|
+
if (current.dev !== originalIdentity.dev || current.ino !== originalIdentity.ino) {
|
|
467
|
+
throw new Error("ledger changed during append rollback");
|
|
468
|
+
}
|
|
469
|
+
ftruncateSync(rollbackFd, originalSize);
|
|
470
|
+
fsyncSync(rollbackFd);
|
|
471
|
+
} finally { closeSync(rollbackFd); }
|
|
472
|
+
}
|
|
473
|
+
} catch { /* retained below after the original append failure */ }
|
|
474
|
+
throw error;
|
|
475
|
+
} finally { closeSync(fd); }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** A writer may have died after writing only part of its newline-terminated record.
|
|
479
|
+
* With the transaction lock held, repair that tail before the next commit. Returns the size
|
|
480
|
+
* observed before repair so a transaction still compacts history it had to trim. */
|
|
481
|
+
function recoverIncompleteTrailingRecordLocked(path: string): number {
|
|
482
|
+
const fd = openLedger(path, false);
|
|
483
|
+
if (fd === undefined) return 0;
|
|
484
|
+
try {
|
|
485
|
+
const size = fstatSync(fd).size;
|
|
486
|
+
if (size === 0) return 0;
|
|
487
|
+
if (size > LEDGER_LIMITS.maxBytes) throw new Error("ledger exceeds bounded size");
|
|
488
|
+
const content = readFileSync(fd);
|
|
489
|
+
if (content[content.length - 1] === 0x0a) return size;
|
|
490
|
+
const lastNewline = content.lastIndexOf(0x0a);
|
|
491
|
+
const tail = content.subarray(lastNewline + 1);
|
|
492
|
+
if (tail.toString("utf8").trim().length === 0) {
|
|
493
|
+
ftruncateSync(fd, lastNewline + 1);
|
|
494
|
+
fsyncSync(fd);
|
|
495
|
+
return size;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
JSON.parse(tail.toString("utf8"));
|
|
499
|
+
// The record itself is complete and only its terminator was lost. Preserve it and
|
|
500
|
+
// restore the delimiter before any O_APPEND writer can concatenate the next record.
|
|
501
|
+
// At the hard size ceiling, leave it byte-identical: commitLedgerEvent uses the
|
|
502
|
+
// returned pre-repair size to compact it before another append.
|
|
503
|
+
if (size < LEDGER_LIMITS.maxBytes) {
|
|
504
|
+
writeSync(fd, Buffer.from("\n"), 0, 1, size);
|
|
505
|
+
fsyncSync(fd);
|
|
506
|
+
}
|
|
507
|
+
return size;
|
|
508
|
+
} catch { /* handled below */ }
|
|
509
|
+
// Every record this writer emits starts with `{`. Unknown garbage must fail closed:
|
|
510
|
+
// appending to it could return success while making the new event unreplayable.
|
|
511
|
+
if (tail[0] !== 0x7b) throw new Error("ledger has an unknown unterminated tail");
|
|
512
|
+
ftruncateSync(fd, lastNewline + 1);
|
|
513
|
+
fsyncSync(fd);
|
|
514
|
+
return size;
|
|
515
|
+
} finally { closeSync(fd); }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function fileSizeLocked(path: string): number {
|
|
519
|
+
const fd = openLedger(path, false);
|
|
520
|
+
if (fd === undefined) return 0;
|
|
521
|
+
try { return fstatSync(fd).size; } finally { closeSync(fd); }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Write a complete compact snapshot to a same-directory temp and atomically replace the journal.
|
|
525
|
+
* There is deliberately no in-place fallback: on a Windows sharing violation the original journal
|
|
526
|
+
* remains authoritative and the caller receives a NACK instead of a torn file. */
|
|
527
|
+
function replaceWithSnapshotLocked(path: string, state: LedgerState, io?: LedgerFileIO): void {
|
|
528
|
+
const payload = Buffer.from(`${JSON.stringify(snapshotOf(state))}\n`, "utf8");
|
|
529
|
+
if (payload.length > LEDGER_LIMITS.maxBytes) throw new Error("ledger compact snapshot exceeds bounded size");
|
|
530
|
+
const tmp = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
531
|
+
let fd: number | undefined;
|
|
532
|
+
try {
|
|
533
|
+
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
534
|
+
writeAllSync(fd, payload, io?.write);
|
|
535
|
+
fsyncSync(fd);
|
|
536
|
+
closeSync(fd);
|
|
537
|
+
fd = undefined;
|
|
538
|
+
for (let attempt = 1; ; attempt++) {
|
|
539
|
+
try {
|
|
540
|
+
(io?.rename ?? renameSync)(tmp, path);
|
|
541
|
+
return;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (attempt < LEDGER_LIMITS.lockAttempts && sharingViolation(error)) {
|
|
544
|
+
sleepSync(backoffMs(attempt));
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} finally {
|
|
551
|
+
if (fd !== undefined) closeSync(fd);
|
|
552
|
+
try { unlinkSync(tmp); } catch { /* successful rename already consumed it */ }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** Strict append for callers that already have a validated event but no state transition. */
|
|
556
|
+
export function appendLedgerEvent(path: string, event: LedgerEvent): void {
|
|
557
|
+
if (!isSemanticFrame(event)) throw new Error("malformed ledger event");
|
|
558
|
+
const release = acquireLedgerLock(path);
|
|
559
|
+
try {
|
|
560
|
+
recoverIncompleteTrailingRecordLocked(path);
|
|
561
|
+
appendLocked(path, event);
|
|
562
|
+
} finally { release(); }
|
|
563
|
+
}
|
|
564
|
+
/** Atomic cross-process read/apply/append transaction. I/O failures become an explicit NACK. */
|
|
565
|
+
export interface LedgerCommitOptions {
|
|
566
|
+
/** Applied under the same transaction lock before validating the new event. */
|
|
567
|
+
prune?: LedgerPruneOptions;
|
|
568
|
+
/** Test/operations override; defaults to half the maximum journal size. */
|
|
569
|
+
compactAtBytes?: number;
|
|
570
|
+
/** Fault-injection seam for short-write and Windows replacement regression tests. */
|
|
571
|
+
io?: LedgerFileIO;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export interface LedgerFileIO {
|
|
575
|
+
write?: LedgerWriter;
|
|
576
|
+
rename?: (from: string, to: string) => void;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function pruningChanged(before: LedgerState, after: LedgerState): boolean {
|
|
580
|
+
return before.claims.length !== after.claims.length || before.asks.length !== after.asks.length
|
|
581
|
+
|| before.answers.length !== after.answers.length || before.askIds.length !== after.askIds.length;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function commitLedgerEvent(path: string, event: LedgerEvent, options: LedgerCommitOptions = {}): ApplyResult {
|
|
585
|
+
let release: (() => void) | undefined;
|
|
586
|
+
try {
|
|
587
|
+
release = acquireLedgerLock(path);
|
|
588
|
+
const sizeBeforeRecovery = recoverIncompleteTrailingRecordLocked(path);
|
|
589
|
+
const loaded = loadLedger(path);
|
|
590
|
+
const pruned = options.prune ? pruneLedger(loaded, options.prune) : loaded;
|
|
591
|
+
const result = applyLedgerEvent(pruned, event);
|
|
592
|
+
const compactAt = Number.isFinite(options.compactAtBytes) && options.compactAtBytes !== undefined
|
|
593
|
+
? Math.max(1, Math.floor(options.compactAtBytes)) : Math.floor(LEDGER_LIMITS.maxBytes / 2);
|
|
594
|
+
const eventBytes = Buffer.byteLength(`${JSON.stringify(event)}\n`, "utf8");
|
|
595
|
+
const mustCompact = pruningChanged(loaded, pruned)
|
|
596
|
+
|| (result.ok && result.duplicate !== true && Math.max(sizeBeforeRecovery, fileSizeLocked(path)) + eventBytes >= compactAt);
|
|
597
|
+
if (mustCompact) replaceWithSnapshotLocked(path, result.ok ? result.state : pruned, options.io);
|
|
598
|
+
else if (result.ok && result.duplicate !== true) appendLocked(path, event, options.io);
|
|
599
|
+
return result;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return { ok: false, error: `ledger transaction failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
602
|
+
} finally { release?.(); }
|
|
603
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** exocom limits and constants. */
|
|
2
|
+
export const EXOCOM = {
|
|
3
|
+
INJECT_MAX_BYTES: 8_192, // R1 head-truncate an inbound message before it enters a turn
|
|
4
|
+
INLINE_MAX_BYTES: 12_288, // R3 above this, spill to an artifact file and send { preview, path }
|
|
5
|
+
PREVIEW_BYTES: 2_000, // R3 preview length carried inline alongside the artifact path
|
|
6
|
+
MAX_HOPS: 6, // reply-chain depth cap
|
|
7
|
+
SENDER_WINDOW_MS: 60_000, // R2 per-sender budget window
|
|
8
|
+
SENDER_MAX_MSGS: 20, // R2 messages per sender per window
|
|
9
|
+
SENDER_MAX_BYTES: 262_144, // R2 bytes per sender per window
|
|
10
|
+
SEEN_TTL_MS: 300_000, // R8 dedup memory
|
|
11
|
+
ACK_TIMEOUT_MS: 30_000, // R4 bound the ack-wait on send — a frozen peer can't hang a live turn
|
|
12
|
+
HEARTBEAT_MS: 30_000, // registry heartbeat + pool refresh
|
|
13
|
+
QUIET_AFTER_MS: 45_000, // a peer that missed a heartbeat looks "quiet" but isn't pruned yet
|
|
14
|
+
STALE_AFTER_MS: 120_000, // R7 evict an entry whose heartbeat is older than this
|
|
15
|
+
LEDGER_RESULT_TTL_MS: 3_600_000, // retain answer/decline evidence for reconnect/read-before-wait recovery
|
|
16
|
+
DELIVER_MIN_INTERVAL_MS: 10_000, // R6 floor between receiver-turn wakes from one plane
|
|
17
|
+
DELIVER_MAX_PER_MIN: 6, // R6 ceiling on wakes/min
|
|
18
|
+
ARTIFACT_TTL_MS: 3_600_000, // successful spill files survive long enough for the receiver to read them
|
|
19
|
+
ARTIFACT_MAX_FILES: 256, // scope-wide bound; oldest spill files are reaped before a new one is written
|
|
20
|
+
} as const;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** exocom path layout — workspace-identity scope + per-instance endpoint. Pure. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
function shortHash(value: string, length = 16): string {
|
|
7
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function workspaceHash(projectRoot: string): string {
|
|
11
|
+
const resolved = resolve(projectRoot);
|
|
12
|
+
const canonical = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
13
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 24);
|
|
14
|
+
}
|
|
15
|
+
export function exocomRoot(agentDir: string, hash: string): string {
|
|
16
|
+
return join(agentDir, "persona", "exocom", hash);
|
|
17
|
+
}
|
|
18
|
+
export function agentsDir(agentDir: string, hash: string): string {
|
|
19
|
+
return join(exocomRoot(agentDir, hash), "agents");
|
|
20
|
+
}
|
|
21
|
+
export function ledgerPath(agentDir: string, hash: string): string {
|
|
22
|
+
return join(exocomRoot(agentDir, hash), "ledger.jsonl");
|
|
23
|
+
}
|
|
24
|
+
export function registryPath(agentDir: string, hash: string, key: string): string {
|
|
25
|
+
return join(agentsDir(agentDir, hash), `${key}.json`);
|
|
26
|
+
}
|
|
27
|
+
export function socketPath(_agentDir: string, hash: string, sessionId: string): string {
|
|
28
|
+
// sockaddr_un is only 104 bytes on macOS and 108 on Linux. Keep both directory and
|
|
29
|
+
// filename fixed-size, outside a potentially long ~/.pi path.
|
|
30
|
+
return join(tmpdir(), `pi-exocom-${shortHash(hash)}`, `${shortHash(sessionId, 20)}.sock`);
|
|
31
|
+
}
|
|
32
|
+
export function endpoint(agentDir: string, hash: string, sessionId: string, platform: NodeJS.Platform): string {
|
|
33
|
+
if (platform === "win32") return `\\\\.\\pipe\\pi-persona-exocom-${shortHash(hash)}-${shortHash(sessionId, 20)}`;
|
|
34
|
+
return socketPath(agentDir, hash, sessionId);
|
|
35
|
+
}
|