@platless/axom-cli 0.6.36 → 0.6.52
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 +7 -1
- package/dist/axom-patch/cli/README.md +31 -2
- package/dist/axom-patch/cli/account-limits/generate.py +2 -2
- package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
- package/dist/axom-patch/cli/codex-terminal-output.json +2 -2
- package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
- package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +1 -1
- package/dist/axom-patch/cli/probes/zcode-env-account.mjs +81 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +137 -41
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +5 -88
- package/dist/axom-patch/cli/qoder-native-acp.json +23 -23
- package/dist/axom-patch/cli/runtime.json +3 -1
- package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
- package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +7 -7
- package/dist/axom-patch/cli/zcode-env-account.json +58 -0
- package/dist/axom-patch/cli/zcode-models.json +9 -9
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -10
- package/dist/cli.js +9 -11
- package/dist/commands/auth.js +4 -1
- package/dist/commands/update.js +68 -7
- package/dist/commands/workstation.js +94 -223
- package/dist/commands/workstationRegistrations.js +116 -0
- package/dist/credentialBroker.js +13 -2
- package/dist/credentials.js +16 -0
- package/dist/install/claude/commands.js +2 -2
- package/dist/keepers/terminal/keeper.mjs +1089 -673
- package/dist/keepers/terminal/manifest.json +4 -4
- package/dist/native/darwin-x64/axom-quic +0 -0
- package/dist/workstation/acp/acpAgents.js +11 -4
- package/dist/workstation/acp/outbox.js +12 -5
- package/dist/workstation/acp/permission.js +2 -2
- package/dist/workstation/acp/processor/checkpoint.js +4 -3
- package/dist/workstation/acp/processor/control.js +2 -2
- package/dist/workstation/acp/processor/jsonrpc.js +8 -0
- package/dist/workstation/acp/processor/session.js +3 -2
- package/dist/workstation/acp/processor/state.js +1 -0
- package/dist/workstation/acp/processor/transport/local.js +19 -3
- package/dist/workstation/acp/processor/updates.js +25 -0
- package/dist/workstation/acp/processorSession.js +12 -1
- package/dist/workstation/acp/registry/catalog.js +6 -6
- package/dist/workstation/acp/test/keeperWorld.js +4 -3
- package/dist/workstation/agentSessions.js +76 -23
- package/dist/workstation/channels/agent.js +9 -1
- package/dist/workstation/channels/computer.js +27 -4
- package/dist/workstation/channels/management.js +36 -5
- package/dist/workstation/config.js +18 -6
- package/dist/workstation/control/bindingStore.js +4 -0
- package/dist/workstation/control/commands.js +1 -0
- package/dist/workstation/control/daemonIdentity.js +90 -0
- package/dist/workstation/control/deviceCommands.js +191 -0
- package/dist/workstation/control/deviceProtocol.js +68 -0
- package/dist/workstation/control/locks.js +51 -4
- package/dist/workstation/control/rootFormat.js +16 -0
- package/dist/workstation/control/runtimeClient.js +60 -5
- package/dist/workstation/control/runtimeHost.js +96 -9
- package/dist/workstation/control/serverIdentity.js +2 -2
- package/dist/workstation/control/updateTransition.js +91 -0
- package/dist/workstation/daemon.js +262 -360
- package/dist/workstation/daemonctl.js +150 -12
- package/dist/workstation/desktopControl/automationDevice.js +219 -0
- package/dist/workstation/desktopControl/automationJson.js +74 -0
- package/dist/workstation/desktopControl/automationLocalState.js +23 -0
- package/dist/workstation/desktopControl/automationPreparation.js +1 -0
- package/dist/workstation/desktopControl/automationProtocol.js +253 -0
- package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
- package/dist/workstation/desktopControl/automationSession.js +81 -0
- package/dist/workstation/desktopControl/automationTransport.js +138 -0
- package/dist/workstation/desktopControl/capabilities.js +2 -1
- package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
- package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
- package/dist/workstation/desktopControl/deviceSockets.js +2 -2
- package/dist/workstation/desktopControl/helperProcess.js +250 -1
- package/dist/workstation/desktopControl/hostGrant.js +65 -0
- package/dist/workstation/desktopControl/lobby.js +4 -2
- package/dist/workstation/desktopControl/macProvider.js +41 -40
- package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
- package/dist/workstation/desktopControl/nativeProvider.js +31 -5
- package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
- package/dist/workstation/desktopControl/protocol.js +15 -6
- package/dist/workstation/desktopControl/supervisor.js +110 -17
- package/dist/workstation/desktopControl/wallpaper.js +26 -6
- package/dist/workstation/exec/policy.js +5 -0
- package/dist/workstation/files/client.js +10 -1
- package/dist/workstation/files/host.js +9 -2
- package/dist/workstation/journal.js +5 -2
- package/dist/workstation/keepers/acp/client.js +38 -2
- package/dist/workstation/keepers/kit/buildVersion.js +35 -11
- package/dist/workstation/keepers/kit/client.js +2 -2
- package/dist/workstation/keepers/kit/launcher.js +80 -4
- package/dist/workstation/keepers/legacy.js +16 -2
- package/dist/workstation/keepers/policy.js +5 -34
- package/dist/workstation/keepers/terminal/client.js +115 -23
- package/dist/workstation/keepers/terminal/exec.js +6 -3
- package/dist/workstation/keepers/terminal/frames.js +73 -0
- package/dist/workstation/keepers/terminal/generations.js +5 -8
- package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
- package/dist/workstation/keepers/terminal/input.js +146 -0
- package/dist/workstation/keepers/terminal/keeper.js +22 -8
- package/dist/workstation/keepers/terminal/main.js +1 -1
- package/dist/workstation/keepers/terminal/metadata.js +138 -0
- package/dist/workstation/keepers/terminal/protocol.js +4 -28
- package/dist/workstation/keepers/terminal/pty.js +1 -330
- package/dist/workstation/keepers/terminal/screen.js +246 -0
- package/dist/workstation/keepers/terminal/sessions.js +526 -0
- package/dist/workstation/oauth/deviceAuthorization.js +31 -17
- package/dist/workstation/protocol/management.js +23 -4
- package/dist/workstation/rd/artifact.js +4 -3
- package/dist/workstation/rd/control.js +3 -5
- package/dist/workstation/rd/host.js +213 -0
- package/dist/workstation/rd/migrationReadiness.js +46 -0
- package/dist/workstation/rd/provider.js +44 -10
- package/dist/workstation/rd/state.js +14 -2
- package/dist/workstation/rd/update.js +5 -8
- package/dist/workstation/rd/upkeep.js +44 -2
- package/dist/workstation/registrations/enrollment.js +250 -0
- package/dist/workstation/registrations/enrollmentPending.js +361 -0
- package/dist/workstation/registrations/migration.js +356 -0
- package/dist/workstation/registrations/model.js +34 -0
- package/dist/workstation/registrations/removal.js +222 -0
- package/dist/workstation/registrations/store.js +737 -0
- package/dist/workstation/runtime/desktopBinding.js +20 -0
- package/dist/workstation/runtime/deviceDesktop.js +193 -0
- package/dist/workstation/runtime/deviceRuntime.js +568 -0
- package/dist/workstation/runtime/deviceServices.js +117 -0
- package/dist/workstation/runtime/events.js +20 -0
- package/dist/workstation/runtime/ownership.js +100 -0
- package/dist/workstation/runtime/registrationRuntime.js +575 -0
- package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
- package/dist/workstation/runtime/removalActivity.js +184 -0
- package/dist/workstation/service/installer.js +93 -1
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/service/systemd.js +1 -0
- package/dist/workstation/systemMetrics.js +3 -0
- package/dist/workstation/tools/client.js +8 -27
- package/dist/workstation/tools/info.js +6 -13
- package/dist/workstation/tools/terminalRouter.js +17 -172
- package/dist/workstation/tools/validation.js +55 -1
- package/dist/workstation/update/archiveEvidence.js +206 -0
- package/dist/workstation/update/coordinator.js +34 -13
- package/dist/workstation/update/deviceCompatibility.js +119 -0
- package/dist/workstation/update/deviceCoordinator.js +369 -0
- package/dist/workstation/update/deviceHealth.js +104 -0
- package/dist/workstation/update/deviceHealthProtocol.js +26 -0
- package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
- package/dist/workstation/update/deviceRollback.js +78 -0
- package/dist/workstation/update/globalInstall.js +80 -10
- package/dist/workstation/update/legacyBridge.js +414 -0
- package/dist/workstation/update/legacyOwner.js +39 -0
- package/dist/workstation/update/npmInstaller.js +2 -2
- package/dist/workstation/update/privateDurability.js +61 -0
- package/dist/workstation/update/recoveryPlan.js +124 -0
- package/dist/workstation/update/runUpdate.js +7 -1
- package/dist/workstation/update/taskRegistry.js +161 -3
- package/dist/workstation/update/watchdog.js +27427 -14410
- package/dist/workstation/update/watchdogProcess.js +72 -0
- package/package.json +7 -3
- package/dist/native/darwin-arm64/axom-quic +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Daemon-side: starts, probes and stops keepers. Exempt from the keeper-side import guard.
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
|
-
import { constants, openSync, fstatSync, readFileSync, closeSync, lstatSync } from 'node:fs';
|
|
4
|
+
import { constants, openSync, fstatSync, readFileSync, closeSync, lstatSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { keeperOneShot, readKeeperToken } from './client.js';
|
|
@@ -10,6 +10,7 @@ import { keeperGen } from './identity.js';
|
|
|
10
10
|
import { encodeLaunchConfig, launchConfigOf } from './launchConfig.js';
|
|
11
11
|
import { acquireStartupLock, liveManifest, manifestProcessLive, pidAlive, processCommand, readPidManifest, releaseStartupLock } from './lock.js';
|
|
12
12
|
import { bundleDirFor, ensureKeeperDirectories, keeperPaths, touchSocket } from './paths.js';
|
|
13
|
+
import { assertOwnedDirectory, assertRegistrationOwner, legacyOwnershipAllowed, legacySourcesOf, readOwnerFile, RegistrationOwnershipError } from '../../runtime/ownership.js';
|
|
13
14
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
15
|
/** `keeper.ping` on a fresh connection. Null unless the answer is from this exact keeper generation. */
|
|
15
16
|
export async function pingKeeper(paths, timeoutMs = 250) {
|
|
@@ -47,11 +48,70 @@ export class KeeperLauncher {
|
|
|
47
48
|
constructor(options) {
|
|
48
49
|
this.options = options;
|
|
49
50
|
}
|
|
51
|
+
get ownership() { return this.options.ownership; }
|
|
52
|
+
assertOwnership(kind, gen = 'ownership') { this.pathsFor(kind, gen); }
|
|
53
|
+
allowsLegacyOwnership(kind, gen) {
|
|
54
|
+
const sources = legacySourcesOf(this.ownership);
|
|
55
|
+
return !!sources && this.options.server === sources.keeperServer && path.resolve(this.options.configDir) === path.resolve(sources.adapterCacheDirectory) && legacyOwnershipAllowed(this.ownership, this.pathsFor(kind, gen).root, kind);
|
|
56
|
+
}
|
|
57
|
+
assertSessionOwnership(label, gen) {
|
|
58
|
+
assertRegistrationOwner(label, this.ownership, this.allowsLegacyOwnership('acp', gen) ? { directory: this.pathsFor('acp', gen).root, kind: 'acp' } : undefined);
|
|
59
|
+
}
|
|
60
|
+
assertResourceOwnership(kind, gen, resource, id, create = false) {
|
|
61
|
+
if (!this.ownership)
|
|
62
|
+
return;
|
|
63
|
+
const paths = this.pathsFor(kind, gen);
|
|
64
|
+
const directory = path.join(paths.dataDir, 'registration-owners', resource);
|
|
65
|
+
const file = path.join(directory, `${encodeURIComponent(id)}.json`);
|
|
66
|
+
try {
|
|
67
|
+
assertRegistrationOwner(readOwnerFile(file), this.ownership);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT')
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (!create && !this.allowsLegacyOwnership(kind, gen))
|
|
75
|
+
throw new RegistrationOwnershipError('Registration resource owner is unknown; state quarantined');
|
|
76
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(file, JSON.stringify({ schemaVersion: 1, registrationId: this.ownership.registrationId }), { mode: 0o600, flag: 'wx' });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== 'EEXIST')
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
assertRegistrationOwner(readOwnerFile(file), this.ownership);
|
|
85
|
+
}
|
|
86
|
+
async assertKeeperResources(kind, gen, timeoutMs = 1000) {
|
|
87
|
+
if (!this.ownership)
|
|
88
|
+
return;
|
|
89
|
+
const paths = this.pathsFor(kind, gen);
|
|
90
|
+
const token = await readKeeperToken(paths.tokenFile);
|
|
91
|
+
if (kind === 'acp') {
|
|
92
|
+
const listed = await keeperOneShot(paths.socket, 'acp.list', {}, timeoutMs, { kind, token });
|
|
93
|
+
if (!Array.isArray(listed.sessions))
|
|
94
|
+
throw new RegistrationOwnershipError('Registration session ownership needs verification');
|
|
95
|
+
for (const session of listed.sessions)
|
|
96
|
+
this.assertSessionOwnership(session.label, gen);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const ptys = await keeperOneShot(paths.socket, 'pty.list', {}, timeoutMs, { kind, token });
|
|
100
|
+
const execs = await keeperOneShot(paths.socket, 'exec.list', {}, timeoutMs, { kind, token });
|
|
101
|
+
if (!Array.isArray(ptys.sessions) || !Array.isArray(execs.execs))
|
|
102
|
+
throw new RegistrationOwnershipError('Registration shell ownership needs verification');
|
|
103
|
+
for (const session of ptys.sessions)
|
|
104
|
+
this.assertResourceOwnership(kind, gen, 'pty', session.id);
|
|
105
|
+
for (const exec of execs.execs)
|
|
106
|
+
this.assertResourceOwnership(kind, gen, 'exec', exec.execId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
50
109
|
get distDir() { return this.options.distDir ?? defaultDistDir(); }
|
|
51
110
|
probe(paths, timeoutMs) { return this.options.probe ? this.options.probe(paths) : pingKeeper(paths, timeoutMs); }
|
|
52
111
|
/** Paths of generation `gen` of `kind` (no build needed). */
|
|
53
112
|
pathsFor(kind, gen) {
|
|
54
|
-
const scoped = keeperPaths(this.options, kind, gen);
|
|
113
|
+
const scoped = keeperPaths(this.options, kind, gen, { fallbackRoot: this.options.fallbackRoot });
|
|
114
|
+
assertOwnedDirectory(scoped.root, this.ownership, kind);
|
|
55
115
|
try {
|
|
56
116
|
lstatSync(scoped.pidFile);
|
|
57
117
|
return scoped;
|
|
@@ -68,12 +128,17 @@ export class KeeperLauncher {
|
|
|
68
128
|
if (!stat.isFile() || stat.size > 4096 || process.getuid && stat.uid !== process.getuid())
|
|
69
129
|
throw new Error('Unsafe keeper PID manifest');
|
|
70
130
|
const manifest = JSON.parse(readFileSync(descriptor, 'utf8'));
|
|
71
|
-
if (manifest.identity === scoped.identity && manifest.kind === kind && manifest.gen === gen)
|
|
131
|
+
if (manifest.identity === scoped.identity && manifest.kind === kind && manifest.gen === gen) {
|
|
132
|
+
assertOwnedDirectory(legacy.root, this.ownership, kind);
|
|
72
133
|
return legacy;
|
|
134
|
+
}
|
|
73
135
|
}
|
|
74
136
|
catch (error) {
|
|
75
|
-
if (error.code !== 'ENOENT')
|
|
137
|
+
if (error.code !== 'ENOENT') {
|
|
138
|
+
if (this.ownership && !(error instanceof RegistrationOwnershipError))
|
|
139
|
+
throw new RegistrationOwnershipError('Keeper registration ownership could not be verified; state quarantined', error);
|
|
76
140
|
throw error;
|
|
141
|
+
}
|
|
77
142
|
}
|
|
78
143
|
finally {
|
|
79
144
|
if (descriptor !== undefined)
|
|
@@ -196,7 +261,10 @@ export class KeeperLauncher {
|
|
|
196
261
|
// `detached` is what makes it survive; do not add launchd keys or drop it.
|
|
197
262
|
const child = spawn(nodePath, [script, config], { detached: true, stdio: 'ignore' });
|
|
198
263
|
child.on('error', () => { });
|
|
264
|
+
this.options.onNativeAcquired?.(child, paths);
|
|
199
265
|
child.unref();
|
|
266
|
+
if (child.pid)
|
|
267
|
+
await this.options.onNativeSpawn?.(child.pid, paths, child);
|
|
200
268
|
}
|
|
201
269
|
/**
|
|
202
270
|
* Spec §3.1: a socket that fell back to `/tmp` (its configDir path is too long for sockaddr_un) is touched for every generation
|
|
@@ -244,6 +312,14 @@ export class KeeperLauncher {
|
|
|
244
312
|
}
|
|
245
313
|
return true;
|
|
246
314
|
};
|
|
315
|
+
if (this.ownership) {
|
|
316
|
+
try {
|
|
317
|
+
await this.assertKeeperResources(kind, gen);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return { stopped: false, pid, reason: 'Registration resource ownership needs verification' };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
247
323
|
// keeper.stop needs the connection token (the daemon owns the run directory it lives in). Without a usable token
|
|
248
324
|
// the keeper cannot be asked, so after the same identity check the force path uses, SIGTERM it (its handler is the graceful stop).
|
|
249
325
|
let reason;
|
|
@@ -9,8 +9,19 @@
|
|
|
9
9
|
import { execFile, spawn } from 'node:child_process';
|
|
10
10
|
import { promises as fs } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { keeperRpc } from '../tools/client.js';
|
|
13
15
|
import { pidAlive } from './kit/lock.js';
|
|
16
|
+
import { legacySourcesOf } from '../runtime/ownership.js';
|
|
17
|
+
export function legacyKeeperPaths(options) {
|
|
18
|
+
const identity = createHash('sha256').update(JSON.stringify([path.resolve(options.configDir), options.workstationId, options.server])).digest('hex').slice(0, 32);
|
|
19
|
+
const namespace = `axom-tools-${process.getuid?.() ?? 'user'}`;
|
|
20
|
+
let dir = path.join(process.platform === 'darwin' ? '/tmp' : os.tmpdir(), namespace, identity);
|
|
21
|
+
if (Buffer.byteLength(path.join(dir, 'keeper.sock')) > 100)
|
|
22
|
+
dir = path.join('/tmp', namespace, identity);
|
|
23
|
+
return { dir, socket: path.join(dir, 'keeper.sock'), lock: path.join(dir, 'startup.lock'), pidFile: path.join(dir, 'keeper.pid.json'), lockPort: 20000 + Number.parseInt(identity.slice(0, 8), 16) % 40000, unit: `axom-tools-keeper-${identity}` };
|
|
24
|
+
}
|
|
14
25
|
/** A legacy keeper with no open PTY is stopped once nothing was routed to it for this long (the user may still read an exited one). */
|
|
15
26
|
export const LEGACY_IDLE_MS = 10 * 60_000;
|
|
16
27
|
/**
|
|
@@ -57,9 +68,12 @@ export class LegacyToolsKeeper {
|
|
|
57
68
|
rpc;
|
|
58
69
|
constructor(options) {
|
|
59
70
|
this.options = options;
|
|
60
|
-
this.paths = options.paths ??
|
|
71
|
+
this.paths = options.paths ?? legacyKeeperPaths(options.tools);
|
|
61
72
|
this.now = options.now ?? Date.now;
|
|
62
73
|
this.rpc = options.rpc ?? keeperRpc;
|
|
74
|
+
const sources = legacySourcesOf(options.ownership);
|
|
75
|
+
if (options.ownership && (!sources || path.resolve(options.tools.configDir) !== path.resolve(sources.adapterCacheDirectory) || options.tools.server !== sources.keeperServer))
|
|
76
|
+
this.current = 'absent';
|
|
63
77
|
}
|
|
64
78
|
get state() { return this.current; }
|
|
65
79
|
/** Present or busy: it may hold sessions, so the router asks it. */
|
|
@@ -19,8 +19,6 @@
|
|
|
19
19
|
// older one gets a persistent intent to move there when idle (`moveOffOlderKeeperGenerations`, Task 24); an older
|
|
20
20
|
// generation that hosts no adapter any more is stopped (`retireIdleGenerations`). Never for `available` unless asked.
|
|
21
21
|
// replace-now stops the older generations at once (their in-flight turns end; the sessions reopen through session/load).
|
|
22
|
-
// legacy the legacy tools keeper (spec §9) retires by itself (keepers/legacy.ts); it is reported `required`/`when-idle`, and
|
|
23
|
-
// replace-now stops it at once.
|
|
24
22
|
import { promises as fs } from 'node:fs';
|
|
25
23
|
import path from 'node:path';
|
|
26
24
|
import { keeperOneShot, readKeeperToken } from './kit/client.js';
|
|
@@ -268,24 +266,8 @@ export class KeeperPolicy {
|
|
|
268
266
|
this.log(`[keepers] keeper-terminal could not be described: ${error instanceof Error ? error.message : String(error)}`);
|
|
269
267
|
}
|
|
270
268
|
}
|
|
271
|
-
const legacy = this.options.legacy;
|
|
272
|
-
if (legacy?.live)
|
|
273
|
-
out.push({ live: true, current: false, status: this.legacyStatus(legacy) });
|
|
274
269
|
return out;
|
|
275
270
|
}
|
|
276
|
-
legacyStatus(legacy) {
|
|
277
|
-
const { state, open } = legacy.status();
|
|
278
|
-
const shell = nameLabel(this.options.legacyShell?.(), 'shell');
|
|
279
|
-
const sessions = legacy.lastSessions();
|
|
280
|
-
return {
|
|
281
|
-
kind: 'terminal', gen: 'legacy', legacy: true, state: state === 'present' || state === 'stopping' ? 'running' : 'unavailable', // busy = not answering
|
|
282
|
-
protocolVersion: 0, buildVersion: 'legacy', replacement: 'required', pending: 'when-idle',
|
|
283
|
-
metrics: { ptyOpen: open ?? sessions.filter((session) => !session.exited).length, ptyActive: 0, execRunning: 0 },
|
|
284
|
-
items: sessions.slice(0, KEEPER_STATUS_MAX_ITEMS).map((session) => ({
|
|
285
|
-
type: 'pty', id: clip(session.id, 256), label: shell, state: session.exited ? 'exited' : 'idle', sinceEpochMs: 0,
|
|
286
|
-
})),
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
271
|
async collectAcp() {
|
|
290
272
|
const acp = this.options.acp;
|
|
291
273
|
if (!acp)
|
|
@@ -351,7 +333,7 @@ export class KeeperPolicy {
|
|
|
351
333
|
/** required/mandatory, never decided before (a cancel or a restart never brings an automatic decision back): queue it. */
|
|
352
334
|
async automatic(sampled) {
|
|
353
335
|
for (const { status, live, current } of sampled) {
|
|
354
|
-
if (
|
|
336
|
+
if (current || !live || !replacesByItself(status.replacement))
|
|
355
337
|
continue;
|
|
356
338
|
if (this.decisions.has(decisionKey(status.kind, status.gen)))
|
|
357
339
|
continue;
|
|
@@ -375,7 +357,7 @@ export class KeeperPolicy {
|
|
|
375
357
|
async runQueued(sampled) {
|
|
376
358
|
const acpPending = [];
|
|
377
359
|
for (const { status, live, current } of sampled) {
|
|
378
|
-
if (
|
|
360
|
+
if (current || this.pendingOf(status.kind, status.gen) !== 'when-idle')
|
|
379
361
|
continue;
|
|
380
362
|
if (status.kind === 'acp') {
|
|
381
363
|
if (live)
|
|
@@ -465,8 +447,7 @@ export class KeeperPolicy {
|
|
|
465
447
|
}
|
|
466
448
|
async apply(frame) {
|
|
467
449
|
const sampled = await this.sampled(true);
|
|
468
|
-
const older = sampled.filter((entry) => entry.status.kind === frame.kind && !entry.current && entry.live
|
|
469
|
-
const legacy = frame.kind === 'terminal' ? sampled.find((entry) => entry.status.legacy) : undefined;
|
|
450
|
+
const older = sampled.filter((entry) => entry.status.kind === frame.kind && !entry.current && entry.live);
|
|
470
451
|
const affected = [];
|
|
471
452
|
let ok = true;
|
|
472
453
|
let incomplete = false;
|
|
@@ -502,15 +483,6 @@ export class KeeperPolicy {
|
|
|
502
483
|
error = `generation ${status.gen} could not be stopped`;
|
|
503
484
|
}
|
|
504
485
|
}
|
|
505
|
-
if (legacy && this.options.legacy) {
|
|
506
|
-
const shell = nameLabel(this.options.legacyShell?.(), 'shell');
|
|
507
|
-
for (const session of await this.options.legacy.replaceNow())
|
|
508
|
-
affected.push({ type: 'pty', id: session.id, label: shell, gen: 'legacy' });
|
|
509
|
-
if (this.options.legacy.live) {
|
|
510
|
-
ok = false;
|
|
511
|
-
error = 'the legacy tools keeper could not be stopped';
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
486
|
break;
|
|
515
487
|
}
|
|
516
488
|
case 'cancel': {
|
|
@@ -525,9 +497,8 @@ export class KeeperPolicy {
|
|
|
525
497
|
}
|
|
526
498
|
}
|
|
527
499
|
this.sample = undefined;
|
|
528
|
-
//
|
|
529
|
-
const queued = [...this.decisions].some(([key, decision]) => key.startsWith(`${frame.kind}:`) && decision.mode === 'when-idle')
|
|
530
|
-
|| (frame.kind === 'terminal' && this.options.legacy?.live === true);
|
|
500
|
+
// Read decisions directly: an in-flight sample may predate them.
|
|
501
|
+
const queued = [...this.decisions].some(([key, decision]) => key.startsWith(`${frame.kind}:`) && decision.mode === 'when-idle');
|
|
531
502
|
if (affected.length > KEEPER_AFFECTED_MAX_ITEMS)
|
|
532
503
|
incomplete = true;
|
|
533
504
|
return {
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { readKeeperManifest } from '../kit/bundle.js';
|
|
6
|
-
import { KeeperClient } from '../kit/client.js';
|
|
6
|
+
import { KeeperClient, KeeperNotConnectedError, KeeperRequestError } from '../kit/client.js';
|
|
7
|
+
import { liveManifest } from '../kit/lock.js';
|
|
7
8
|
import { keeperGen } from '../kit/identity.js';
|
|
8
9
|
import { defaultDistDir } from '../kit/launcher.js';
|
|
9
10
|
import { protocolWindow } from '../kit/protocol.js';
|
|
11
|
+
import { RegistrationOwnershipError } from '../../runtime/ownership.js';
|
|
10
12
|
import { PTY_CAPABILITY, PTY_UNAVAILABLE_ERROR, TERMINAL_PROTOCOL_VERSION, execStream, } from './protocol.js';
|
|
13
|
+
import { TERMINAL_GRID_LIMITS } from './gridProtocol.js';
|
|
11
14
|
/** keeper-terminal could not be reached (launch, connect or hello failed): the request was never sent. */
|
|
12
15
|
export class KeeperLaunchError extends Error {
|
|
13
16
|
cause;
|
|
@@ -27,7 +30,12 @@ export function terminalStatusOf(hello) {
|
|
|
27
30
|
const NOT_RUNNING_TTL_MS = 5000;
|
|
28
31
|
/** The default bound of {@link TerminalKeeperClient.probe}: a real connect and hello, not a quick peek. */
|
|
29
32
|
export const KEEPER_PROBE_TIMEOUT_MS = 3000;
|
|
30
|
-
const errnoOf = (error) =>
|
|
33
|
+
const errnoOf = (error) => {
|
|
34
|
+
// The kit keeps its public not_connected code; classify only the underlying socket errno.
|
|
35
|
+
const source = error instanceof KeeperNotConnectedError ? error.cause : error;
|
|
36
|
+
return typeof source === 'object' && source !== null && typeof source.code === 'string'
|
|
37
|
+
? source.code : undefined;
|
|
38
|
+
};
|
|
31
39
|
/** Connect errors that prove nothing listens: the socket file is gone, or a stale one with no listener behind it. */
|
|
32
40
|
const ABSENT_ERRNOS = new Set(['ENOENT', 'ECONNREFUSED']);
|
|
33
41
|
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
@@ -48,7 +56,7 @@ export class TerminalKeeperClient {
|
|
|
48
56
|
}
|
|
49
57
|
get daemonProtocol() { return this.options.daemonProtocol ?? protocolWindow(TERMINAL_PROTOCOL_VERSION); }
|
|
50
58
|
clientFor(paths) {
|
|
51
|
-
if (this.client && this.clientGen === paths.gen)
|
|
59
|
+
if (this.client && this.client.state !== 'closed' && this.clientGen === paths.gen)
|
|
52
60
|
return this.client;
|
|
53
61
|
this.client?.close();
|
|
54
62
|
this.client = new KeeperClient({
|
|
@@ -70,6 +78,7 @@ export class TerminalKeeperClient {
|
|
|
70
78
|
* most one keeper per kind at a time. A `gen`-bound client only connects to its generation.
|
|
71
79
|
*/
|
|
72
80
|
async ensure() {
|
|
81
|
+
this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
|
|
73
82
|
const current = this.client;
|
|
74
83
|
if (this.options.gen !== undefined) {
|
|
75
84
|
if (current && current.state === 'ready')
|
|
@@ -123,24 +132,41 @@ export class TerminalKeeperClient {
|
|
|
123
132
|
return probing;
|
|
124
133
|
}
|
|
125
134
|
async probeOnce(timeoutMs) {
|
|
135
|
+
try {
|
|
136
|
+
this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { state: 'unknown', reason: 'Registration owner needs verification' };
|
|
140
|
+
}
|
|
126
141
|
const absent = () => { this.notRunningUntil = Date.now() + NOT_RUNNING_TTL_MS; return { state: 'absent' }; };
|
|
127
142
|
let gen;
|
|
128
143
|
try {
|
|
129
|
-
gen = this.options.gen
|
|
144
|
+
gen = this.options.gen ?? this.currentGen
|
|
130
145
|
?? keeperGen((await readKeeperManifest(path.join(this.options.distDir ?? defaultDistDir(), 'terminal'))).buildVersion);
|
|
131
146
|
}
|
|
132
|
-
catch {
|
|
133
|
-
return absent();
|
|
134
|
-
} //
|
|
147
|
+
catch (error) {
|
|
148
|
+
return errnoOf(error) === 'ENOENT' ? absent() : { state: 'unknown', reason: messageOf(error) };
|
|
149
|
+
} // A missing build is absent; unreadable or invalid metadata is inconclusive.
|
|
135
150
|
const paths = this.options.launcher.pathsFor('terminal', gen);
|
|
136
151
|
if (this.options.gen === undefined)
|
|
137
152
|
this.currentGen = paths.gen;
|
|
153
|
+
// A live owner may be initializing, busy, or have lost its socket. None proves its sessions are gone.
|
|
154
|
+
const unreachable = async () => {
|
|
155
|
+
try {
|
|
156
|
+
if (await liveManifest(paths))
|
|
157
|
+
return { state: 'unknown', reason: 'live keeper owner is unreachable' };
|
|
158
|
+
return absent();
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return { state: 'unknown', reason: messageOf(error) };
|
|
162
|
+
}
|
|
163
|
+
};
|
|
138
164
|
try {
|
|
139
165
|
await fs.access(paths.tokenFile);
|
|
140
166
|
await fs.access(paths.socket);
|
|
141
167
|
}
|
|
142
168
|
catch (error) {
|
|
143
|
-
return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ?
|
|
169
|
+
return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ? unreachable() : { state: 'unknown', reason: messageOf(error) };
|
|
144
170
|
}
|
|
145
171
|
const client = this.clientFor(paths);
|
|
146
172
|
let timer;
|
|
@@ -159,41 +185,105 @@ export class TerminalKeeperClient {
|
|
|
159
185
|
return { state: 'unknown', reason: `protocol ${result.hello.protocolVersion} is outside the daemon's window` };
|
|
160
186
|
}
|
|
161
187
|
catch (error) {
|
|
162
|
-
//
|
|
163
|
-
return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ?
|
|
188
|
+
// Missing endpoints prove absence only when there is no verified live owner.
|
|
189
|
+
return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ? unreachable() : { state: 'unknown', reason: messageOf(error) };
|
|
164
190
|
}
|
|
165
191
|
finally {
|
|
166
192
|
clearTimeout(timer);
|
|
167
193
|
}
|
|
168
194
|
}
|
|
169
|
-
/** `tools.info` terminal fields from a running keeper's hello, else from
|
|
195
|
+
/** `tools.info` terminal fields from a running keeper's hello, else from the caller's unavailable status. Starts nothing. */
|
|
170
196
|
async terminalStatus(fallback) {
|
|
171
197
|
const hello = await this.runningHello();
|
|
172
198
|
return hello ? terminalStatusOf(hello) : fallback();
|
|
173
199
|
}
|
|
174
200
|
/** A failure to reach the keeper (launch, connect, hello) surfaces as {@link KeeperLaunchError}: nothing was sent. */
|
|
175
201
|
async call(method, params, options = {}) {
|
|
202
|
+
this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
|
|
176
203
|
let client;
|
|
177
204
|
try {
|
|
178
205
|
client = await this.ensure();
|
|
179
206
|
}
|
|
180
207
|
catch (error) {
|
|
208
|
+
if (error instanceof RegistrationOwnershipError)
|
|
209
|
+
throw error;
|
|
181
210
|
throw new KeeperLaunchError(error);
|
|
182
211
|
}
|
|
183
|
-
|
|
212
|
+
const input = params;
|
|
213
|
+
const resource = method.startsWith('pty.') ? 'pty' : 'exec';
|
|
214
|
+
const id = resource === 'pty' ? input.id : input.execId;
|
|
215
|
+
if (this.options.launcher.ownership && typeof id === 'string' && method !== 'exec.status') {
|
|
216
|
+
let create = false;
|
|
217
|
+
if (method === 'pty.create')
|
|
218
|
+
create = !(await client.request('pty.list', {})).sessions.some(session => session.id === id);
|
|
219
|
+
if (method === 'exec.start')
|
|
220
|
+
create = !(await client.request('exec.list', {})).execs.some(exec => exec.execId === id);
|
|
221
|
+
this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, resource, id, create);
|
|
222
|
+
}
|
|
223
|
+
const result = await client.request(method, input, options);
|
|
224
|
+
if (this.options.launcher.ownership && typeof id === 'string' && method === 'exec.status') {
|
|
225
|
+
this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, resource, id);
|
|
226
|
+
}
|
|
227
|
+
if (this.options.launcher.ownership && (method === 'pty.list' || method === 'exec.list')) {
|
|
228
|
+
const registrationId = this.options.launcher.ownership.registrationId;
|
|
229
|
+
if (method === 'pty.list')
|
|
230
|
+
result.sessions = result.sessions.filter(session => !session.registrationId || session.registrationId === registrationId);
|
|
231
|
+
else
|
|
232
|
+
result.execs = result.execs.filter(exec => !exec.registrationId || exec.registrationId === registrationId);
|
|
233
|
+
const ids = method === 'pty.list' ? result.sessions.map(session => session.id) : result.execs.map(exec => exec.execId);
|
|
234
|
+
for (const current of ids)
|
|
235
|
+
this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, resource, current);
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
/** Connect only to existing terminal work. Never launch or retry a possibly delivered event. */
|
|
240
|
+
async callNative(method, params, absentResult) {
|
|
241
|
+
this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
|
|
242
|
+
// A cached absence may predate another client's create; cleanup needs a fresh absence check.
|
|
243
|
+
const presence = this.hello ? { state: 'running' } : await this.probeOnce(KEEPER_PROBE_TIMEOUT_MS);
|
|
244
|
+
if (presence.state === 'absent') {
|
|
245
|
+
if (absentResult !== undefined)
|
|
246
|
+
return absentResult;
|
|
247
|
+
throw new KeeperRequestError('pty_not_found', 'Terminal session not found; keeper restart loses live PTYs');
|
|
248
|
+
}
|
|
249
|
+
if (presence.state === 'unknown')
|
|
250
|
+
throw new KeeperRequestError('pty_unavailable', `Terminal keeper unavailable: ${presence.reason}`);
|
|
251
|
+
const client = this.client;
|
|
252
|
+
if (!client)
|
|
253
|
+
throw new KeeperRequestError('pty_unavailable', 'Terminal keeper disconnected');
|
|
254
|
+
const input = params;
|
|
255
|
+
if (typeof input.id === 'string')
|
|
256
|
+
this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, 'pty', input.id);
|
|
257
|
+
return client.request(method, input);
|
|
258
|
+
}
|
|
259
|
+
create(params) { return this.call('pty.create', { ...params, ...(this.options.launcher.ownership ? { registrationId: this.options.launcher.ownership.registrationId } : {}) }); }
|
|
260
|
+
attach(params) { return this.callNative('pty.attach', params); }
|
|
261
|
+
poll(params) { return this.callNative('pty.poll', params); }
|
|
262
|
+
input(params) { return this.callNative('pty.input', params); }
|
|
263
|
+
resize(params) { return this.callNative('pty.resize', params); }
|
|
264
|
+
scroll(params) { return this.callNative('pty.scroll', params); }
|
|
265
|
+
detach(params) { return this.callNative('pty.detach', params, { ok: true }); }
|
|
266
|
+
close(params) { return this.callNative('pty.close', params, { ok: true }); }
|
|
267
|
+
/** Listing is a probe even if the ready socket disappears: reconnect, never launch. */
|
|
268
|
+
async list() {
|
|
269
|
+
const presence = await this.probe();
|
|
270
|
+
if (presence.state === 'absent')
|
|
271
|
+
return { schema: 1, limits: { ...TERMINAL_GRID_LIMITS }, sessions: [] };
|
|
272
|
+
if (presence.state === 'unknown')
|
|
273
|
+
throw new KeeperLaunchError(new Error(presence.reason));
|
|
274
|
+
const result = await this.client.request('pty.list', {});
|
|
275
|
+
const registrationId = this.options.launcher.ownership?.registrationId;
|
|
276
|
+
if (registrationId)
|
|
277
|
+
result.sessions = result.sessions.filter(session => !session.registrationId || session.registrationId === registrationId);
|
|
278
|
+
for (const session of result.sessions)
|
|
279
|
+
this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, 'pty', session.id);
|
|
280
|
+
return result;
|
|
184
281
|
}
|
|
185
|
-
create(params) { return this.call('pty.create', params); }
|
|
186
|
-
attach(params) { return this.call('pty.attach', params); }
|
|
187
|
-
poll(params) { return this.call('pty.poll', params); }
|
|
188
|
-
write(params) { return this.call('pty.write', params); }
|
|
189
|
-
resize(params) { return this.call('pty.resize', params); }
|
|
190
|
-
takeover(params) { return this.call('pty.takeover', params); }
|
|
191
|
-
detach(params) { return this.call('pty.detach', params); }
|
|
192
|
-
close(params) { return this.call('pty.close', params); }
|
|
193
|
-
list() { return this.call('pty.list', {}); }
|
|
194
282
|
activity() { return this.call('keeper.activity', {}); }
|
|
195
283
|
// exec.* (spec §4.2). The daemon decides argv/env/deadline/quotas; the keeper only executes them.
|
|
196
|
-
execStart(params, options = {}) {
|
|
284
|
+
execStart(params, options = {}) {
|
|
285
|
+
return this.call('exec.start', { ...params, ...(this.options.launcher.ownership ? { registrationId: this.options.launcher.ownership.registrationId } : {}) }, options);
|
|
286
|
+
}
|
|
197
287
|
execList() { return this.call('exec.list', {}); }
|
|
198
288
|
/** Descriptor plus the latest checkpoint (blob as given to `execCheckpoint`). */
|
|
199
289
|
execStatus(execId, options = {}) { return this.call('exec.status', { execId }, options); }
|
|
@@ -206,7 +296,9 @@ export class TerminalKeeperClient {
|
|
|
206
296
|
* resumes after the last record delivered.
|
|
207
297
|
*/
|
|
208
298
|
async execSubscribe(execId, handler, options) {
|
|
209
|
-
|
|
299
|
+
const client = await this.ensure();
|
|
300
|
+
this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, 'exec', execId);
|
|
301
|
+
return client.subscribe(execStream(execId), handler, options);
|
|
210
302
|
}
|
|
211
303
|
/** Drops the connection (the keeper and its PTYs keep running). */
|
|
212
304
|
disconnect() { this.client?.close(); this.client = undefined; this.clientGen = undefined; }
|
|
@@ -44,7 +44,7 @@ const MAX_READ_RECORDS = 1024;
|
|
|
44
44
|
const MAX_READ_BYTES = 1536 * 1024;
|
|
45
45
|
const MAX_GRACE_MS = 10 * 60 * 1000;
|
|
46
46
|
const EXEC_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
47
|
-
const START_KEYS = ['execId', 'argv', 'cwd', 'env', 'deadlineEpochMs', 'killGraceMs', 'retainHeadBytes', 'maxUncommittedBytes', 'onKeeperLoss', 'label', 'jobId'];
|
|
47
|
+
const START_KEYS = ['execId', 'argv', 'cwd', 'env', 'deadlineEpochMs', 'killGraceMs', 'retainHeadBytes', 'maxUncommittedBytes', 'onKeeperLoss', 'label', 'jobId', 'registrationId'];
|
|
48
48
|
/**
|
|
49
49
|
* Whether a kept record stays in keep.jsonl once the daemon committed through `committed`. The head reserve (kept `out`/`err`
|
|
50
50
|
* records: the first `retainHeadBytes` of each stream) and the spawn record survive every non-final commit: the daemon shapes the
|
|
@@ -305,11 +305,14 @@ export class ExecHost {
|
|
|
305
305
|
bad('Invalid jobId');
|
|
306
306
|
if (this.runningCount() >= (this.options.runningLimit ?? EXEC_RUNNING_LIMIT))
|
|
307
307
|
throw new KeeperMethodError(ExecErrorCode.limit, 'Too many running commands in keeper-terminal');
|
|
308
|
+
const registrationId = params.registrationId;
|
|
309
|
+
if (registrationId !== undefined && (typeof registrationId !== 'string' || !registrationId || registrationId.length > 128 || /[\x00-\x1f]/.test(registrationId)))
|
|
310
|
+
bad('Invalid registrationId');
|
|
308
311
|
const meta = {
|
|
309
312
|
v: 1, execId, label: params.label.slice(0, EXEC_LABEL_CHARS), ...(jobId ? { jobId: jobId } : {}),
|
|
310
313
|
argv: argv, cwd, envKeys: Object.keys(env).sort(),
|
|
311
314
|
deadlineEpochMs: typeof deadline === 'number' ? deadline : null, killGraceMs, retainHeadBytes, maxUncommittedBytes, onKeeperLoss: onKeeperLoss,
|
|
312
|
-
createdAtMs: this.now(), owner: this.options.owner, spawn: null, exit: null, logReclaimed: null,
|
|
315
|
+
createdAtMs: this.now(), owner: this.options.owner, ...(typeof registrationId === 'string' ? { registrationId } : {}), spawn: null, exit: null, logReclaimed: null,
|
|
313
316
|
};
|
|
314
317
|
// Synchronously up to the spawn: a concurrent start of the same id finds the entry and waits for it.
|
|
315
318
|
let entry;
|
|
@@ -811,7 +814,7 @@ export class ExecHost {
|
|
|
811
814
|
describe(entry) {
|
|
812
815
|
const { meta } = entry;
|
|
813
816
|
return {
|
|
814
|
-
execId: meta.execId, label: meta.label, ...(meta.jobId ? { jobId: meta.jobId } : {}), state: this.stateOf(entry),
|
|
817
|
+
execId: meta.execId, label: meta.label, ...(meta.registrationId ? { registrationId: meta.registrationId } : {}), ...(meta.jobId ? { jobId: meta.jobId } : {}), state: this.stateOf(entry),
|
|
815
818
|
pid: meta.spawn?.pid ?? null, pgid: meta.spawn?.pgid ?? null, startTime: meta.spawn?.startTime ?? null,
|
|
816
819
|
createdAtMs: meta.createdAtMs, deadlineEpochMs: meta.deadlineEpochMs, onKeeperLoss: meta.onKeeperLoss, exit: meta.exit,
|
|
817
820
|
head: this.headOf(entry), committed: entry.committed, dropped: entry.dropped,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { KeeperMethodError } from '../kit/protocol.js';
|
|
2
|
+
import { TERMINAL_GRID_LIMITS as limits } from './gridProtocol.js';
|
|
3
|
+
/** Complete immutable snapshots, with deltas computed against any retained base. */
|
|
4
|
+
export class TerminalFrames {
|
|
5
|
+
cache = [];
|
|
6
|
+
bytes = 0;
|
|
7
|
+
/** A failed/oversized capture leaves both the revision and recovery cache untouched. */
|
|
8
|
+
publish(snapshot, session, geometry) {
|
|
9
|
+
validate(snapshot, session);
|
|
10
|
+
const previous = this.cache.at(-1)?.frame;
|
|
11
|
+
const sameSession = previous?.incarnation === session.incarnation && previous.id === session.id;
|
|
12
|
+
const nextGeometry = geometry ?? (sameSession ? previous.geometry + Number(previous.cols !== snapshot.cols || previous.rows !== snapshot.rows) : 1);
|
|
13
|
+
if (!Number.isSafeInteger(nextGeometry) || nextGeometry < 1 || (sameSession && nextGeometry < previous.geometry))
|
|
14
|
+
throw new Error('Invalid terminal geometry');
|
|
15
|
+
const next = structuredClone({
|
|
16
|
+
...snapshot, schema: 1, id: session.id, incarnation: session.incarnation,
|
|
17
|
+
revision: sameSession ? previous.revision + 1 : 1, baseRevision: 0,
|
|
18
|
+
geometry: nextGeometry, full: true, session,
|
|
19
|
+
});
|
|
20
|
+
if (sameSession && equalContent(previous, next))
|
|
21
|
+
return;
|
|
22
|
+
const bytes = resultBytes(next);
|
|
23
|
+
if (!sameSession) {
|
|
24
|
+
this.cache = [];
|
|
25
|
+
this.bytes = 0;
|
|
26
|
+
}
|
|
27
|
+
this.cache.push({ frame: next, bytes });
|
|
28
|
+
this.bytes += bytes;
|
|
29
|
+
while (this.cache.length > limits.maxCachedFrames || this.bytes > limits.maxFrameCacheBytes)
|
|
30
|
+
this.bytes -= this.cache.shift().bytes;
|
|
31
|
+
}
|
|
32
|
+
poll(after) {
|
|
33
|
+
const current = this.cache.at(-1)?.frame;
|
|
34
|
+
if (!current)
|
|
35
|
+
throw new Error('Terminal frame has not been captured');
|
|
36
|
+
if (after === current.revision)
|
|
37
|
+
return structuredClone({ ...current, full: false, baseRevision: after, styles: [], changedRows: [] });
|
|
38
|
+
const base = after === 0 ? undefined : this.cache.find(entry => entry.frame.revision === after)?.frame;
|
|
39
|
+
if (!base || base.geometry !== current.geometry || base.buffer !== current.buffer || base.incarnation !== current.incarnation)
|
|
40
|
+
return structuredClone(current);
|
|
41
|
+
const changedRows = current.changedRows.filter((row, index) => semanticRow(row, current) !== semanticRow(base.changedRows[index], base));
|
|
42
|
+
const delta = { ...current, full: false, baseRevision: after, changedRows };
|
|
43
|
+
resultBytes(delta);
|
|
44
|
+
return structuredClone(delta);
|
|
45
|
+
}
|
|
46
|
+
cacheStats() { return { frames: this.cache.length, bytes: this.bytes }; }
|
|
47
|
+
}
|
|
48
|
+
function semanticRow(row, frame) {
|
|
49
|
+
return JSON.stringify([row.wrapped ?? false, row.runs.flatMap(run => run.cells.map(cell => [cell.text, cell.width, frame.styles[run.style]]))]);
|
|
50
|
+
}
|
|
51
|
+
function equalContent(a, b) {
|
|
52
|
+
const header = (frame) => JSON.stringify([
|
|
53
|
+
frame.geometry, frame.cols, frame.rows, frame.buffer, frame.viewport, frame.cursor, frame.modes, frame.session,
|
|
54
|
+
]);
|
|
55
|
+
return header(a) === header(b) && a.changedRows.every((row, index) => semanticRow(row, a) === semanticRow(b.changedRows[index], b));
|
|
56
|
+
}
|
|
57
|
+
function resultBytes(frame) {
|
|
58
|
+
const bytes = Buffer.byteLength(JSON.stringify(frame));
|
|
59
|
+
// Leave room for the keeper response envelope and the tool response wrapper.
|
|
60
|
+
if (bytes > limits.maxResultBytes - 1024)
|
|
61
|
+
throw new KeeperMethodError('pty_snapshot_too_large', 'Terminal frame exceeds result limit; resize to a smaller viewport');
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
function validate(snapshot, session) {
|
|
65
|
+
if (snapshot.cols < limits.minCols || snapshot.cols > limits.maxCols || snapshot.rows < limits.minRows || snapshot.rows > limits.maxRows
|
|
66
|
+
|| snapshot.cols !== session.cols || snapshot.rows !== session.rows || snapshot.changedRows.length !== snapshot.rows)
|
|
67
|
+
throw new Error('Terminal snapshot must contain a complete matching grid');
|
|
68
|
+
for (const [index, row] of snapshot.changedRows.entries()) {
|
|
69
|
+
if (row.index !== index || row.runs.reduce((count, run) => count + run.cells.length, 0) !== snapshot.cols
|
|
70
|
+
|| row.runs.some(run => !snapshot.styles[run.style] || run.cells.some(cell => typeof cell.text !== 'string' || ![0, 1, 2].includes(cell.width))))
|
|
71
|
+
throw new Error('Terminal snapshot must contain complete rows and styles');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
// CLI upgrade an OLDER generation can still host PTYs and execs while a newer one takes new work. Nothing here starts an older
|
|
3
3
|
// generation: only the current build's client launches keeper-terminal. Exempt from the keeper-side import guard (daemon module).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// it — spec §8.3: an available update never does anything by itself).
|
|
8
|
-
// - Everything else is routed by OWNER: a PTY id or an exec id is served by the generation that runs it, through a client bound to
|
|
9
|
-
// that generation (tools/terminalRouter.ts, exec/policy.ts).
|
|
5
|
+
// Durable exec work uses the serving picker and retains owner routing across generations.
|
|
6
|
+
// Native terminal tools bypass this selection and always use `primary` (tools/terminalRouter.ts).
|
|
10
7
|
import path from 'node:path';
|
|
11
8
|
import { readKeeperManifest } from '../kit/bundle.js';
|
|
12
9
|
import { TerminalKeeperClient } from './client.js';
|
|
@@ -93,7 +90,7 @@ export class TerminalGenerations {
|
|
|
93
90
|
}
|
|
94
91
|
return older !== undefined && older !== current ? { gen: older, client: await this.client(older) } : { gen: current, client: this.primary };
|
|
95
92
|
}
|
|
96
|
-
/**
|
|
93
|
+
/** Durable exec creation target. Native terminal creation uses primary directly. */
|
|
97
94
|
async serving() { return (await this.servingGeneration()).client; }
|
|
98
95
|
/** Every live generation with its client (the current build's only when it runs). Starts nothing. */
|
|
99
96
|
async running() {
|
|
@@ -109,9 +106,9 @@ export class TerminalGenerations {
|
|
|
109
106
|
const serving = await this.serving();
|
|
110
107
|
return (await this.running()).map((generation) => generation.client).filter((client) => client !== serving);
|
|
111
108
|
}
|
|
112
|
-
/**
|
|
109
|
+
/** Native terminal status belongs only to the current build. Starts nothing. */
|
|
113
110
|
async terminalStatus(fallback) {
|
|
114
|
-
return
|
|
111
|
+
return this.primary.terminalStatus(fallback);
|
|
115
112
|
}
|
|
116
113
|
/** Drops the connection to `gen` (it was stopped). */
|
|
117
114
|
forget(gen) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Native terminal wire contract. No runtime/platform dependencies. */
|
|
2
|
+
export const TERMINAL_GRID_SCHEMA = 1;
|
|
3
|
+
export const TERMINAL_KEEPER_PROTOCOL = 2;
|
|
4
|
+
export const TERMINAL_GRID_CAPABILITY = 'pty-grid-v1';
|
|
5
|
+
export const TERMINAL_GRID_LIMITS = {
|
|
6
|
+
minCols: 20, maxCols: 300, minRows: 5, maxRows: 100,
|
|
7
|
+
maxSessions: 8, maxAttachments: 16, leaseMs: 15_000, scrollbackLines: 10_000,
|
|
8
|
+
maxInputBytes: 64 * 1024, maxRequestBytes: 128 * 1024, maxResultBytes: 2 * 1024 * 1024,
|
|
9
|
+
maxCachedFrames: 8, maxFrameCacheBytes: 8 * 1024 * 1024,
|
|
10
|
+
};
|
|
11
|
+
export const TERMINAL_MODIFIERS = { shift: 1, alt: 2, ctrl: 4, meta: 8 };
|
|
12
|
+
export const TERMINAL_STYLE_FLAGS = {
|
|
13
|
+
bold: 1, dim: 2, italic: 4, underline: 8, blink: 16, inverse: 32, invisible: 64, strike: 128,
|
|
14
|
+
};
|
|
15
|
+
export const TERMINAL_DEFAULT_COLOR = -1;
|
|
16
|
+
export const TERMINAL_RGB_COLOR_BASE = 0x1000000;
|