@zq-silk/yui 0.2.0 → 0.4.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/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { completeProcessing, createWorkMailbox, enqueueSignal, mailboxEntityRefKey } from "./workMailbox.js";
|
|
2
|
+
/** Atomically useful when called inside the caller's TaskStore transaction. */
|
|
3
|
+
export function enqueueWork(store, target, reason, occurredAt, refs = []) {
|
|
4
|
+
const mailbox = store.getWorkMailbox(target) ?? createWorkMailbox(target);
|
|
5
|
+
const queued = enqueueSignal(mailbox, {
|
|
6
|
+
reason,
|
|
7
|
+
refs,
|
|
8
|
+
occurredAt: timestamp(occurredAt)
|
|
9
|
+
});
|
|
10
|
+
store.saveWorkMailbox(queued);
|
|
11
|
+
return queued;
|
|
12
|
+
}
|
|
13
|
+
/** Completes only the batch owned by the matching durable execution. */
|
|
14
|
+
export function completeWorkExecution(store, target, executionRef) {
|
|
15
|
+
const mailbox = store.getWorkMailbox(target);
|
|
16
|
+
const processing = mailbox?.processing;
|
|
17
|
+
if (mailbox === null
|
|
18
|
+
|| processing === undefined
|
|
19
|
+
|| processing === null
|
|
20
|
+
|| processing.executionRef === undefined
|
|
21
|
+
|| mailboxEntityRefKey(processing.executionRef) !== mailboxEntityRefKey(executionRef)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
store.saveWorkMailbox(completeProcessing(mailbox, processing.batchId));
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Completes a mailbox execution or fails the surrounding transaction.
|
|
29
|
+
*
|
|
30
|
+
* A terminal Run and its processing mailbox batch are one consistency
|
|
31
|
+
* boundary. Silently accepting a missing or mismatched batch would leave
|
|
32
|
+
* durable work stuck in `processing` after the Run has already ended.
|
|
33
|
+
*/
|
|
34
|
+
export function requireCompleteWorkExecution(store, target, executionRef) {
|
|
35
|
+
const mailbox = store.getWorkMailbox(target);
|
|
36
|
+
const processing = mailbox?.processing;
|
|
37
|
+
if (mailbox === null || processing === null || processing === undefined) {
|
|
38
|
+
throw new Error(`Work mailbox has no processing execution for ${targetLabel(target)}: `
|
|
39
|
+
+ `${executionRef.type}/${executionRef.id}.`);
|
|
40
|
+
}
|
|
41
|
+
if (processing.executionRef === undefined) {
|
|
42
|
+
throw new Error(`Work mailbox processing batch is not bound for ${targetLabel(target)}: `
|
|
43
|
+
+ `${executionRef.type}/${executionRef.id}.`);
|
|
44
|
+
}
|
|
45
|
+
if (completeWorkExecution(store, target, executionRef))
|
|
46
|
+
return;
|
|
47
|
+
throw new Error(`Work mailbox execution mismatch for ${targetLabel(target)}: `
|
|
48
|
+
+ `${executionRef.type}/${executionRef.id}.`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Settles the exact Run delivery boundary, including the short window before
|
|
52
|
+
* the scheduler has claimed its single pending dispatch. A merged pending
|
|
53
|
+
* batch is never discarded because its signal-to-ref mapping is no longer
|
|
54
|
+
* recoverable.
|
|
55
|
+
*/
|
|
56
|
+
export function settleExactWorkExecution(store, target, executionRef) {
|
|
57
|
+
if (completeWorkExecution(store, target, executionRef))
|
|
58
|
+
return "processing";
|
|
59
|
+
const mailbox = store.getWorkMailbox(target);
|
|
60
|
+
if (mailbox === null)
|
|
61
|
+
return "absent";
|
|
62
|
+
const pending = mailbox.pending;
|
|
63
|
+
if (pending === null)
|
|
64
|
+
return "absent";
|
|
65
|
+
const matches = pending.refs.some((ref) => mailboxEntityRefKey(ref) === mailboxEntityRefKey(executionRef));
|
|
66
|
+
if (!matches)
|
|
67
|
+
return "absent";
|
|
68
|
+
if (pending.requestCount !== 1) {
|
|
69
|
+
throw new Error(`Cannot settle a merged pending mailbox batch for ${targetLabel(target)}: `
|
|
70
|
+
+ `${executionRef.type}/${executionRef.id}.`);
|
|
71
|
+
}
|
|
72
|
+
store.saveWorkMailbox({ ...mailbox, pending: null });
|
|
73
|
+
return "pending";
|
|
74
|
+
}
|
|
75
|
+
function timestamp(value) {
|
|
76
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
77
|
+
}
|
|
78
|
+
function targetLabel(target) {
|
|
79
|
+
switch (target.kind) {
|
|
80
|
+
case "operator": return "operator";
|
|
81
|
+
case "task": return `task/${target.taskId}`;
|
|
82
|
+
case "role": return `role/${target.taskId}/${target.roleName}`;
|
|
83
|
+
case "role-runtime": return `role-runtime/${target.taskId}/${target.roleName}`;
|
|
84
|
+
case "global-role-runtime": return `global-role-runtime/${target.roleName}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, readFile } from "node:fs/promises";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { CONTROLLER_DISCOVERY_PATH,
|
|
5
|
+
import { CONTROLLER_DISCOVERY_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
|
|
6
|
+
import { isControllerSocketPathForHome } from "./controllerEndpoint.js";
|
|
6
7
|
export class ControllerClientError extends Error {
|
|
7
8
|
code;
|
|
8
9
|
constructor(code, message) {
|
|
@@ -13,26 +14,39 @@ export class ControllerClientError extends Error {
|
|
|
13
14
|
}
|
|
14
15
|
export async function readControllerDiscovery(home) {
|
|
15
16
|
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
16
|
-
const expectedSocketPath = join(home, CONTROLLER_SOCKET_PATH);
|
|
17
17
|
try {
|
|
18
18
|
const metadata = await lstat(discoveryPath);
|
|
19
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
19
20
|
if (!metadata.isFile()
|
|
20
21
|
|| (metadata.mode & 0o077) !== 0
|
|
21
|
-
|| metadata.size > 4_096
|
|
22
|
+
|| metadata.size > 4_096
|
|
23
|
+
|| (uid !== undefined && metadata.uid !== uid)) {
|
|
22
24
|
throw invalidDiscovery();
|
|
23
25
|
}
|
|
24
26
|
const value = JSON.parse(await readFile(discoveryPath, "utf8"));
|
|
25
|
-
|
|
27
|
+
const socketPath = discoverySocketPath(home, value);
|
|
28
|
+
return parseControllerDiscovery(value, socketPath);
|
|
26
29
|
}
|
|
27
30
|
catch (error) {
|
|
28
31
|
if (error instanceof ControllerClientError)
|
|
29
32
|
throw error;
|
|
30
33
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
31
|
-
throw new ControllerClientError("
|
|
34
|
+
throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
|
|
32
35
|
}
|
|
33
36
|
throw invalidDiscovery();
|
|
34
37
|
}
|
|
35
38
|
}
|
|
39
|
+
function discoverySocketPath(home, value) {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
41
|
+
throw invalidDiscovery();
|
|
42
|
+
}
|
|
43
|
+
const socketPath = Reflect.get(value, "socketPath");
|
|
44
|
+
if (typeof socketPath !== "string"
|
|
45
|
+
|| !isControllerSocketPathForHome(home, socketPath)) {
|
|
46
|
+
throw invalidDiscovery();
|
|
47
|
+
}
|
|
48
|
+
return socketPath;
|
|
49
|
+
}
|
|
36
50
|
export async function callController(home, method, params = {}, options = {}) {
|
|
37
51
|
const discovery = await readControllerDiscovery(home);
|
|
38
52
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
const LINUX_UNIX_SOCKET_PATH_BUDGET = 100;
|
|
5
|
+
export function controllerSocketPath(home) {
|
|
6
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
7
|
+
const socketName = `${controllerSocketIdentity(home)}.sock`;
|
|
8
|
+
const isolatedPath = join(tmpdir(), `yui-${uid}`, socketName);
|
|
9
|
+
if (process.platform !== "linux"
|
|
10
|
+
|| Buffer.byteLength(isolatedPath) < LINUX_UNIX_SOCKET_PATH_BUDGET) {
|
|
11
|
+
return isolatedPath;
|
|
12
|
+
}
|
|
13
|
+
// Linux limits Unix-socket paths to a small fixed budget. Managed Task
|
|
14
|
+
// runtimes intentionally use isolated TMPDIR roots, which may themselves
|
|
15
|
+
// be nested deeply enough to exhaust that budget. Keep the Home/uid/name
|
|
16
|
+
// fence while using the compact system temporary root only for this
|
|
17
|
+
// exceptional path; clients follow the Home-owned discovery record.
|
|
18
|
+
return join("/tmp", `yui-${uid}`, socketName);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A protected Home discovery record owns the Controller endpoint. Clients may
|
|
22
|
+
* run with an isolated TMPDIR, so validate the published Home/uid identity
|
|
23
|
+
* without recomputing the Controller process's temporary root.
|
|
24
|
+
*/
|
|
25
|
+
export function isControllerSocketPathForHome(home, candidate) {
|
|
26
|
+
if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
|
|
27
|
+
return false;
|
|
28
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
29
|
+
return basename(dirname(candidate)) === `yui-${uid}`
|
|
30
|
+
&& basename(candidate) === `${controllerSocketIdentity(home)}.sock`;
|
|
31
|
+
}
|
|
32
|
+
function controllerSocketIdentity(home) {
|
|
33
|
+
return createHash("sha256")
|
|
34
|
+
.update(resolve(home))
|
|
35
|
+
.digest("hex")
|
|
36
|
+
.slice(0, 24);
|
|
37
|
+
}
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createConnection, createServer } from "node:net";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import { CONTROLLER_DISCOVERY_PATH,
|
|
6
|
-
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
|
|
6
|
+
import { controllerSocketPath } from "./controllerEndpoint.js";
|
|
7
|
+
import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
|
|
8
|
+
import { removeEphemeralDomainIdentity, readEphemeralDomainIdentity, writeEphemeralDomainIdentity } from "../controller/domainIdentity.js";
|
|
9
|
+
export async function startControllerServer(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
|
|
10
|
+
const releaseLifecycleLock = await acquireHomeLifecycleLock(home);
|
|
11
|
+
try {
|
|
12
|
+
return await startControllerServerLocked(home, dispatcher, beforeDiscoveryRemoval, options);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
await releaseLifecycleLock();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
|
|
7
19
|
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
8
|
-
const socketPath =
|
|
20
|
+
const socketPath = controllerSocketPath(home);
|
|
9
21
|
const runtimeDirectory = dirname(discoveryPath);
|
|
22
|
+
const socketDirectory = dirname(socketPath);
|
|
10
23
|
await mkdir(runtimeDirectory, { recursive: true, mode: 0o700 });
|
|
11
24
|
await chmod(runtimeDirectory, 0o700);
|
|
25
|
+
await mkdir(socketDirectory, { recursive: true, mode: 0o700 });
|
|
26
|
+
await chmod(socketDirectory, 0o700);
|
|
12
27
|
const token = randomBytes(32).toString("hex");
|
|
13
28
|
let closeRunning = async () => undefined;
|
|
14
29
|
const netServer = createServer((socket) => {
|
|
@@ -34,13 +49,17 @@ export async function startControllerServer(home, dispatcher) {
|
|
|
34
49
|
}
|
|
35
50
|
try {
|
|
36
51
|
await chmod(socketPath, 0o600);
|
|
52
|
+
const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
|
|
37
53
|
const discovery = Object.freeze({
|
|
38
54
|
pid: process.pid,
|
|
55
|
+
processStartIdentity,
|
|
39
56
|
socketPath,
|
|
40
57
|
token
|
|
41
58
|
});
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
if (options.domainIdentity !== undefined) {
|
|
60
|
+
writeEphemeralDomainIdentity(home, options.domainIdentity);
|
|
61
|
+
}
|
|
62
|
+
await writeDiscoveryAtomically(discoveryPath, discovery);
|
|
44
63
|
let resolveClosed = () => undefined;
|
|
45
64
|
const closed = new Promise((resolve) => {
|
|
46
65
|
resolveClosed = resolve;
|
|
@@ -50,8 +69,21 @@ export async function startControllerServer(home, dispatcher) {
|
|
|
50
69
|
if (closePromise !== undefined)
|
|
51
70
|
return closePromise;
|
|
52
71
|
closePromise = (async () => {
|
|
72
|
+
await beforeDiscoveryRemoval?.();
|
|
53
73
|
await closeNetServer(netServer);
|
|
54
74
|
await removeOwnedDiscovery(discoveryPath, token);
|
|
75
|
+
if (options.domainIdentity !== undefined) {
|
|
76
|
+
// Keep the exact target fence while detached Role panes survive a
|
|
77
|
+
// Controller stop/restart. The owning test teardown or an expired
|
|
78
|
+
// domain reaper removes the identity only after those resources
|
|
79
|
+
// converge, never by dropping the fence during a normal stop.
|
|
80
|
+
const current = readEphemeralDomainIdentity(home);
|
|
81
|
+
if (current.status === "valid"
|
|
82
|
+
&& current.identity?.token === options.domainIdentity.token
|
|
83
|
+
&& current.identity.tmuxTargets.length === 0) {
|
|
84
|
+
removeEphemeralDomainIdentity(home, options.domainIdentity.token);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
55
87
|
resolveClosed();
|
|
56
88
|
})();
|
|
57
89
|
return closePromise;
|
|
@@ -63,6 +95,21 @@ export async function startControllerServer(home, dispatcher) {
|
|
|
63
95
|
throw error;
|
|
64
96
|
}
|
|
65
97
|
}
|
|
98
|
+
async function writeDiscoveryAtomically(discoveryPath, discovery) {
|
|
99
|
+
const temporaryPath = `${discoveryPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
100
|
+
try {
|
|
101
|
+
await writeFile(temporaryPath, `${JSON.stringify(discovery)}\n`, {
|
|
102
|
+
mode: 0o600,
|
|
103
|
+
flag: "wx"
|
|
104
|
+
});
|
|
105
|
+
await chmod(temporaryPath, 0o600);
|
|
106
|
+
await rename(temporaryPath, discoveryPath);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
66
113
|
function receiveRequest(socket, token, dispatcher, stop) {
|
|
67
114
|
let buffer = Buffer.alloc(0);
|
|
68
115
|
let complete = false;
|
|
@@ -116,6 +163,25 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
|
116
163
|
sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
|
|
117
164
|
return;
|
|
118
165
|
}
|
|
166
|
+
if (request.method === "controller.identity") {
|
|
167
|
+
if (!isEmptyParams(request.params)) {
|
|
168
|
+
sendResponse(socket, controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// This is an authenticated, private lifecycle response. Unlike the public
|
|
172
|
+
// resource inventory, it retains the exact executable/argv that the socket
|
|
173
|
+
// owner was launched with so an update can restore that same Controller.
|
|
174
|
+
sendResponse(socket, {
|
|
175
|
+
id: request.id,
|
|
176
|
+
ok: true,
|
|
177
|
+
result: {
|
|
178
|
+
executablePath: process.execPath,
|
|
179
|
+
args: process.argv.slice(1),
|
|
180
|
+
version: YUI_VERSION
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
119
185
|
if (request.method === "controller.status") {
|
|
120
186
|
if (!isEmptyParams(request.params)) {
|
|
121
187
|
sendResponse(socket, controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
@@ -124,16 +190,35 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
|
124
190
|
sendResponse(socket, {
|
|
125
191
|
id: request.id,
|
|
126
192
|
ok: true,
|
|
127
|
-
result: {
|
|
193
|
+
result: {
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
running: true,
|
|
196
|
+
protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
197
|
+
version: YUI_VERSION,
|
|
198
|
+
storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
|
|
199
|
+
aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion
|
|
200
|
+
}
|
|
128
201
|
});
|
|
129
202
|
return;
|
|
130
203
|
}
|
|
131
204
|
if (request.method === "controller.stop") {
|
|
132
|
-
|
|
205
|
+
const expectedPid = expectedControllerStopPid(request.params);
|
|
206
|
+
if (expectedPid === null) {
|
|
133
207
|
sendResponse(socket, controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
|
|
134
208
|
return;
|
|
135
209
|
}
|
|
136
|
-
|
|
210
|
+
if (expectedPid !== undefined && expectedPid !== process.pid) {
|
|
211
|
+
sendResponse(socket, controllerFailure(request.id, "CONTROLLER_OWNERSHIP_MISMATCH", `Controller PID ${process.pid} does not match the expected PID ${expectedPid}.`));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
sendResponse(socket, {
|
|
215
|
+
id: request.id,
|
|
216
|
+
ok: true,
|
|
217
|
+
result: {
|
|
218
|
+
stopped: true,
|
|
219
|
+
...(expectedPid === undefined ? {} : { pid: process.pid })
|
|
220
|
+
}
|
|
221
|
+
}, () => void stop());
|
|
137
222
|
return;
|
|
138
223
|
}
|
|
139
224
|
if (dispatcher === undefined) {
|
|
@@ -151,6 +236,25 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
|
|
|
151
236
|
: controllerFailure(request.id, safeError.code, safeError.message));
|
|
152
237
|
}
|
|
153
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* `controller.stop` is public with `{}` params, but update's replacement
|
|
241
|
+
* mismatch path may provide one exact PID fence. `null` means malformed;
|
|
242
|
+
* `undefined` is the ordinary unfenced public request.
|
|
243
|
+
*/
|
|
244
|
+
function expectedControllerStopPid(value) {
|
|
245
|
+
if (isEmptyParams(value))
|
|
246
|
+
return undefined;
|
|
247
|
+
if (typeof value !== "object"
|
|
248
|
+
|| value === null
|
|
249
|
+
|| Array.isArray(value)
|
|
250
|
+
|| Reflect.ownKeys(value).length !== 1
|
|
251
|
+
|| !("expectedPid" in value)
|
|
252
|
+
|| !Number.isSafeInteger(value.expectedPid)
|
|
253
|
+
|| value.expectedPid <= 0) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return value.expectedPid;
|
|
257
|
+
}
|
|
154
258
|
function sendResponse(socket, response, onFlushed) {
|
|
155
259
|
let line;
|
|
156
260
|
try {
|
|
@@ -258,6 +362,110 @@ function safeErrorMessage(message) {
|
|
|
258
362
|
.slice(0, 512);
|
|
259
363
|
return safe.length === 0 ? undefined : safe;
|
|
260
364
|
}
|
|
365
|
+
async function acquireHomeLifecycleLock(home) {
|
|
366
|
+
const lockPath = homeLifecycleLockPath(home);
|
|
367
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
368
|
+
const owner = Object.freeze({
|
|
369
|
+
pid: process.pid,
|
|
370
|
+
token: randomBytes(16).toString("hex"),
|
|
371
|
+
createdAt: new Date().toISOString()
|
|
372
|
+
});
|
|
373
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
374
|
+
try {
|
|
375
|
+
await writeFile(lockPath, `${JSON.stringify(owner)}\n`, {
|
|
376
|
+
flag: "wx",
|
|
377
|
+
mode: 0o600
|
|
378
|
+
});
|
|
379
|
+
return () => releaseHomeLifecycleLock(lockPath, owner);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
let existing;
|
|
386
|
+
try {
|
|
387
|
+
existing = await readHomeLifecycleLockOwner(lockPath);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
391
|
+
continue;
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
const ownerDescription = `owner PID ${existing.pid}, createdAt ${existing.createdAt}`;
|
|
395
|
+
if (isProcessAlive(existing.pid)) {
|
|
396
|
+
throw new Error(`Another Yui home lifecycle operation is already running (${ownerDescription}): ${lockPath}`);
|
|
397
|
+
}
|
|
398
|
+
throw new Error(`A previous Yui home lifecycle operation left a stale lock `
|
|
399
|
+
+ `(${ownerDescription}): ${lockPath}. `
|
|
400
|
+
+ "If no Controller startup or development reset is running, "
|
|
401
|
+
+ "remove this exact lock file and retry.");
|
|
402
|
+
}
|
|
403
|
+
throw new Error(`Cannot safely acquire the Yui home lifecycle lock because its owner changed repeatedly: `
|
|
404
|
+
+ lockPath);
|
|
405
|
+
}
|
|
406
|
+
function homeLifecycleLockPath(home) {
|
|
407
|
+
const resolvedHome = resolve(home);
|
|
408
|
+
return join(dirname(resolvedHome), `.${basename(resolvedHome)}.controller-lifecycle.lock`);
|
|
409
|
+
}
|
|
410
|
+
async function readHomeLifecycleLockOwner(lockPath) {
|
|
411
|
+
try {
|
|
412
|
+
const value = JSON.parse(await readFile(lockPath, "utf8"));
|
|
413
|
+
if (typeof value !== "object" || value === null
|
|
414
|
+
|| !("pid" in value) || !Number.isSafeInteger(value.pid) || value.pid <= 0
|
|
415
|
+
|| !("token" in value) || typeof value.token !== "string"
|
|
416
|
+
|| value.token.length === 0 || value.token.length > 128
|
|
417
|
+
|| !("createdAt" in value) || typeof value.createdAt !== "string"
|
|
418
|
+
|| Number.isNaN(Date.parse(value.createdAt))) {
|
|
419
|
+
throw new Error("invalid owner");
|
|
420
|
+
}
|
|
421
|
+
return Object.freeze({
|
|
422
|
+
pid: value.pid,
|
|
423
|
+
token: value.token,
|
|
424
|
+
createdAt: value.createdAt
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
429
|
+
throw error;
|
|
430
|
+
throw new Error(`Cannot verify the existing Yui home lifecycle lock: ${lockPath}. `
|
|
431
|
+
+ "If no Controller startup or development reset is running, remove this exact lock file and retry.");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function releaseHomeLifecycleLock(lockPath, owner) {
|
|
435
|
+
let current;
|
|
436
|
+
try {
|
|
437
|
+
current = await readHomeLifecycleLockOwner(lockPath);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (current.pid === owner.pid && current.token === owner.token) {
|
|
443
|
+
await rm(lockPath, { force: true });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function isProcessAlive(pid) {
|
|
447
|
+
try {
|
|
448
|
+
process.kill(pid, 0);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
return !isNodeError(error) || error.code !== "ESRCH";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function readLinuxProcessStartIdentity(pid) {
|
|
456
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
457
|
+
const closingParenthesis = stat.lastIndexOf(")");
|
|
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)) {
|
|
465
|
+
throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
|
|
466
|
+
}
|
|
467
|
+
return processStartIdentity;
|
|
468
|
+
}
|
|
261
469
|
function controllerAlreadyRunning() {
|
|
262
470
|
return new Error("Controller is already running.");
|
|
263
471
|
}
|
package/dist/core/protocol.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const MAX_CONTROLLER_MESSAGE_BYTES = 1_048_576;
|
|
2
2
|
export const CONTROLLER_DISCOVERY_PATH = "runtime/controller.json";
|
|
3
|
-
|
|
3
|
+
/** Bump when a running Controller cannot safely share one YUI_HOME with this CLI. */
|
|
4
|
+
export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 3;
|
|
4
5
|
export class ControllerProtocolError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
id;
|
|
@@ -97,9 +98,11 @@ export function controllerFailure(id, code, message) {
|
|
|
97
98
|
}
|
|
98
99
|
export function parseControllerDiscovery(value, expectedSocketPath) {
|
|
99
100
|
if (!isRecord(value)
|
|
100
|
-
|| !hasExactKeys(value, ["pid", "socketPath", "token"])
|
|
101
|
+
|| !hasExactKeys(value, ["pid", "processStartIdentity", "socketPath", "token"])
|
|
101
102
|
|| !Number.isSafeInteger(value.pid)
|
|
102
103
|
|| value.pid < 1
|
|
104
|
+
|| typeof value.processStartIdentity !== "string"
|
|
105
|
+
|| !/^[0-9]{1,32}$/u.test(value.processStartIdentity)
|
|
103
106
|
|| value.socketPath !== expectedSocketPath
|
|
104
107
|
|| typeof value.token !== "string"
|
|
105
108
|
|| !/^[a-f0-9]{64}$/u.test(value.token)) {
|
|
@@ -107,6 +110,7 @@ export function parseControllerDiscovery(value, expectedSocketPath) {
|
|
|
107
110
|
}
|
|
108
111
|
return Object.freeze({
|
|
109
112
|
pid: value.pid,
|
|
113
|
+
processStartIdentity: value.processStartIdentity,
|
|
110
114
|
socketPath: value.socketPath,
|
|
111
115
|
token: value.token
|
|
112
116
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
1
2
|
export function createDecision(id, taskId, title, rationale, now) {
|
|
2
3
|
const timestamp = now.toISOString();
|
|
3
4
|
return {
|
|
4
5
|
schemaVersion: 1,
|
|
5
|
-
id:
|
|
6
|
+
id: validateTaskRecordReference({ taskId, localId: id }, "decision").localId,
|
|
6
7
|
taskId: requireSafeIdentity(taskId, "Task id"),
|
|
7
8
|
title: requireText(title, "Decision title"),
|
|
8
9
|
rationale: requireText(rationale, "Decision rationale"),
|