@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,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
|
+
}
|