pi-agent-replicate 0.0.0-stage → 1.4.1
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 +27 -0
- package/CHANGELOG.md +152 -0
- package/CONTEXT.md +63 -0
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/RELEASING.md +132 -0
- package/assets/banner.png +0 -0
- package/config.json.example +6 -0
- package/docs/README.md +23 -0
- package/docs/adr/0003-installable-role-packs.md +29 -0
- package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
- package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
- package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
- package/docs/configuration.md +14 -0
- package/docs/development.md +27 -0
- package/docs/public-subagent-tree-api.md +58 -0
- package/docs/retained-roles.md +71 -0
- package/docs/settled-lifecycle-contract.md +71 -0
- package/docs/worktree-subagents.md +58 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/index.d.ts +116 -0
- package/index.js +1064 -0
- package/index.ts +155 -0
- package/package.json +70 -4
- package/pi-extension/subagents/activity.js +575 -0
- package/pi-extension/subagents/activity.ts +980 -0
- package/pi-extension/subagents/completion.js +184 -0
- package/pi-extension/subagents/completion.ts +229 -0
- package/pi-extension/subagents/delivery-drain.js +15 -0
- package/pi-extension/subagents/delivery-drain.ts +20 -0
- package/pi-extension/subagents/delivery-files.js +86 -0
- package/pi-extension/subagents/delivery-files.ts +105 -0
- package/pi-extension/subagents/delivery-log.js +51 -0
- package/pi-extension/subagents/delivery-log.ts +51 -0
- package/pi-extension/subagents/delivery-payload.ts +55 -0
- package/pi-extension/subagents/delivery.ts +219 -0
- package/pi-extension/subagents/execution-owner.js +30 -0
- package/pi-extension/subagents/execution-owner.ts +38 -0
- package/pi-extension/subagents/herdr.js +645 -0
- package/pi-extension/subagents/herdr.ts +927 -0
- package/pi-extension/subagents/index.ts +3400 -0
- package/pi-extension/subagents/launch.js +774 -0
- package/pi-extension/subagents/launch.ts +1124 -0
- package/pi-extension/subagents/lifecycle.js +477 -0
- package/pi-extension/subagents/lifecycle.ts +582 -0
- package/pi-extension/subagents/lineage.js +806 -0
- package/pi-extension/subagents/lineage.ts +841 -0
- package/pi-extension/subagents/replica-background.js +32 -0
- package/pi-extension/subagents/replica-background.ts +38 -0
- package/pi-extension/subagents/replica-branch.ts +45 -0
- package/pi-extension/subagents/replica-directory-settings.ts +51 -0
- package/pi-extension/subagents/replica-history.js +22 -0
- package/pi-extension/subagents/replica-history.ts +21 -0
- package/pi-extension/subagents/replica-work.js +326 -0
- package/pi-extension/subagents/replica-work.ts +336 -0
- package/pi-extension/subagents/replica-worker.js +74 -0
- package/pi-extension/subagents/replica-worker.ts +71 -0
- package/pi-extension/subagents/replication.ts +99 -0
- package/pi-extension/subagents/result-delivery-queue.ts +33 -0
- package/pi-extension/subagents/role-catalog.js +123 -0
- package/pi-extension/subagents/role-catalog.ts +24 -0
- package/pi-extension/subagents/runtime-routing.js +154 -0
- package/pi-extension/subagents/runtime-routing.ts +239 -0
- package/pi-extension/subagents/session-binding.js +346 -0
- package/pi-extension/subagents/session-binding.ts +339 -0
- package/pi-extension/subagents/session-work.js +219 -0
- package/pi-extension/subagents/session-work.ts +167 -0
- package/pi-extension/subagents/session.js +318 -0
- package/pi-extension/subagents/session.ts +448 -0
- package/pi-extension/subagents/settled-contract.js +47 -0
- package/pi-extension/subagents/settled-contract.ts +175 -0
- package/pi-extension/subagents/source-state.js +180 -0
- package/pi-extension/subagents/source-state.ts +184 -0
- package/pi-extension/subagents/status.ts +493 -0
- package/pi-extension/subagents/subagent-done.js +805 -0
- package/pi-extension/subagents/subagent-done.ts +878 -0
- package/pi-extension/subagents/terminal.js +111 -0
- package/pi-extension/subagents/terminal.ts +208 -0
- package/pi-extension/subagents/watchdog.js +122 -0
- package/pi-extension/subagents/watchdog.ts +171 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readSessionBinding, readSessionHeader, sessionBindingStore, sessionLineageRoots } from "./session-binding.js";
|
|
5
|
+
import { readExecutionOwner } from "./execution-owner.js";
|
|
6
|
+
/** Acknowledge an exact-parent native launch error without sending a second result. */
|
|
7
|
+
export function acknowledgeReportedLaunchFailure(lineage, error, publishEvent = appendLineageEvent) {
|
|
8
|
+
const { rootDir, nodeId } = lineage;
|
|
9
|
+
if (hasUndrainedDescendants(reduceLineage(rootDir), nodeId))
|
|
10
|
+
throw new Error("Replica descendants have not drained.");
|
|
11
|
+
const publish = (eventId, type, fields = {}) => {
|
|
12
|
+
if (!publishEvent(rootDir, eventId, type, nodeId, fields) && !hasLineageEvent(rootDir, eventId, type)) {
|
|
13
|
+
throw new Error(`Unable to publish reported launch acknowledgement: ${type}`);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
publish(`terminal:${nodeId}`, "terminal", { outcome: "error", resultContent: error });
|
|
17
|
+
// A crash after delivery must leave resource cleanup discoverable.
|
|
18
|
+
publish(`cleanup-pending:${nodeId}`, "cleanup_pending");
|
|
19
|
+
publish(`terminal-delivered:${nodeId}`, "terminal_delivered", { deliveryId: `terminal:${nodeId}` });
|
|
20
|
+
}
|
|
21
|
+
function safeId(value) {
|
|
22
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
23
|
+
}
|
|
24
|
+
function eventDir(rootDir) {
|
|
25
|
+
const dir = join(rootDir, "events");
|
|
26
|
+
if (!existsSync(dir)) {
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
fsyncDirectory(dirname(dir));
|
|
29
|
+
}
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
function fsyncDirectory(path) {
|
|
33
|
+
try {
|
|
34
|
+
const fd = openSync(path, "r");
|
|
35
|
+
try {
|
|
36
|
+
fsyncSync(fd);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Some filesystems do not permit directory fsync. Publication remains atomic.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeDurable(path, content) {
|
|
47
|
+
const fd = openSync(path, "wx", 0o600);
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(fd, content, "utf8");
|
|
50
|
+
fsyncSync(fd);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Register a node before creating any Herdr pane or workspace. */
|
|
57
|
+
export function registerLineage(options) {
|
|
58
|
+
const rootId = options.inheritedRootId ?? `lineage-${safeId(options.nodeId)}-${randomUUID()}`;
|
|
59
|
+
const rootDir = options.inheritedRootDir ?? join(options.artifactDir, "lineage", rootId);
|
|
60
|
+
if (options.parentNodeId === options.nodeId) {
|
|
61
|
+
throw new Error(`Lineage integrity error: node ${options.nodeId} cannot be its own parent`);
|
|
62
|
+
}
|
|
63
|
+
if (options.inheritedRootDir && options.parentNodeId) {
|
|
64
|
+
const inherited = reduceLineage(options.inheritedRootDir);
|
|
65
|
+
const error = lineageIntegrityError(inherited);
|
|
66
|
+
if (error)
|
|
67
|
+
throw new Error(error);
|
|
68
|
+
if (!inherited.nodes.has(options.parentNodeId)) {
|
|
69
|
+
throw new Error(`Lineage integrity error: parent ${options.parentNodeId} does not exist`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(rootDir, { recursive: true });
|
|
73
|
+
fsyncDirectory(dirname(rootDir));
|
|
74
|
+
const registration = {
|
|
75
|
+
rootDir,
|
|
76
|
+
rootId,
|
|
77
|
+
nodeId: options.nodeId,
|
|
78
|
+
...(options.parentNodeId ? { parentNodeId: options.parentNodeId } : {}),
|
|
79
|
+
...(options.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
|
|
80
|
+
...(options.parentSessionFile ? { parentSessionFile: options.parentSessionFile } : {}),
|
|
81
|
+
launchKind: options.launchKind ?? "fresh",
|
|
82
|
+
};
|
|
83
|
+
appendLineageEvent(rootDir, `launch:${options.nodeId}`, "launch", options.nodeId, {
|
|
84
|
+
rootId,
|
|
85
|
+
parentNodeId: registration.parentNodeId,
|
|
86
|
+
parentSessionId: registration.parentSessionId,
|
|
87
|
+
parentSessionFile: registration.parentSessionFile,
|
|
88
|
+
launchKind: registration.launchKind,
|
|
89
|
+
});
|
|
90
|
+
return registration;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Publish one immutable event atomically. link(2), rather than rename(2),
|
|
94
|
+
* gives no-replace semantics when two Pi processes race on one effect ID.
|
|
95
|
+
*/
|
|
96
|
+
export function appendLineageEvent(rootDir, eventId, type, nodeId, data = {}) {
|
|
97
|
+
if (!eventId || !type || !nodeId)
|
|
98
|
+
return false;
|
|
99
|
+
const dir = eventDir(rootDir);
|
|
100
|
+
const encoded = safeId(eventId);
|
|
101
|
+
const file = join(dir, `${encoded}.json`);
|
|
102
|
+
const pending = join(dir, `.${encoded}.${process.pid}.${randomUUID()}.pending`);
|
|
103
|
+
const event = {
|
|
104
|
+
...data,
|
|
105
|
+
version: 1,
|
|
106
|
+
eventId,
|
|
107
|
+
type,
|
|
108
|
+
nodeId,
|
|
109
|
+
at: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
writeDurable(pending, JSON.stringify(event));
|
|
113
|
+
// Hard-link publication is exclusive and leaves the pending file harmless
|
|
114
|
+
// if another process won the same stable ID.
|
|
115
|
+
linkSync(pending, file);
|
|
116
|
+
unlinkSync(pending);
|
|
117
|
+
fsyncDirectory(dir);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(pending);
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function validEvent(value, fileName, rootId) {
|
|
129
|
+
const e = value;
|
|
130
|
+
if (!e || e.version !== 1 || typeof e.eventId !== "string" || !e.eventId ||
|
|
131
|
+
typeof e.type !== "string" || !e.type || typeof e.nodeId !== "string" ||
|
|
132
|
+
!e.nodeId || !Number.isFinite(e.at))
|
|
133
|
+
return false;
|
|
134
|
+
if (rootId && e.rootId !== undefined && e.rootId !== rootId)
|
|
135
|
+
return false;
|
|
136
|
+
if (fileName && fileName !== `${safeId(e.eventId)}.json`)
|
|
137
|
+
return false;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const eventRank = {
|
|
141
|
+
launch: 0,
|
|
142
|
+
launch_metadata: 1,
|
|
143
|
+
cancel_intent: 2,
|
|
144
|
+
cancel_identity: 3,
|
|
145
|
+
cancel_proven: 4,
|
|
146
|
+
settled_delivered: 5,
|
|
147
|
+
terminal: 6,
|
|
148
|
+
inbox: 7,
|
|
149
|
+
terminal_delivered: 8,
|
|
150
|
+
inbox_materialized: 9,
|
|
151
|
+
cleanup_pending: 10,
|
|
152
|
+
cleanup_done: 11,
|
|
153
|
+
};
|
|
154
|
+
/** Reduce valid immutable events deterministically; wall-clock order is ignored. */
|
|
155
|
+
export function reduceLineage(rootDir) {
|
|
156
|
+
const rootId = rootDir.split(/[\\/]/).pop() ?? "unknown";
|
|
157
|
+
const nodes = new Map();
|
|
158
|
+
let events = 0;
|
|
159
|
+
let files = [];
|
|
160
|
+
try {
|
|
161
|
+
files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json"));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { rootId, nodes, events };
|
|
165
|
+
}
|
|
166
|
+
const parsed = [];
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
try {
|
|
169
|
+
const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
|
|
170
|
+
if (validEvent(value, file, rootId))
|
|
171
|
+
parsed.push(value);
|
|
172
|
+
}
|
|
173
|
+
catch { }
|
|
174
|
+
}
|
|
175
|
+
parsed.sort((a, b) => (eventRank[a.type] ?? 50) - (eventRank[b.type] ?? 50) || a.eventId.localeCompare(b.eventId));
|
|
176
|
+
const launched = new Set();
|
|
177
|
+
for (const event of parsed) {
|
|
178
|
+
if (event.type !== "launch" && !launched.has(event.nodeId))
|
|
179
|
+
continue;
|
|
180
|
+
let node = nodes.get(event.nodeId);
|
|
181
|
+
if (!node) {
|
|
182
|
+
node = { nodeId: event.nodeId, settledDelivered: [] };
|
|
183
|
+
nodes.set(event.nodeId, node);
|
|
184
|
+
}
|
|
185
|
+
if (event.type === "launch") {
|
|
186
|
+
launched.add(event.nodeId);
|
|
187
|
+
// First launch identity wins. A conflicting duplicate is not a legal
|
|
188
|
+
// transition and cannot change the logical parent after publication.
|
|
189
|
+
if (!node.parentNodeId && typeof event.parentNodeId === "string")
|
|
190
|
+
node.parentNodeId = event.parentNodeId;
|
|
191
|
+
if (!node.parentSessionId && typeof event.parentSessionId === "string")
|
|
192
|
+
node.parentSessionId = event.parentSessionId;
|
|
193
|
+
if (!node.parentSessionFile && typeof event.parentSessionFile === "string")
|
|
194
|
+
node.parentSessionFile = event.parentSessionFile;
|
|
195
|
+
if (!node.launchKind && typeof event.launchKind === "string")
|
|
196
|
+
node.launchKind = event.launchKind;
|
|
197
|
+
}
|
|
198
|
+
else if (event.type === "launch_metadata") {
|
|
199
|
+
// Metadata may be rewritten by a resume. Prefer the latest recorded run;
|
|
200
|
+
// this comparison is deterministic and does not use event publication time.
|
|
201
|
+
const runStart = typeof event.startTime === "number" ? event.startTime : 0;
|
|
202
|
+
if (node.startTime !== undefined && runStart < node.startTime)
|
|
203
|
+
continue;
|
|
204
|
+
node.executionOwner = readExecutionOwner(event.executionOwner);
|
|
205
|
+
if (typeof event.name === "string")
|
|
206
|
+
node.name = event.name;
|
|
207
|
+
if (typeof event.task === "string")
|
|
208
|
+
node.task = event.task;
|
|
209
|
+
if (typeof event.agent === "string")
|
|
210
|
+
node.agent = event.agent;
|
|
211
|
+
if (typeof event.surface === "string")
|
|
212
|
+
node.surface = event.surface;
|
|
213
|
+
if (typeof event.tabId === "string")
|
|
214
|
+
node.tabId = event.tabId;
|
|
215
|
+
if (typeof event.sessionFile === "string")
|
|
216
|
+
node.sessionFile = event.sessionFile;
|
|
217
|
+
if (typeof event.activityFile === "string")
|
|
218
|
+
node.activityFile = event.activityFile;
|
|
219
|
+
if (typeof event.settledEventsFile === "string")
|
|
220
|
+
node.settledEventsFile = event.settledEventsFile;
|
|
221
|
+
if (typeof event.startTime === "number")
|
|
222
|
+
node.startTime = event.startTime;
|
|
223
|
+
if (typeof event.interactive === "boolean")
|
|
224
|
+
node.interactive = event.interactive;
|
|
225
|
+
if (typeof event.autoExit === "boolean")
|
|
226
|
+
node.autoExit = event.autoExit;
|
|
227
|
+
if (typeof event.replication === "boolean")
|
|
228
|
+
node.replication = event.replication;
|
|
229
|
+
if (typeof event.replicaWorkFile === "string")
|
|
230
|
+
node.replicaWorkFile = event.replicaWorkFile;
|
|
231
|
+
if (typeof event.workspaceId === "string")
|
|
232
|
+
node.workspaceId = event.workspaceId;
|
|
233
|
+
if (typeof event.cwd === "string")
|
|
234
|
+
node.cwd = event.cwd;
|
|
235
|
+
if (event.worktree && typeof event.worktree === "object" &&
|
|
236
|
+
["path", "workspaceId", "paneId", "branch", "baseRef", "baseSha", "manifestFile"].every((key) => typeof event.worktree[key] === "string")) {
|
|
237
|
+
node.worktree = event.worktree;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (event.type === "cancel_intent" || event.type === "cancel_identity" || event.type === "cancel_proven") {
|
|
241
|
+
const prior = node.cancellation ?? { intent: false, pids: [], proven: false };
|
|
242
|
+
prior.intent = true;
|
|
243
|
+
if (typeof event.surface === "string")
|
|
244
|
+
prior.surface = event.surface;
|
|
245
|
+
if (Array.isArray(event.pids)) {
|
|
246
|
+
const pids = event.pids.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
247
|
+
if (pids.length > 0)
|
|
248
|
+
prior.pids = [...new Set(pids)];
|
|
249
|
+
}
|
|
250
|
+
if (event.type === "cancel_proven")
|
|
251
|
+
prior.proven = true;
|
|
252
|
+
node.cancellation = prior;
|
|
253
|
+
}
|
|
254
|
+
else if (event.type === "terminal") {
|
|
255
|
+
if (!node.terminal)
|
|
256
|
+
node.terminal = {
|
|
257
|
+
outcome: typeof event.outcome === "string" ? event.outcome : "unknown",
|
|
258
|
+
resultId: typeof event.resultId === "string" ? event.resultId : undefined,
|
|
259
|
+
resultContent: typeof event.resultContent === "string" ? event.resultContent : undefined,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
else if (event.type === "terminal_delivered" || event.type === "inbox") {
|
|
263
|
+
const kind = event.type === "inbox" ? event.kind : undefined;
|
|
264
|
+
const payload = event.payload;
|
|
265
|
+
if (node.terminal && (event.type === "terminal_delivered" || kind === "terminal" || payload?.kind === "terminal")) {
|
|
266
|
+
node.terminalDelivered ??= String(event.deliveryId ?? event.eventId);
|
|
267
|
+
}
|
|
268
|
+
node.lastDeliveredAt = Math.max(node.lastDeliveredAt ?? 0, event.at);
|
|
269
|
+
}
|
|
270
|
+
else if (event.type === "inbox_materialized") {
|
|
271
|
+
node.lastDeliveredAt = Math.max(node.lastDeliveredAt ?? 0, event.at);
|
|
272
|
+
}
|
|
273
|
+
else if (event.type === "settled_delivered" && typeof event.resultId === "string" && !node.settledDelivered.includes(event.resultId)) {
|
|
274
|
+
node.settledDelivered.push(event.resultId);
|
|
275
|
+
node.lastDeliveredAt = Math.max(node.lastDeliveredAt ?? 0, event.at);
|
|
276
|
+
}
|
|
277
|
+
else if (event.type === "cleanup_pending") {
|
|
278
|
+
if (!node.terminalDelivered)
|
|
279
|
+
continue;
|
|
280
|
+
node.cleanupPending = true;
|
|
281
|
+
}
|
|
282
|
+
else if (event.type === "cleanup_done") {
|
|
283
|
+
if (node.terminalDelivered)
|
|
284
|
+
node.cleanupPending = false;
|
|
285
|
+
}
|
|
286
|
+
events++;
|
|
287
|
+
}
|
|
288
|
+
return { rootId, nodes, events };
|
|
289
|
+
}
|
|
290
|
+
/** Return an attributable graph error instead of treating a cycle as an endless drain. */
|
|
291
|
+
export function lineageIntegrityError(state) {
|
|
292
|
+
for (const node of state.nodes.values()) {
|
|
293
|
+
if (node.parentNodeId && !state.nodes.has(node.parentNodeId)) {
|
|
294
|
+
return `Lineage integrity error: parent ${node.parentNodeId} of ${node.nodeId} does not exist`;
|
|
295
|
+
}
|
|
296
|
+
const path = new Set();
|
|
297
|
+
let current = node;
|
|
298
|
+
while (current?.parentNodeId) {
|
|
299
|
+
if (path.has(current.nodeId) || current.parentNodeId === current.nodeId) {
|
|
300
|
+
return `Lineage integrity error: cycle contains ${current.nodeId}`;
|
|
301
|
+
}
|
|
302
|
+
path.add(current.nodeId);
|
|
303
|
+
current = state.nodes.get(current.parentNodeId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
/** A node is drained only after terminal delivery and all recursive children. */
|
|
309
|
+
export function isLineageNodeDrained(state, nodeId) {
|
|
310
|
+
const integrity = lineageIntegrityError(state);
|
|
311
|
+
if (integrity)
|
|
312
|
+
throw new Error(integrity);
|
|
313
|
+
const visit = (id) => {
|
|
314
|
+
const node = state.nodes.get(id);
|
|
315
|
+
if (!node || !node.terminal || !node.terminalDelivered)
|
|
316
|
+
return false;
|
|
317
|
+
for (const child of state.nodes.values()) {
|
|
318
|
+
if (child.parentNodeId === id && !visit(child.nodeId))
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
return true;
|
|
322
|
+
};
|
|
323
|
+
return visit(nodeId);
|
|
324
|
+
}
|
|
325
|
+
/** Latest durable delivery activity from any direct or recursive descendant. */
|
|
326
|
+
export function latestDescendantDeliveryAt(state, nodeId) {
|
|
327
|
+
const integrity = lineageIntegrityError(state);
|
|
328
|
+
if (integrity)
|
|
329
|
+
throw new Error(integrity);
|
|
330
|
+
let latest;
|
|
331
|
+
const visit = (parentId) => {
|
|
332
|
+
for (const child of state.nodes.values()) {
|
|
333
|
+
if (child.parentNodeId !== parentId)
|
|
334
|
+
continue;
|
|
335
|
+
if (child.lastDeliveredAt !== undefined) {
|
|
336
|
+
latest = Math.max(latest ?? 0, child.lastDeliveredAt);
|
|
337
|
+
}
|
|
338
|
+
visit(child.nodeId);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
visit(nodeId);
|
|
342
|
+
return latest;
|
|
343
|
+
}
|
|
344
|
+
export function hasUndrainedDescendants(state, nodeId) {
|
|
345
|
+
const integrity = lineageIntegrityError(state);
|
|
346
|
+
if (integrity)
|
|
347
|
+
throw new Error(integrity);
|
|
348
|
+
return [...state.nodes.values()].some((child) => child.parentNodeId === nodeId && !isLineageNodeDrained(state, child.nodeId));
|
|
349
|
+
}
|
|
350
|
+
/** Record the once-only durable sink entry used by the drain boundary. */
|
|
351
|
+
export function appendLineageInbox(rootDir, nodeId, deliveryId, sink, payload = {}) {
|
|
352
|
+
if (!sink.sessionId || !sink.sessionFile)
|
|
353
|
+
return false;
|
|
354
|
+
const eventId = `inbox:${deliveryId}`;
|
|
355
|
+
const file = join(eventDir(rootDir), `${safeId(eventId)}.json`);
|
|
356
|
+
try {
|
|
357
|
+
const existing = JSON.parse(readFileSync(file, "utf8"));
|
|
358
|
+
if (validEvent(existing, `${safeId(eventId)}.json`, rootDir.split(/[\\/]/).pop()) && existing.type === "inbox" && existing.nodeId === nodeId)
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
catch { }
|
|
362
|
+
return appendLineageEvent(rootDir, eventId, "inbox", nodeId, {
|
|
363
|
+
deliveryId,
|
|
364
|
+
...sink,
|
|
365
|
+
payload,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function materializationClaimDir(rootDir) {
|
|
369
|
+
const dir = join(rootDir, "materialization-claims");
|
|
370
|
+
if (!existsSync(dir)) {
|
|
371
|
+
mkdirSync(dir, { recursive: true });
|
|
372
|
+
fsyncDirectory(rootDir);
|
|
373
|
+
}
|
|
374
|
+
return dir;
|
|
375
|
+
}
|
|
376
|
+
function claimFile(rootDir, deliveryId) {
|
|
377
|
+
return join(materializationClaimDir(rootDir), `${safeId(deliveryId)}.claim`);
|
|
378
|
+
}
|
|
379
|
+
/** Linux stat fields after the command name, starting with process state. */
|
|
380
|
+
function linuxProcessFields(pid) {
|
|
381
|
+
if (process.platform !== "linux")
|
|
382
|
+
return undefined;
|
|
383
|
+
try {
|
|
384
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
385
|
+
const closingParen = stat.lastIndexOf(")");
|
|
386
|
+
return closingParen >= 0 ? stat.slice(closingParen + 2).trim().split(/\s+/) : undefined;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function processAlive(pid) {
|
|
393
|
+
try {
|
|
394
|
+
process.kill(pid, 0);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (error.code !== "EPERM")
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
// A zombie retains its PID and start time but cannot finish or release its gate.
|
|
401
|
+
const state = linuxProcessFields(pid)?.[0];
|
|
402
|
+
return state !== "Z" && state !== "X";
|
|
403
|
+
}
|
|
404
|
+
/** Linux process start time (field 22 of /proc/<pid>/stat). */
|
|
405
|
+
function processStartTime(pid) {
|
|
406
|
+
return linuxProcessFields(pid)?.[19] || undefined;
|
|
407
|
+
}
|
|
408
|
+
function currentProcessIncarnation() {
|
|
409
|
+
const startTime = processStartTime(process.pid);
|
|
410
|
+
return { pid: process.pid, ...(startTime ? { startTime } : {}) };
|
|
411
|
+
}
|
|
412
|
+
function exactSessionEvidence(check) {
|
|
413
|
+
try {
|
|
414
|
+
return check() === true;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function readMaterializationClaim(path, deliveryId, nodeId) {
|
|
421
|
+
try {
|
|
422
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
423
|
+
const identity = value.process ?? (Number.isInteger(value.pid) ? { pid: value.pid, startTime: value.startTime } : undefined);
|
|
424
|
+
if (value.version !== 1 || value.deliveryId !== deliveryId || value.nodeId !== nodeId ||
|
|
425
|
+
typeof value.token !== "string" || !identity || !Number.isInteger(identity.pid) || (identity.pid ?? 0) <= 0 ||
|
|
426
|
+
(identity.startTime != null && typeof identity.startTime !== "string") || !Number.isFinite(value.claimedAt))
|
|
427
|
+
return undefined;
|
|
428
|
+
return { ...value, process: identity };
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function readMaterializationGate(path) {
|
|
435
|
+
try {
|
|
436
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
437
|
+
if (value.version !== 1 || typeof value.token !== "string" || !value.process ||
|
|
438
|
+
!Number.isInteger(value.process.pid) || value.process.pid <= 0 ||
|
|
439
|
+
(value.process.startTime != null && typeof value.process.startTime !== "string") ||
|
|
440
|
+
!Number.isFinite(value.claimedAt))
|
|
441
|
+
return undefined;
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Check a saved process identity without treating an unknown live incarnation as dead. */
|
|
449
|
+
export function processIncarnationAlive(identity, observer) {
|
|
450
|
+
if (!processAlive(identity.pid))
|
|
451
|
+
return false;
|
|
452
|
+
const observed = identity.pid === observer.pid ? observer : { pid: identity.pid, startTime: processStartTime(identity.pid) };
|
|
453
|
+
// Portable filesystems/platforms may not expose an incarnation identity. Do
|
|
454
|
+
// not reclaim a live PID unless its identity can be disproved.
|
|
455
|
+
if (!identity.startTime || !observed.startTime)
|
|
456
|
+
return true;
|
|
457
|
+
return identity.startTime === observed.startTime;
|
|
458
|
+
}
|
|
459
|
+
function materializationGateFile(rootDir) {
|
|
460
|
+
return join(materializationClaimDir(rootDir), "lineage.gate");
|
|
461
|
+
}
|
|
462
|
+
function acquireMaterializationGate(rootDir, observer) {
|
|
463
|
+
const path = materializationGateFile(rootDir);
|
|
464
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
465
|
+
const token = randomUUID();
|
|
466
|
+
const pending = join(dirname(path), `.${process.pid}.${randomUUID()}.gate.pending`);
|
|
467
|
+
try {
|
|
468
|
+
writeDurable(pending, JSON.stringify({ version: 1, token, process: observer, claimedAt: Date.now() }));
|
|
469
|
+
linkSync(pending, path);
|
|
470
|
+
unlinkSync(pending);
|
|
471
|
+
fsyncDirectory(dirname(path));
|
|
472
|
+
return { token };
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
try {
|
|
476
|
+
unlinkSync(pending);
|
|
477
|
+
}
|
|
478
|
+
catch { }
|
|
479
|
+
}
|
|
480
|
+
const current = readMaterializationGate(path);
|
|
481
|
+
if (!current && existsSync(path))
|
|
482
|
+
return null;
|
|
483
|
+
if (current && processIncarnationAlive(current.process, observer))
|
|
484
|
+
return null;
|
|
485
|
+
try {
|
|
486
|
+
const tombstone = `${path}.${process.pid}.${randomUUID()}.stale`;
|
|
487
|
+
renameSync(path, tombstone);
|
|
488
|
+
try {
|
|
489
|
+
unlinkSync(tombstone);
|
|
490
|
+
}
|
|
491
|
+
catch { }
|
|
492
|
+
}
|
|
493
|
+
catch { }
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
function releaseMaterializationGate(rootDir, token) {
|
|
498
|
+
const path = materializationGateFile(rootDir);
|
|
499
|
+
const current = readMaterializationGate(path);
|
|
500
|
+
if (current?.token === token)
|
|
501
|
+
try {
|
|
502
|
+
unlinkSync(path);
|
|
503
|
+
}
|
|
504
|
+
catch { }
|
|
505
|
+
}
|
|
506
|
+
/** Run one synchronous durable mutation under a process-owned filesystem gate. */
|
|
507
|
+
export function withLineageMutationGate(rootDir, mutate) {
|
|
508
|
+
const owner = currentProcessIncarnation();
|
|
509
|
+
const gate = acquireMaterializationGate(rootDir, owner);
|
|
510
|
+
if (!gate)
|
|
511
|
+
throw new Error("Durable state has an active mutation.");
|
|
512
|
+
try {
|
|
513
|
+
return mutate(owner);
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
releaseMaterializationGate(rootDir, gate.token);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function readLineageInboxes(rootDir) {
|
|
520
|
+
const result = [];
|
|
521
|
+
const materialized = new Set();
|
|
522
|
+
let files = [];
|
|
523
|
+
try {
|
|
524
|
+
files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json"));
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
const records = [];
|
|
530
|
+
for (const file of files) {
|
|
531
|
+
try {
|
|
532
|
+
const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
|
|
533
|
+
if (!validEvent(value, file, rootDir.split(/[\\/]/).pop()))
|
|
534
|
+
continue;
|
|
535
|
+
if (value.type === "inbox_materialized" && typeof value.deliveryId === "string")
|
|
536
|
+
materialized.add(value.deliveryId);
|
|
537
|
+
if (value.type !== "inbox" || typeof value.deliveryId !== "string")
|
|
538
|
+
continue;
|
|
539
|
+
const payload = value.payload && typeof value.payload === "object" ? value.payload : {};
|
|
540
|
+
records.push({ rootDir, nodeId: value.nodeId, deliveryId: value.deliveryId, sessionId: typeof value.sessionId === "string" ? value.sessionId : undefined, sessionFile: typeof value.sessionFile === "string" ? value.sessionFile : undefined, payload, activitySequence: typeof payload.activitySequence === "number" ? payload.activitySequence : undefined });
|
|
541
|
+
}
|
|
542
|
+
catch { }
|
|
543
|
+
}
|
|
544
|
+
return records.map((record) => ({ record, materialized: materialized.has(record.deliveryId) }));
|
|
545
|
+
}
|
|
546
|
+
function sameMaterializationSink(left, right) {
|
|
547
|
+
return Boolean(left.sessionId && left.sessionFile && left.sessionId === right.sessionId && left.sessionFile === right.sessionFile);
|
|
548
|
+
}
|
|
549
|
+
function pendingBefore(rootDir, deliveryId) {
|
|
550
|
+
const entries = readLineageInboxes(rootDir);
|
|
551
|
+
const target = entries.find((entry) => entry.record.deliveryId === deliveryId)?.record;
|
|
552
|
+
if (!target)
|
|
553
|
+
return false;
|
|
554
|
+
const pending = entries.filter((entry) => !entry.materialized && sameMaterializationSink(entry.record, target)).map((entry) => entry.record);
|
|
555
|
+
pending.sort((left, right) => (left.activitySequence ?? Number.MAX_SAFE_INTEGER) - (right.activitySequence ?? Number.MAX_SAFE_INTEGER) || left.deliveryId.localeCompare(right.deliveryId));
|
|
556
|
+
return pending.findIndex((record) => record.deliveryId === deliveryId) > 0;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Acquire the cross-process claim used before materializing one inbox entry.
|
|
560
|
+
* The durable lineage gate makes all delivery IDs for one sink observe the
|
|
561
|
+
* same activity order, not merely one claim per delivery.
|
|
562
|
+
*/
|
|
563
|
+
export function claimLineageInboxMaterialization(options) {
|
|
564
|
+
const { rootDir, deliveryId, nodeId } = options;
|
|
565
|
+
if (isLineageInboxMaterialized(rootDir, deliveryId))
|
|
566
|
+
return { status: "materialized" };
|
|
567
|
+
const observer = options.processIdentity ?? currentProcessIncarnation();
|
|
568
|
+
const gate = acquireMaterializationGate(rootDir, observer);
|
|
569
|
+
if (!gate)
|
|
570
|
+
return { status: "busy" };
|
|
571
|
+
try {
|
|
572
|
+
if (isLineageInboxMaterialized(rootDir, deliveryId))
|
|
573
|
+
return { status: "materialized" };
|
|
574
|
+
const path = claimFile(rootDir, deliveryId);
|
|
575
|
+
const existing = readMaterializationClaim(path, deliveryId, nodeId);
|
|
576
|
+
if (existing) {
|
|
577
|
+
if (exactSessionEvidence(options.hasExactSessionEvidence)) {
|
|
578
|
+
if (appendLineageEvent(rootDir, `materialized:${deliveryId}`, "inbox_materialized", nodeId, { deliveryId })) {
|
|
579
|
+
try {
|
|
580
|
+
unlinkSync(path);
|
|
581
|
+
}
|
|
582
|
+
catch { }
|
|
583
|
+
return { status: "materialized" };
|
|
584
|
+
}
|
|
585
|
+
return isLineageInboxMaterialized(rootDir, deliveryId) ? { status: "materialized" } : { status: "busy" };
|
|
586
|
+
}
|
|
587
|
+
if (processIncarnationAlive(existing.process, observer))
|
|
588
|
+
return { status: "busy" };
|
|
589
|
+
try {
|
|
590
|
+
const tombstone = `${path}.${process.pid}.${randomUUID()}.stale`;
|
|
591
|
+
renameSync(path, tombstone);
|
|
592
|
+
try {
|
|
593
|
+
unlinkSync(tombstone);
|
|
594
|
+
}
|
|
595
|
+
catch { }
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return { status: "busy" };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (pendingBefore(rootDir, deliveryId))
|
|
602
|
+
return { status: "busy" };
|
|
603
|
+
const token = randomUUID();
|
|
604
|
+
const pending = join(dirname(path), `.${safeId(deliveryId)}.${process.pid}.${randomUUID()}.pending`);
|
|
605
|
+
try {
|
|
606
|
+
writeDurable(pending, JSON.stringify({ version: 1, deliveryId, nodeId, token, process: observer, claimedAt: Date.now() }));
|
|
607
|
+
linkSync(pending, path);
|
|
608
|
+
unlinkSync(pending);
|
|
609
|
+
fsyncDirectory(dirname(path));
|
|
610
|
+
return { status: "acquired", token };
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
try {
|
|
614
|
+
unlinkSync(pending);
|
|
615
|
+
}
|
|
616
|
+
catch { }
|
|
617
|
+
return { status: "busy" };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
releaseMaterializationGate(rootDir, gate.token);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** Acknowledge a materialization and release only the claim held by this observer. */
|
|
625
|
+
export function completeLineageInboxMaterialization(rootDir, deliveryId, nodeId, token) {
|
|
626
|
+
const acknowledged = appendLineageEvent(rootDir, `materialized:${deliveryId}`, "inbox_materialized", nodeId, { deliveryId }) ||
|
|
627
|
+
isLineageInboxMaterialized(rootDir, deliveryId);
|
|
628
|
+
if (!acknowledged)
|
|
629
|
+
return false;
|
|
630
|
+
const path = claimFile(rootDir, deliveryId);
|
|
631
|
+
const claim = readMaterializationClaim(path, deliveryId, nodeId);
|
|
632
|
+
return claim?.token === token || isLineageInboxMaterialized(rootDir, deliveryId);
|
|
633
|
+
}
|
|
634
|
+
/** Release a failed claim only after exact-session evidence is checked. */
|
|
635
|
+
export function releaseLineageInboxMaterialization(options) {
|
|
636
|
+
if (isLineageInboxMaterialized(options.rootDir, options.deliveryId))
|
|
637
|
+
return true;
|
|
638
|
+
if (exactSessionEvidence(options.hasExactSessionEvidence)) {
|
|
639
|
+
return completeLineageInboxMaterialization(options.rootDir, options.deliveryId, options.nodeId, options.token);
|
|
640
|
+
}
|
|
641
|
+
const path = claimFile(options.rootDir, options.deliveryId);
|
|
642
|
+
const claim = readMaterializationClaim(path, options.deliveryId, options.nodeId);
|
|
643
|
+
if (claim?.token !== options.token)
|
|
644
|
+
return false;
|
|
645
|
+
try {
|
|
646
|
+
unlinkSync(path);
|
|
647
|
+
}
|
|
648
|
+
catch { }
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
/** Return whether an inbox has a durable materialization acknowledgement. */
|
|
652
|
+
export function isLineageInboxMaterialized(rootDir, deliveryId) {
|
|
653
|
+
let files = [];
|
|
654
|
+
try {
|
|
655
|
+
files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json"));
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
for (const file of files) {
|
|
661
|
+
try {
|
|
662
|
+
const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
|
|
663
|
+
if (validEvent(value, file, rootDir.split(/[\\/]/).pop()) && value.type === "inbox_materialized" && value.deliveryId === deliveryId)
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
catch { }
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
/** Return whether a specific immutable event was already published. */
|
|
671
|
+
export function hasLineageEvent(rootDir, eventId, type) {
|
|
672
|
+
const file = join(eventDir(rootDir), `${safeId(eventId)}.json`);
|
|
673
|
+
try {
|
|
674
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
675
|
+
return validEvent(value, `${safeId(eventId)}.json`, rootDir.split(/[\\/]/).pop()) && value.eventId === eventId && (!type || value.type === type);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** Find durable inbox entries not yet materialized for their exact session sink. */
|
|
682
|
+
export function pendingLineageInboxes(rootDir, sessionId, sessionFile) {
|
|
683
|
+
const pending = [];
|
|
684
|
+
let files = [];
|
|
685
|
+
try {
|
|
686
|
+
files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json"));
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return pending;
|
|
690
|
+
}
|
|
691
|
+
const materialized = new Set();
|
|
692
|
+
const inboxes = [];
|
|
693
|
+
for (const file of files) {
|
|
694
|
+
try {
|
|
695
|
+
const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
|
|
696
|
+
if (!validEvent(value, file, rootDir.split(/[\\/]/).pop()))
|
|
697
|
+
continue;
|
|
698
|
+
if (value.type === "inbox_materialized" && typeof value.deliveryId === "string")
|
|
699
|
+
materialized.add(value.deliveryId);
|
|
700
|
+
else if (value.type === "inbox" && value.sessionId === sessionId && typeof value.deliveryId === "string") {
|
|
701
|
+
const payload = value.payload && typeof value.payload === "object" ? value.payload : {};
|
|
702
|
+
inboxes.push({ rootDir, nodeId: value.nodeId, deliveryId: value.deliveryId, sessionId, sessionFile, payload, activitySequence: typeof payload.activitySequence === "number" ? payload.activitySequence : undefined });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch { }
|
|
706
|
+
}
|
|
707
|
+
for (const inbox of inboxes)
|
|
708
|
+
if (!materialized.has(inbox.deliveryId))
|
|
709
|
+
pending.push(inbox);
|
|
710
|
+
return pending.sort((a, b) => (a.activitySequence ?? Number.MAX_SAFE_INTEGER) - (b.activitySequence ?? Number.MAX_SAFE_INTEGER) || a.deliveryId.localeCompare(b.deliveryId));
|
|
711
|
+
}
|
|
712
|
+
/** Read the authoritative lineage pointer attached to a child session. */
|
|
713
|
+
export function readLineageAttachment(sessionFile) {
|
|
714
|
+
const embedded = readSessionBinding(sessionFile)?.child?.lineage;
|
|
715
|
+
if (embedded && existsSync(join(embedded.rootDir, "events")))
|
|
716
|
+
return embedded;
|
|
717
|
+
try {
|
|
718
|
+
const value = JSON.parse(readFileSync(`${sessionFile}.lineage.json`, "utf8"));
|
|
719
|
+
if (typeof value.rootDir !== "string" || typeof value.rootId !== "string" || typeof value.nodeId !== "string")
|
|
720
|
+
return undefined;
|
|
721
|
+
if (!existsSync(join(value.rootDir, "events")))
|
|
722
|
+
return undefined;
|
|
723
|
+
return value;
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** Discover durable roots, preferring session attachments over directory guesses. */
|
|
730
|
+
export function discoverLineageRoots(sessionDir, sessionFile) {
|
|
731
|
+
const result = new Set();
|
|
732
|
+
if (sessionFile) {
|
|
733
|
+
const attachment = readLineageAttachment(sessionFile);
|
|
734
|
+
if (attachment)
|
|
735
|
+
result.add(attachment.rootDir);
|
|
736
|
+
const binding = readSessionBinding(sessionFile);
|
|
737
|
+
const header = readSessionHeader(sessionFile);
|
|
738
|
+
if (header) {
|
|
739
|
+
for (const rootDir of sessionLineageRoots(binding?.storeDir ?? sessionBindingStore(), header.id))
|
|
740
|
+
result.add(rootDir);
|
|
741
|
+
}
|
|
742
|
+
// Bound sessions need only their index and their own legacy artifacts,
|
|
743
|
+
// including a root published after startup but not yet indexed by a launcher.
|
|
744
|
+
if (binding) {
|
|
745
|
+
const lineageDir = join(sessionDir, "artifacts", binding.sessionId, "lineage");
|
|
746
|
+
try {
|
|
747
|
+
for (const root of readdirSync(lineageDir, { withFileTypes: true })) {
|
|
748
|
+
if (root.isDirectory())
|
|
749
|
+
result.add(join(lineageDir, root.name));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch { /* This session may have no local artifacts after a move. */ }
|
|
753
|
+
return [...result].sort();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
|
|
758
|
+
if (entry.isFile() && entry.name.endsWith(".lineage.json")) {
|
|
759
|
+
const attachment = readLineageAttachment(join(sessionDir, entry.name.slice(0, -".lineage.json".length)));
|
|
760
|
+
if (attachment)
|
|
761
|
+
result.add(attachment.rootDir);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch { }
|
|
766
|
+
try {
|
|
767
|
+
for (const artifact of readdirSync(join(sessionDir, "artifacts"), { withFileTypes: true })) {
|
|
768
|
+
if (!artifact.isDirectory())
|
|
769
|
+
continue;
|
|
770
|
+
const lineageDir = join(sessionDir, "artifacts", artifact.name, "lineage");
|
|
771
|
+
for (const root of readdirSync(lineageDir, { withFileTypes: true })) {
|
|
772
|
+
if (root.isDirectory())
|
|
773
|
+
result.add(join(lineageDir, root.name));
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch { }
|
|
778
|
+
return [...result].sort();
|
|
779
|
+
}
|
|
780
|
+
export function lineageEnvironment(registration) {
|
|
781
|
+
return {
|
|
782
|
+
PI_SUBAGENT_LINEAGE_DIR: registration.rootDir,
|
|
783
|
+
PI_SUBAGENT_LINEAGE_ROOT: registration.rootId,
|
|
784
|
+
...(registration.parentNodeId ? { PI_SUBAGENT_PARENT_NODE: registration.parentNodeId } : {}),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Read lineage only in the Pi process launched for this node.
|
|
789
|
+
*
|
|
790
|
+
* Shell commands inherit the Pi environment. The launcher PID prevents an
|
|
791
|
+
* ordinary child process from impersonating its Pi owner and registering test
|
|
792
|
+
* fixtures or other local work as real descendants.
|
|
793
|
+
*/
|
|
794
|
+
export function lineageFromEnvironment(env = process.env, parentPid = process.ppid) {
|
|
795
|
+
if (!env.PI_SUBAGENT_LINEAGE_DIR ||
|
|
796
|
+
!env.PI_SUBAGENT_LINEAGE_ROOT ||
|
|
797
|
+
!env.PI_SUBAGENT_ID ||
|
|
798
|
+
env.PI_SUBAGENT_LAUNCHER_PID !== String(parentPid))
|
|
799
|
+
return undefined;
|
|
800
|
+
return {
|
|
801
|
+
rootDir: env.PI_SUBAGENT_LINEAGE_DIR,
|
|
802
|
+
rootId: env.PI_SUBAGENT_LINEAGE_ROOT,
|
|
803
|
+
nodeId: env.PI_SUBAGENT_ID,
|
|
804
|
+
...(env.PI_SUBAGENT_PARENT_NODE ? { parentNodeId: env.PI_SUBAGENT_PARENT_NODE } : {}),
|
|
805
|
+
};
|
|
806
|
+
}
|