@zq-silk/yui 0.6.7 → 0.6.9
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 +8 -4
- package/dist/commands/taskCommands.js +1 -1
- package/dist/controller/clientRuntime.js +26 -3
- package/dist/controller/fileSchedulerStoreAdapter.js +27 -4
- package/dist/controller/resourceCleanupLinux.js +2 -1
- package/dist/controller/resourceInventory.js +4 -2
- package/dist/controller/resourceInventoryLinux.js +62 -5
- package/dist/controller/runtime.js +2 -1
- package/dist/core/controllerClient.js +141 -7
- package/dist/core/controllerEndpoint.js +18 -30
- package/dist/core/controllerProcessIdentity.js +56 -0
- package/dist/core/controllerServer.js +90 -8
- package/dist/core/homeFilesystemIdentity.js +24 -0
- package/dist/core/protocol.js +68 -5
- package/dist/integration/gitIntegrationService.js +3 -3
- package/dist/repository/homeIdentity.js +7 -3
- package/dist/resources/liveReferences.js +35 -2
- package/dist/runtime/runtimeObservation.js +22 -0
- package/dist/runtime/runtimeProjection.js +10 -4
- package/dist/scheduler/leaderWakeupProcessor.js +2 -1
- package/dist/storage/compatibleTaskStore.js +36 -1
- package/dist/storage/sqliteStore.js +25 -1
- package/dist/storage/taskStore.js +48 -3
- package/dist/task/nextAction.js +1 -1
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +27 -23
- package/skills/yui-operator/SKILL.md +7 -5
|
@@ -6,6 +6,10 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
|
|
8
8
|
import { controllerSocketPath } from "./controllerEndpoint.js";
|
|
9
|
+
import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
10
|
+
import { findLiveControllerProcessForHome } from "./controllerProcessIdentity.js";
|
|
11
|
+
import { validateHomeId } from "../repository/homeIdentity.js";
|
|
12
|
+
import { readCompatibleHomeIdentity } from "../storage/compatibleTaskStore.js";
|
|
9
13
|
import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
|
|
10
14
|
import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
|
|
11
15
|
import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
|
|
@@ -32,7 +36,14 @@ export async function startControllerServer(home, dispatcher, beforeDiscoveryRem
|
|
|
32
36
|
}
|
|
33
37
|
async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
|
|
34
38
|
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
35
|
-
const
|
|
39
|
+
const homeId = validateHomeId(readCompatibleHomeIdentity(home).homeId);
|
|
40
|
+
const homeFilesystemId = readHomeFilesystemId(home);
|
|
41
|
+
await assertExistingDiscoveryReplaceable(discoveryPath);
|
|
42
|
+
if (findLiveControllerProcessForHome(homeFilesystemId) !== undefined) {
|
|
43
|
+
throw controllerAlreadyRunning();
|
|
44
|
+
}
|
|
45
|
+
const controllerInstanceId = randomBytes(16).toString("hex");
|
|
46
|
+
const socketPath = controllerSocketPath(homeId);
|
|
36
47
|
const runtimeDirectory = dirname(discoveryPath);
|
|
37
48
|
const socketDirectory = dirname(socketPath);
|
|
38
49
|
await mkdir(runtimeDirectory, { recursive: true, mode: 0o700 });
|
|
@@ -66,7 +77,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
66
77
|
// the live primary, so its startup identity is still truthful.
|
|
67
78
|
let primaryIdentity = null;
|
|
68
79
|
const netServer = createServer((socket) => {
|
|
69
|
-
receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, () => primaryIdentity);
|
|
80
|
+
receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, () => primaryIdentity);
|
|
70
81
|
});
|
|
71
82
|
try {
|
|
72
83
|
await listen(netServer, socketPath);
|
|
@@ -91,6 +102,11 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
91
102
|
await chmod(socketPath, 0o600);
|
|
92
103
|
const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
|
|
93
104
|
const discovery = Object.freeze({
|
|
105
|
+
schemaVersion: 1,
|
|
106
|
+
protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
107
|
+
homeId,
|
|
108
|
+
homeFilesystemId,
|
|
109
|
+
controllerInstanceId,
|
|
94
110
|
pid: process.pid,
|
|
95
111
|
processStartIdentity,
|
|
96
112
|
socketPath,
|
|
@@ -125,7 +141,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
125
141
|
eventLoopDelay.stop();
|
|
126
142
|
await beforeDiscoveryRemoval?.();
|
|
127
143
|
await closeNetServer(netServer);
|
|
128
|
-
await removeOwnedDiscovery(discoveryPath, token);
|
|
144
|
+
await removeOwnedDiscovery(discoveryPath, token, controllerInstanceId);
|
|
129
145
|
if (options.domainIdentity !== undefined) {
|
|
130
146
|
// Keep the exact target fence while detached Role panes survive a
|
|
131
147
|
// Controller stop/restart. The owning test teardown or an expired
|
|
@@ -156,6 +172,54 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
156
172
|
throw error;
|
|
157
173
|
}
|
|
158
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Fence an older Controller whose socket name was derived by another protocol.
|
|
177
|
+
* A dead or PID-reused owner is stale and may be replaced; an unverifiable
|
|
178
|
+
* live owner fails closed so an endpoint migration can never create dual
|
|
179
|
+
* writers for one durable Home.
|
|
180
|
+
*/
|
|
181
|
+
async function assertExistingDiscoveryReplaceable(discoveryPath) {
|
|
182
|
+
let value;
|
|
183
|
+
try {
|
|
184
|
+
value = JSON.parse(await readFile(discoveryPath, "utf8"));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
188
|
+
return;
|
|
189
|
+
throw unsafeExistingDiscovery(discoveryPath, error);
|
|
190
|
+
}
|
|
191
|
+
if (typeof value !== "object"
|
|
192
|
+
|| value === null
|
|
193
|
+
|| Array.isArray(value)
|
|
194
|
+
|| !("pid" in value)
|
|
195
|
+
|| !Number.isSafeInteger(value.pid)
|
|
196
|
+
|| value.pid < 1) {
|
|
197
|
+
throw unsafeExistingDiscovery(discoveryPath);
|
|
198
|
+
}
|
|
199
|
+
const pid = value.pid;
|
|
200
|
+
if (!isProcessAlive(pid))
|
|
201
|
+
return;
|
|
202
|
+
if (!("processStartIdentity" in value)
|
|
203
|
+
|| typeof value.processStartIdentity !== "string"
|
|
204
|
+
|| !/^[0-9]{1,32}$/u.test(value.processStartIdentity)) {
|
|
205
|
+
throw unsafeExistingDiscovery(discoveryPath);
|
|
206
|
+
}
|
|
207
|
+
let currentIdentity;
|
|
208
|
+
try {
|
|
209
|
+
currentIdentity = await readLinuxProcessStartIdentity(pid);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (!isProcessAlive(pid))
|
|
213
|
+
return;
|
|
214
|
+
throw unsafeExistingDiscovery(discoveryPath, error);
|
|
215
|
+
}
|
|
216
|
+
if (currentIdentity === value.processStartIdentity) {
|
|
217
|
+
throw controllerAlreadyRunning();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function unsafeExistingDiscovery(discoveryPath, cause) {
|
|
221
|
+
return new Error(`Cannot safely replace existing Controller discovery: ${discoveryPath}.`, cause === undefined ? undefined : { cause });
|
|
222
|
+
}
|
|
159
223
|
async function writeDiscoveryAtomically(discoveryPath, discovery) {
|
|
160
224
|
const temporaryPath = `${discoveryPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
161
225
|
try {
|
|
@@ -171,7 +235,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
|
|
|
171
235
|
throw error;
|
|
172
236
|
}
|
|
173
237
|
}
|
|
174
|
-
function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
|
|
238
|
+
function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
|
|
175
239
|
let buffer = Buffer.alloc(0);
|
|
176
240
|
let complete = false;
|
|
177
241
|
const fail = (code, message, id = "invalid") => {
|
|
@@ -200,7 +264,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
|
|
|
200
264
|
return;
|
|
201
265
|
}
|
|
202
266
|
complete = true;
|
|
203
|
-
void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity);
|
|
267
|
+
void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity);
|
|
204
268
|
});
|
|
205
269
|
socket.on("end", () => {
|
|
206
270
|
if (!complete)
|
|
@@ -208,7 +272,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
|
|
|
208
272
|
});
|
|
209
273
|
socket.on("error", () => undefined);
|
|
210
274
|
}
|
|
211
|
-
async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
|
|
275
|
+
async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
|
|
212
276
|
let request;
|
|
213
277
|
try {
|
|
214
278
|
request = parseControllerRequest(line);
|
|
@@ -224,6 +288,16 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
|
|
|
224
288
|
sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
|
|
225
289
|
return;
|
|
226
290
|
}
|
|
291
|
+
if (request.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION) {
|
|
292
|
+
sendResponse(socket, controllerFailure(request.id, "CONTROLLER_PROTOCOL_MISMATCH", "Controller protocol version does not match the running Controller."));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (request.homeId !== homeId
|
|
296
|
+
|| request.homeFilesystemId !== homeFilesystemId
|
|
297
|
+
|| request.controllerInstanceId !== controllerInstanceId) {
|
|
298
|
+
sendResponse(socket, controllerFailure(request.id, "CONTROLLER_IDENTITY_MISMATCH", "Controller request identity does not match the running Controller."));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
227
301
|
// Every authenticated request is observed once at the routing layer. The
|
|
228
302
|
// observation completes when the response is handed to the socket, so a
|
|
229
303
|
// later status snapshot sees prior built-in completions and the dispatcher
|
|
@@ -290,6 +364,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
|
|
|
290
364
|
cliRealpath: controllerCliRealpath(),
|
|
291
365
|
controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
|
|
292
366
|
controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
367
|
+
homeId,
|
|
368
|
+
homeFilesystemId,
|
|
369
|
+
controllerInstanceId,
|
|
293
370
|
storageBackend: resolveTaskStoreBackendForHome(home, process.env),
|
|
294
371
|
workerEnabled: resolveStoreWorkerEnabledForHome(home, process.env),
|
|
295
372
|
mode: "primary",
|
|
@@ -322,6 +399,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
|
|
|
322
399
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
323
400
|
rssBytes: process.memoryUsage().rss,
|
|
324
401
|
protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
402
|
+
homeId,
|
|
403
|
+
homeFilesystemId,
|
|
404
|
+
controllerInstanceId,
|
|
325
405
|
version: YUI_VERSION,
|
|
326
406
|
storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
|
|
327
407
|
aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
|
|
@@ -688,13 +768,15 @@ function closeNetServer(server) {
|
|
|
688
768
|
server.close((error) => error === undefined ? resolve() : reject(error));
|
|
689
769
|
});
|
|
690
770
|
}
|
|
691
|
-
async function removeOwnedDiscovery(path, token) {
|
|
771
|
+
async function removeOwnedDiscovery(path, token, controllerInstanceId) {
|
|
692
772
|
try {
|
|
693
773
|
const value = JSON.parse(await readFile(path, "utf8"));
|
|
694
774
|
if (typeof value === "object"
|
|
695
775
|
&& value !== null
|
|
696
776
|
&& "token" in value
|
|
697
|
-
&& value.token === token
|
|
777
|
+
&& value.token === token
|
|
778
|
+
&& "controllerInstanceId" in value
|
|
779
|
+
&& value.controllerInstanceId === controllerInstanceId) {
|
|
698
780
|
await rm(path, { force: true });
|
|
699
781
|
}
|
|
700
782
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
const HOME_FILESYSTEM_ID_PATTERN = /^[0-9]{1,32}:[0-9]{1,32}$/u;
|
|
3
|
+
/**
|
|
4
|
+
* Runtime-only identity of the physical Home directory.
|
|
5
|
+
*
|
|
6
|
+
* Durable `homeId` names the logical Home. Device + inode distinguishes an
|
|
7
|
+
* accidental filesystem copy while allowing symlink aliases of the same
|
|
8
|
+
* directory to share one Controller. It is deliberately not persisted in the
|
|
9
|
+
* Home aggregate because replacing or copying the directory must change this
|
|
10
|
+
* runtime fence.
|
|
11
|
+
*/
|
|
12
|
+
export function readHomeFilesystemId(home) {
|
|
13
|
+
const metadata = statSync(home, { bigint: true });
|
|
14
|
+
if (!metadata.isDirectory()) {
|
|
15
|
+
throw new Error("YUI_HOME is not a directory.");
|
|
16
|
+
}
|
|
17
|
+
return `${metadata.dev}:${metadata.ino}`;
|
|
18
|
+
}
|
|
19
|
+
export function validateHomeFilesystemId(value) {
|
|
20
|
+
if (typeof value !== "string" || !HOME_FILESYSTEM_ID_PATTERN.test(value)) {
|
|
21
|
+
throw new Error("Home filesystem identity is invalid.");
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
package/dist/core/protocol.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { validateHomeId } from "../repository/homeIdentity.js";
|
|
2
|
+
import { validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
1
3
|
export const MAX_CONTROLLER_MESSAGE_BYTES = 1_048_576;
|
|
2
4
|
export const CONTROLLER_DISCOVERY_PATH = "runtime/controller.json";
|
|
3
5
|
/** Bump when a running Controller cannot safely share one YUI_HOME with this CLI. */
|
|
4
|
-
export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION =
|
|
6
|
+
export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 4;
|
|
5
7
|
export class ControllerProtocolError extends Error {
|
|
6
8
|
code;
|
|
7
9
|
id;
|
|
@@ -18,12 +20,27 @@ export function parseControllerRequest(line) {
|
|
|
18
20
|
}
|
|
19
21
|
try {
|
|
20
22
|
const value = JSON.parse(line);
|
|
21
|
-
if (!isRecord(value) || !hasExactKeys(value, [
|
|
23
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
24
|
+
"id",
|
|
25
|
+
"token",
|
|
26
|
+
"protocolVersion",
|
|
27
|
+
"homeId",
|
|
28
|
+
"homeFilesystemId",
|
|
29
|
+
"controllerInstanceId",
|
|
30
|
+
"method",
|
|
31
|
+
"params"
|
|
32
|
+
])) {
|
|
22
33
|
throw new Error("shape");
|
|
23
34
|
}
|
|
24
35
|
if (!isIdentifier(value.id)
|
|
25
36
|
|| typeof value.token !== "string"
|
|
26
37
|
|| !/^[a-f0-9]{64}$/u.test(value.token)
|
|
38
|
+
|| !Number.isSafeInteger(value.protocolVersion)
|
|
39
|
+
|| value.protocolVersion < 1
|
|
40
|
+
|| !isHomeId(value.homeId)
|
|
41
|
+
|| !isHomeFilesystemId(value.homeFilesystemId)
|
|
42
|
+
|| typeof value.controllerInstanceId !== "string"
|
|
43
|
+
|| !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
|
|
27
44
|
|| typeof value.method !== "string"
|
|
28
45
|
|| value.method.length > 128
|
|
29
46
|
|| !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/u.test(value.method)
|
|
@@ -33,6 +50,10 @@ export function parseControllerRequest(line) {
|
|
|
33
50
|
return Object.freeze({
|
|
34
51
|
id: value.id,
|
|
35
52
|
token: value.token,
|
|
53
|
+
protocolVersion: value.protocolVersion,
|
|
54
|
+
homeId: value.homeId,
|
|
55
|
+
homeFilesystemId: value.homeFilesystemId,
|
|
56
|
+
controllerInstanceId: value.controllerInstanceId,
|
|
36
57
|
method: value.method,
|
|
37
58
|
params: value.params
|
|
38
59
|
});
|
|
@@ -96,19 +117,41 @@ export function controllerFailure(id, code, message) {
|
|
|
96
117
|
error: Object.freeze({ code, message })
|
|
97
118
|
});
|
|
98
119
|
}
|
|
99
|
-
export function parseControllerDiscovery(value,
|
|
120
|
+
export function parseControllerDiscovery(value, expected) {
|
|
100
121
|
if (!isRecord(value)
|
|
101
|
-
|| !hasExactKeys(value, [
|
|
122
|
+
|| !hasExactKeys(value, [
|
|
123
|
+
"schemaVersion",
|
|
124
|
+
"protocolVersion",
|
|
125
|
+
"homeId",
|
|
126
|
+
"homeFilesystemId",
|
|
127
|
+
"controllerInstanceId",
|
|
128
|
+
"pid",
|
|
129
|
+
"processStartIdentity",
|
|
130
|
+
"socketPath",
|
|
131
|
+
"token"
|
|
132
|
+
])
|
|
133
|
+
|| value.schemaVersion !== 1
|
|
134
|
+
|| value.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION
|
|
135
|
+
|| !isHomeId(value.homeId)
|
|
136
|
+
|| value.homeFilesystemId !== expected.homeFilesystemId
|
|
137
|
+
|| !isHomeFilesystemId(value.homeFilesystemId)
|
|
138
|
+
|| typeof value.controllerInstanceId !== "string"
|
|
139
|
+
|| !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
|
|
102
140
|
|| !Number.isSafeInteger(value.pid)
|
|
103
141
|
|| value.pid < 1
|
|
104
142
|
|| typeof value.processStartIdentity !== "string"
|
|
105
143
|
|| !/^[0-9]{1,32}$/u.test(value.processStartIdentity)
|
|
106
|
-
|| value.socketPath !==
|
|
144
|
+
|| value.socketPath !== expected.socketPath
|
|
107
145
|
|| typeof value.token !== "string"
|
|
108
146
|
|| !/^[a-f0-9]{64}$/u.test(value.token)) {
|
|
109
147
|
throw new ControllerProtocolError("CONTROLLER_DISCOVERY_INVALID", "Controller discovery is invalid.");
|
|
110
148
|
}
|
|
111
149
|
return Object.freeze({
|
|
150
|
+
schemaVersion: 1,
|
|
151
|
+
protocolVersion: value.protocolVersion,
|
|
152
|
+
homeId: value.homeId,
|
|
153
|
+
homeFilesystemId: value.homeFilesystemId,
|
|
154
|
+
controllerInstanceId: value.controllerInstanceId,
|
|
112
155
|
pid: value.pid,
|
|
113
156
|
processStartIdentity: value.processStartIdentity,
|
|
114
157
|
socketPath: value.socketPath,
|
|
@@ -131,6 +174,26 @@ function isIdentifier(value) {
|
|
|
131
174
|
return typeof value === "string"
|
|
132
175
|
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
|
|
133
176
|
}
|
|
177
|
+
function isHomeId(value) {
|
|
178
|
+
if (typeof value !== "string")
|
|
179
|
+
return false;
|
|
180
|
+
try {
|
|
181
|
+
validateHomeId(value);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function isHomeFilesystemId(value) {
|
|
189
|
+
try {
|
|
190
|
+
validateHomeFilesystemId(value);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
134
197
|
function isRecord(value) {
|
|
135
198
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
199
|
}
|
|
@@ -59,7 +59,7 @@ export class GitIntegrationService {
|
|
|
59
59
|
environment;
|
|
60
60
|
runtimeIsolation;
|
|
61
61
|
#resourceRegistrarValue;
|
|
62
|
-
constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home), jobPort) {
|
|
62
|
+
constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home, store.getHomeIdentity().homeId), jobPort) {
|
|
63
63
|
this.store = store;
|
|
64
64
|
this.git = git;
|
|
65
65
|
this.now = now;
|
|
@@ -883,14 +883,14 @@ function isNodeCode(error, code) {
|
|
|
883
883
|
&& "code" in error
|
|
884
884
|
&& error.code === code;
|
|
885
885
|
}
|
|
886
|
-
function defaultIntegrationRuntimeIsolation(home) {
|
|
886
|
+
function defaultIntegrationRuntimeIsolation(home, homeId) {
|
|
887
887
|
const controlHome = resolve(home);
|
|
888
888
|
return new FileTaskRuntimeIsolation({
|
|
889
889
|
runtimeRoot: integrationRuntimeRoot(),
|
|
890
890
|
pathLayout: "compact",
|
|
891
891
|
controlPlane: {
|
|
892
892
|
yuiHome: controlHome,
|
|
893
|
-
controllerSocketPath: controllerSocketPath(
|
|
893
|
+
controllerSocketPath: controllerSocketPath(homeId),
|
|
894
894
|
tmuxNamespace: yuiTmuxServerName(controlHome),
|
|
895
895
|
globalInstallPaths: [process.execPath]
|
|
896
896
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
export const HOME_IDENTITY_PATTERN = /^home-[a-f0-9]{16}$/;
|
|
3
3
|
const HOME_ENTROPY_BYTES = 16;
|
|
4
|
+
export function validateHomeId(homeId) {
|
|
5
|
+
if (typeof homeId !== "string" || !HOME_IDENTITY_PATTERN.test(homeId)) {
|
|
6
|
+
throw new Error("Home identity id is invalid.");
|
|
7
|
+
}
|
|
8
|
+
return homeId;
|
|
9
|
+
}
|
|
4
10
|
export function generateHomeIdentity(now, source = () => randomBytes(HOME_ENTROPY_BYTES)) {
|
|
5
11
|
const entropy = source().toString("hex");
|
|
6
12
|
const homeId = `home-${randomBytes(8).toString("hex")}`;
|
|
@@ -15,9 +21,7 @@ export function validateHomeIdentity(identity) {
|
|
|
15
21
|
if (identity.schemaVersion !== 1) {
|
|
16
22
|
throw new Error("Home identity must use schemaVersion 1.");
|
|
17
23
|
}
|
|
18
|
-
|
|
19
|
-
throw new Error("Home identity id is invalid.");
|
|
20
|
-
}
|
|
24
|
+
validateHomeId(identity.homeId);
|
|
21
25
|
if (typeof identity.entropy !== "string" || !/^[a-f0-9]{32}$/.test(identity.entropy)) {
|
|
22
26
|
throw new Error("Home identity entropy is invalid.");
|
|
23
27
|
}
|
|
@@ -12,6 +12,9 @@ import { join, resolve } from "node:path";
|
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { parseExactTaskRuntimeDescriptor } from "../runtime/exactControlPlane.js";
|
|
14
14
|
import { readRuntimeIdentity, releasesDirectory, resolveActiveRelease } from "../release/runtimeRelease.js";
|
|
15
|
+
import { isControllerSocketPathForHome } from "../core/controllerEndpoint.js";
|
|
16
|
+
import { parseControllerDiscovery } from "../core/protocol.js";
|
|
17
|
+
import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
|
|
15
18
|
const executeFile = promisify(execFile);
|
|
16
19
|
/**
|
|
17
20
|
* Collect every live reference for the given paths. The scan is fail-closed:
|
|
@@ -481,7 +484,11 @@ export function readControllerDiscovery(home) {
|
|
|
481
484
|
}
|
|
482
485
|
let record;
|
|
483
486
|
try {
|
|
484
|
-
|
|
487
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
488
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
489
|
+
throw new Error("controller.json is not an object");
|
|
490
|
+
}
|
|
491
|
+
record = value;
|
|
485
492
|
}
|
|
486
493
|
catch (error) {
|
|
487
494
|
return Object.freeze({
|
|
@@ -537,9 +544,35 @@ export function readControllerDiscovery(home) {
|
|
|
537
544
|
}
|
|
538
545
|
});
|
|
539
546
|
}
|
|
547
|
+
let discovery;
|
|
548
|
+
try {
|
|
549
|
+
const homeId = record.homeId;
|
|
550
|
+
const socketPath = record.socketPath;
|
|
551
|
+
if (typeof homeId !== "string"
|
|
552
|
+
|| typeof socketPath !== "string"
|
|
553
|
+
|| !isControllerSocketPathForHome(homeId, socketPath)) {
|
|
554
|
+
throw new Error("Controller endpoint identity is invalid.");
|
|
555
|
+
}
|
|
556
|
+
discovery = parseControllerDiscovery(record, {
|
|
557
|
+
homeFilesystemId: readHomeFilesystemId(home),
|
|
558
|
+
socketPath
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
return Object.freeze({
|
|
563
|
+
protects: false,
|
|
564
|
+
token: "",
|
|
565
|
+
protectedPaths: Object.freeze([]),
|
|
566
|
+
diagnostic: {
|
|
567
|
+
source: "controller",
|
|
568
|
+
severity: "error",
|
|
569
|
+
message: `controller.json identity is invalid: ${error instanceof Error ? error.message : "unknown error"}`
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
540
573
|
return Object.freeze({
|
|
541
574
|
protects: true,
|
|
542
|
-
token: `controller:${record.pid}`,
|
|
575
|
+
token: `controller:${discovery.homeId}:${discovery.controllerInstanceId}:${record.pid}`,
|
|
543
576
|
protectedPaths: Object.freeze([path])
|
|
544
577
|
});
|
|
545
578
|
}
|
|
@@ -118,6 +118,28 @@ export function runtimeObservationFenceMatches(expected, actual) {
|
|
|
118
118
|
}
|
|
119
119
|
return true;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Matches observations that belong to one durable Run/session generation.
|
|
123
|
+
* A provider may advance its native Turn while background subagents from an
|
|
124
|
+
* earlier Turn are still active, so nativeTurnId is intentionally excluded.
|
|
125
|
+
*/
|
|
126
|
+
export function runtimeObservationRunFenceMatches(expected, actual) {
|
|
127
|
+
for (const field of [
|
|
128
|
+
"taskId",
|
|
129
|
+
"roleName",
|
|
130
|
+
"runId",
|
|
131
|
+
"agentId",
|
|
132
|
+
"driverId",
|
|
133
|
+
"launchId",
|
|
134
|
+
"sessionGenerationId",
|
|
135
|
+
"nativeSessionId",
|
|
136
|
+
"receiptId"
|
|
137
|
+
]) {
|
|
138
|
+
if (expected[field] !== actual[field])
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
121
143
|
/** Compact TaskEvent payload used for durable state-boundary observations. */
|
|
122
144
|
export function runtimeObservationTaskEventPayload(input) {
|
|
123
145
|
const observation = createRuntimeObservation(input);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRuntimeObservation,
|
|
1
|
+
import { createRuntimeObservation, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches } from "./runtimeObservation.js";
|
|
2
2
|
export function createRuntimeProjection(fence, createdAt) {
|
|
3
3
|
const timestamp = requireTimestamp(createdAt);
|
|
4
4
|
return Object.freeze({
|
|
@@ -20,7 +20,7 @@ export function createRuntimeProjection(fence, createdAt) {
|
|
|
20
20
|
export function projectRuntimeTaskEvents(fence, createdAt, events) {
|
|
21
21
|
const observations = events
|
|
22
22
|
.map(runtimeObservationFromTaskEvent)
|
|
23
|
-
.filter((event) => (event !== null &&
|
|
23
|
+
.filter((event) => (event !== null && runtimeObservationRunFenceMatches(fence, event.fence)))
|
|
24
24
|
.sort((left, right) => (left.receivedAt.localeCompare(right.receivedAt)
|
|
25
25
|
|| (left.sequence ?? -1) - (right.sequence ?? -1)
|
|
26
26
|
|| (left.ordinal ?? -1) - (right.ordinal ?? -1)
|
|
@@ -29,7 +29,7 @@ export function projectRuntimeTaskEvents(fence, createdAt, events) {
|
|
|
29
29
|
}
|
|
30
30
|
export function projectRuntimeObservation(current, raw) {
|
|
31
31
|
const event = createRuntimeObservation(raw);
|
|
32
|
-
if (!
|
|
32
|
+
if (!runtimeObservationRunFenceMatches(current.fence, event.fence)) {
|
|
33
33
|
throw new Error("Runtime observation fence does not match the projection.");
|
|
34
34
|
}
|
|
35
35
|
const at = event.receivedAt;
|
|
@@ -79,7 +79,10 @@ export function projectRuntimeObservation(current, raw) {
|
|
|
79
79
|
turn: "completed",
|
|
80
80
|
waitingReason: undefined,
|
|
81
81
|
waitId: undefined,
|
|
82
|
-
operations
|
|
82
|
+
// Tool operations belong to the completed native Turn. Provider-owned
|
|
83
|
+
// background subagents may legitimately outlive it and wake later
|
|
84
|
+
// native Turns inside the same durable Yui Run.
|
|
85
|
+
operations: activeSubagentOperations(current.operations),
|
|
83
86
|
stateSince: at
|
|
84
87
|
}), "provider", at);
|
|
85
88
|
case "turn.failed":
|
|
@@ -261,6 +264,9 @@ function dominantOperation(operations) {
|
|
|
261
264
|
return "model";
|
|
262
265
|
return null;
|
|
263
266
|
}
|
|
267
|
+
function activeSubagentOperations(operations) {
|
|
268
|
+
return Object.freeze(Object.fromEntries(Object.entries(operations).filter(([, operation]) => operation.kind === "subagent")));
|
|
269
|
+
}
|
|
264
270
|
function terminalTurn(current, fallback = "cancelled") {
|
|
265
271
|
return current === "completed" || current === "failed" || current === "cancelled"
|
|
266
272
|
? current
|
|
@@ -410,7 +410,8 @@ function leaderWakeupInput(taskId, runId, reasons, projectBindings) {
|
|
|
410
410
|
: `Project Policy references: ${projectBindings.map((binding) => `${binding.directory} (${binding.projectId})`).join(", ")}. Read each with yui project show <project>, then yui project knowledge list <project> and yui project knowledge show <project> <knowledge>.`,
|
|
411
411
|
"Use narrower Task message, WorkItem, decision, milestone, and input commands only when a specific record needs closer inspection.",
|
|
412
412
|
`When the requested outcome is finished and there are no active Worker Runs or unresolved inputs, complete the Task with yui task complete ${taskId} --summary-file - and a quoted heredoc containing the final outcome and evidence.`,
|
|
413
|
-
|
|
413
|
+
"Provider-native subagents remain inside this Leader AgentRun. Yui observes their structured lifecycle and keeps the Run active across intermediate provider Turn boundaries while children remain active; let the provider deliver completion notifications and continue synthesis without polling or yielding merely for that native wait.",
|
|
414
|
+
`Before ending this turn, if the Task was not completed, no InputRequest terminalized this Run, and no provider-native child work remains active, release the active fence with yui task run yield ${runId} --summary-file - and a quoted heredoc containing the current result or waiting state. For managed Task Role or Reviewer results, checkpoint and yield before waiting so their durable mailbox can wake a later Leader Run. The yield command must be the final tool action: after it succeeds, stop immediately and do not inspect, poll, accept, or perform further work in the same native turn.`
|
|
414
415
|
];
|
|
415
416
|
return lines.join("\n");
|
|
416
417
|
}
|
|
@@ -3,8 +3,9 @@ import { join } from "node:path";
|
|
|
3
3
|
import { StorageCompatibilityError, loadCompatibleSnapshot } from "./migration/index.js";
|
|
4
4
|
import { createProductionStorageRegistry } from "./migration/productionRegistry.js";
|
|
5
5
|
import { FileTaskStore, STORAGE_STATE_FILE, stateFileFingerprint, StorageRecordError, validateCurrentStorageStateSnapshot } from "./taskStore.js";
|
|
6
|
-
import { SqliteTaskStore } from "./sqliteStore.js";
|
|
6
|
+
import { readSqliteHomeIdentity, SqliteTaskStore } from "./sqliteStore.js";
|
|
7
7
|
import { COMMITTED_DATABASE_FILENAME } from "./upgrade/sqliteStateMigration.js";
|
|
8
|
+
import { validateHomeIdentity } from "../repository/homeIdentity.js";
|
|
8
9
|
import { ensureStorageSchema, inspectStorageSchema, readStorageSchemaManifest, STORAGE_SCHEMA_FILE } from "./storageSchema.js";
|
|
9
10
|
import { classifyHome } from "./upgrade/homeClassification.js";
|
|
10
11
|
import { inspectSnapshotVersionState } from "./upgrade/homeMigrationTarget.js";
|
|
@@ -81,6 +82,40 @@ export function openCompatibleFileTaskStore(home, options = {}) {
|
|
|
81
82
|
throw new StorageCompatibilityError(describeUnsupported(classification.classification));
|
|
82
83
|
}
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Read the one authoritative durable Home identity without creating runtime
|
|
87
|
+
* state. SQLite is opened read-only; file-backed Homes read only the immutable
|
|
88
|
+
* identity field, so an aggregate that requires offline migration can still
|
|
89
|
+
* authenticate its already-running Controller before the migration stops it.
|
|
90
|
+
*/
|
|
91
|
+
export function readCompatibleHomeIdentity(home, options = {}) {
|
|
92
|
+
const schema = inspectStorageSchema(home);
|
|
93
|
+
if (!options.forceStateSource
|
|
94
|
+
&& (schema.status === "current" || schema.status === "unsupported")
|
|
95
|
+
&& schema.currentLayoutVersion >= 7
|
|
96
|
+
&& existsSync(join(home, COMMITTED_DATABASE_FILENAME))) {
|
|
97
|
+
return readSqliteHomeIdentity(home, COMMITTED_DATABASE_FILENAME);
|
|
98
|
+
}
|
|
99
|
+
if (!existsSync(join(home, STORAGE_STATE_FILE))) {
|
|
100
|
+
throw new StorageRecordError("Durable Home identity is missing.");
|
|
101
|
+
}
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(readFileSync(join(home, STORAGE_STATE_FILE), "utf8"));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new StorageRecordError("Durable Home identity state is invalid JSON.");
|
|
108
|
+
}
|
|
109
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
110
|
+
throw new StorageRecordError("Durable Home identity state is invalid.");
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return validateHomeIdentity(parsed.homeIdentity);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new StorageRecordError("Durable Home identity is invalid.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
84
119
|
/**
|
|
85
120
|
* The one-snapshot open for a Home whose manifest is already current. A single
|
|
86
121
|
* fingerprint-fenced read of `state.json` supplies:
|
|
@@ -43,7 +43,7 @@ import { mailboxTargetKey } from "../coordination/workMailbox.js";
|
|
|
43
43
|
import { validatePendingTurnCompletion } from "../executor/turnCompletion.js";
|
|
44
44
|
import { compareRuntimeSessionCandidates, projectRuntimeSessionCandidate } from "../runtime/runtimeSessionCandidate.js";
|
|
45
45
|
import { validateReviewFinding } from "../review/reviewFinding.js";
|
|
46
|
-
import { generateHomeIdentity } from "../repository/homeIdentity.js";
|
|
46
|
+
import { generateHomeIdentity, validateHomeIdentity } from "../repository/homeIdentity.js";
|
|
47
47
|
import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
|
|
48
48
|
import { validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
|
|
49
49
|
import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
|
|
@@ -53,6 +53,30 @@ import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, C
|
|
|
53
53
|
import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
|
|
54
54
|
import { migrateSqliteSchema, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
|
|
55
55
|
import { inspectStorageSchema } from "./storageSchema.js";
|
|
56
|
+
/** Read the immutable Home identity without opening a writable Store connection. */
|
|
57
|
+
export function readSqliteHomeIdentity(rootDir, databaseFilename = "yui.db") {
|
|
58
|
+
const database = new Database(join(rootDir, databaseFilename), {
|
|
59
|
+
readonly: true,
|
|
60
|
+
fileMustExist: true
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
const row = database.prepare("SELECT home_identity FROM home_meta WHERE id = 1").get();
|
|
64
|
+
if (typeof row?.home_identity !== "string") {
|
|
65
|
+
throw new StorageRecordError("SQLite Home identity is missing.");
|
|
66
|
+
}
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(row.home_identity);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new StorageRecordError("SQLite Home identity is invalid JSON.");
|
|
73
|
+
}
|
|
74
|
+
return validateHomeIdentity(parsed);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
database.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
56
80
|
const DEFAULT_CONFIG = { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION };
|
|
57
81
|
function numericCompare(left, right) {
|
|
58
82
|
return left.localeCompare(right, undefined, { numeric: true });
|