@zq-silk/yui 0.6.0 → 0.6.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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { requireSafeIdentity, requireText, requireTimestamp } from "./validation.js";
|
|
3
|
+
/** Validates and freezes one physical owner record; fails closed on malformed input. */
|
|
4
|
+
export function createSessionOwnerIdentity(input) {
|
|
5
|
+
const owner = input.owner;
|
|
6
|
+
if (owner.scope !== "task" && owner.scope !== "global") {
|
|
7
|
+
throw new TypeError("Session owner scope is invalid.");
|
|
8
|
+
}
|
|
9
|
+
if (owner.scope === "task" && owner.taskId === undefined) {
|
|
10
|
+
throw new TypeError("Task session owner requires a task id.");
|
|
11
|
+
}
|
|
12
|
+
if (owner.scope === "global" && owner.taskId !== undefined) {
|
|
13
|
+
throw new TypeError("Global session owner must not carry a task id.");
|
|
14
|
+
}
|
|
15
|
+
const providerRoot = input.providerRoot;
|
|
16
|
+
if (!Number.isSafeInteger(providerRoot.pid) || providerRoot.pid <= 0) {
|
|
17
|
+
throw new TypeError("Session owner provider root pid is invalid.");
|
|
18
|
+
}
|
|
19
|
+
if (!/^[0-9]{1,32}$/u.test(providerRoot.startIdentity)) {
|
|
20
|
+
throw new TypeError("Session owner provider start identity is invalid.");
|
|
21
|
+
}
|
|
22
|
+
if (providerRoot.processGroupId !== undefined
|
|
23
|
+
&& (!Number.isSafeInteger(providerRoot.processGroupId) || providerRoot.processGroupId <= 0)) {
|
|
24
|
+
throw new TypeError("Session owner process group id is invalid.");
|
|
25
|
+
}
|
|
26
|
+
if (providerRoot.processSessionId !== undefined
|
|
27
|
+
&& (!Number.isSafeInteger(providerRoot.processSessionId) || providerRoot.processSessionId <= 0)) {
|
|
28
|
+
throw new TypeError("Session owner process session id is invalid.");
|
|
29
|
+
}
|
|
30
|
+
const tmux = input.tmux;
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
schemaVersion: 1,
|
|
33
|
+
kind: "yui-session-owner",
|
|
34
|
+
owner: Object.freeze({
|
|
35
|
+
scope: owner.scope,
|
|
36
|
+
...(owner.taskId === undefined ? {} : { taskId: requireSafeIdentity(owner.taskId, "Task id") }),
|
|
37
|
+
roleName: requireSafeIdentity(owner.roleName, "Role name")
|
|
38
|
+
}),
|
|
39
|
+
agentId: requireSafeIdentity(input.agentId, "Agent id"),
|
|
40
|
+
adapterId: requireText(input.adapterId, "Adapter id"),
|
|
41
|
+
launchId: requireSafeIdentity(input.launchId, "Launch id"),
|
|
42
|
+
...(input.nativeSessionId === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { nativeSessionId: requireText(input.nativeSessionId, "Native session id") }),
|
|
45
|
+
tmux: Object.freeze({
|
|
46
|
+
serverName: requireText(tmux.serverName, "tmux server name"),
|
|
47
|
+
socketPath: requireText(tmux.socketPath, "tmux socket path"),
|
|
48
|
+
sessionName: requireText(tmux.sessionName, "tmux session name"),
|
|
49
|
+
windowName: requireText(tmux.windowName, "tmux window name"),
|
|
50
|
+
...(tmux.panePid === undefined ? {} : { panePid: tmux.panePid })
|
|
51
|
+
}),
|
|
52
|
+
providerRoot: Object.freeze({
|
|
53
|
+
pid: providerRoot.pid,
|
|
54
|
+
startIdentity: providerRoot.startIdentity,
|
|
55
|
+
...(providerRoot.processGroupId === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { processGroupId: providerRoot.processGroupId }),
|
|
58
|
+
...(providerRoot.processSessionId === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { processSessionId: providerRoot.processSessionId }),
|
|
61
|
+
attribution: providerRoot.attribution
|
|
62
|
+
}),
|
|
63
|
+
...(input.runtimeRoot === undefined ? {} : { runtimeRoot: input.runtimeRoot }),
|
|
64
|
+
recordedAt: requireTimestamp(input.recordedAt, "Recorded at")
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Reads one Linux process identity from /proc. Returns undefined when /proc is
|
|
69
|
+
* unavailable, unreadable, or the platform does not expose it — the caller
|
|
70
|
+
* must treat that as a verification gap, never as proof of absence.
|
|
71
|
+
*/
|
|
72
|
+
export function readLinuxProcessIdentity(pid) {
|
|
73
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
try {
|
|
76
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
77
|
+
const closing = stat.lastIndexOf(")");
|
|
78
|
+
if (closing < 0)
|
|
79
|
+
return undefined;
|
|
80
|
+
const fields = stat.slice(closing + 1).trim().split(/\s+/u);
|
|
81
|
+
const state = fields[0];
|
|
82
|
+
const processGroupId = Number(fields[2]);
|
|
83
|
+
const processSessionId = Number(fields[3]);
|
|
84
|
+
const startIdentity = fields[19];
|
|
85
|
+
if (startIdentity === undefined || !/^[0-9]{1,32}$/u.test(startIdentity)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const rssBytes = readLinuxProcessRssBytes(pid) ?? 0;
|
|
89
|
+
return {
|
|
90
|
+
pid,
|
|
91
|
+
startIdentity,
|
|
92
|
+
...(typeof state === "string" && state.length > 0 ? { state } : {}),
|
|
93
|
+
...(Number.isSafeInteger(processGroupId) && processGroupId > 0
|
|
94
|
+
? { processGroupId }
|
|
95
|
+
: {}),
|
|
96
|
+
...(Number.isSafeInteger(processSessionId) && processSessionId > 0
|
|
97
|
+
? { processSessionId }
|
|
98
|
+
: {}),
|
|
99
|
+
rssBytes
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function readLinuxProcessRssBytes(pid) {
|
|
107
|
+
try {
|
|
108
|
+
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
109
|
+
for (const line of status.split("\n")) {
|
|
110
|
+
if (line.startsWith("VmRSS:")) {
|
|
111
|
+
const value = Number(line.slice("VmRSS:".length).trim().split(/\s+/u)[0]);
|
|
112
|
+
if (Number.isFinite(value) && value >= 0)
|
|
113
|
+
return Math.round(value) * 1024;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* True only when /proc still holds the exact process identity. A reused PID
|
|
124
|
+
* with a different start identity is absent, not live.
|
|
125
|
+
*/
|
|
126
|
+
export function isLinuxProcessLive(pid, startIdentity) {
|
|
127
|
+
const current = readLinuxProcessIdentity(pid);
|
|
128
|
+
if (current === undefined)
|
|
129
|
+
return false;
|
|
130
|
+
// A zombie has already exited; only the task struct remains for the parent
|
|
131
|
+
// to reap. It cannot execute or hold resources, so it is not live.
|
|
132
|
+
if (current.state === "Z")
|
|
133
|
+
return false;
|
|
134
|
+
return current.startIdentity === startIdentity;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Discovers the Provider root for one launch by the exact YUI_LAUNCH_ID
|
|
138
|
+
* environment fence. Returns undefined when no same-user process carries the
|
|
139
|
+
* fence — the caller must not guess from a process name or cwd.
|
|
140
|
+
*/
|
|
141
|
+
export function discoverProviderRootByLaunchEnv(launchId) {
|
|
142
|
+
const matches = listLaunchFencedProcesses(launchId);
|
|
143
|
+
for (const pid of matches) {
|
|
144
|
+
const identity = readLinuxProcessIdentity(pid);
|
|
145
|
+
if (identity !== undefined)
|
|
146
|
+
return { pid, identity };
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Lists every live process whose initial environment carries the exact
|
|
152
|
+
* `YUI_LAUNCH_ID=<launchId>` fence. The fence is inherited by Provider
|
|
153
|
+
* children, so this is the exact, PID-reuse-safe attribution for a surviving
|
|
154
|
+
* child after its root has exited: a recycled PID never carries the fence.
|
|
155
|
+
* Processes without the fence are unattributed and must never be signaled.
|
|
156
|
+
*/
|
|
157
|
+
export function listLaunchFencedProcesses(launchId) {
|
|
158
|
+
if (!Number.isSafeInteger(launchId) && !/^[A-Za-z0-9_.:-]+$/u.test(launchId)) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = readdirSync("/proc", { encoding: "utf8" });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const needle = `YUI_LAUNCH_ID=${launchId}`;
|
|
169
|
+
const matches = [];
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (!/^[0-9]+$/u.test(entry))
|
|
172
|
+
continue;
|
|
173
|
+
const pid = Number(entry);
|
|
174
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
175
|
+
continue;
|
|
176
|
+
let environment;
|
|
177
|
+
try {
|
|
178
|
+
environment = readFileSync(`/proc/${pid}/environ`, "utf8");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!environment.includes(needle))
|
|
184
|
+
continue;
|
|
185
|
+
matches.push(pid);
|
|
186
|
+
}
|
|
187
|
+
return matches;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Lists the process tree owned by one Provider root: the root itself plus
|
|
191
|
+
* descendants proven by process-group membership or an ancestor chain. A
|
|
192
|
+
* process that merely shares a name or cwd is never included.
|
|
193
|
+
*/
|
|
194
|
+
export function listOwnedProcessTree(rootPid, rootProcessGroupId) {
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = readdirSync("/proc", { encoding: "utf8" });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const processes = new Map();
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (!/^[0-9]+$/u.test(entry))
|
|
205
|
+
continue;
|
|
206
|
+
const pid = Number(entry);
|
|
207
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
208
|
+
continue;
|
|
209
|
+
try {
|
|
210
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
211
|
+
const closing = stat.lastIndexOf(")");
|
|
212
|
+
if (closing < 0)
|
|
213
|
+
continue;
|
|
214
|
+
const fields = stat.slice(closing + 1).trim().split(/\s+/u);
|
|
215
|
+
const ppid = Number(fields[1]);
|
|
216
|
+
const processGroupId = Number(fields[2]);
|
|
217
|
+
const startIdentity = fields[19];
|
|
218
|
+
if (!Number.isSafeInteger(ppid)
|
|
219
|
+
|| ppid < 0
|
|
220
|
+
|| startIdentity === undefined
|
|
221
|
+
|| !/^[0-9]{1,32}$/u.test(startIdentity)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (rootProcessGroupId !== undefined
|
|
225
|
+
&& Number.isSafeInteger(processGroupId)
|
|
226
|
+
&& processGroupId === rootProcessGroupId) {
|
|
227
|
+
const identity = readLinuxProcessIdentity(pid);
|
|
228
|
+
if (identity !== undefined) {
|
|
229
|
+
processes.set(pid, { ppid, identity });
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
processes.set(pid, {
|
|
234
|
+
ppid,
|
|
235
|
+
identity: {
|
|
236
|
+
pid,
|
|
237
|
+
startIdentity,
|
|
238
|
+
...(Number.isSafeInteger(processGroupId) && processGroupId > 0
|
|
239
|
+
? { processGroupId }
|
|
240
|
+
: {}),
|
|
241
|
+
rssBytes: readLinuxProcessRssBytes(pid) ?? 0
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// A process exiting mid-scan is simply omitted from this point-in-time
|
|
247
|
+
// tree; the termination guard re-verifies the root identity afterwards.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const owned = new Map();
|
|
251
|
+
const root = processes.get(rootPid);
|
|
252
|
+
if (root === undefined)
|
|
253
|
+
return [];
|
|
254
|
+
owned.set(rootPid, root.identity);
|
|
255
|
+
// Ancestry walk: any process whose parent chain reaches the root is owned.
|
|
256
|
+
let grew = true;
|
|
257
|
+
while (grew) {
|
|
258
|
+
grew = false;
|
|
259
|
+
for (const [pid, process] of processes) {
|
|
260
|
+
if (owned.has(pid))
|
|
261
|
+
continue;
|
|
262
|
+
if (owned.has(process.ppid)) {
|
|
263
|
+
owned.set(pid, process.identity);
|
|
264
|
+
grew = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return [...owned.values()];
|
|
269
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { createSessionOwnerIdentity } from "./sessionOwnerIdentity.js";
|
|
4
|
+
/**
|
|
5
|
+
* Durable, enumerable physical owner records for runtime generations.
|
|
6
|
+
*
|
|
7
|
+
* One JSON file per launch id under `<home>/runtime/session-owners/`. The
|
|
8
|
+
* directory is runtime state (like the tmux socket), not aggregate domain
|
|
9
|
+
* state, so it needs no schema migration; it survives a Controller restart and
|
|
10
|
+
* stays enumerable after the durable Session map or history is cleared, which
|
|
11
|
+
* is exactly the gap the audit proved (durable pointers empty, physical
|
|
12
|
+
* processes still live).
|
|
13
|
+
*/
|
|
14
|
+
export class FileSessionOwnerRegistry {
|
|
15
|
+
#directory;
|
|
16
|
+
constructor(home) {
|
|
17
|
+
this.#directory = join(resolve(home), "runtime", "session-owners");
|
|
18
|
+
}
|
|
19
|
+
get directory() {
|
|
20
|
+
return this.#directory;
|
|
21
|
+
}
|
|
22
|
+
/** Atomically records (or replaces) one generation's owner identity. */
|
|
23
|
+
record(identity) {
|
|
24
|
+
mkdirSync(this.#directory, { recursive: true, mode: 0o700 });
|
|
25
|
+
const target = this.#path(identity.launchId);
|
|
26
|
+
const temporary = `${target}.tmp-${process.pid}`;
|
|
27
|
+
writeFileSync(temporary, JSON.stringify(identity, null, 2) + "\n", { mode: 0o600 });
|
|
28
|
+
renameSync(temporary, target);
|
|
29
|
+
}
|
|
30
|
+
get(launchId) {
|
|
31
|
+
if (!isSafeLaunchId(launchId))
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
return parseOwnerRecord(readFileSync(this.#path(launchId), "utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
list() {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(this.#directory, { encoding: "utf8" });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const records = [];
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.endsWith(".json"))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
records.push(parseOwnerRecord(readFileSync(join(this.#directory, entry), "utf8")));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A malformed record must not hide the exact candidates beside it.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
records.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
|
|
60
|
+
return records;
|
|
61
|
+
}
|
|
62
|
+
listForOwner(owner) {
|
|
63
|
+
return this.list().filter((record) => (record.owner.scope === owner.scope
|
|
64
|
+
&& (owner.scope === "global"
|
|
65
|
+
|| record.owner.taskId === owner.taskId)
|
|
66
|
+
&& record.owner.roleName === owner.roleName));
|
|
67
|
+
}
|
|
68
|
+
/** Removes a record whose physical resources were proven absent. */
|
|
69
|
+
remove(launchId) {
|
|
70
|
+
if (!isSafeLaunchId(launchId))
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
rmSync(this.#path(launchId), { force: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// A stale record is harmless: reconciliation re-verifies physical
|
|
77
|
+
// identity before acting on it.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
exists() {
|
|
81
|
+
return existsSync(this.#directory);
|
|
82
|
+
}
|
|
83
|
+
#path(launchId) {
|
|
84
|
+
return join(this.#directory, `${launchId}.json`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function isSafeLaunchId(launchId) {
|
|
88
|
+
return /^[A-Za-z0-9_.:-]+$/u.test(launchId);
|
|
89
|
+
}
|
|
90
|
+
function parseOwnerRecord(raw) {
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
93
|
+
throw new Error("Session owner record is invalid.");
|
|
94
|
+
}
|
|
95
|
+
const record = parsed;
|
|
96
|
+
const owner = record.owner;
|
|
97
|
+
const tmux = record.tmux;
|
|
98
|
+
const providerRoot = record.providerRoot;
|
|
99
|
+
return createSessionOwnerIdentity({
|
|
100
|
+
owner: {
|
|
101
|
+
scope: owner.scope === "global" ? "global" : "task",
|
|
102
|
+
...(owner.taskId === undefined ? {} : { taskId: String(owner.taskId) }),
|
|
103
|
+
roleName: String(owner.roleName)
|
|
104
|
+
},
|
|
105
|
+
agentId: String(record.agentId),
|
|
106
|
+
adapterId: String(record.adapterId),
|
|
107
|
+
launchId: String(record.launchId),
|
|
108
|
+
...(record.nativeSessionId === undefined
|
|
109
|
+
? {}
|
|
110
|
+
: { nativeSessionId: String(record.nativeSessionId) }),
|
|
111
|
+
tmux: {
|
|
112
|
+
serverName: String(tmux.serverName),
|
|
113
|
+
socketPath: String(tmux.socketPath),
|
|
114
|
+
sessionName: String(tmux.sessionName),
|
|
115
|
+
windowName: String(tmux.windowName),
|
|
116
|
+
...(tmux.panePid === undefined ? {} : { panePid: Number(tmux.panePid) })
|
|
117
|
+
},
|
|
118
|
+
providerRoot: {
|
|
119
|
+
pid: Number(providerRoot.pid),
|
|
120
|
+
startIdentity: String(providerRoot.startIdentity),
|
|
121
|
+
...(providerRoot.processGroupId === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { processGroupId: Number(providerRoot.processGroupId) }),
|
|
124
|
+
...(providerRoot.processSessionId === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: { processSessionId: Number(providerRoot.processSessionId) }),
|
|
127
|
+
attribution: providerRoot.attribution === "pane-pid" ? "pane-pid" : "launch-env"
|
|
128
|
+
},
|
|
129
|
+
...(record.runtimeRoot === undefined ? {} : { runtimeRoot: String(record.runtimeRoot) }),
|
|
130
|
+
recordedAt: new Date(String(record.recordedAt))
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bidirectional durable <-> physical reconciliation for Session generations.
|
|
3
|
+
*
|
|
4
|
+
* Durable -> physical: every owner record's Provider root must be absent once
|
|
5
|
+
* its durable Session is terminal; a live root with a terminal durable state
|
|
6
|
+
* is the exact leak the audit found. Physical -> durable: the owner registry
|
|
7
|
+
* stays enumerable after the durable Session map or history is cleared, so a
|
|
8
|
+
* live generation can always be re-attributed and reported.
|
|
9
|
+
*
|
|
10
|
+
* Pure: all I/O is injected. Unknown owners are reported, never cleaned.
|
|
11
|
+
* An entry is archive-blocking when its durable state is terminal but the
|
|
12
|
+
* physical Provider root is still live, and the Task is terminal/archived.
|
|
13
|
+
*/
|
|
14
|
+
export function reconcileSessionOwners(input) {
|
|
15
|
+
const entries = input.records.map((record) => reconcileOne(record, input));
|
|
16
|
+
const summary = {
|
|
17
|
+
owners: entries.length,
|
|
18
|
+
livePhysicalRoots: entries.filter((entry) => entry.physical?.alive === true).length,
|
|
19
|
+
archiveBlockers: entries.filter((entry) => entry.archiveBlocked).length,
|
|
20
|
+
verificationGaps: entries.filter((entry) => entry.verificationGap !== undefined).length
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
observedAt: input.now.toISOString(),
|
|
25
|
+
entries,
|
|
26
|
+
summary
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function reconcileOne(record, input) {
|
|
30
|
+
const durable = matchDurable(record, input.durable);
|
|
31
|
+
const physical = input.observe(record);
|
|
32
|
+
const owner = record.owner;
|
|
33
|
+
const taskStatus = owner.scope === "task" && owner.taskId !== undefined
|
|
34
|
+
? input.taskStatus(owner.taskId)
|
|
35
|
+
: undefined;
|
|
36
|
+
const tmuxPane = input.inspectPane(owner.taskId, owner.roleName);
|
|
37
|
+
const lastStopOutcome = input.lastStopOutcome(owner.taskId, owner.roleName, record.launchId);
|
|
38
|
+
let mismatch;
|
|
39
|
+
let verificationGap;
|
|
40
|
+
if (physical?.identityConflict === true) {
|
|
41
|
+
mismatch = "identity-conflict";
|
|
42
|
+
}
|
|
43
|
+
else if (physical?.alive === true) {
|
|
44
|
+
if (durable === undefined || durable.status === "stopped" || durable.status === "broken") {
|
|
45
|
+
mismatch = "durable-terminal-physical-live";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (physical === undefined) {
|
|
49
|
+
verificationGap = "/proc identity unavailable";
|
|
50
|
+
}
|
|
51
|
+
else if (durable !== undefined && durable.status === "running") {
|
|
52
|
+
mismatch = "durable-live-physical-absent";
|
|
53
|
+
}
|
|
54
|
+
const terminalTask = taskStatus === "completed"
|
|
55
|
+
|| taskStatus === "retired"
|
|
56
|
+
|| taskStatus === "archived";
|
|
57
|
+
const archiveBlocked = mismatch === "durable-terminal-physical-live"
|
|
58
|
+
&& terminalTask;
|
|
59
|
+
return {
|
|
60
|
+
owner: {
|
|
61
|
+
scope: owner.scope,
|
|
62
|
+
...(owner.taskId === undefined ? {} : { taskId: owner.taskId }),
|
|
63
|
+
roleName: owner.roleName
|
|
64
|
+
},
|
|
65
|
+
agentId: record.agentId,
|
|
66
|
+
adapterId: record.adapterId,
|
|
67
|
+
launchId: record.launchId,
|
|
68
|
+
...(record.nativeSessionId === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { nativeSessionId: record.nativeSessionId }),
|
|
71
|
+
...(taskStatus === undefined ? {} : { taskStatus }),
|
|
72
|
+
durableStatus: durable?.status ?? "absent",
|
|
73
|
+
...(tmuxPane === undefined ? {} : { tmuxPane }),
|
|
74
|
+
...(physical === undefined ? {} : { physical }),
|
|
75
|
+
...(lastStopOutcome === undefined ? {} : { lastStopOutcome }),
|
|
76
|
+
...(mismatch === undefined ? {} : { mismatch }),
|
|
77
|
+
archiveBlocked,
|
|
78
|
+
...(verificationGap === undefined ? {} : { verificationGap })
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function matchDurable(record, durable) {
|
|
82
|
+
const candidates = durable.filter((fact) => (fact.scope === record.owner.scope
|
|
83
|
+
&& fact.roleName === record.owner.roleName
|
|
84
|
+
&& (record.owner.scope === "global" || fact.taskId === record.owner.taskId)
|
|
85
|
+
&& fact.agentId === record.agentId));
|
|
86
|
+
const byLaunch = candidates.find((fact) => fact.launchId !== undefined && fact.launchId === record.launchId);
|
|
87
|
+
if (byLaunch !== undefined)
|
|
88
|
+
return byLaunch;
|
|
89
|
+
const byNative = candidates.find((fact) => fact.nativeSessionId !== undefined
|
|
90
|
+
&& record.nativeSessionId !== undefined
|
|
91
|
+
&& fact.nativeSessionId === record.nativeSessionId);
|
|
92
|
+
return byNative;
|
|
93
|
+
}
|