@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.
Files changed (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. 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, CONTROLLER_SOCKET_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
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
- return parseControllerDiscovery(value, expectedSocketPath);
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("CONTROLLER_UNAVAILABLE", "Controller is not running.");
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, CONTROLLER_SOCKET_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
6
- export async function startControllerServer(home, dispatcher) {
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 = join(home, CONTROLLER_SOCKET_PATH);
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
- await writeFile(discoveryPath, `${JSON.stringify(discovery)}\n`, { mode: 0o600 });
43
- await chmod(discoveryPath, 0o600);
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: { pid: process.pid, running: true }
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
- if (!isEmptyParams(request.params)) {
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
- sendResponse(socket, { id: request.id, ok: true, result: { stopped: true } }, () => void stop());
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
  }
@@ -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
- export const CONTROLLER_SOCKET_PATH = "runtime/controller.sock";
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: requireSafeIdentity(id, "Decision 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"),