@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
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
4
|
import { createConnection, createServer } from "node:net";
|
|
4
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
|
|
6
8
|
import { controllerSocketPath } from "./controllerEndpoint.js";
|
|
7
9
|
import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
|
|
8
|
-
import {
|
|
10
|
+
import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
|
|
11
|
+
import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
|
|
12
|
+
import { CommandExecutionError } from "../tmux/commandExecutor.js";
|
|
13
|
+
import { detectRunningRelease, readActiveReleasePointer, readHandoverFence, verifyReleaseIntegrity, writeHandoverFence, writeRuntimeIdentity } from "../release/runtimeRelease.js";
|
|
14
|
+
import { isBuiltinControllerMethod, ControllerCommandObserver, ControllerEventLoopDelay } from "./controllerTelemetry.js";
|
|
15
|
+
import { parseLinuxProcessStartIdentity, removeEphemeralDomainIdentity, readEphemeralDomainIdentity, writeEphemeralDomainIdentity } from "../controller/domainIdentity.js";
|
|
16
|
+
/** Methods that stay available while a handover fence is active. */
|
|
17
|
+
const HANDOVER_ALLOWED_METHODS = new Set([
|
|
18
|
+
"controller.identity",
|
|
19
|
+
"controller.status",
|
|
20
|
+
"controller.handover-state",
|
|
21
|
+
"controller.commit-handover",
|
|
22
|
+
"controller.rollback-handover"
|
|
23
|
+
]);
|
|
9
24
|
export async function startControllerServer(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
|
|
10
25
|
const releaseLifecycleLock = await acquireHomeLifecycleLock(home);
|
|
11
26
|
try {
|
|
@@ -26,8 +41,32 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
26
41
|
await chmod(socketDirectory, 0o700);
|
|
27
42
|
const token = randomBytes(32).toString("hex");
|
|
28
43
|
let closeRunning = async () => undefined;
|
|
44
|
+
const commandObserver = new ControllerCommandObserver();
|
|
45
|
+
const eventLoopDelay = new ControllerEventLoopDelay();
|
|
46
|
+
const telemetry = Object.freeze({
|
|
47
|
+
commandObserver,
|
|
48
|
+
eventLoopDelay
|
|
49
|
+
});
|
|
50
|
+
const handover = { fence: null };
|
|
51
|
+
const release = options.release === undefined
|
|
52
|
+
? detectRunningRelease(fileURLToPath(import.meta.url))
|
|
53
|
+
: options.release;
|
|
54
|
+
// Issue 01: the Home decides the backend. The receipt reports the truthful
|
|
55
|
+
// Home-decided backend (SQLite for a layout-7 Home with yui.db, file store
|
|
56
|
+
// for older layouts) instead of deriving it from YUI_STORE_BACKEND, which is
|
|
57
|
+
// reserved for tests and explicit recovery commands.
|
|
58
|
+
const storageBackend = options.storageBackend
|
|
59
|
+
?? resolveTaskStoreBackendForHome(home, process.env);
|
|
60
|
+
const workerEnabled = options.workerEnabled
|
|
61
|
+
?? resolveStoreWorkerEnabledForHome(home, process.env);
|
|
62
|
+
// The primary identity receipt is captured at startup and re-published on a
|
|
63
|
+
// handover rollback: the candidate overwrites runtime-identity.json while it
|
|
64
|
+
// runs, and after the rollback kills the candidate the file must not keep
|
|
65
|
+
// pointing at the dead candidate's release/PID. The old Controller is still
|
|
66
|
+
// the live primary, so its startup identity is still truthful.
|
|
67
|
+
let primaryIdentity = null;
|
|
29
68
|
const netServer = createServer((socket) => {
|
|
30
|
-
receiveRequest(socket, token, dispatcher, () => closeRunning());
|
|
69
|
+
receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, () => primaryIdentity);
|
|
31
70
|
});
|
|
32
71
|
try {
|
|
33
72
|
await listen(netServer, socketPath);
|
|
@@ -47,6 +86,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
47
86
|
throw retryError;
|
|
48
87
|
}
|
|
49
88
|
}
|
|
89
|
+
eventLoopDelay.start();
|
|
50
90
|
try {
|
|
51
91
|
await chmod(socketPath, 0o600);
|
|
52
92
|
const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
|
|
@@ -60,6 +100,19 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
60
100
|
writeEphemeralDomainIdentity(home, options.domainIdentity);
|
|
61
101
|
}
|
|
62
102
|
await writeDiscoveryAtomically(discoveryPath, discovery);
|
|
103
|
+
// Issue 02: every Controller start publishes its provenance. The receipt
|
|
104
|
+
// is the stable, file-based identity that survives a Controller stop and
|
|
105
|
+
// backs the read-only `controller identity --json` command.
|
|
106
|
+
primaryIdentity = buildRuntimeIdentityReceipt({
|
|
107
|
+
home,
|
|
108
|
+
release,
|
|
109
|
+
storageBackend,
|
|
110
|
+
workerEnabled,
|
|
111
|
+
processStartIdentity,
|
|
112
|
+
mode: "primary",
|
|
113
|
+
dualOwner: false
|
|
114
|
+
});
|
|
115
|
+
writeRuntimeIdentity(home, primaryIdentity);
|
|
63
116
|
let resolveClosed = () => undefined;
|
|
64
117
|
const closed = new Promise((resolve) => {
|
|
65
118
|
resolveClosed = resolve;
|
|
@@ -69,6 +122,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
69
122
|
if (closePromise !== undefined)
|
|
70
123
|
return closePromise;
|
|
71
124
|
closePromise = (async () => {
|
|
125
|
+
eventLoopDelay.stop();
|
|
72
126
|
await beforeDiscoveryRemoval?.();
|
|
73
127
|
await closeNetServer(netServer);
|
|
74
128
|
await removeOwnedDiscovery(discoveryPath, token);
|
|
@@ -88,9 +142,16 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
88
142
|
})();
|
|
89
143
|
return closePromise;
|
|
90
144
|
};
|
|
91
|
-
return Object.freeze({
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
discovery,
|
|
147
|
+
closed,
|
|
148
|
+
close: closeRunning,
|
|
149
|
+
commandObserver,
|
|
150
|
+
eventLoopDelay
|
|
151
|
+
});
|
|
92
152
|
}
|
|
93
153
|
catch (error) {
|
|
154
|
+
eventLoopDelay.stop();
|
|
94
155
|
await closeNetServer(netServer);
|
|
95
156
|
throw error;
|
|
96
157
|
}
|
|
@@ -110,7 +171,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
|
|
|
110
171
|
throw error;
|
|
111
172
|
}
|
|
112
173
|
}
|
|
113
|
-
function receiveRequest(socket, token, dispatcher, stop) {
|
|
174
|
+
function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
|
|
114
175
|
let buffer = Buffer.alloc(0);
|
|
115
176
|
let complete = false;
|
|
116
177
|
const fail = (code, message, id = "invalid") => {
|
|
@@ -139,7 +200,7 @@ function receiveRequest(socket, token, dispatcher, stop) {
|
|
|
139
200
|
return;
|
|
140
201
|
}
|
|
141
202
|
complete = true;
|
|
142
|
-
void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop);
|
|
203
|
+
void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity);
|
|
143
204
|
});
|
|
144
205
|
socket.on("end", () => {
|
|
145
206
|
if (!complete)
|
|
@@ -147,7 +208,7 @@ function receiveRequest(socket, token, dispatcher, stop) {
|
|
|
147
208
|
});
|
|
148
209
|
socket.on("error", () => undefined);
|
|
149
210
|
}
|
|
150
|
-
async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
211
|
+
async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
|
|
151
212
|
let request;
|
|
152
213
|
try {
|
|
153
214
|
request = parseControllerRequest(line);
|
|
@@ -163,40 +224,108 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
|
163
224
|
sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
|
|
164
225
|
return;
|
|
165
226
|
}
|
|
227
|
+
// Every authenticated request is observed once at the routing layer. The
|
|
228
|
+
// observation completes when the response is handed to the socket, so a
|
|
229
|
+
// later status snapshot sees prior built-in completions and the dispatcher
|
|
230
|
+
// service-time metric stays a distinct observation.
|
|
231
|
+
const observation = telemetry.commandObserver.start();
|
|
232
|
+
const send = (response, onFlushed) => {
|
|
233
|
+
observation.complete(isBuiltinControllerMethod(request.method) ? "builtin" : "dispatched", response.ok ? "success" : "failure");
|
|
234
|
+
sendResponse(socket, response, onFlushed);
|
|
235
|
+
};
|
|
236
|
+
// Issue 02: a fenced Controller is mid-handover. It stops accepting new
|
|
237
|
+
// mutations and only answers the handover control methods plus read-only
|
|
238
|
+
// identity/status queries until the candidate promotes or the handover
|
|
239
|
+
// rolls back.
|
|
240
|
+
if (handover.fence !== null && !HANDOVER_ALLOWED_METHODS.has(request.method)) {
|
|
241
|
+
send(controllerFailure(request.id, "CONTROLLER_HANDOVER_FENCED", "Controller is fenced for a release handover; retry after the handover completes."));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
166
244
|
if (request.method === "controller.identity") {
|
|
167
245
|
if (!isEmptyParams(request.params)) {
|
|
168
|
-
|
|
246
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
169
247
|
return;
|
|
170
248
|
}
|
|
171
249
|
// This is an authenticated, private lifecycle response. Unlike the public
|
|
172
250
|
// resource inventory, it retains the exact executable/argv that the socket
|
|
173
251
|
// owner was launched with so an update can restore that same Controller.
|
|
174
|
-
|
|
252
|
+
// Issue 02 extends it with the release provenance the handover and the
|
|
253
|
+
// exact control-plane gate consume.
|
|
254
|
+
// The pid + process start identity let `release activate` fence this exact
|
|
255
|
+
// owner as the old Controller during a handover; without them the
|
|
256
|
+
// activator cannot distinguish a live owner from an empty Home and would
|
|
257
|
+
// bypass the handover sequence.
|
|
258
|
+
const release = detectRunningRelease(fileURLToPath(import.meta.url));
|
|
259
|
+
// Verify the running release's integrity so a drifted Controller can be
|
|
260
|
+
// refused by a handover activator. The Controller stays alive; the flag
|
|
261
|
+
// lets the activator fail closed without killing the serving process.
|
|
262
|
+
let releaseDrifted = false;
|
|
263
|
+
if (release !== null) {
|
|
264
|
+
try {
|
|
265
|
+
verifyReleaseIntegrity(release.releaseDir);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
releaseDrifted = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
let processStartIdentity;
|
|
272
|
+
try {
|
|
273
|
+
processStartIdentity = readLinuxProcessStartIdentitySync(process.pid);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Non-Linux or an unreadable /proc: the owner cannot be fenced, so the
|
|
277
|
+
// handover path treats it as absent. The rest of the identity stays
|
|
278
|
+
// available for read-only consumers.
|
|
279
|
+
}
|
|
280
|
+
send({
|
|
175
281
|
id: request.id,
|
|
176
282
|
ok: true,
|
|
177
283
|
result: {
|
|
178
284
|
executablePath: process.execPath,
|
|
179
285
|
args: process.argv.slice(1),
|
|
180
|
-
version: YUI_VERSION
|
|
286
|
+
version: YUI_VERSION,
|
|
287
|
+
buildId: release?.manifest.buildId ?? "dev",
|
|
288
|
+
packageDigest: release?.manifest.packageDigest ?? null,
|
|
289
|
+
sourceCommit: release?.manifest.sourceCommit ?? null,
|
|
290
|
+
cliRealpath: controllerCliRealpath(),
|
|
291
|
+
controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
|
|
292
|
+
controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
293
|
+
storageBackend: resolveTaskStoreBackendForHome(home, process.env),
|
|
294
|
+
workerEnabled: resolveStoreWorkerEnabledForHome(home, process.env),
|
|
295
|
+
mode: "primary",
|
|
296
|
+
dualOwner: false,
|
|
297
|
+
...(releaseDrifted ? { releaseDrifted: true } : {}),
|
|
298
|
+
// A corrupted pointer file must not kill the serving Controller with
|
|
299
|
+
// an unhandled rejection; surface a degraded identity so the caller
|
|
300
|
+
// can diagnose, and let `release activate` fail on its own read.
|
|
301
|
+
activeRelease: readActiveReleasePointerSafe(home),
|
|
302
|
+
pid: process.pid,
|
|
303
|
+
...(processStartIdentity === undefined
|
|
304
|
+
? {}
|
|
305
|
+
: { processStartIdentity })
|
|
181
306
|
}
|
|
182
307
|
});
|
|
183
308
|
return;
|
|
184
309
|
}
|
|
185
310
|
if (request.method === "controller.status") {
|
|
186
311
|
if (!isEmptyParams(request.params)) {
|
|
187
|
-
|
|
312
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
188
313
|
return;
|
|
189
314
|
}
|
|
190
|
-
|
|
315
|
+
send({
|
|
191
316
|
id: request.id,
|
|
192
317
|
ok: true,
|
|
193
318
|
result: {
|
|
194
319
|
pid: process.pid,
|
|
195
320
|
running: true,
|
|
321
|
+
// Issue 11: process identity facts for status/audit. Read-only.
|
|
322
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
323
|
+
rssBytes: process.memoryUsage().rss,
|
|
196
324
|
protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
197
325
|
version: YUI_VERSION,
|
|
198
326
|
storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
|
|
199
|
-
aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion
|
|
327
|
+
aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
|
|
328
|
+
...(status === undefined ? {} : { runtime: status(telemetry) })
|
|
200
329
|
}
|
|
201
330
|
});
|
|
202
331
|
return;
|
|
@@ -204,14 +333,14 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
|
204
333
|
if (request.method === "controller.stop") {
|
|
205
334
|
const expectedPid = expectedControllerStopPid(request.params);
|
|
206
335
|
if (expectedPid === null) {
|
|
207
|
-
|
|
336
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
208
337
|
return;
|
|
209
338
|
}
|
|
210
339
|
if (expectedPid !== undefined && expectedPid !== process.pid) {
|
|
211
|
-
|
|
340
|
+
send(controllerFailure(request.id, "CONTROLLER_OWNERSHIP_MISMATCH", `Controller PID ${process.pid} does not match the expected PID ${expectedPid}.`));
|
|
212
341
|
return;
|
|
213
342
|
}
|
|
214
|
-
|
|
343
|
+
send({
|
|
215
344
|
id: request.id,
|
|
216
345
|
ok: true,
|
|
217
346
|
result: {
|
|
@@ -221,21 +350,275 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
|
221
350
|
}, () => void stop());
|
|
222
351
|
return;
|
|
223
352
|
}
|
|
353
|
+
if (request.method === "controller.begin-handover") {
|
|
354
|
+
const params = parseBeginHandoverParams(request.params);
|
|
355
|
+
if (params === null) {
|
|
356
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (handover.fence !== null) {
|
|
360
|
+
send(controllerFailure(request.id, "CONTROLLER_HANDOVER_ACTIVE", "Controller is already fenced for a release handover."));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const fencedAt = new Date().toISOString();
|
|
364
|
+
const owner = currentHandoverOwner();
|
|
365
|
+
const fence = Object.freeze({
|
|
366
|
+
schemaVersion: 1,
|
|
367
|
+
handoverId: params.handoverId,
|
|
368
|
+
phase: "fenced",
|
|
369
|
+
old: owner,
|
|
370
|
+
candidate: null,
|
|
371
|
+
fromReleaseId: params.fromReleaseId,
|
|
372
|
+
toReleaseId: params.toReleaseId,
|
|
373
|
+
createdAt: fencedAt,
|
|
374
|
+
updatedAt: fencedAt
|
|
375
|
+
});
|
|
376
|
+
// Write the durable fence BEFORE setting the in-memory fence. If the
|
|
377
|
+
// durable write fails, the Controller must not be left permanently fenced
|
|
378
|
+
// in memory (retry would get CONTROLLER_HANDOVER_ACTIVE with no durable
|
|
379
|
+
// state to roll back).
|
|
380
|
+
try {
|
|
381
|
+
writeHandoverFence(home, fence);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
const detail = error instanceof Error ? safeErrorMessage(error.message) : undefined;
|
|
385
|
+
send(controllerFailure(request.id, "INTERNAL_ERROR", `Failed to write the handover fence${detail === undefined ? "" : `: ${detail}`}`));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
handover.fence = { handoverId: params.handoverId, fencedAt };
|
|
389
|
+
send({
|
|
390
|
+
id: request.id,
|
|
391
|
+
ok: true,
|
|
392
|
+
result: handoverFenceResult(fence)
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (request.method === "controller.commit-handover") {
|
|
397
|
+
const handoverId = parseHandoverIdParam(request.params);
|
|
398
|
+
if (handoverId === null) {
|
|
399
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (handover.fence === null || handover.fence.handoverId !== handoverId) {
|
|
403
|
+
send(controllerFailure(request.id, "CONTROLLER_HANDOVER_MISMATCH", "Controller handover fence does not match the requested handover."));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const committedAt = new Date().toISOString();
|
|
407
|
+
const existing = readHandoverFenceOrNull(home);
|
|
408
|
+
if (existing !== null) {
|
|
409
|
+
try {
|
|
410
|
+
writeHandoverFence(home, Object.freeze({
|
|
411
|
+
...existing,
|
|
412
|
+
phase: "committed",
|
|
413
|
+
updatedAt: committedAt
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
const detail = error instanceof Error ? safeErrorMessage(error.message) : undefined;
|
|
418
|
+
send(controllerFailure(request.id, "INTERNAL_ERROR", `Failed to commit the handover fence${detail === undefined ? "" : `: ${detail}`}`));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Keep the in-memory fence set until this process exits. The pointer has
|
|
423
|
+
// switched and the Controller is draining; clearing the fence here would
|
|
424
|
+
// reopen the mutation window on the old release before stop() completes.
|
|
425
|
+
// The in-memory state dies with the process.
|
|
426
|
+
send({
|
|
427
|
+
id: request.id,
|
|
428
|
+
ok: true,
|
|
429
|
+
result: { committed: true, pid: process.pid }
|
|
430
|
+
}, () => void stop());
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (request.method === "controller.rollback-handover") {
|
|
434
|
+
const handoverId = parseHandoverIdParam(request.params);
|
|
435
|
+
if (handoverId === null) {
|
|
436
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (handover.fence === null || handover.fence.handoverId !== handoverId) {
|
|
440
|
+
send(controllerFailure(request.id, "CONTROLLER_HANDOVER_MISMATCH", "Controller handover fence does not match the requested handover."));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const rolledBackAt = new Date().toISOString();
|
|
444
|
+
const existing = readHandoverFenceOrNull(home);
|
|
445
|
+
if (existing !== null) {
|
|
446
|
+
writeHandoverFence(home, Object.freeze({
|
|
447
|
+
...existing,
|
|
448
|
+
phase: "rolled-back",
|
|
449
|
+
updatedAt: rolledBackAt
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
handover.fence = null;
|
|
453
|
+
// The candidate overwrote runtime-identity.json while it ran; now that the
|
|
454
|
+
// handover is rolled back and the candidate is dead, restore this
|
|
455
|
+
// Controller's primary identity so the file does not keep pointing at the
|
|
456
|
+
// dead candidate's release/PID.
|
|
457
|
+
const primaryIdentity = getPrimaryIdentity();
|
|
458
|
+
if (primaryIdentity !== null) {
|
|
459
|
+
writeRuntimeIdentity(home, primaryIdentity);
|
|
460
|
+
}
|
|
461
|
+
send({
|
|
462
|
+
id: request.id,
|
|
463
|
+
ok: true,
|
|
464
|
+
result: { resumed: true, pid: process.pid }
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (request.method === "controller.handover-state") {
|
|
469
|
+
if (!isEmptyParams(request.params)) {
|
|
470
|
+
send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const fence = readHandoverFenceOrNull(home);
|
|
474
|
+
send({
|
|
475
|
+
id: request.id,
|
|
476
|
+
ok: true,
|
|
477
|
+
result: {
|
|
478
|
+
fenced: handover.fence !== null,
|
|
479
|
+
...(handover.fence === null ? {} : { handoverId: handover.fence.handoverId }),
|
|
480
|
+
...(fence === null ? {} : { phase: fence.phase })
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
224
485
|
if (dispatcher === undefined) {
|
|
225
|
-
|
|
486
|
+
send(controllerFailure(request.id, "METHOD_NOT_FOUND", "Controller method was not found."));
|
|
226
487
|
return;
|
|
227
488
|
}
|
|
228
489
|
try {
|
|
229
490
|
const result = await dispatcher(request.method, request.params);
|
|
230
|
-
|
|
491
|
+
send({ id: request.id, ok: true, result });
|
|
231
492
|
}
|
|
232
493
|
catch (error) {
|
|
233
494
|
const safeError = safeDispatcherError(error);
|
|
234
|
-
|
|
495
|
+
send(safeError === undefined
|
|
235
496
|
? controllerFailure(request.id, "INTERNAL_ERROR", "Controller request failed.")
|
|
236
497
|
: controllerFailure(request.id, safeError.code, safeError.message));
|
|
237
498
|
}
|
|
238
499
|
}
|
|
500
|
+
function parseBeginHandoverParams(value) {
|
|
501
|
+
if (typeof value !== "object"
|
|
502
|
+
|| value === null
|
|
503
|
+
|| Array.isArray(value)) {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const record = value;
|
|
507
|
+
const handoverId = record.handoverId;
|
|
508
|
+
const toReleaseId = record.toReleaseId;
|
|
509
|
+
if (typeof handoverId !== "string"
|
|
510
|
+
|| handoverId.length === 0
|
|
511
|
+
|| handoverId.length > 128
|
|
512
|
+
|| typeof toReleaseId !== "string"
|
|
513
|
+
|| toReleaseId.length === 0
|
|
514
|
+
|| toReleaseId.length > 256) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const fromReleaseId = record.fromReleaseId;
|
|
518
|
+
if (fromReleaseId !== undefined
|
|
519
|
+
&& (typeof fromReleaseId !== "string" || fromReleaseId.length > 256)) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
handoverId,
|
|
524
|
+
fromReleaseId: typeof fromReleaseId === "string" ? fromReleaseId : null,
|
|
525
|
+
toReleaseId
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function parseHandoverIdParam(value) {
|
|
529
|
+
if (typeof value !== "object"
|
|
530
|
+
|| value === null
|
|
531
|
+
|| Array.isArray(value)) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
const handoverId = value.handoverId;
|
|
535
|
+
return typeof handoverId === "string" && handoverId.length > 0 && handoverId.length <= 128
|
|
536
|
+
? handoverId
|
|
537
|
+
: null;
|
|
538
|
+
}
|
|
539
|
+
function currentHandoverOwner() {
|
|
540
|
+
const release = detectRunningRelease(fileURLToPath(import.meta.url));
|
|
541
|
+
return Object.freeze({
|
|
542
|
+
pid: process.pid,
|
|
543
|
+
processStartIdentity: readLinuxProcessStartIdentitySync(process.pid),
|
|
544
|
+
buildId: release?.manifest.buildId ?? "dev",
|
|
545
|
+
version: YUI_VERSION
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
function handoverFenceResult(fence) {
|
|
549
|
+
return {
|
|
550
|
+
handoverId: fence.handoverId,
|
|
551
|
+
phase: fence.phase,
|
|
552
|
+
old: {
|
|
553
|
+
pid: fence.old.pid,
|
|
554
|
+
processStartIdentity: fence.old.processStartIdentity,
|
|
555
|
+
buildId: fence.old.buildId,
|
|
556
|
+
version: fence.old.version
|
|
557
|
+
},
|
|
558
|
+
toReleaseId: fence.toReleaseId
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function readHandoverFenceOrNull(home) {
|
|
562
|
+
try {
|
|
563
|
+
return readHandoverFence(home);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** Read the active release pointer without letting a corrupted file kill the Controller. */
|
|
570
|
+
function readActiveReleasePointerSafe(home) {
|
|
571
|
+
try {
|
|
572
|
+
return readActiveReleasePointer(home);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function readLinuxProcessStartIdentitySync(pid) {
|
|
579
|
+
// The async /proc reader is used at startup; handover RPCs need a sync
|
|
580
|
+
// identity to keep the request handler simple. Both parse the same field.
|
|
581
|
+
const processStartIdentity = parseLinuxProcessStartIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
582
|
+
if (processStartIdentity === undefined) {
|
|
583
|
+
throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
|
|
584
|
+
}
|
|
585
|
+
return processStartIdentity;
|
|
586
|
+
}
|
|
587
|
+
function controllerCliRealpath() {
|
|
588
|
+
return realpathSync(fileURLToPath(new URL("../cli.js", import.meta.url)));
|
|
589
|
+
}
|
|
590
|
+
export function buildRuntimeIdentityReceipt(input) {
|
|
591
|
+
const activeRelease = (() => {
|
|
592
|
+
try {
|
|
593
|
+
return readActiveReleasePointer(input.home);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
})();
|
|
599
|
+
return Object.freeze({
|
|
600
|
+
schemaVersion: 1,
|
|
601
|
+
version: YUI_VERSION,
|
|
602
|
+
executablePath: process.execPath,
|
|
603
|
+
args: process.argv.slice(1),
|
|
604
|
+
buildId: input.release?.manifest.buildId ?? "dev",
|
|
605
|
+
packageDigest: input.release?.manifest.packageDigest ?? null,
|
|
606
|
+
sourceCommit: input.release?.manifest.sourceCommit ?? null,
|
|
607
|
+
cliRealpath: controllerCliRealpath(),
|
|
608
|
+
controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
|
|
609
|
+
controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
610
|
+
storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
|
|
611
|
+
aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
|
|
612
|
+
storageBackend: input.storageBackend,
|
|
613
|
+
workerEnabled: input.workerEnabled,
|
|
614
|
+
pid: process.pid,
|
|
615
|
+
processStartIdentity: input.processStartIdentity,
|
|
616
|
+
mode: input.mode,
|
|
617
|
+
dualOwner: input.dualOwner,
|
|
618
|
+
activeRelease,
|
|
619
|
+
writtenAt: new Date().toISOString()
|
|
620
|
+
});
|
|
621
|
+
}
|
|
239
622
|
/**
|
|
240
623
|
* `controller.stop` is public with `{}` params, but update's replacement
|
|
241
624
|
* mismatch path may provide one exact PID fence. `null` means malformed;
|
|
@@ -337,6 +720,18 @@ function safeDispatcherError(error) {
|
|
|
337
720
|
return { code: "SERVICE_ERROR", message };
|
|
338
721
|
case "CoreJobError":
|
|
339
722
|
return { code: "JOB_ERROR", message };
|
|
723
|
+
case "CommandExecutionError": {
|
|
724
|
+
// Transient tmux/process launch failures (e.g. under CI load) carry
|
|
725
|
+
// the real stderr; surface it instead of the generic fallback so the
|
|
726
|
+
// caller can distinguish a retryable infrastructure failure from a
|
|
727
|
+
// permanent contract violation.
|
|
728
|
+
if (error instanceof CommandExecutionError && error.stderr.length > 0) {
|
|
729
|
+
const detail = safeErrorMessage(error.stderr);
|
|
730
|
+
if (detail !== undefined)
|
|
731
|
+
return { code: "SERVICE_ERROR", message: detail };
|
|
732
|
+
}
|
|
733
|
+
return { code: "SERVICE_ERROR", message };
|
|
734
|
+
}
|
|
340
735
|
default:
|
|
341
736
|
return undefined;
|
|
342
737
|
}
|
|
@@ -350,6 +745,7 @@ function safeApplicationErrorCode(code) {
|
|
|
350
745
|
case "INVALID_PARAMS":
|
|
351
746
|
case "METHOD_NOT_FOUND":
|
|
352
747
|
case "NOT_FOUND":
|
|
748
|
+
case "UNAUTHORIZED":
|
|
353
749
|
return code;
|
|
354
750
|
default:
|
|
355
751
|
return undefined;
|
|
@@ -453,15 +849,8 @@ function isProcessAlive(pid) {
|
|
|
453
849
|
}
|
|
454
850
|
}
|
|
455
851
|
async function readLinuxProcessStartIdentity(pid) {
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
if (closingParenthesis < 0) {
|
|
459
|
-
throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
|
|
460
|
-
}
|
|
461
|
-
const fieldsAfterCommand = stat.slice(closingParenthesis + 1).trim().split(/\s+/u);
|
|
462
|
-
const processStartIdentity = fieldsAfterCommand[19];
|
|
463
|
-
if (processStartIdentity === undefined
|
|
464
|
-
|| !/^[0-9]{1,32}$/u.test(processStartIdentity)) {
|
|
852
|
+
const processStartIdentity = parseLinuxProcessStartIdentity(await readFile(`/proc/${pid}/stat`, "utf8"));
|
|
853
|
+
if (processStartIdentity === undefined) {
|
|
465
854
|
throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
|
|
466
855
|
}
|
|
467
856
|
return processStartIdentity;
|