@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,127 @@
|
|
|
1
|
+
import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
|
|
2
|
+
import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
3
|
+
import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
|
|
4
|
+
import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolves turn identity from the current durable in-flight fence. The one
|
|
7
|
+
* exception is Claude SessionStart(startup), which can arrive synchronously
|
|
8
|
+
* before Session projection and is fenced by the exact Run-bound launch
|
|
9
|
+
* reservation plus deterministic native identity. The immutable hook event is
|
|
10
|
+
* revalidated by the normal inbox fold before changing any state.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveProviderHookRunFence(environment, adapterId, payloadNativeSessionId, options = {}) {
|
|
13
|
+
if (environment.YUI_SESSION_SCOPE !== "task") {
|
|
14
|
+
throw new Error("Provider lifecycle hook requires a Task session scope.");
|
|
15
|
+
}
|
|
16
|
+
if (environment.YUI_ADAPTER_ID !== adapterId) {
|
|
17
|
+
throw new Error(`Provider lifecycle hook requires the ${adapterId} adapter.`);
|
|
18
|
+
}
|
|
19
|
+
const home = requireIdentity(environment.YUI_HOME, "YUI_HOME");
|
|
20
|
+
const taskId = requireIdentity(environment.YUI_TASK_ID, "Task id");
|
|
21
|
+
const roleName = requireIdentity(environment.YUI_ROLE, "Role name");
|
|
22
|
+
const agentId = requireIdentity(environment.YUI_AGENT_ID, "Agent id");
|
|
23
|
+
const workspace = requireIdentity(environment.YUI_WORKSPACE, "YUI workspace");
|
|
24
|
+
const launchId = requireIdentity(environment.YUI_LAUNCH_ID, "Launch id");
|
|
25
|
+
const nativeSessionId = requireIdentity(payloadNativeSessionId, "Provider session id");
|
|
26
|
+
const expectedNativeSessionId = environment.YUI_NATIVE_SESSION_ID;
|
|
27
|
+
if (expectedNativeSessionId !== undefined
|
|
28
|
+
&& nativeSessionId !== requireIdentity(expectedNativeSessionId, "YUI native session id")) {
|
|
29
|
+
throw new Error("Provider lifecycle hook native session does not match its launch envelope.");
|
|
30
|
+
}
|
|
31
|
+
const store = openCompatibleFileTaskStore(home);
|
|
32
|
+
const task = store.getTask(taskId);
|
|
33
|
+
if (task === null || task.status !== "active") {
|
|
34
|
+
throw new Error("Provider lifecycle hook Task is not current and active.");
|
|
35
|
+
}
|
|
36
|
+
const role = store.getRole(taskId, roleName);
|
|
37
|
+
if (role === null || role.activeAgentId !== agentId) {
|
|
38
|
+
throw new Error("Provider lifecycle hook Role or Agent is not current.");
|
|
39
|
+
}
|
|
40
|
+
const sessions = store.getTaskRoleSessionSet(taskId, roleName);
|
|
41
|
+
if (sessions !== null && sessions.activeAgentId !== agentId) {
|
|
42
|
+
throw new Error("Provider lifecycle hook Session Agent is not current.");
|
|
43
|
+
}
|
|
44
|
+
const inFlight = sessions?.inFlight;
|
|
45
|
+
const session = sessions?.sessions[agentId];
|
|
46
|
+
const mailbox = store.getWorkMailbox(runtimeLifecycleTarget({
|
|
47
|
+
scope: "task",
|
|
48
|
+
taskId,
|
|
49
|
+
roleName
|
|
50
|
+
}));
|
|
51
|
+
const exactReservation = isRuntimeLaunchReservation(mailbox?.processing, launchId)
|
|
52
|
+
&& !hasRuntimeCleanupObligation(mailbox);
|
|
53
|
+
const executionRef = mailbox?.processing?.executionRef;
|
|
54
|
+
const startupRunId = options.allowPreallocatedClaudeStartup === true
|
|
55
|
+
? requireIdentity(environment.YUI_RUN_ID, "Run id")
|
|
56
|
+
: undefined;
|
|
57
|
+
const deterministicClaudeStartup = adapterId === "claude"
|
|
58
|
+
&& options.allowPreallocatedClaudeStartup === true
|
|
59
|
+
&& expectedNativeSessionId !== undefined
|
|
60
|
+
&& session === undefined
|
|
61
|
+
&& exactReservation
|
|
62
|
+
&& executionRef?.type === "run"
|
|
63
|
+
&& executionRef.taskId === taskId
|
|
64
|
+
&& executionRef.id === startupRunId
|
|
65
|
+
&& nativeSessionId === nativeSessionIdForLaunch(home, launchId, agentId, adapterId);
|
|
66
|
+
if ((inFlight === null || inFlight === undefined) && !deterministicClaudeStartup) {
|
|
67
|
+
throw new Error("Provider lifecycle hook has no matching durable in-flight Run.");
|
|
68
|
+
}
|
|
69
|
+
if (inFlight !== null && inFlight !== undefined && inFlight.agentId !== agentId) {
|
|
70
|
+
throw new Error("Provider lifecycle hook has no matching durable in-flight Run.");
|
|
71
|
+
}
|
|
72
|
+
if (deterministicClaudeStartup
|
|
73
|
+
&& inFlight !== null
|
|
74
|
+
&& inFlight !== undefined
|
|
75
|
+
&& (inFlight.runId !== startupRunId
|
|
76
|
+
|| inFlight.receiptId !== formatAgentRunReceiptId(taskId, startupRunId))) {
|
|
77
|
+
throw new Error("Provider lifecycle hook has no matching durable in-flight Run.");
|
|
78
|
+
}
|
|
79
|
+
const runId = inFlight?.runId ?? startupRunId;
|
|
80
|
+
const run = store.getActiveAgentRun(taskId, roleName);
|
|
81
|
+
if (run === null
|
|
82
|
+
|| run.id !== runId
|
|
83
|
+
|| run.status !== "active"
|
|
84
|
+
|| run.effective.agentId !== agentId
|
|
85
|
+
|| run.effective.adapterId !== adapterId) {
|
|
86
|
+
throw new Error("Provider lifecycle hook Run does not match durable active state.");
|
|
87
|
+
}
|
|
88
|
+
if (run.effective.workspace.root !== workspace) {
|
|
89
|
+
throw new Error("Provider lifecycle hook workspace does not match the durable Run snapshot.");
|
|
90
|
+
}
|
|
91
|
+
if (session !== undefined) {
|
|
92
|
+
if (session.adapterId !== adapterId
|
|
93
|
+
|| session.launchId !== launchId
|
|
94
|
+
|| session.nativeSessionId !== nativeSessionId
|
|
95
|
+
|| session.effective.workspace.root !== workspace) {
|
|
96
|
+
throw new Error("Provider lifecycle hook Session does not match its durable generation.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const runtimeDiscoveredCodex = adapterId === "codex"
|
|
101
|
+
&& expectedNativeSessionId === undefined
|
|
102
|
+
&& exactReservation;
|
|
103
|
+
if (!runtimeDiscoveredCodex && !deterministicClaudeStartup) {
|
|
104
|
+
throw new Error("Provider lifecycle hook launch is not durably reserved.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
taskId,
|
|
109
|
+
roleName,
|
|
110
|
+
agentId,
|
|
111
|
+
launchId,
|
|
112
|
+
runId,
|
|
113
|
+
...(inFlight?.receiptId === undefined ? {} : { receiptId: inFlight.receiptId }),
|
|
114
|
+
nativeSessionId,
|
|
115
|
+
workspace
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function requireIdentity(value, label) {
|
|
119
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
120
|
+
throw new Error(`${label} is required.`);
|
|
121
|
+
}
|
|
122
|
+
const normalized = value.trim();
|
|
123
|
+
if (normalized.length === 0 || normalized.length > 1_024) {
|
|
124
|
+
throw new Error(`${label} is invalid.`);
|
|
125
|
+
}
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
|
|
5
|
+
import { TmuxManager } from "../tmux/tmuxManager.js";
|
|
6
|
+
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
7
|
+
import { CONTROLLER_DOMAIN_PATH, domainIdentityPath, removeEphemeralDomainIdentityIfUnchanged, readEphemeralDomainIdentity, withEphemeralDomainIdentityCleanupEpoch } from "./domainIdentity.js";
|
|
8
|
+
const DEFAULT_TERM_GRACE_MS = 1_000;
|
|
9
|
+
const DEFAULT_KILL_GRACE_MS = 1_000;
|
|
10
|
+
const DEFAULT_POLL_MS = 50;
|
|
11
|
+
export async function cleanControllerResource(resource, options = {}) {
|
|
12
|
+
if (resource.disposition !== "safe" && resource.disposition !== "review") {
|
|
13
|
+
throw new Error(`Resource is not eligible for cleanup: ${resource.id}.`);
|
|
14
|
+
}
|
|
15
|
+
assertEphemeralDomainFence(resource);
|
|
16
|
+
const environment = options.environment ?? process.env;
|
|
17
|
+
const ports = options.ports ?? linuxCleanupPorts(environment);
|
|
18
|
+
const cleanup = async () => {
|
|
19
|
+
if (resource.artifact !== undefined) {
|
|
20
|
+
cleanArtifact(resource, ports);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (resource.kind === "agent-session") {
|
|
24
|
+
assertProcessIdentities(resource, ports);
|
|
25
|
+
await ports.killPane(resource);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (resource.processes.length === 0) {
|
|
29
|
+
throw new Error(`Resource has no cleanup target: ${resource.id}.`);
|
|
30
|
+
}
|
|
31
|
+
if (resource.kind === "tmux-server") {
|
|
32
|
+
const panes = await ports.inspectTmuxServerPanes(resource);
|
|
33
|
+
if (panes.length > 0) {
|
|
34
|
+
throw new Error(`Tmux server still owns Role panes: ${panes.join(", ")}.`);
|
|
35
|
+
}
|
|
36
|
+
// Close the small inspect-to-signal race without inventing a wait or
|
|
37
|
+
// broad process-name rule. A target appearing on the second exact query
|
|
38
|
+
// keeps the server protected for the next bounded pass.
|
|
39
|
+
const recheckedPanes = await ports.inspectTmuxServerPanes(resource);
|
|
40
|
+
if (recheckedPanes.length > 0) {
|
|
41
|
+
throw new Error(`Tmux server still owns Role panes: ${recheckedPanes.join(", ")}.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
assertProcessIdentities(resource, ports);
|
|
45
|
+
for (const process of [...resource.processes].reverse()) {
|
|
46
|
+
signalIfOwned(process.pid, process.startIdentity, "SIGTERM", ports);
|
|
47
|
+
}
|
|
48
|
+
const termGraceMs = positiveDuration(options.termGraceMs, DEFAULT_TERM_GRACE_MS, "termGraceMs");
|
|
49
|
+
const killGraceMs = positiveDuration(options.killGraceMs, DEFAULT_KILL_GRACE_MS, "killGraceMs");
|
|
50
|
+
const pollMs = positiveDuration(options.pollMs, DEFAULT_POLL_MS, "pollMs");
|
|
51
|
+
await waitForProcesses(resource, ports, termGraceMs, pollMs);
|
|
52
|
+
for (const process of [...resource.processes].reverse()) {
|
|
53
|
+
signalIfOwned(process.pid, process.startIdentity, "SIGKILL", ports);
|
|
54
|
+
}
|
|
55
|
+
await waitForProcesses(resource, ports, killGraceMs, pollMs);
|
|
56
|
+
const remaining = resource.processes.filter((process) => (ports.processStartIdentity(process.pid) === process.startIdentity));
|
|
57
|
+
if (remaining.length > 0) {
|
|
58
|
+
throw new Error(`Resource processes did not stop: ${remaining.map(({ pid }) => pid).join(", ")}.`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const domain = resource.domain;
|
|
62
|
+
if (resource.artifact === undefined
|
|
63
|
+
&& domain?.kind === "ephemeral-test") {
|
|
64
|
+
const home = resource.yuiHome;
|
|
65
|
+
const token = domain.token;
|
|
66
|
+
if (home === undefined || token === undefined) {
|
|
67
|
+
throw new Error(`Ephemeral domain identity is unavailable: ${resource.id}.`);
|
|
68
|
+
}
|
|
69
|
+
ports.beforeDomainCleanupEpoch?.(resource);
|
|
70
|
+
const epoch = await withEphemeralDomainIdentityCleanupEpoch(home, token, domain.fingerprint, cleanup);
|
|
71
|
+
if (epoch !== "completed") {
|
|
72
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await cleanup();
|
|
77
|
+
}
|
|
78
|
+
function assertEphemeralDomainFence(resource) {
|
|
79
|
+
const domain = resource.domain;
|
|
80
|
+
if (domain?.kind !== "ephemeral-test")
|
|
81
|
+
return;
|
|
82
|
+
const home = resource.yuiHome;
|
|
83
|
+
if (home === undefined || domain.token === undefined) {
|
|
84
|
+
throw new Error(`Ephemeral domain identity is unavailable: ${resource.id}.`);
|
|
85
|
+
}
|
|
86
|
+
const identity = readEphemeralDomainIdentity(home);
|
|
87
|
+
if (identity.status !== "valid" || identity.identity?.token !== domain.token) {
|
|
88
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function cleanArtifact(resource, ports) {
|
|
92
|
+
const artifact = resource.artifact;
|
|
93
|
+
if (artifact === undefined)
|
|
94
|
+
throw new Error(`Artifact is unavailable: ${resource.id}.`);
|
|
95
|
+
assertOwnedArtifactPath(resource);
|
|
96
|
+
const currentFingerprint = ports.artifactFingerprint(artifact.path);
|
|
97
|
+
// A concurrent exact cleanup may have already removed this artifact. The
|
|
98
|
+
// desired state is still satisfied; only a changed live inode is ambiguous.
|
|
99
|
+
if (currentFingerprint === undefined)
|
|
100
|
+
return;
|
|
101
|
+
if (currentFingerprint !== artifact.fingerprint) {
|
|
102
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
103
|
+
}
|
|
104
|
+
if (artifact.artifactKind === "domain-identity") {
|
|
105
|
+
const home = resource.yuiHome;
|
|
106
|
+
const expectedToken = resource.domain?.token;
|
|
107
|
+
if (home === undefined || expectedToken === undefined) {
|
|
108
|
+
throw new Error(`Ephemeral domain identity is unavailable: ${resource.id}.`);
|
|
109
|
+
}
|
|
110
|
+
const removal = removeEphemeralDomainIdentityIfUnchanged(home, expectedToken, artifact.fingerprint);
|
|
111
|
+
if (removal === "absent")
|
|
112
|
+
return;
|
|
113
|
+
if (removal !== "removed") {
|
|
114
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (artifact.artifactKind.endsWith("-socket")
|
|
119
|
+
&& ports.socketActive(artifact.path)) {
|
|
120
|
+
throw new Error(`Resource socket is active: ${artifact.path}.`);
|
|
121
|
+
}
|
|
122
|
+
ports.removeArtifact(artifact.path);
|
|
123
|
+
}
|
|
124
|
+
function assertProcessIdentities(resource, ports) {
|
|
125
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
126
|
+
for (const candidate of resource.processes) {
|
|
127
|
+
if (candidate.pid <= 1
|
|
128
|
+
|| candidate.pid === process.pid
|
|
129
|
+
|| candidate.uid !== uid
|
|
130
|
+
|| ports.processStartIdentity(candidate.pid) !== candidate.startIdentity) {
|
|
131
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function signalIfOwned(pid, startIdentity, signal, ports) {
|
|
136
|
+
if (ports.processStartIdentity(pid) !== startIdentity)
|
|
137
|
+
return;
|
|
138
|
+
try {
|
|
139
|
+
ports.signal(pid, signal);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!isMissingProcess(error))
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function waitForProcesses(resource, ports, graceMs, pollMs) {
|
|
147
|
+
for (let elapsed = 0; elapsed < graceMs; elapsed += pollMs) {
|
|
148
|
+
if (resource.processes.every((candidate) => (ports.processStartIdentity(candidate.pid) !== candidate.startIdentity))) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await ports.sleep(Math.min(pollMs, graceMs - elapsed));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function linuxCleanupPorts(environment) {
|
|
155
|
+
return {
|
|
156
|
+
processStartIdentity: readProcessStartIdentity,
|
|
157
|
+
signal: (pid, signal) => process.kill(pid, signal),
|
|
158
|
+
sleep: (milliseconds) => new Promise((resolveSleep) => {
|
|
159
|
+
setTimeout(resolveSleep, milliseconds);
|
|
160
|
+
}),
|
|
161
|
+
artifactFingerprint: (path) => {
|
|
162
|
+
try {
|
|
163
|
+
return statFingerprint(lstatSync(path));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
socketActive: unixSocketIsActive,
|
|
170
|
+
removeArtifact: (path) => unlinkSync(path),
|
|
171
|
+
inspectTmuxServerPanes: async (resource) => {
|
|
172
|
+
const manager = tmuxManagerForResource(resource, environment);
|
|
173
|
+
return manager.inspectRolePaneInventory().map((pane) => (`${pane.taskId}/${pane.roleName}`));
|
|
174
|
+
},
|
|
175
|
+
killPane: async (resource) => {
|
|
176
|
+
const target = resource.target;
|
|
177
|
+
if (target === undefined) {
|
|
178
|
+
throw new Error(`Role pane identity is unavailable: ${resource.id}.`);
|
|
179
|
+
}
|
|
180
|
+
const manager = tmuxManagerForResource(resource, environment);
|
|
181
|
+
const pane = manager.inspectRolePaneInventory().find((candidate) => (candidate.target === target));
|
|
182
|
+
if (pane === undefined)
|
|
183
|
+
return;
|
|
184
|
+
if (resource.paneDead !== undefined
|
|
185
|
+
&& pane.dead !== resource.paneDead) {
|
|
186
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
187
|
+
}
|
|
188
|
+
if (resource.paneCommand !== undefined
|
|
189
|
+
&& pane.currentCommand !== resource.paneCommand) {
|
|
190
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
191
|
+
}
|
|
192
|
+
const expectedPid = resource.processes[0]?.pid;
|
|
193
|
+
if (expectedPid !== undefined && pane.pid !== expectedPid) {
|
|
194
|
+
throw new Error(`Resource changed since scan: ${resource.id}.`);
|
|
195
|
+
}
|
|
196
|
+
manager.killRole(pane.taskId, pane.roleName);
|
|
197
|
+
if (manager.inspectRolePaneInventory().some((candidate) => candidate.target === target)) {
|
|
198
|
+
throw new Error(`Role pane did not stop: ${target}.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function tmuxManagerForResource(resource, environment) {
|
|
204
|
+
const home = resource.yuiHome;
|
|
205
|
+
if (home === undefined) {
|
|
206
|
+
throw new Error(`Tmux resource home is unavailable: ${resource.id}.`);
|
|
207
|
+
}
|
|
208
|
+
return new TmuxManager(environment.YUI_TMUX_BIN ?? "tmux", new NodeCommandExecutor(), { yuiHome: home });
|
|
209
|
+
}
|
|
210
|
+
function readProcessStartIdentity(pid) {
|
|
211
|
+
try {
|
|
212
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
213
|
+
const closing = stat.lastIndexOf(")");
|
|
214
|
+
if (closing < 0)
|
|
215
|
+
return undefined;
|
|
216
|
+
const identity = stat.slice(closing + 1).trim().split(/\s+/u)[19];
|
|
217
|
+
return identity !== undefined && /^[0-9]{1,32}$/u.test(identity)
|
|
218
|
+
? identity
|
|
219
|
+
: undefined;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function unixSocketIsActive(expectedPath) {
|
|
226
|
+
try {
|
|
227
|
+
return readFileSync("/proc/net/unix", "utf8").split("\n").slice(1)
|
|
228
|
+
.some((line) => line.trim().split(/\s+/u)[7] === expectedPath);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Without a liveness proof, refuse socket deletion.
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function assertOwnedArtifactPath(resource) {
|
|
236
|
+
const artifact = resource.artifact;
|
|
237
|
+
if (artifact === undefined)
|
|
238
|
+
throw new Error(`Artifact is unavailable: ${resource.id}.`);
|
|
239
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
240
|
+
const path = resolve(artifact.path);
|
|
241
|
+
if (artifact.artifactKind === "tmux-socket") {
|
|
242
|
+
const directory = join(tmpdir(), `tmux-${uid}`);
|
|
243
|
+
if (dirname(path) !== directory
|
|
244
|
+
|| !/^yui-[a-f0-9]{24}$/u.test(basename(path))) {
|
|
245
|
+
throw new Error(`Artifact path is outside the Yui tmux namespace: ${path}.`);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const home = resource.yuiHome;
|
|
250
|
+
if (home !== undefined
|
|
251
|
+
&& artifact.artifactKind === "controller-socket"
|
|
252
|
+
&& path === controllerSocketPath(home)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (home !== undefined
|
|
256
|
+
&& artifact.artifactKind === "domain-identity"
|
|
257
|
+
&& path === domainIdentityPath(home)
|
|
258
|
+
&& basename(path) === basename(join(resolve(home), CONTROLLER_DOMAIN_PATH))) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (home === undefined
|
|
262
|
+
|| dirname(path) !== join(resolve(home), "runtime")
|
|
263
|
+
|| !/^controller\.(?:json|sock)$/u.test(basename(path))) {
|
|
264
|
+
throw new Error(`Artifact path is outside the Yui Controller namespace: ${path}.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function statFingerprint(metadata) {
|
|
268
|
+
return [
|
|
269
|
+
metadata.dev,
|
|
270
|
+
metadata.ino,
|
|
271
|
+
metadata.mode,
|
|
272
|
+
metadata.size,
|
|
273
|
+
Math.trunc(metadata.mtimeMs)
|
|
274
|
+
].join(":");
|
|
275
|
+
}
|
|
276
|
+
function positiveDuration(value, fallback, label) {
|
|
277
|
+
const resolved = value ?? fallback;
|
|
278
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
279
|
+
throw new TypeError(`${label} must be a positive integer.`);
|
|
280
|
+
}
|
|
281
|
+
return resolved;
|
|
282
|
+
}
|
|
283
|
+
function isMissingProcess(error) {
|
|
284
|
+
return error instanceof Error && "code" in error
|
|
285
|
+
&& error.code === "ESRCH";
|
|
286
|
+
}
|