@platless/axom-cli 0.1.0
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/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute paths, as they appear inside an error message.
|
|
3
|
+
*
|
|
4
|
+
* Two things the previous pattern got wrong. It matched every non-space, non-colon character, so
|
|
5
|
+
* `open '/a/b/c'` lost its closing quote along with the path and the operator saw a message that
|
|
6
|
+
* ended mid-quote. And it had no left boundary, so the `/b` inside a relative `a/b` was redacted as
|
|
7
|
+
* though it were absolute -- the lookbehind requires the slash to follow something that cannot be
|
|
8
|
+
* part of a path segment.
|
|
9
|
+
*/
|
|
10
|
+
const ABSOLUTE_PATH = /(?<![\w.~-])(?:\/[^\s:'"()[\]]+)+\/?/g;
|
|
11
|
+
/**
|
|
12
|
+
* Replaces the directories of an absolute path with `[path]`, keeping the final segment.
|
|
13
|
+
*
|
|
14
|
+
* Redaction is here so a workstation's status field cannot publish the daemon's filesystem layout
|
|
15
|
+
* to the server. Replacing the *whole* path also removes the one thing that makes such a message
|
|
16
|
+
* actionable: on 2026-09-06 a container with a deleted `/data/ws` mount reported
|
|
17
|
+
* `ENOENT: no such file or directory, open '[path]` for every failed action, which named neither
|
|
18
|
+
* the file nor anything that pointed at the mount. `[path]/.update-state.json...tmp` would have.
|
|
19
|
+
*
|
|
20
|
+
* The file name is the diagnostic; the directories are the layout. This keeps the first and drops
|
|
21
|
+
* the second.
|
|
22
|
+
*/
|
|
23
|
+
export function redactPaths(value) {
|
|
24
|
+
return value.replace(ABSOLUTE_PATH, (match) => {
|
|
25
|
+
if (match.endsWith('/'))
|
|
26
|
+
return '[path]/';
|
|
27
|
+
const name = match.slice(match.lastIndexOf('/') + 1);
|
|
28
|
+
return name ? `[path]/${name}` : '[path]';
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Credentials that must never reach a status field, whatever else a message carries. */
|
|
32
|
+
export function redactSecrets(value) {
|
|
33
|
+
return value
|
|
34
|
+
.replace(/https?:\/\/[^\s/@]+:[^\s/@]+@[^\s/]+[^\s]*/gi, '[redacted-url]')
|
|
35
|
+
.replace(/(?:token|password|secret)=\S+/gi, '$1=[redacted]');
|
|
36
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync } from 'node:fs';
|
|
3
|
+
import { downloadVerified } from './artifact.js';
|
|
4
|
+
import { UpdateCoordinator } from './coordinator.js';
|
|
5
|
+
import { NpmInstaller } from './npmInstaller.js';
|
|
6
|
+
import { UpdateStateStore } from './stateStore.js';
|
|
7
|
+
import { TaskRegistry } from './taskRegistry.js';
|
|
8
|
+
import { LastKnownGoodCache, SERVICE_UNREGISTERED_REMEDIATION, writeReadinessMarker } from './watchdog.js';
|
|
9
|
+
import { updateServiceContext } from '../config.js';
|
|
10
|
+
import { CLIENT_VERSION } from '../version.js';
|
|
11
|
+
/**
|
|
12
|
+
* The argv `provisionService` spawns the installed CLI with. Exported so a test can spawn the
|
|
13
|
+
* same command out-of-process and prove it resolves against the unified CLI's actual command
|
|
14
|
+
* surface, instead of only ever seeing it through a mock.
|
|
15
|
+
*/
|
|
16
|
+
export const PROVISION_SERVICE_ARGV = ['workstation', 'install'];
|
|
17
|
+
/**
|
|
18
|
+
* How long the update waits for the provisioning child to say whether the OS service is back.
|
|
19
|
+
*
|
|
20
|
+
* The one hard bound: it must exceed the child's own worst case — `axom workstation install`
|
|
21
|
+
* retries a still-transitioning launchd job for the whole of installer.ts's
|
|
22
|
+
* `LAUNCHD_TRANSITION_BACKOFF_MS` schedule (23.75s) plus the `bootout`/`print`/`kickstart`
|
|
23
|
+
* subprocesses around it. A shorter window records "did not confirm" — and rolls the machine
|
|
24
|
+
* back — while the child is seconds from succeeding, which is exactly how two real machines were
|
|
25
|
+
* stranded: this window was 15s, the child's budget 3.25s, and both numbers sat *below* launchd's
|
|
26
|
+
* 20s SIGTERM-to-SIGKILL grace that the transition actually takes.
|
|
27
|
+
*
|
|
28
|
+
* That grace is no longer an upper bound here, and being SIGKILLed mid-wait is the *designed*
|
|
29
|
+
* outcome of the slow-success path, not a failure: on macOS the child's `bootout` SIGTERMs this
|
|
30
|
+
* very process, launchd keeps the job in transition while we linger observing the child, and
|
|
31
|
+
* kills us at 20s — which is the moment the transition ends and the child's next attempt can
|
|
32
|
+
* bootstrap the replacement. Dying here records nothing, deliberately: the state is already the
|
|
33
|
+
* durably persisted RESTARTING, the new daemon's authenticated Ready proves success, and the
|
|
34
|
+
* armed watchdog's 120s readiness deadline owns the failure side. What this window still catches
|
|
35
|
+
* is the child failing outright (fast non-zero exit — recorded within seconds) or hanging without
|
|
36
|
+
* ever having booted us out.
|
|
37
|
+
*/
|
|
38
|
+
export const PROVISION_CONFIRM_TIMEOUT_MS = 30_000;
|
|
39
|
+
/**
|
|
40
|
+
* Appended to every provisioning failure, so the one line a person reads in `daemon.log` — and the
|
|
41
|
+
* `error` the server shows on the failed update — names the command that repairs the machine.
|
|
42
|
+
*
|
|
43
|
+
* This is not decoration. When provisioning fails after `install` has already booted the old job
|
|
44
|
+
* out, the end state is *worse* than a rolled-back update: the service is gone from launchd
|
|
45
|
+
* entirely, so nothing will ever restart the workstation on its own.
|
|
46
|
+
*
|
|
47
|
+
* The wording lives in `watchdog.ts` because the rollback path reaches the same end state from the
|
|
48
|
+
* other side and has to say the same thing. Kept as an alias rather than folded into its call sites
|
|
49
|
+
* so this file's own tests and readers keep the name that describes *when* it is used.
|
|
50
|
+
*/
|
|
51
|
+
export const PROVISION_REMEDIATION = SERVICE_UNREGISTERED_REMEDIATION;
|
|
52
|
+
/**
|
|
53
|
+
* Resolves only when the provisioning child has *proved* it re-provisioned the service, rejects
|
|
54
|
+
* otherwise — including when it simply has not answered in time.
|
|
55
|
+
*
|
|
56
|
+
* The child stays `detached` and is deliberately not killed on timeout: it may well be seconds away
|
|
57
|
+
* from succeeding, and killing it mid-`bootstrap` is how a machine loses its service definition.
|
|
58
|
+
* What is bounded is only how long the *update* is willing to be held open by it. An unconfirmed
|
|
59
|
+
* outcome is reported as a failure rather than as success: on the real machines this fix comes
|
|
60
|
+
* from, "we never looked" was rendered as SUCCEEDED while the workstation was offline and
|
|
61
|
+
* unregistered, and a false alarm costs an idempotent `axom workstation install` while a false
|
|
62
|
+
* SUCCEEDED costs a machine nobody knows is broken.
|
|
63
|
+
*/
|
|
64
|
+
export function awaitProvisionOutcome(child, timeoutMs = PROVISION_CONFIRM_TIMEOUT_MS) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
const settle = (error) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
child.removeListener('exit', onExit);
|
|
73
|
+
child.removeListener('error', onError);
|
|
74
|
+
if (error)
|
|
75
|
+
reject(error);
|
|
76
|
+
else
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
const onExit = (code, signal) => {
|
|
80
|
+
if (code === 0)
|
|
81
|
+
return settle();
|
|
82
|
+
settle(new Error(`service re-provisioning ${signal ? `was killed by ${signal}` : `exited with code ${code ?? 'unknown'}`}`
|
|
83
|
+
+ ` (see daemon.log for what \`axom ${PROVISION_SERVICE_ARGV.join(' ')}\` reported). ${PROVISION_REMEDIATION}`));
|
|
84
|
+
};
|
|
85
|
+
const onError = (error) => {
|
|
86
|
+
settle(new Error(`service re-provisioning could not be started: ${error.message}. ${PROVISION_REMEDIATION}`));
|
|
87
|
+
};
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
// Stop holding this process's event loop open for a child we are no longer waiting on.
|
|
90
|
+
child.unref();
|
|
91
|
+
settle(new Error(`service re-provisioning did not confirm within ${timeoutMs}ms. ${PROVISION_REMEDIATION}`));
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
child.once('exit', onExit);
|
|
94
|
+
child.once('error', onError);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* What each `serviceLifecycle` permits, computed once so `createUpdateCoordinator` has a single
|
|
99
|
+
* place deciding it and a test can pin the whole table without reaching into
|
|
100
|
+
* `UpdateCoordinator`'s private options.
|
|
101
|
+
*/
|
|
102
|
+
export function lifecyclePolicy(lifecycle) {
|
|
103
|
+
return {
|
|
104
|
+
provisions: lifecycle === 'provision-and-arm',
|
|
105
|
+
rollsBack: lifecycle === 'provision-and-arm',
|
|
106
|
+
cachesRelease: lifecycle === 'provision-and-arm',
|
|
107
|
+
reconcilesOnBoot: lifecycle === 'container',
|
|
108
|
+
restartsOnHandoff: lifecycle !== 'none',
|
|
109
|
+
logsPhases: lifecycle === 'container',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Assembles the one `UpdateCoordinator` every caller runs — the management-channel handler
|
|
114
|
+
* (daemon.ts), the bare `axom workstation` foreground daemon, and the manual `axom update`
|
|
115
|
+
* command alike — same npm installer, same last-known-good rollback cache, same on-disk state
|
|
116
|
+
* file. There is deliberately no second implementation of this assembly.
|
|
117
|
+
*
|
|
118
|
+
* Callers differ only in `serviceLifecycle`: `'provision-and-arm'` for the OS service manager's own
|
|
119
|
+
* supervised process, `'container'` for a detected Docker `ENTRYPOINT`, and `'none'` for everything
|
|
120
|
+
* else — which includes both the one-shot manual `axom update` command and a long-lived foreground
|
|
121
|
+
* daemon that is neither service-supervised nor a container (see the field's doc for why `'none'`
|
|
122
|
+
* does no provisioning, no rollback and no boot reconciliation).
|
|
123
|
+
*/
|
|
124
|
+
export function createUpdateCoordinator(deps) {
|
|
125
|
+
const policy = lifecyclePolicy(deps.serviceLifecycle);
|
|
126
|
+
const updateContext = updateServiceContext(deps.dir);
|
|
127
|
+
const npm = new NpmInstaller(updateContext);
|
|
128
|
+
// This cache also resumes the restart handshake after service supervision starts a new daemon.
|
|
129
|
+
const releaseCache = new LastKnownGoodCache({
|
|
130
|
+
...updateContext, runningVersion: CLIENT_VERSION, pack: (root, destination) => npm.pack(root, destination),
|
|
131
|
+
download: (release) => downloadVerified(release, { server: deps.server }),
|
|
132
|
+
// The same `cli.js` `provisionService` below spawns. It is staged into the watchdog's plan so a
|
|
133
|
+
// rollback can re-provision with it; the watchdog runs from a copy outside the package and has
|
|
134
|
+
// no way to find it otherwise.
|
|
135
|
+
cliPath: deps.cliPath,
|
|
136
|
+
});
|
|
137
|
+
return new UpdateCoordinator({
|
|
138
|
+
tasks: deps.tasks ?? new TaskRegistry(), installer: npm, state: new UpdateStateStore(deps.dir),
|
|
139
|
+
drainTimeoutMs: deps.drainTimeoutMs,
|
|
140
|
+
// Without an artifact source the coordinator cannot fetch the release at all: every update and
|
|
141
|
+
// reinstall fails at DOWNLOADING with "requires the configured server URL". The artifact is
|
|
142
|
+
// served by the same authenticated server this call is already connected to — which is why
|
|
143
|
+
// the rollback cache constructed just above could download while the update itself could not.
|
|
144
|
+
artifact: { server: deps.server },
|
|
145
|
+
// Only armed for the caller that declared 'provision-and-arm' — see UpdateCoordinatorDeps.
|
|
146
|
+
rollback: policy.rollsBack ? releaseCache : undefined,
|
|
147
|
+
releaseCache: policy.cachesRelease ? releaseCache : undefined,
|
|
148
|
+
readiness: { clientVersion: CLIENT_VERSION, write: async (record) => { await writeReadinessMarker(deps.dir, record); } },
|
|
149
|
+
/**
|
|
150
|
+
* Re-writes the service definition from the package that was just installed.
|
|
151
|
+
*
|
|
152
|
+
* Has to be a detached child rather than an in-process call: `install()` boots the service out
|
|
153
|
+
* of launchd's domain before bootstrapping the replacement, and this process *is* that
|
|
154
|
+
* service — an in-process call would die at the bootout and never reach the bootstrap, leaving
|
|
155
|
+
* the daemon down. The child outlives us on purpose, and re-running the same hardened
|
|
156
|
+
* `workstation install` path a person would run keeps one implementation of the lifecycle
|
|
157
|
+
* races.
|
|
158
|
+
*
|
|
159
|
+
* On macOS this is also the only thing that makes a changed definition take effect at all: a
|
|
160
|
+
* bootstrapped job keeps the environment it was loaded with, so writing the plist and exiting
|
|
161
|
+
* would restart the daemon with the *old* environment until the next login.
|
|
162
|
+
*
|
|
163
|
+
* stdio is redirected to the same `daemon.log` the service definition itself points
|
|
164
|
+
* `StandardOutPath`/`StandardErrorPath` at — not discarded — so an `unknown command` or any
|
|
165
|
+
* other failure from this child leaves a trace instead of vanishing silently.
|
|
166
|
+
*
|
|
167
|
+
* Detached, but **awaited** for a bounded window (see {@link PROVISION_CONFIRM_TIMEOUT_MS}).
|
|
168
|
+
* The original `spawn(); unref(); resolve()` made provisioning failure structurally
|
|
169
|
+
* unobservable — the promise could not reject, so `onProvisionFailure` was dead code and a
|
|
170
|
+
* machine whose service had just been booted out and never bootstrapped reported SUCCEEDED.
|
|
171
|
+
* Not waiting also *caused* that report: this process exited immediately afterwards, launchd's
|
|
172
|
+
* KeepAlive started a replacement daemon, whose heartbeat of the persisted RESTARTING is what
|
|
173
|
+
* the server promotes to SUCCEEDED — and only then did the child boot that replacement out.
|
|
174
|
+
* Waiting first removes the race as well as the blindness.
|
|
175
|
+
*
|
|
176
|
+
* Only wired up for the caller that declared 'provision-and-arm'. On the manual path a service
|
|
177
|
+
* definition that needs to change is left for the next server-driven update to rewrite.
|
|
178
|
+
*/
|
|
179
|
+
provisionService: policy.provisions ? async () => {
|
|
180
|
+
const log = openSync(`${deps.dir}/daemon.log`, 'a');
|
|
181
|
+
let child;
|
|
182
|
+
try {
|
|
183
|
+
child = spawn(process.execPath, [deps.cliPath, ...PROVISION_SERVICE_ARGV], {
|
|
184
|
+
detached: true,
|
|
185
|
+
stdio: ['ignore', log, log],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
closeSync(log);
|
|
190
|
+
}
|
|
191
|
+
await awaitProvisionOutcome(child);
|
|
192
|
+
} : undefined,
|
|
193
|
+
/**
|
|
194
|
+
* Now genuinely reachable (it never was before), and the only place this message reaches a
|
|
195
|
+
* person on the machine: `console.error` lands in the same `daemon.log` the plist points at.
|
|
196
|
+
* The coordinator additionally fails the update, which is how the server gets to see it.
|
|
197
|
+
*/
|
|
198
|
+
onProvisionFailure: policy.provisions ? (error) => {
|
|
199
|
+
console.error(`service re-provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
200
|
+
} : undefined,
|
|
201
|
+
// Gated here, alongside the other three policy fields, rather than at daemon.ts's call site:
|
|
202
|
+
// `runDaemon` passes `() => process.exit(0)` for every lifecycle, and a caller that has not
|
|
203
|
+
// thought about which mode it is in must not be able to exit a daemon nothing will restart.
|
|
204
|
+
restart: policy.restartsOnHandoff ? deps.restart : undefined,
|
|
205
|
+
// Only the container lifecycle needs this — see reconcilesOnBoot's doc above and
|
|
206
|
+
// UpdateCoordinatorOptions.reconcileRestartOnBoot in coordinator.ts.
|
|
207
|
+
reconcileRestartOnBoot: policy.reconcilesOnBoot ? { runningVersion: CLIENT_VERSION } : undefined,
|
|
208
|
+
// See logsPhases' doc: in a container this is the only trace an update leaves outside the
|
|
209
|
+
// volume. `version` is on every phase but the initial SUCCEEDED, and `error` only on FAILED.
|
|
210
|
+
onPhase: policy.logsPhases ? (status) => {
|
|
211
|
+
const detail = [status.version && `version ${status.version}`, status.error].filter(Boolean).join(': ');
|
|
212
|
+
console.log(`[update] ${status.phase}${detail ? ` (${detail})` : ''}`);
|
|
213
|
+
} : undefined,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const STATE_FILE = 'update-state.json';
|
|
4
|
+
const PHASES = new Set([
|
|
5
|
+
'REQUESTED', 'DRAINING', 'BUSY', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING',
|
|
6
|
+
'SUCCEEDED', 'FAILED', 'ROLLED_BACK', 'UPDATE_IN_PROGRESS',
|
|
7
|
+
]);
|
|
8
|
+
function isUpdateStatus(value) {
|
|
9
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
10
|
+
return false;
|
|
11
|
+
const status = value;
|
|
12
|
+
if (typeof status.phase !== 'string' || !PHASES.has(status.phase))
|
|
13
|
+
return false;
|
|
14
|
+
for (const field of ['commandId', 'version', 'releaseSha512', 'error'])
|
|
15
|
+
if (status[field] !== undefined && typeof status[field] !== 'string')
|
|
16
|
+
return false;
|
|
17
|
+
for (const field of ['force'])
|
|
18
|
+
if (status[field] !== undefined && typeof status[field] !== 'boolean')
|
|
19
|
+
return false;
|
|
20
|
+
for (const field of ['activeShellTasks', 'activeAgentTasks'])
|
|
21
|
+
if (status[field] !== undefined && (!Number.isSafeInteger(status[field]) || status[field] < 0))
|
|
22
|
+
return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
/** Persists only non-secret update progress. Atomic rename makes a crash recoverable. */
|
|
26
|
+
export class UpdateStateStore {
|
|
27
|
+
directory;
|
|
28
|
+
path;
|
|
29
|
+
constructor(directory) {
|
|
30
|
+
this.directory = directory;
|
|
31
|
+
this.path = path.join(directory, STATE_FILE);
|
|
32
|
+
}
|
|
33
|
+
async load() {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(await fs.readFile(this.path, 'utf8'));
|
|
36
|
+
return isUpdateStatus(parsed) ? parsed : null;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT')
|
|
40
|
+
return null;
|
|
41
|
+
// A partial/corrupt state must not make the daemon unavailable. The former command can be
|
|
42
|
+
// retried; a later watchdog task owns recovery for an already-installed package.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Proves the state directory can actually be written, and leaves nothing behind.
|
|
48
|
+
*
|
|
49
|
+
* A real create-and-delete, not `stat` or `access`: a directory can resolve and still refuse
|
|
50
|
+
* `open`. That is precisely what a bind-mount source deleted on the host does -- the container
|
|
51
|
+
* keeps resolving the mount to an inode with `nlink=0`, so `ls` succeeds, `stat` succeeds, and
|
|
52
|
+
* every write returns ENOENT. On 2026-09-06 a workstation in that state reported ONLINE with all
|
|
53
|
+
* three channels connected while it had not persisted a byte for hours.
|
|
54
|
+
*
|
|
55
|
+
* Throws with the directory named, because the errno alone ("ENOENT ... open") tells an operator
|
|
56
|
+
* nothing about which directory has gone.
|
|
57
|
+
*/
|
|
58
|
+
async assertWritable() {
|
|
59
|
+
const probe = path.join(this.directory, `.writable-probe.${process.pid}.${Date.now()}`);
|
|
60
|
+
try {
|
|
61
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
62
|
+
await fs.writeFile(probe, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const code = error.code ?? 'unknown error';
|
|
66
|
+
throw new Error(`workstation state directory is not writable: ${this.directory} (${code})`);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await fs.rm(probe, { force: true }).catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async save(status) {
|
|
73
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
74
|
+
const temporary = path.join(this.directory, `.${STATE_FILE}.${process.pid}.${Date.now()}.tmp`);
|
|
75
|
+
try {
|
|
76
|
+
await fs.writeFile(temporary, `${JSON.stringify(status)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
77
|
+
await fs.chmod(temporary, 0o600);
|
|
78
|
+
await fs.rename(temporary, this.path);
|
|
79
|
+
// Existing state files might have been created by an older client under a permissive umask.
|
|
80
|
+
await fs.chmod(this.path, 0o600);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await fs.rm(temporary, { force: true }).catch(() => undefined);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the process-lifetime update gate. A graceful update may reopen it after a BUSY timeout; a
|
|
3
|
+
* forced update closes it permanently because this daemon is about to be replaced.
|
|
4
|
+
*/
|
|
5
|
+
export class TaskRegistry {
|
|
6
|
+
/** Every running shell's abort handle plus the promise that says its finally cleanup has run. */
|
|
7
|
+
shellTasks = new Map();
|
|
8
|
+
agentTasks = new Set();
|
|
9
|
+
agentOpenReservations = new Map();
|
|
10
|
+
agentSessions = new Map();
|
|
11
|
+
idleWaiters = new Set();
|
|
12
|
+
stopping = null;
|
|
13
|
+
accepting = true;
|
|
14
|
+
snapshot() {
|
|
15
|
+
const activeAgentRefs = new Set(this.agentTasks);
|
|
16
|
+
for (const sessionRef of this.agentOpenReservations.keys())
|
|
17
|
+
activeAgentRefs.add(sessionRef);
|
|
18
|
+
return { activeShellTasks: this.shellTasks.size, activeAgentTasks: activeAgentRefs.size };
|
|
19
|
+
}
|
|
20
|
+
canUpdate() {
|
|
21
|
+
const snapshot = this.snapshot();
|
|
22
|
+
return this.accepting && snapshot.activeShellTasks === 0 && snapshot.activeAgentTasks === 0;
|
|
23
|
+
}
|
|
24
|
+
/** Count-only drain predicate: unlike canUpdate(), closing admission does not make this false. */
|
|
25
|
+
isIdle() {
|
|
26
|
+
const snapshot = this.snapshot();
|
|
27
|
+
return snapshot.activeShellTasks === 0 && snapshot.activeAgentTasks === 0;
|
|
28
|
+
}
|
|
29
|
+
/** Waits until both active counts are zero, or returns false at the bounded drain deadline. */
|
|
30
|
+
waitForIdle(timeoutMs, signal) {
|
|
31
|
+
if (this.isIdle())
|
|
32
|
+
return Promise.resolve(true);
|
|
33
|
+
if (signal?.aborted)
|
|
34
|
+
return Promise.resolve(false);
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
let timer;
|
|
37
|
+
const finish = (idle) => {
|
|
38
|
+
if (timer === undefined)
|
|
39
|
+
return;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
timer = undefined;
|
|
42
|
+
this.idleWaiters.delete(onIdle);
|
|
43
|
+
signal?.removeEventListener('abort', onAbort);
|
|
44
|
+
resolve(idle);
|
|
45
|
+
};
|
|
46
|
+
const onIdle = () => finish(true);
|
|
47
|
+
const onAbort = () => finish(false);
|
|
48
|
+
this.idleWaiters.add(onIdle);
|
|
49
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
50
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
notifyIfIdle() {
|
|
54
|
+
if (!this.isIdle())
|
|
55
|
+
return;
|
|
56
|
+
for (const waiter of [...this.idleWaiters])
|
|
57
|
+
waiter();
|
|
58
|
+
}
|
|
59
|
+
/** Run one shell task under an abort signal which force-update owns. */
|
|
60
|
+
async withShellTask(run) {
|
|
61
|
+
if (!this.accepting)
|
|
62
|
+
throw new Error('Workstation is not accepting new work');
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
let complete;
|
|
65
|
+
const cleanedUp = new Promise((resolve) => { complete = resolve; });
|
|
66
|
+
this.shellTasks.set(controller, cleanedUp);
|
|
67
|
+
try {
|
|
68
|
+
return await run(controller.signal);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
this.shellTasks.delete(controller);
|
|
72
|
+
complete();
|
|
73
|
+
this.notifyIfIdle();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Register a live native agent for force-stop; idle sessions deliberately do not count as busy. */
|
|
77
|
+
registerAgentSession(sessionRef, close) {
|
|
78
|
+
this.agentSessions.set(sessionRef, close);
|
|
79
|
+
}
|
|
80
|
+
/** Atomically admits and counts an asynchronous open before any journal or subprocess await. */
|
|
81
|
+
tryReserveAgentOpen(sessionRef) {
|
|
82
|
+
if (!this.accepting)
|
|
83
|
+
return false;
|
|
84
|
+
this.agentOpenReservations.set(sessionRef, (this.agentOpenReservations.get(sessionRef) ?? 0) + 1);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
agentOpenFinished(sessionRef) {
|
|
88
|
+
const reservations = this.agentOpenReservations.get(sessionRef) ?? 0;
|
|
89
|
+
if (reservations <= 1)
|
|
90
|
+
this.agentOpenReservations.delete(sessionRef);
|
|
91
|
+
else
|
|
92
|
+
this.agentOpenReservations.set(sessionRef, reservations - 1);
|
|
93
|
+
this.notifyIfIdle();
|
|
94
|
+
}
|
|
95
|
+
/** Graceful drain closes admission too; only this flag authorizes destructive late-open cleanup. */
|
|
96
|
+
isForceStopping() {
|
|
97
|
+
return this.stopping !== null;
|
|
98
|
+
}
|
|
99
|
+
unregisterAgentSession(sessionRef) {
|
|
100
|
+
this.agentSessions.delete(sessionRef);
|
|
101
|
+
this.agentTasks.delete(sessionRef);
|
|
102
|
+
this.notifyIfIdle();
|
|
103
|
+
}
|
|
104
|
+
/** Returns false whenever an update has closed the new-turn admission gate. */
|
|
105
|
+
agentTurnStarted(sessionRef) {
|
|
106
|
+
if (!this.accepting)
|
|
107
|
+
return false;
|
|
108
|
+
this.agentTasks.add(sessionRef);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
agentTurnFinished(sessionRef) {
|
|
112
|
+
this.agentTasks.delete(sessionRef);
|
|
113
|
+
this.notifyIfIdle();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Atomically refuse future work, signal every shell process group through its owning executor,
|
|
117
|
+
* and close every native agent. All closes are attempted even when one bridge fails.
|
|
118
|
+
*/
|
|
119
|
+
async stopAll() {
|
|
120
|
+
if (this.stopping)
|
|
121
|
+
return this.stopping;
|
|
122
|
+
this.accepting = false;
|
|
123
|
+
this.stopping = (async () => {
|
|
124
|
+
// Snapshot before aborting: an executor may synchronously observe abort and finish, deleting
|
|
125
|
+
// itself from the live map. The snapshot's cleanup promises are the authoritative barrier.
|
|
126
|
+
const shells = [...this.shellTasks.entries()];
|
|
127
|
+
for (const [task] of shells)
|
|
128
|
+
task.abort();
|
|
129
|
+
this.agentTasks.clear();
|
|
130
|
+
this.agentOpenReservations.clear();
|
|
131
|
+
this.notifyIfIdle();
|
|
132
|
+
const [outcomes] = await Promise.all([
|
|
133
|
+
Promise.allSettled([...this.agentSessions.values()].map(async (close) => close())),
|
|
134
|
+
Promise.all(shells.map(([, cleanedUp]) => cleanedUp)),
|
|
135
|
+
]);
|
|
136
|
+
const failures = outcomes.filter((outcome) => outcome.status === 'rejected');
|
|
137
|
+
if (failures.length > 0)
|
|
138
|
+
throw new AggregateError(failures.map((failure) => failure.reason), 'Unable to close all agent sessions');
|
|
139
|
+
})();
|
|
140
|
+
return this.stopping;
|
|
141
|
+
}
|
|
142
|
+
}
|