@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
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import { agentNotFound, usageError } from "../errors/cliError.js";
|
|
2
3
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
3
|
-
import { createConfiguredAgent } from "../agent/agent.js";
|
|
4
|
+
import { createConfiguredAgent, validateConfiguredAgent } from "../agent/agent.js";
|
|
5
|
+
import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
4
6
|
const SUPPORTED_ADAPTERS = Object.freeze(["codex", "claude"]);
|
|
5
7
|
export function runAgentCommand(args, store) {
|
|
6
8
|
const [command, ...rest] = args;
|
|
@@ -95,7 +97,37 @@ function updateAgent(args, store) {
|
|
|
95
97
|
? { environment: parsed.many("--env").map(parseEnvironmentBinding) }
|
|
96
98
|
: parsed.has("--clear-env") ? { environment: [] } : {})
|
|
97
99
|
};
|
|
98
|
-
const
|
|
100
|
+
const now = new Date();
|
|
101
|
+
const result = store.transaction((tx) => {
|
|
102
|
+
const existing = tx.getConfiguredAgent(id);
|
|
103
|
+
if (existing === null)
|
|
104
|
+
return null;
|
|
105
|
+
const changes = actualAgentChanges(existing, patch);
|
|
106
|
+
if (!changes.operational) {
|
|
107
|
+
return { status: "unchanged", agent: existing };
|
|
108
|
+
}
|
|
109
|
+
const lifecycle = findRuntimeLifecycleReference(tx, id);
|
|
110
|
+
if (lifecycle !== null) {
|
|
111
|
+
throw usageError(`Agent ${id} cannot be updated because ${describeReference(lifecycle)} `
|
|
112
|
+
+ "has pending runtime lifecycle launch or cleanup work. "
|
|
113
|
+
+ "Wait for lifecycle reconciliation to finish before changing Agent launch settings.");
|
|
114
|
+
}
|
|
115
|
+
const liveSession = findNonStoppedSessionReference(tx, id);
|
|
116
|
+
if (liveSession !== null) {
|
|
117
|
+
throw usageError(`Agent ${id} cannot be updated because ${describeReference(liveSession)} `
|
|
118
|
+
+ `retains a non-stopped native session (${liveSession.status}). `
|
|
119
|
+
+ "Stop that Role session before changing Agent launch settings.");
|
|
120
|
+
}
|
|
121
|
+
if (changes.adapter) {
|
|
122
|
+
const binding = findRoleBindingReference(tx, id);
|
|
123
|
+
if (binding !== null) {
|
|
124
|
+
throw usageError(`Agent ${id} adapter cannot change because ${describeReference(binding)} references it. `
|
|
125
|
+
+ "Create a new Agent ID with the target adapter and bind the Role to it instead.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
assertValidAgentCandidate(existing, patch, now);
|
|
129
|
+
return tx.updateConfiguredAgent(id, patch, now);
|
|
130
|
+
});
|
|
99
131
|
if (result === null)
|
|
100
132
|
throw agentNotFound(id);
|
|
101
133
|
return result.status === "unchanged"
|
|
@@ -106,10 +138,121 @@ function removeAgent(args, store) {
|
|
|
106
138
|
const [rawId, ...rest] = args;
|
|
107
139
|
const id = agentId(rawId);
|
|
108
140
|
assertNoArguments(rest, "Agent remove usage: yui agent remove <agent-id>");
|
|
109
|
-
|
|
141
|
+
const removed = store.transaction((tx) => {
|
|
142
|
+
if (tx.getConfiguredAgent(id) === null)
|
|
143
|
+
return false;
|
|
144
|
+
if (tx.getConfig().defaultAgent === id) {
|
|
145
|
+
throw usageError(`Agent ${id} cannot be removed because config.defaultAgent references it. `
|
|
146
|
+
+ "Set another default Agent first.");
|
|
147
|
+
}
|
|
148
|
+
const lifecycle = findRuntimeLifecycleReference(tx, id);
|
|
149
|
+
if (lifecycle !== null) {
|
|
150
|
+
throw usageError(`Agent ${id} cannot be removed because ${describeReference(lifecycle)} `
|
|
151
|
+
+ "has pending runtime lifecycle launch or cleanup work. "
|
|
152
|
+
+ "Wait for lifecycle reconciliation to finish first.");
|
|
153
|
+
}
|
|
154
|
+
const binding = findRoleBindingReference(tx, id);
|
|
155
|
+
if (binding !== null) {
|
|
156
|
+
throw usageError(`Agent ${id} cannot be removed because ${describeReference(binding)} references it. `
|
|
157
|
+
+ "Migrate or remove that Role binding before removing this Agent.");
|
|
158
|
+
}
|
|
159
|
+
const session = findNonStoppedSessionReference(tx, id);
|
|
160
|
+
if (session !== null) {
|
|
161
|
+
throw usageError(`Agent ${id} cannot be removed because ${describeReference(session)} `
|
|
162
|
+
+ `retains a native session (${session.status}). Stop that Role session first.`);
|
|
163
|
+
}
|
|
164
|
+
return tx.removeConfiguredAgent(id);
|
|
165
|
+
});
|
|
166
|
+
if (!removed)
|
|
110
167
|
throw agentNotFound(id);
|
|
111
168
|
return `Removed agent ${id}\n`;
|
|
112
169
|
}
|
|
170
|
+
function actualAgentChanges(existing, patch) {
|
|
171
|
+
const adapter = patch.adapterId !== undefined && patch.adapterId !== existing.adapterId;
|
|
172
|
+
const operational = adapter
|
|
173
|
+
|| (patch.command !== undefined && patch.command !== existing.command)
|
|
174
|
+
|| (patch.baseArgs !== undefined && !isDeepStrictEqual(patch.baseArgs, existing.baseArgs))
|
|
175
|
+
|| (patch.environment !== undefined
|
|
176
|
+
&& !isDeepStrictEqual(patch.environment, existing.environment));
|
|
177
|
+
return { adapter, operational };
|
|
178
|
+
}
|
|
179
|
+
function assertValidAgentCandidate(existing, patch, now) {
|
|
180
|
+
try {
|
|
181
|
+
validateConfiguredAgent({
|
|
182
|
+
...existing,
|
|
183
|
+
...structuredClone(patch),
|
|
184
|
+
updatedAt: now.toISOString()
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
throw usageError(error instanceof Error ? error.message : String(error));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function findRuntimeLifecycleReference(store, agentId) {
|
|
192
|
+
for (const role of store.listGlobalRoles()) {
|
|
193
|
+
if (role.activeAgentId === agentId
|
|
194
|
+
&& hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
|
|
195
|
+
scope: "global",
|
|
196
|
+
roleName: role.name
|
|
197
|
+
})))) {
|
|
198
|
+
return { scope: "global", roleName: role.name };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const task of store.listTasks()) {
|
|
202
|
+
for (const role of store.listRoles(task.id)) {
|
|
203
|
+
if (role.activeAgentId === agentId
|
|
204
|
+
&& hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
|
|
205
|
+
scope: "task",
|
|
206
|
+
taskId: task.id,
|
|
207
|
+
roleName: role.name
|
|
208
|
+
})))) {
|
|
209
|
+
return { scope: "task", taskId: task.id, roleName: role.name };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
function findRoleBindingReference(store, agentId) {
|
|
216
|
+
for (const role of store.listGlobalRoles()) {
|
|
217
|
+
if (Object.hasOwn(role.agentBindings, agentId)) {
|
|
218
|
+
return { scope: "global", roleName: role.name };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const task of store.listTasks()) {
|
|
222
|
+
for (const role of store.listRoles(task.id)) {
|
|
223
|
+
if (Object.hasOwn(role.agentBindings, agentId)) {
|
|
224
|
+
return { scope: "task", taskId: task.id, roleName: role.name };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function findNonStoppedSessionReference(store, agentId) {
|
|
231
|
+
for (const set of store.listGlobalRoleSessionSets()) {
|
|
232
|
+
const reference = sessionReference(set, agentId, { scope: "global", roleName: set.owner.roleName });
|
|
233
|
+
if (reference !== null)
|
|
234
|
+
return reference;
|
|
235
|
+
}
|
|
236
|
+
for (const task of store.listTasks()) {
|
|
237
|
+
for (const set of store.listRoleSessionSets(task.id)) {
|
|
238
|
+
const reference = sessionReference(set, agentId, { scope: "task", taskId: task.id, roleName: set.owner.roleName });
|
|
239
|
+
if (reference !== null)
|
|
240
|
+
return reference;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
function sessionReference(set, agentId, reference) {
|
|
246
|
+
const session = set.sessions[agentId];
|
|
247
|
+
if (session === undefined || session.status === "stopped")
|
|
248
|
+
return null;
|
|
249
|
+
return { ...reference, status: session.status };
|
|
250
|
+
}
|
|
251
|
+
function describeReference(reference) {
|
|
252
|
+
return reference.scope === "global"
|
|
253
|
+
? `Global Role ${reference.roleName}`
|
|
254
|
+
: `Task ${reference.taskId ?? "?"} Role ${reference.roleName}`;
|
|
255
|
+
}
|
|
113
256
|
function parseAgentOptions(args, mode) {
|
|
114
257
|
const valueOptions = new Map([
|
|
115
258
|
["--adapter", { repeatable: false, allowOptionLikeValue: false }],
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { usageError } from "../errors/cliError.js";
|
|
2
|
+
import { DEFAULT_RECONCILIATION_INTERVAL_SECONDS, reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
|
|
3
|
+
import { resolveTimeZone } from "../output/timePresentation.js";
|
|
4
|
+
import { REVIEW_TRIGGERS } from "../review/reviewConfig.js";
|
|
5
|
+
const CONFIG_SET_USAGE = "Config set usage: yui config set "
|
|
6
|
+
+ "<--time-zone <IANA timezone> | "
|
|
7
|
+
+ "--reconciliation-interval-seconds <seconds>>.";
|
|
8
|
+
export function runConfigCommand(args, store) {
|
|
9
|
+
const [command, ...rest] = args;
|
|
10
|
+
if (command === "review")
|
|
11
|
+
return runReviewConfigCommand(rest, store);
|
|
12
|
+
if (command === "show") {
|
|
13
|
+
if (rest.length !== 0)
|
|
14
|
+
throw usageError("Config show usage: yui config show.");
|
|
15
|
+
const config = store.getConfig();
|
|
16
|
+
const reconciliationIntervalSeconds = config.reconciliationIntervalSeconds
|
|
17
|
+
?? DEFAULT_RECONCILIATION_INTERVAL_SECONDS;
|
|
18
|
+
reconciliationIntervalMilliseconds(reconciliationIntervalSeconds);
|
|
19
|
+
return [
|
|
20
|
+
`Time zone: ${resolveTimeZone(config.timeZone)}`,
|
|
21
|
+
`Reconciliation interval: ${reconciliationIntervalSeconds} seconds`,
|
|
22
|
+
""
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
if (command === "set") {
|
|
26
|
+
if (rest.length !== 2) {
|
|
27
|
+
throw configSetUsageError();
|
|
28
|
+
}
|
|
29
|
+
if (rest[0] === "--time-zone") {
|
|
30
|
+
const timeZone = validatedConfigValue(() => resolveTimeZone(rest[1]));
|
|
31
|
+
store.transaction((tx) => {
|
|
32
|
+
tx.saveConfig({ ...tx.getConfig(), timeZone });
|
|
33
|
+
});
|
|
34
|
+
return `Time zone set to ${timeZone}\n`;
|
|
35
|
+
}
|
|
36
|
+
if (rest[0] === "--reconciliation-interval-seconds") {
|
|
37
|
+
const reconciliationIntervalSeconds = parseReconciliationIntervalSeconds(rest[1]);
|
|
38
|
+
validatedConfigValue(() => reconciliationIntervalMilliseconds(reconciliationIntervalSeconds));
|
|
39
|
+
store.transaction((tx) => {
|
|
40
|
+
tx.saveConfig({ ...tx.getConfig(), reconciliationIntervalSeconds });
|
|
41
|
+
});
|
|
42
|
+
return `Reconciliation interval set to ${reconciliationIntervalSeconds} seconds\n`;
|
|
43
|
+
}
|
|
44
|
+
throw configSetUsageError();
|
|
45
|
+
}
|
|
46
|
+
throw usageError(command === undefined
|
|
47
|
+
? "Config command is required."
|
|
48
|
+
: `Unknown command: config ${command}`);
|
|
49
|
+
}
|
|
50
|
+
function runReviewConfigCommand(args, store) {
|
|
51
|
+
const [command, ...rest] = args;
|
|
52
|
+
if (command === "show") {
|
|
53
|
+
if (rest.length !== 0)
|
|
54
|
+
throw usageError("Config review show usage: yui config review show.");
|
|
55
|
+
const review = store.getConfig().review;
|
|
56
|
+
return review === undefined
|
|
57
|
+
? "Review: disabled\n"
|
|
58
|
+
: `Review: ${review.roleName} (${review.trigger})\n`;
|
|
59
|
+
}
|
|
60
|
+
if (command === "clear") {
|
|
61
|
+
if (rest.length !== 0)
|
|
62
|
+
throw usageError("Config review clear usage: yui config review clear.");
|
|
63
|
+
store.transaction((tx) => {
|
|
64
|
+
const { review: _review, ...config } = tx.getConfig();
|
|
65
|
+
tx.saveConfig(config);
|
|
66
|
+
});
|
|
67
|
+
return "Review disabled\n";
|
|
68
|
+
}
|
|
69
|
+
if (command === "set") {
|
|
70
|
+
const usage = "Config review set usage: "
|
|
71
|
+
+ "yui config review set --role <global-role> --trigger <always|leader|final>.";
|
|
72
|
+
if (rest.length !== 4)
|
|
73
|
+
throw usageError(usage);
|
|
74
|
+
const options = new Map();
|
|
75
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
76
|
+
const name = rest[index];
|
|
77
|
+
const value = rest[index + 1];
|
|
78
|
+
if (!["--role", "--trigger"].includes(name)
|
|
79
|
+
|| value === undefined
|
|
80
|
+
|| options.has(name)) {
|
|
81
|
+
throw usageError(usage);
|
|
82
|
+
}
|
|
83
|
+
options.set(name, value);
|
|
84
|
+
}
|
|
85
|
+
const roleName = options.get("--role")?.trim();
|
|
86
|
+
const rawTrigger = options.get("--trigger")?.trim();
|
|
87
|
+
if (roleName === undefined || roleName.length === 0
|
|
88
|
+
|| rawTrigger === undefined
|
|
89
|
+
|| !REVIEW_TRIGGERS.includes(rawTrigger)) {
|
|
90
|
+
throw usageError(usage);
|
|
91
|
+
}
|
|
92
|
+
if (store.getGlobalRole?.(roleName) === null) {
|
|
93
|
+
throw usageError(`Global Role not found: ${roleName}.`);
|
|
94
|
+
}
|
|
95
|
+
const trigger = rawTrigger;
|
|
96
|
+
store.transaction((tx) => {
|
|
97
|
+
tx.saveConfig({
|
|
98
|
+
...tx.getConfig(),
|
|
99
|
+
review: { roleName, trigger }
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
return `Review set to ${roleName} (${trigger})\n`;
|
|
103
|
+
}
|
|
104
|
+
throw usageError(command === undefined
|
|
105
|
+
? "Config review command is required."
|
|
106
|
+
: `Unknown command: config review ${command}`);
|
|
107
|
+
}
|
|
108
|
+
function parseReconciliationIntervalSeconds(value) {
|
|
109
|
+
if (!/^\d+$/.test(value))
|
|
110
|
+
return Number.NaN;
|
|
111
|
+
return Number(value);
|
|
112
|
+
}
|
|
113
|
+
function validatedConfigValue(validate) {
|
|
114
|
+
try {
|
|
115
|
+
return validate();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof TypeError) {
|
|
119
|
+
throw usageError(error.message, CONFIG_SET_USAGE);
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function configSetUsageError() {
|
|
125
|
+
return usageError(CONFIG_SET_USAGE);
|
|
126
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { usageError } from "../errors/cliError.js";
|
|
2
|
+
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
3
|
+
import { DEFAULT_RECONCILIATION_INTERVAL_SECONDS } from "../config/yuiConfig.js";
|
|
4
|
+
export function parseControllerStatusOptions(args) {
|
|
5
|
+
const allowed = new Set(["--all", "--verbose"]);
|
|
6
|
+
if (args.some((argument) => !allowed.has(argument))
|
|
7
|
+
|| new Set(args).size !== args.length) {
|
|
8
|
+
throw usageError("Controller status usage: yui controller status [--all] [--verbose].");
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
scope: args.includes("--all") ? "all" : "current",
|
|
12
|
+
verbose: args.includes("--verbose")
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function parseControllerCleanupOptions(args) {
|
|
16
|
+
if (args.some((argument) => argument !== "--all")
|
|
17
|
+
|| new Set(args).size !== args.length) {
|
|
18
|
+
throw usageError("Controller cleanup usage: yui controller cleanup [--all].");
|
|
19
|
+
}
|
|
20
|
+
return { scope: args.includes("--all") ? "all" : "current" };
|
|
21
|
+
}
|
|
22
|
+
export function renderControllerResourceStatus(snapshot, verbose, width = defaultTableWidth()) {
|
|
23
|
+
const warnings = snapshot.warnings ?? [];
|
|
24
|
+
const current = snapshot.resources.find((resource) => (resource.kind === "controller"
|
|
25
|
+
&& resource.state === "current"
|
|
26
|
+
&& resource.yuiHome === snapshot.currentHome));
|
|
27
|
+
const currentPid = current?.processes[0]?.pid;
|
|
28
|
+
const lines = [
|
|
29
|
+
currentPid === undefined
|
|
30
|
+
? "Controller is not running."
|
|
31
|
+
: `Controller is running (PID ${currentPid}).`,
|
|
32
|
+
"",
|
|
33
|
+
`Observed ${snapshot.summary.domainCount} control domain(s), ${snapshot.summary.liveProcessCount} live process(es), ${formatBytes(snapshot.summary.rssBytes)} RSS.`
|
|
34
|
+
];
|
|
35
|
+
if (snapshot.scope === "all"
|
|
36
|
+
|| snapshot.domains.length > 1
|
|
37
|
+
|| snapshot.domains.some(({ domainKind }) => domainKind !== undefined)) {
|
|
38
|
+
const visibleDomains = selectVisibleDomains(snapshot, verbose);
|
|
39
|
+
lines.push("", renderTable("Control domains", [
|
|
40
|
+
{ header: "YUI_HOME", minWidth: 16, maxWidth: 54 },
|
|
41
|
+
{ header: "Domain", minWidth: 10, maxWidth: 18 },
|
|
42
|
+
{ header: "Lifetime", minWidth: 10, maxWidth: 18 },
|
|
43
|
+
{ header: "Storage", minWidth: 8, maxWidth: 13 },
|
|
44
|
+
{ header: "Resources", minWidth: 9, maxWidth: 9 },
|
|
45
|
+
{ header: "Processes", minWidth: 9, maxWidth: 9 },
|
|
46
|
+
{ header: "RSS", minWidth: 8, maxWidth: 11 },
|
|
47
|
+
{ header: "Issues", minWidth: 6, maxWidth: 6 },
|
|
48
|
+
{ header: "Reason", minWidth: 12, maxWidth: 30 }
|
|
49
|
+
], visibleDomains.map((domain) => [
|
|
50
|
+
domain.yuiHome,
|
|
51
|
+
domain.domainKind ?? "legacy",
|
|
52
|
+
domain.liveness === undefined
|
|
53
|
+
? "—"
|
|
54
|
+
: `${domain.liveness}/${domain.disposition ?? "review"}`,
|
|
55
|
+
domain.storageStatus,
|
|
56
|
+
String(domain.resourceCount),
|
|
57
|
+
String(domain.liveProcessCount),
|
|
58
|
+
formatBytes(domain.rssBytes),
|
|
59
|
+
String(domain.issueCount),
|
|
60
|
+
domain.reasonCode ?? "—"
|
|
61
|
+
]), width));
|
|
62
|
+
lines.push("", `Automatic ephemeral reap: every ${DEFAULT_RECONCILIATION_INTERVAL_SECONDS}s; `
|
|
63
|
+
+ "only expired marked test domains with matching identities are eligible.");
|
|
64
|
+
if (visibleDomains.length < snapshot.domains.length) {
|
|
65
|
+
lines.push("", `${snapshot.domains.length - visibleDomains.length} residual-only control domain(s) `
|
|
66
|
+
+ "are summarized below; use --verbose to expand them.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const sessions = snapshot.resources.filter((resource) => (resource.kind === "agent-session"
|
|
70
|
+
&& (verbose || resource.disposition === "protected")));
|
|
71
|
+
if (sessions.length > 0) {
|
|
72
|
+
lines.push("", renderTable("Agent sessions", [
|
|
73
|
+
{ header: "Task", minWidth: 8, maxWidth: 18 },
|
|
74
|
+
{ header: "Role", minWidth: 12, maxWidth: 22 },
|
|
75
|
+
{ header: "Agent", minWidth: 7, maxWidth: 14 },
|
|
76
|
+
{ header: "State", minWidth: 8, maxWidth: 12 },
|
|
77
|
+
{ header: "PID", minWidth: 5, maxWidth: 8 },
|
|
78
|
+
{ header: "RSS", minWidth: 8, maxWidth: 11 }
|
|
79
|
+
], sessions.map((resource) => sessionRow(resource)), width));
|
|
80
|
+
}
|
|
81
|
+
const residual = snapshot.resources.filter(({ disposition }) => disposition !== "protected");
|
|
82
|
+
if (residual.length > 0) {
|
|
83
|
+
lines.push("", renderTable("Residual resources", [
|
|
84
|
+
{ header: "Reason", minWidth: 16, maxWidth: 34 },
|
|
85
|
+
{ header: "Count", minWidth: 5, maxWidth: 5 },
|
|
86
|
+
{ header: "Live", minWidth: 4, maxWidth: 4 },
|
|
87
|
+
{ header: "RSS", minWidth: 8, maxWidth: 11 },
|
|
88
|
+
{ header: "Oldest", minWidth: 7, maxWidth: 10 },
|
|
89
|
+
{ header: "Action", minWidth: 10, maxWidth: 12 }
|
|
90
|
+
], residualSummaryRows(residual), width));
|
|
91
|
+
}
|
|
92
|
+
const liveAnomalies = residual.filter((resource) => resource.processes.length > 0)
|
|
93
|
+
.sort((left, right) => right.rssBytes - left.rssBytes);
|
|
94
|
+
const visible = verbose ? liveAnomalies : liveAnomalies.slice(0, 5);
|
|
95
|
+
if (visible.length > 0) {
|
|
96
|
+
lines.push("", renderTable(verbose ? "Live anomalous resources" : "Largest live anomalies", [
|
|
97
|
+
{ header: "Kind", minWidth: 10, maxWidth: 18 },
|
|
98
|
+
{ header: "Reason", minWidth: 16, maxWidth: 30 },
|
|
99
|
+
{ header: "PID", minWidth: 5, maxWidth: 8 },
|
|
100
|
+
{ header: "RSS", minWidth: 8, maxWidth: 11 },
|
|
101
|
+
{ header: "Age", minWidth: 7, maxWidth: 10 },
|
|
102
|
+
{ header: "Owner", minWidth: 12, maxWidth: 30 }
|
|
103
|
+
], visible.map((resource) => [
|
|
104
|
+
resource.kind,
|
|
105
|
+
resource.reasonCode,
|
|
106
|
+
processLabel(resource),
|
|
107
|
+
formatBytes(resource.rssBytes),
|
|
108
|
+
formatDuration(resource.ageMs),
|
|
109
|
+
ownerLabel(resource.owner)
|
|
110
|
+
]), width));
|
|
111
|
+
if (!verbose && visible.length < liveAnomalies.length) {
|
|
112
|
+
lines.push("", `${liveAnomalies.length - visible.length} more live anomalous resource(s); `
|
|
113
|
+
+ "use --verbose to expand them.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (warnings.length > 0) {
|
|
117
|
+
lines.push("", "Warnings", ...warnings.slice(0, verbose ? warnings.length : 5)
|
|
118
|
+
.map((warning) => ` ! ${warning}`));
|
|
119
|
+
if (!verbose && warnings.length > 5) {
|
|
120
|
+
lines.push(` ! ${warnings.length - 5} more warning(s); use --verbose.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
export async function runInteractiveControllerCleanup(input) {
|
|
126
|
+
if (!input.io.interactive) {
|
|
127
|
+
throw usageError("Controller cleanup requires an interactive terminal.");
|
|
128
|
+
}
|
|
129
|
+
const initial = await input.scan();
|
|
130
|
+
const safe = initial.resources.filter(({ disposition }) => disposition === "safe");
|
|
131
|
+
const review = initial.resources.filter(({ disposition }) => disposition === "review");
|
|
132
|
+
const protectedCount = initial.resources.filter(({ disposition }) => disposition === "protected").length;
|
|
133
|
+
const reportOnlyCount = initial.resources.filter(({ disposition }) => disposition === "report-only").length;
|
|
134
|
+
input.io.write(renderCleanupPlan(safe, review, protectedCount, reportOnlyCount));
|
|
135
|
+
const selected = [];
|
|
136
|
+
if (safe.length > 0 && await confirmed(input.io, `Clean ${safe.length} safe resource(s), reclaiming up to ${formatBytes(totalRss(safe))}? [y/N]: `)) {
|
|
137
|
+
selected.push(...safe);
|
|
138
|
+
}
|
|
139
|
+
if (review.length > 0 && await confirmed(input.io, `Review ${review.length} live resource(s) individually? [y/N]: `)) {
|
|
140
|
+
for (const resource of review) {
|
|
141
|
+
input.io.write(`${cleanupResourceLabel(resource)}\n`);
|
|
142
|
+
if (await confirmed(input.io, `Clean ${resource.kind} ${resource.id}? This may interrupt running work. [y/N]: `)) {
|
|
143
|
+
selected.push(resource);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (selected.length === 0) {
|
|
148
|
+
return cleanupResult("No resources were selected; nothing was changed.", [], [], [], [], initial);
|
|
149
|
+
}
|
|
150
|
+
if (selected.some(({ processes }) => processes.length > 0)
|
|
151
|
+
&& (await input.io.question(`Type cleanup to stop ${selected.filter(({ processes }) => processes.length > 0).length} `
|
|
152
|
+
+ `live resource(s) and clean ${selected.length} selected resource(s): `))?.trim().toLowerCase() !== "cleanup") {
|
|
153
|
+
return cleanupResult("Cancelled; nothing was changed.", selected, [], [], [], initial);
|
|
154
|
+
}
|
|
155
|
+
const revalidated = await input.scan();
|
|
156
|
+
const currentById = new Map(revalidated.resources.map((resource) => [resource.id, resource]));
|
|
157
|
+
const ready = [];
|
|
158
|
+
const skipped = [];
|
|
159
|
+
for (const resource of selected) {
|
|
160
|
+
const current = currentById.get(resource.id);
|
|
161
|
+
if (current === undefined
|
|
162
|
+
|| current.fingerprint !== resource.fingerprint
|
|
163
|
+
|| (current.disposition !== "safe" && current.disposition !== "review")
|
|
164
|
+
|| (resource.disposition === "safe" && current.disposition !== "safe")) {
|
|
165
|
+
skipped.push({ id: resource.id, reason: "changed-since-scan" });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
ready.push(current);
|
|
169
|
+
}
|
|
170
|
+
const cleaned = [];
|
|
171
|
+
const failed = [];
|
|
172
|
+
for (const resource of ready) {
|
|
173
|
+
try {
|
|
174
|
+
await input.clean(resource);
|
|
175
|
+
cleaned.push({ id: resource.id, rssBytes: resource.rssBytes });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
failed.push({
|
|
179
|
+
id: resource.id,
|
|
180
|
+
message: error instanceof Error ? error.message : String(error)
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const after = await input.scan();
|
|
185
|
+
const output = [
|
|
186
|
+
`Cleaned ${cleaned.length} resource(s); skipped ${skipped.length}; failed ${failed.length}.`,
|
|
187
|
+
`Estimated reclaimed RSS: ${formatBytes(cleaned.reduce((total, resource) => total + resource.rssBytes, 0))}.`,
|
|
188
|
+
`Remaining issues: ${after.resources.filter(({ disposition }) => disposition !== "protected").length}.`,
|
|
189
|
+
...failed.map((failure) => `Failed ${failure.id}: ${failure.message}`)
|
|
190
|
+
].join("\n");
|
|
191
|
+
return cleanupResult(output, selected, cleaned, skipped, failed, after);
|
|
192
|
+
}
|
|
193
|
+
function sessionRow(resource) {
|
|
194
|
+
const pid = resource.processes[0]?.pid;
|
|
195
|
+
if (resource.owner.kind === "task-role") {
|
|
196
|
+
return [
|
|
197
|
+
resource.owner.taskId,
|
|
198
|
+
resource.owner.roleName,
|
|
199
|
+
resource.owner.agentId,
|
|
200
|
+
resource.state,
|
|
201
|
+
pid === undefined ? "—" : String(pid),
|
|
202
|
+
formatBytes(resource.rssBytes)
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
if (resource.owner.kind === "global-role") {
|
|
206
|
+
return [
|
|
207
|
+
"global",
|
|
208
|
+
resource.owner.roleName,
|
|
209
|
+
resource.owner.agentId,
|
|
210
|
+
resource.state,
|
|
211
|
+
pid === undefined ? "—" : String(pid),
|
|
212
|
+
formatBytes(resource.rssBytes)
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
return [
|
|
216
|
+
"—",
|
|
217
|
+
resource.target ?? resource.kind,
|
|
218
|
+
"—",
|
|
219
|
+
resource.state,
|
|
220
|
+
pid === undefined ? "—" : String(pid),
|
|
221
|
+
formatBytes(resource.rssBytes)
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
function selectVisibleDomains(snapshot, verbose) {
|
|
225
|
+
if (verbose)
|
|
226
|
+
return snapshot.domains;
|
|
227
|
+
const activeHomes = new Set([
|
|
228
|
+
snapshot.currentHome,
|
|
229
|
+
...snapshot.resources
|
|
230
|
+
.filter(({ disposition }) => disposition === "protected")
|
|
231
|
+
.flatMap(({ yuiHome }) => yuiHome === undefined ? [] : [yuiHome])
|
|
232
|
+
]);
|
|
233
|
+
const active = snapshot.domains.filter(({ yuiHome }) => activeHomes.has(yuiHome));
|
|
234
|
+
const residual = snapshot.domains
|
|
235
|
+
.filter(({ yuiHome }) => !activeHomes.has(yuiHome))
|
|
236
|
+
.sort((left, right) => right.rssBytes - left.rssBytes)
|
|
237
|
+
.slice(0, 5);
|
|
238
|
+
return [...active, ...residual];
|
|
239
|
+
}
|
|
240
|
+
function residualSummaryRows(resources) {
|
|
241
|
+
const groups = new Map();
|
|
242
|
+
for (const resource of resources) {
|
|
243
|
+
const entries = groups.get(resource.reasonCode) ?? [];
|
|
244
|
+
entries.push(resource);
|
|
245
|
+
groups.set(resource.reasonCode, entries);
|
|
246
|
+
}
|
|
247
|
+
return [...groups.entries()]
|
|
248
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
249
|
+
.map(([reason, entries]) => [
|
|
250
|
+
reason,
|
|
251
|
+
String(entries.length),
|
|
252
|
+
String(entries.filter(({ processes }) => processes.length > 0).length),
|
|
253
|
+
formatBytes(entries.reduce((total, resource) => total + resource.rssBytes, 0)),
|
|
254
|
+
formatDuration(Math.max(0, ...entries.map(({ ageMs }) => ageMs))),
|
|
255
|
+
strongestDisposition(entries)
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
function strongestDisposition(resources) {
|
|
259
|
+
if (resources.some(({ disposition }) => disposition === "review"))
|
|
260
|
+
return "review";
|
|
261
|
+
if (resources.some(({ disposition }) => disposition === "safe"))
|
|
262
|
+
return "safe";
|
|
263
|
+
return "report-only";
|
|
264
|
+
}
|
|
265
|
+
function ownerLabel(owner) {
|
|
266
|
+
switch (owner.kind) {
|
|
267
|
+
case "controller-domain": return owner.yuiHome;
|
|
268
|
+
case "task-role": return `${owner.taskId}/${owner.roleName}`;
|
|
269
|
+
case "global-role": return `global/${owner.roleName}`;
|
|
270
|
+
case "none": return "unattributed";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function processLabel(resource) {
|
|
274
|
+
const first = resource.processes[0]?.pid;
|
|
275
|
+
if (first === undefined)
|
|
276
|
+
return "—";
|
|
277
|
+
return resource.processes.length === 1
|
|
278
|
+
? String(first)
|
|
279
|
+
: `${first} +${resource.processes.length - 1}`;
|
|
280
|
+
}
|
|
281
|
+
function renderCleanupPlan(safe, review, protectedCount, reportOnlyCount) {
|
|
282
|
+
const domains = [...safe, ...review].reduce((counts, resource) => {
|
|
283
|
+
const home = resource.yuiHome ?? "unattributed";
|
|
284
|
+
counts.set(home, (counts.get(home) ?? 0) + 1);
|
|
285
|
+
return counts;
|
|
286
|
+
}, new Map());
|
|
287
|
+
return [
|
|
288
|
+
"Controller cleanup plan",
|
|
289
|
+
"",
|
|
290
|
+
` Safe ${safe.length} resource(s), ${formatBytes(totalRss(safe))} RSS`,
|
|
291
|
+
` Review ${review.length} resource(s), ${formatBytes(totalRss(review))} RSS`,
|
|
292
|
+
` Protected ${protectedCount} resource(s)`,
|
|
293
|
+
` Report only ${reportOnlyCount} resource(s)`,
|
|
294
|
+
...(domains.size === 0 ? [] : [
|
|
295
|
+
"",
|
|
296
|
+
" Domains",
|
|
297
|
+
...[...domains.entries()].map(([home, count]) => ` ${home}: ${count} candidate(s)`)
|
|
298
|
+
]),
|
|
299
|
+
""
|
|
300
|
+
].join("\n");
|
|
301
|
+
}
|
|
302
|
+
function cleanupResourceLabel(resource) {
|
|
303
|
+
const pids = resource.processes.map(({ pid }) => pid).join(",") || "—";
|
|
304
|
+
const domain = resource.domain;
|
|
305
|
+
return [
|
|
306
|
+
`${resource.kind}: ${resource.reasonCode}`,
|
|
307
|
+
` Home ${resource.yuiHome ?? "unattributed"}`,
|
|
308
|
+
` PID ${pids}`,
|
|
309
|
+
` RSS ${formatBytes(resource.rssBytes)}`,
|
|
310
|
+
` Age ${formatDuration(resource.ageMs)}`,
|
|
311
|
+
` Owner ${ownerLabel(resource.owner)}`,
|
|
312
|
+
...(domain === undefined ? [] : [
|
|
313
|
+
` Domain ${domain.kind}/${domain.liveness}/${domain.disposition}`,
|
|
314
|
+
` Host ${domain.hostPid === undefined ? "—" : `${domain.hostPid}:${domain.hostProcessStartIdentity ?? "?"}`}`,
|
|
315
|
+
` Cost ${formatBytes(resource.rssBytes)} RSS; tmux=${domain.tmuxTargets.length}`
|
|
316
|
+
])
|
|
317
|
+
].join("\n");
|
|
318
|
+
}
|
|
319
|
+
async function confirmed(io, prompt) {
|
|
320
|
+
const answer = (await io.question(prompt))?.trim().toLowerCase();
|
|
321
|
+
return answer === "y" || answer === "yes";
|
|
322
|
+
}
|
|
323
|
+
function cleanupResult(output, selected, cleaned, skipped, failed, finalSnapshot) {
|
|
324
|
+
return {
|
|
325
|
+
output,
|
|
326
|
+
data: {
|
|
327
|
+
selected: selected.map(({ id }) => id),
|
|
328
|
+
cleaned,
|
|
329
|
+
skipped,
|
|
330
|
+
failed,
|
|
331
|
+
reclaimedRssBytes: cleaned.reduce((total, resource) => total + resource.rssBytes, 0),
|
|
332
|
+
remainingIssues: finalSnapshot.resources.filter(({ disposition }) => disposition !== "protected").length
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function totalRss(resources) {
|
|
337
|
+
return resources.reduce((total, resource) => total + resource.rssBytes, 0);
|
|
338
|
+
}
|
|
339
|
+
function formatBytes(bytes) {
|
|
340
|
+
if (bytes <= 0)
|
|
341
|
+
return "—";
|
|
342
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
343
|
+
let value = bytes;
|
|
344
|
+
let unit = 0;
|
|
345
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
346
|
+
value /= 1024;
|
|
347
|
+
unit += 1;
|
|
348
|
+
}
|
|
349
|
+
const digits = value >= 10 || unit === 0 ? 0 : 1;
|
|
350
|
+
return `${value.toFixed(digits)} ${units[unit]}`;
|
|
351
|
+
}
|
|
352
|
+
function formatDuration(milliseconds) {
|
|
353
|
+
if (milliseconds <= 0)
|
|
354
|
+
return "—";
|
|
355
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
356
|
+
if (seconds < 60)
|
|
357
|
+
return `${seconds}s`;
|
|
358
|
+
const minutes = Math.floor(seconds / 60);
|
|
359
|
+
if (minutes < 60)
|
|
360
|
+
return `${minutes}m`;
|
|
361
|
+
const hours = Math.floor(minutes / 60);
|
|
362
|
+
if (hours < 48)
|
|
363
|
+
return `${hours}h`;
|
|
364
|
+
return `${Math.floor(hours / 24)}d`;
|
|
365
|
+
}
|