@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,505 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, openSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ /** Durable identity written by an isolated integration-test runtime. */
5
+ export const CONTROLLER_DOMAIN_PATH = "runtime/domain.json";
6
+ const EPHEMERAL_DOMAIN_LOCK_PATH = "runtime/domain.identity.lock";
7
+ export const EPHEMERAL_DOMAIN_GRACE_MS = 1_000;
8
+ export const EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES = [
9
+ "YUI_EPHEMERAL_DOMAIN",
10
+ "YUI_EPHEMERAL_TOKEN",
11
+ "YUI_EPHEMERAL_HOST_PID",
12
+ "YUI_EPHEMERAL_HOST_START_IDENTITY",
13
+ "YUI_EPHEMERAL_TMUX_SERVER",
14
+ "YUI_EPHEMERAL_TMUX_TARGETS",
15
+ "YUI_EPHEMERAL_CREATED_AT"
16
+ ];
17
+ /**
18
+ * Creates the one durable identity used to fence a disposable test domain.
19
+ * The host process is the test runner, not the detached Controller child.
20
+ */
21
+ export function createEphemeralDomainIdentity(options) {
22
+ const hostPid = options.hostPid ?? process.pid;
23
+ const hostProcessStartIdentity = options.hostProcessStartIdentity
24
+ ?? readLinuxProcessStartIdentity(hostPid);
25
+ if (hostProcessStartIdentity === undefined) {
26
+ throw new Error(`Cannot read Linux process start identity for host PID ${hostPid}.`);
27
+ }
28
+ const token = options.token ?? randomBytes(32).toString("hex");
29
+ const identity = {
30
+ schemaVersion: 1,
31
+ domainKind: "ephemeral-test",
32
+ test: true,
33
+ token,
34
+ hostPid,
35
+ hostProcessStartIdentity,
36
+ tmuxServer: options.tmuxServer,
37
+ tmuxTargets: [...(options.tmuxTargets ?? [])],
38
+ createdAt: (options.createdAt ?? new Date()).toISOString()
39
+ };
40
+ validateEphemeralDomainIdentity(identity);
41
+ return Object.freeze(identity);
42
+ }
43
+ /** Converts identity facts to the narrowly allow-listed Controller env. */
44
+ export function ephemeralDomainEnvironment(identity) {
45
+ validateEphemeralDomainIdentity(identity);
46
+ return {
47
+ YUI_EPHEMERAL_DOMAIN: "1",
48
+ YUI_EPHEMERAL_TOKEN: identity.token,
49
+ YUI_EPHEMERAL_HOST_PID: String(identity.hostPid),
50
+ YUI_EPHEMERAL_HOST_START_IDENTITY: identity.hostProcessStartIdentity,
51
+ YUI_EPHEMERAL_TMUX_SERVER: identity.tmuxServer,
52
+ YUI_EPHEMERAL_TMUX_TARGETS: JSON.stringify(identity.tmuxTargets),
53
+ YUI_EPHEMERAL_CREATED_AT: identity.createdAt
54
+ };
55
+ }
56
+ /** Parses the isolated-runtime marker inherited by a detached Controller. */
57
+ export function ephemeralDomainFromEnvironment(environment) {
58
+ const marker = environment.YUI_EPHEMERAL_DOMAIN;
59
+ const values = EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES.map((name) => environment[name]);
60
+ if (values.every((value) => value === undefined))
61
+ return undefined;
62
+ if (marker !== "1")
63
+ throw new Error("YUI ephemeral domain marker is invalid.");
64
+ const hostPid = Number(environment.YUI_EPHEMERAL_HOST_PID);
65
+ const targets = parseTargets(environment.YUI_EPHEMERAL_TMUX_TARGETS);
66
+ const identity = {
67
+ schemaVersion: 1,
68
+ domainKind: "ephemeral-test",
69
+ test: true,
70
+ token: environment.YUI_EPHEMERAL_TOKEN ?? "",
71
+ hostPid,
72
+ hostProcessStartIdentity: environment.YUI_EPHEMERAL_HOST_START_IDENTITY ?? "",
73
+ tmuxServer: environment.YUI_EPHEMERAL_TMUX_SERVER ?? "",
74
+ tmuxTargets: targets,
75
+ createdAt: environment.YUI_EPHEMERAL_CREATED_AT ?? ""
76
+ };
77
+ validateEphemeralDomainIdentity(identity);
78
+ return Object.freeze(identity);
79
+ }
80
+ /** Writes identity with the same restrictive permissions as Controller discovery. */
81
+ export function writeEphemeralDomainIdentity(home, identity) {
82
+ validateEphemeralDomainIdentity(identity);
83
+ withEphemeralDomainIdentityLock(home, () => {
84
+ writeEphemeralDomainIdentityUnlocked(home, identity);
85
+ });
86
+ }
87
+ function writeEphemeralDomainIdentityUnlocked(home, identity) {
88
+ const existing = readEphemeralDomainIdentity(home);
89
+ if (existing.status === "invalid") {
90
+ throw new Error("Cannot replace an invalid ephemeral domain identity.");
91
+ }
92
+ if (existing.status === "valid") {
93
+ if (existing.identity?.token !== identity.token) {
94
+ throw new Error("Ephemeral domain identity token changed.");
95
+ }
96
+ if (existing.identity.hostPid !== identity.hostPid
97
+ || existing.identity.hostProcessStartIdentity !== identity.hostProcessStartIdentity
98
+ || existing.identity.tmuxServer !== identity.tmuxServer
99
+ || existing.identity.createdAt !== identity.createdAt) {
100
+ throw new Error("Ephemeral domain identity ownership changed.");
101
+ }
102
+ identity = {
103
+ ...identity,
104
+ tmuxTargets: [...new Set([
105
+ ...existing.identity.tmuxTargets,
106
+ ...identity.tmuxTargets
107
+ ])].sort()
108
+ };
109
+ }
110
+ const path = domainIdentityPath(home);
111
+ const directory = dirname(path);
112
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
113
+ chmodSync(directory, 0o700);
114
+ const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
115
+ try {
116
+ writeFileSync(temporary, `${JSON.stringify(identity)}\n`, {
117
+ flag: "wx",
118
+ mode: 0o600
119
+ });
120
+ chmodSync(temporary, 0o600);
121
+ renameSync(temporary, path);
122
+ }
123
+ catch (error) {
124
+ rmSync(temporary, { force: true });
125
+ throw error;
126
+ }
127
+ }
128
+ /**
129
+ * Adds one exact Role pane target to the current domain fence. A missing,
130
+ * invalid, or different-token identity is never repaired by omission: the
131
+ * caller must fail the launch/cleanup operation closed.
132
+ */
133
+ export function recordEphemeralTmuxTarget(home, token, target) {
134
+ if (typeof token !== "string"
135
+ || !/^[a-f0-9]{64}$/u.test(token)
136
+ || typeof target !== "string"
137
+ || target.length === 0
138
+ || target.length > 256
139
+ || target.includes("\0"))
140
+ return false;
141
+ const current = readEphemeralDomainIdentity(home);
142
+ if (current.status !== "valid" || current.identity?.token !== token)
143
+ return false;
144
+ try {
145
+ return withEphemeralDomainIdentityLock(home, () => {
146
+ const latest = readEphemeralDomainIdentity(home);
147
+ if (latest.status !== "valid" || latest.identity?.token !== token)
148
+ return false;
149
+ if (latest.identity.tmuxTargets.includes(target))
150
+ return true;
151
+ writeEphemeralDomainIdentityUnlocked(home, {
152
+ ...latest.identity,
153
+ tmuxTargets: [...latest.identity.tmuxTargets, target].sort()
154
+ });
155
+ return true;
156
+ });
157
+ }
158
+ catch (error) {
159
+ if (error instanceof EphemeralDomainIdentityLockBusyError)
160
+ return false;
161
+ throw error;
162
+ }
163
+ }
164
+ export function readEphemeralDomainIdentity(home) {
165
+ const path = domainIdentityPath(home);
166
+ let metadata;
167
+ try {
168
+ metadata = lstatSync(path);
169
+ }
170
+ catch (error) {
171
+ if (isNodeCode(error, "ENOENT"))
172
+ return { status: "absent" };
173
+ return { status: "invalid" };
174
+ }
175
+ try {
176
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
177
+ return { status: "invalid", fingerprint: statFingerprint(metadata) };
178
+ }
179
+ const identity = JSON.parse(readFileSync(path, "utf8"));
180
+ validateEphemeralDomainIdentity(identity);
181
+ return {
182
+ status: "valid",
183
+ identity,
184
+ fingerprint: statFingerprint(metadata)
185
+ };
186
+ }
187
+ catch {
188
+ return { status: "invalid", fingerprint: statFingerprint(metadata) };
189
+ }
190
+ }
191
+ /** Removes only the identity owned by this Controller generation. */
192
+ export function removeEphemeralDomainIdentity(home, token) {
193
+ const current = readEphemeralDomainIdentity(home);
194
+ if (current.status !== "valid" || current.identity?.token !== token)
195
+ return false;
196
+ return removeEphemeralDomainIdentityIfUnchanged(home, token, current.fingerprint) === "removed";
197
+ }
198
+ /**
199
+ * Removes one exact identity generation while holding the same lock used by
200
+ * target-fence writers. A changed fingerprint, token, target fence, or busy
201
+ * lock is a bounded defer; callers can safely retry the next scan.
202
+ */
203
+ export function removeEphemeralDomainIdentityIfUnchanged(home, token, expectedFingerprint, options = {}) {
204
+ if (typeof token !== "string"
205
+ || !/^[a-f0-9]{64}$/u.test(token)
206
+ || typeof expectedFingerprint !== "string"
207
+ || expectedFingerprint.length === 0)
208
+ return "deferred";
209
+ const initial = readEphemeralDomainIdentity(home);
210
+ if (initial.status === "absent")
211
+ return "absent";
212
+ if (initial.status !== "valid"
213
+ || initial.identity?.token !== token
214
+ || initial.fingerprint !== expectedFingerprint
215
+ || (options.requireNoTargets === true && initial.identity.tmuxTargets.length > 0))
216
+ return "deferred";
217
+ options.beforeAcquire?.();
218
+ try {
219
+ return withEphemeralDomainIdentityLock(home, () => {
220
+ const current = readEphemeralDomainIdentity(home);
221
+ if (current.status === "absent")
222
+ return "absent";
223
+ if (current.status !== "valid"
224
+ || current.identity?.token !== token
225
+ || current.fingerprint !== expectedFingerprint
226
+ || (options.requireNoTargets === true && current.identity.tmuxTargets.length > 0))
227
+ return "deferred";
228
+ try {
229
+ rmSync(domainIdentityPath(home), { force: false });
230
+ }
231
+ catch (error) {
232
+ if (isNodeCode(error, "ENOENT"))
233
+ return "absent";
234
+ throw error;
235
+ }
236
+ return "removed";
237
+ });
238
+ }
239
+ catch (error) {
240
+ if (error instanceof EphemeralDomainIdentityLockBusyError)
241
+ return "deferred";
242
+ throw error;
243
+ }
244
+ }
245
+ /**
246
+ * Runs one bounded final cleanup epoch while the exact Home identity fence is
247
+ * held. Target writers either serialize before this epoch and change the
248
+ * expected generation, or fail closed while the epoch owns the lock.
249
+ */
250
+ export async function withEphemeralDomainIdentityCleanupEpoch(home, token, expectedDomainFingerprint, action) {
251
+ if (typeof token !== "string"
252
+ || !/^[a-f0-9]{64}$/u.test(token)
253
+ || typeof expectedDomainFingerprint !== "string"
254
+ || expectedDomainFingerprint.length === 0)
255
+ return "deferred";
256
+ const initial = readEphemeralDomainIdentity(home);
257
+ if (initial.status === "absent")
258
+ return "absent";
259
+ if (!matchesEphemeralDomainGeneration(initial, token, expectedDomainFingerprint)) {
260
+ return "deferred";
261
+ }
262
+ let release;
263
+ try {
264
+ release = acquireEphemeralDomainIdentityLock(home);
265
+ }
266
+ catch (error) {
267
+ if (error instanceof EphemeralDomainIdentityLockBusyError)
268
+ return "deferred";
269
+ throw error;
270
+ }
271
+ try {
272
+ const current = readEphemeralDomainIdentity(home);
273
+ if (current.status === "absent")
274
+ return "absent";
275
+ if (!matchesEphemeralDomainGeneration(current, token, expectedDomainFingerprint)) {
276
+ return "deferred";
277
+ }
278
+ await action();
279
+ return "completed";
280
+ }
281
+ finally {
282
+ release();
283
+ }
284
+ }
285
+ export function domainIdentityPath(home) {
286
+ return join(resolve(home), CONTROLLER_DOMAIN_PATH);
287
+ }
288
+ /** The durable generation used by RuntimeDomainFact and cleanup CAS checks. */
289
+ export function ephemeralDomainFingerprint(identity, fileFingerprint) {
290
+ validateEphemeralDomainIdentity(identity);
291
+ return [
292
+ identity.token,
293
+ identity.hostPid,
294
+ identity.hostProcessStartIdentity,
295
+ identity.tmuxServer,
296
+ [...identity.tmuxTargets].sort().join(","),
297
+ fileFingerprint ?? ""
298
+ ].join(":");
299
+ }
300
+ export function readLinuxProcessStartIdentity(pid) {
301
+ if (!Number.isSafeInteger(pid) || pid <= 0)
302
+ return undefined;
303
+ try {
304
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
305
+ const closing = stat.lastIndexOf(")");
306
+ if (closing < 0)
307
+ return undefined;
308
+ const identity = stat.slice(closing + 1).trim().split(/\s+/u)[19];
309
+ return identity !== undefined && /^[0-9]{1,32}$/u.test(identity)
310
+ ? identity
311
+ : undefined;
312
+ }
313
+ catch {
314
+ return undefined;
315
+ }
316
+ }
317
+ export function validateEphemeralDomainIdentity(value) {
318
+ if (typeof value !== "object"
319
+ || value === null
320
+ || Array.isArray(value))
321
+ throw new Error("Ephemeral domain identity is invalid.");
322
+ const record = value;
323
+ const expected = [
324
+ "schemaVersion",
325
+ "domainKind",
326
+ "test",
327
+ "token",
328
+ "hostPid",
329
+ "hostProcessStartIdentity",
330
+ "tmuxServer",
331
+ "tmuxTargets",
332
+ "createdAt"
333
+ ];
334
+ if (Object.keys(record).length !== expected.length
335
+ || expected.some((key) => !Object.hasOwn(record, key))
336
+ || record.schemaVersion !== 1
337
+ || record.domainKind !== "ephemeral-test"
338
+ || record.test !== true
339
+ || typeof record.token !== "string"
340
+ || !/^[a-f0-9]{64}$/u.test(record.token)
341
+ || !Number.isSafeInteger(record.hostPid)
342
+ || record.hostPid <= 0
343
+ || typeof record.hostProcessStartIdentity !== "string"
344
+ || !/^[0-9]{1,32}$/u.test(record.hostProcessStartIdentity)
345
+ || typeof record.tmuxServer !== "string"
346
+ || !/^yui-[a-f0-9]{24}$/u.test(record.tmuxServer)
347
+ || !Array.isArray(record.tmuxTargets)
348
+ || record.tmuxTargets.some((target) => (typeof target !== "string"
349
+ || target.length === 0
350
+ || target.length > 256
351
+ || target.includes("\0")))
352
+ || typeof record.createdAt !== "string"
353
+ || !Number.isFinite(Date.parse(record.createdAt)))
354
+ throw new Error("Ephemeral domain identity is invalid.");
355
+ }
356
+ function parseTargets(value) {
357
+ if (value === undefined)
358
+ throw new Error("Ephemeral tmux target identity is missing.");
359
+ const parsed = JSON.parse(value);
360
+ if (!Array.isArray(parsed))
361
+ throw new Error("Ephemeral tmux target identity is invalid.");
362
+ return parsed;
363
+ }
364
+ function statFingerprint(metadata) {
365
+ return [
366
+ metadata.dev,
367
+ metadata.ino,
368
+ metadata.mode,
369
+ metadata.size,
370
+ Math.trunc(metadata.mtimeMs)
371
+ ].join(":");
372
+ }
373
+ function isNodeCode(error, code) {
374
+ return error instanceof Error && "code" in error && error.code === code;
375
+ }
376
+ function matchesEphemeralDomainGeneration(current, token, expectedDomainFingerprint) {
377
+ return current.status === "valid"
378
+ && current.identity?.token === token
379
+ && ephemeralDomainFingerprint(current.identity, current.fingerprint)
380
+ === expectedDomainFingerprint;
381
+ }
382
+ class EphemeralDomainIdentityLockBusyError extends Error {
383
+ constructor(lockPath) {
384
+ super(`Ephemeral domain identity fence is busy: ${lockPath}.`);
385
+ this.name = "EphemeralDomainIdentityLockBusyError";
386
+ }
387
+ }
388
+ function withEphemeralDomainIdentityLock(home, action) {
389
+ const release = acquireEphemeralDomainIdentityLock(home);
390
+ try {
391
+ return action();
392
+ }
393
+ finally {
394
+ release();
395
+ }
396
+ }
397
+ function acquireEphemeralDomainIdentityLock(home) {
398
+ const lockPath = domainIdentityLockPath(home);
399
+ const directory = dirname(lockPath);
400
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
401
+ chmodSync(directory, 0o700);
402
+ const processStartIdentity = readLinuxProcessStartIdentity(process.pid);
403
+ if (processStartIdentity === undefined) {
404
+ throw new Error(`Cannot read Linux process start identity for lock owner PID ${process.pid}.`);
405
+ }
406
+ const owner = {
407
+ pid: process.pid,
408
+ processStartIdentity,
409
+ token: randomBytes(16).toString("hex")
410
+ };
411
+ let fileDescriptor;
412
+ for (let attempt = 0; attempt < 2; attempt += 1) {
413
+ try {
414
+ fileDescriptor = openSync(lockPath, "wx", 0o600);
415
+ writeFileSync(fileDescriptor, `${JSON.stringify(owner)}\n`);
416
+ chmodSync(lockPath, 0o600);
417
+ break;
418
+ }
419
+ catch (error) {
420
+ if (fileDescriptor !== undefined) {
421
+ closeSync(fileDescriptor);
422
+ fileDescriptor = undefined;
423
+ rmSync(lockPath, { force: true });
424
+ }
425
+ if (!isNodeCode(error, "EEXIST"))
426
+ throw error;
427
+ if (!reclaimStaleEphemeralDomainLock(lockPath)) {
428
+ throw new EphemeralDomainIdentityLockBusyError(lockPath);
429
+ }
430
+ }
431
+ }
432
+ if (fileDescriptor === undefined) {
433
+ throw new EphemeralDomainIdentityLockBusyError(lockPath);
434
+ }
435
+ return () => {
436
+ closeSync(fileDescriptor);
437
+ releaseEphemeralDomainIdentityLock(lockPath, owner);
438
+ };
439
+ }
440
+ function domainIdentityLockPath(home) {
441
+ return join(resolve(home), EPHEMERAL_DOMAIN_LOCK_PATH);
442
+ }
443
+ function reclaimStaleEphemeralDomainLock(lockPath) {
444
+ let owner;
445
+ try {
446
+ const metadata = lstatSync(lockPath);
447
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
448
+ return false;
449
+ }
450
+ owner = JSON.parse(readFileSync(lockPath, "utf8"));
451
+ if (typeof owner !== "object"
452
+ || owner === null
453
+ || !Number.isSafeInteger(owner.pid)
454
+ || owner.pid <= 0
455
+ || typeof owner.processStartIdentity !== "string"
456
+ || !/^[0-9]{1,32}$/u.test(owner.processStartIdentity)
457
+ || typeof owner.token !== "string"
458
+ || !/^[a-f0-9]{32}$/u.test(owner.token))
459
+ return false;
460
+ }
461
+ catch {
462
+ return false;
463
+ }
464
+ const currentStartIdentity = readLinuxProcessStartIdentity(owner.pid);
465
+ if (currentStartIdentity !== undefined
466
+ && currentStartIdentity === owner.processStartIdentity)
467
+ return false;
468
+ if (currentStartIdentity === undefined && isProcessAlive(owner.pid))
469
+ return false;
470
+ try {
471
+ rmSync(lockPath, { force: false });
472
+ return true;
473
+ }
474
+ catch (error) {
475
+ if (isNodeCode(error, "ENOENT"))
476
+ return true;
477
+ return false;
478
+ }
479
+ }
480
+ function releaseEphemeralDomainIdentityLock(lockPath, owner) {
481
+ try {
482
+ const current = JSON.parse(readFileSync(lockPath, "utf8"));
483
+ if (current.pid === owner.pid
484
+ && current.processStartIdentity === owner.processStartIdentity
485
+ && current.token === owner.token) {
486
+ rmSync(lockPath, { force: true });
487
+ }
488
+ }
489
+ catch {
490
+ // A missing or externally invalidated lock is already fail-closed.
491
+ }
492
+ }
493
+ function isProcessAlive(pid) {
494
+ try {
495
+ process.kill(pid, 0);
496
+ return true;
497
+ }
498
+ catch (error) {
499
+ return !isNodeCode(error, "ESRCH");
500
+ }
501
+ }
502
+ // Keep this import-free helper usable by test fixtures without requiring tmux.
503
+ export function defaultEphemeralTmuxServer(home) {
504
+ return `yui-${createHash("sha256").update(resolve(home)).digest("hex").slice(0, 24)}`;
505
+ }
@@ -0,0 +1,131 @@
1
+ import { cleanControllerResource } from "./resourceCleanupLinux.js";
2
+ import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
3
+ /**
4
+ * One bounded, idempotent pass over expired explicitly-marked test domains.
5
+ * Every candidate is rescanned and fingerprint-checked before the existing
6
+ * identity-fenced cleanup executor is invoked. Unknown or concurrent state is
7
+ * reported and left for the next periodic pass.
8
+ */
9
+ export async function reapExpiredEphemeralResources(options = {}) {
10
+ const scan = options.scan ?? (() => Promise.reject(new Error("Ephemeral resource scanner is unavailable.")));
11
+ const clean = options.clean ?? ((resource) => cleanControllerResource(resource));
12
+ const initial = await scan();
13
+ const candidates = initial.resources
14
+ .filter(isExpiredEphemeralResource)
15
+ .filter((resource) => isDomainIdentityReadyForCleanup(resource, initial.resources))
16
+ .sort(compareCleanupOrder);
17
+ if (candidates.length === 0) {
18
+ const expiredDomains = safeExpiredDomains(initial)
19
+ .filter((domain) => domainHasNoResources(initial, domain.yuiHome));
20
+ for (const domain of expiredDomains)
21
+ options.onExpiredDomain?.(domain);
22
+ return {
23
+ scanned: initial.domains.length,
24
+ candidates: 0,
25
+ cleaned: 0,
26
+ failed: [],
27
+ expiredDomains
28
+ };
29
+ }
30
+ const revalidated = await scan();
31
+ const eligibleExpiredDomains = safeExpiredDomains(revalidated);
32
+ const byId = new Map(revalidated.resources.map((resource) => [resource.id, resource]));
33
+ const failed = [];
34
+ let cleaned = 0;
35
+ for (const candidate of candidates) {
36
+ const current = byId.get(candidate.id);
37
+ if (current === undefined
38
+ || current.fingerprint !== candidate.fingerprint
39
+ || !isExpiredEphemeralResource(current)) {
40
+ failed.push({ id: candidate.id, message: "changed-since-scan" });
41
+ continue;
42
+ }
43
+ if (!isDomainIdentityReadyForCleanup(current, revalidated.resources)) {
44
+ // The first scan may have seen only runtime/domain.json, while a pane
45
+ // or another exact sibling appeared before this clean. Keep the sole
46
+ // token fence and let the next bounded pass converge the sibling first.
47
+ failed.push({ id: current.id, message: "domain-sibling-appeared" });
48
+ continue;
49
+ }
50
+ try {
51
+ await clean(current);
52
+ cleaned += 1;
53
+ }
54
+ catch (error) {
55
+ failed.push({
56
+ id: current.id,
57
+ message: error instanceof Error ? error.message : String(error)
58
+ });
59
+ }
60
+ }
61
+ let expiredDomains = [];
62
+ if (failed.length === 0 && eligibleExpiredDomains.length > 0) {
63
+ // A domain callback is authority to stop the detached Controller. Verify
64
+ // the post-cleanup snapshot first so a retained identity, a concurrent
65
+ // resource, or a newly appeared target cannot trigger self-close.
66
+ const converged = await scan();
67
+ expiredDomains = eligibleExpiredDomains.filter((domain) => (domainHasNoResources(converged, domain.yuiHome)));
68
+ }
69
+ const result = {
70
+ scanned: revalidated.domains.length,
71
+ candidates: candidates.length,
72
+ cleaned,
73
+ failed,
74
+ expiredDomains
75
+ };
76
+ if (failed.length === 0) {
77
+ for (const domain of expiredDomains)
78
+ options.onExpiredDomain?.(domain);
79
+ }
80
+ return result;
81
+ }
82
+ export function createEphemeralResourceReaper(options) {
83
+ return () => reapExpiredEphemeralResources({
84
+ scan: () => scanControllerResourceInventory(options),
85
+ clean: (resource) => cleanControllerResource(resource, {
86
+ environment: options.environment
87
+ }),
88
+ onExpiredDomain: options.onExpiredDomain
89
+ });
90
+ }
91
+ function isExpiredEphemeralResource(resource) {
92
+ return resource.domain?.kind === "ephemeral-test"
93
+ && resource.domain.liveness === "expired"
94
+ && resource.domain.disposition === "safe"
95
+ && resource.disposition === "safe";
96
+ }
97
+ function isDomainIdentityReadyForCleanup(resource, resources) {
98
+ if (resource.artifact?.artifactKind !== "domain-identity")
99
+ return true;
100
+ // Keep the durable domain fence until every other resource in that exact
101
+ // home has converged. Otherwise a surviving tmux server would become an
102
+ // unmarked/review resource on the next scan and could never trigger the
103
+ // Controller self-close. Deferring the identity to a later pass also keeps
104
+ // one failed sibling cleanup from dropping the only remaining authority.
105
+ return resources.every((candidate) => (candidate.id === resource.id || candidate.yuiHome !== resource.yuiHome));
106
+ }
107
+ function safeExpiredDomains(snapshot) {
108
+ return snapshot.domains
109
+ .filter((domain) => (domain.domainKind === "ephemeral-test"
110
+ && domain.liveness === "expired"
111
+ && domain.disposition === "safe"
112
+ && snapshot.resources
113
+ .filter((resource) => resource.yuiHome === domain.yuiHome)
114
+ .every(isExpiredEphemeralResource)))
115
+ .map((domain) => ({
116
+ yuiHome: domain.yuiHome,
117
+ ...(domain.token === undefined ? {} : { token: domain.token })
118
+ }));
119
+ }
120
+ function domainHasNoResources(snapshot, yuiHome) {
121
+ return snapshot.resources.every((resource) => resource.yuiHome !== yuiHome);
122
+ }
123
+ function compareCleanupOrder(left, right) {
124
+ const order = (resource) => resource.artifact === undefined
125
+ ? resource.kind === "controller" ? 0
126
+ : resource.kind === "agent-session" ? 1
127
+ : resource.kind === "tmux-server" ? 2 : 3
128
+ : 4;
129
+ const byKind = order(left) - order(right);
130
+ return byKind !== 0 ? byKind : left.id.localeCompare(right.id);
131
+ }