@addai/node 0.24.1 → 0.25.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/dist/capabilities.d.ts +23 -0
- package/dist/capabilities.js +48 -0
- package/dist/codex-spawn.js +19 -31
- package/dist/command-runner.js +35 -2
- package/dist/desktop/creds.d.ts +15 -0
- package/dist/desktop/creds.js +28 -0
- package/dist/desktop/manager.d.ts +19 -0
- package/dist/desktop/manager.js +87 -0
- package/dist/desktop/relay-client.js +29 -5
- package/dist/flows/engine-loader.d.ts +8 -0
- package/dist/flows/engine-loader.js +40 -0
- package/dist/flows/host.d.ts +40 -0
- package/dist/flows/host.js +125 -0
- package/dist/flows/pump.d.ts +31 -0
- package/dist/flows/pump.js +264 -0
- package/dist/flows/run.d.ts +7 -0
- package/dist/flows/run.js +219 -0
- package/dist/gemini-spawn.js +54 -2
- package/dist/grok-spawn.js +10 -39
- package/dist/heartbeat.js +24 -0
- package/dist/index.js +22 -4
- package/dist/mcp-headers.d.ts +12 -6
- package/dist/mcp-headers.js +12 -6
- package/package.json +2 -1
package/dist/capabilities.d.ts
CHANGED
|
@@ -20,6 +20,23 @@ interface BinaryProbe extends AuthShape {
|
|
|
20
20
|
/** All remote login methods (primary strategy first, then alternates). */
|
|
21
21
|
login_methods?: string[];
|
|
22
22
|
}
|
|
23
|
+
interface FlowsCapability {
|
|
24
|
+
/** Whether this machine is claiming flow runs. Mirrors the server's switch;
|
|
25
|
+
* reported back so the Studio can show agreement rather than intent. */
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
/** Version of the shared engine this daemon bundles. */
|
|
28
|
+
engine_version: string | null;
|
|
29
|
+
/** 'isolated-vm' or the weaker 'vm' fallback, stated plainly. */
|
|
30
|
+
sandbox: {
|
|
31
|
+
kind: string;
|
|
32
|
+
isolated: boolean;
|
|
33
|
+
note: string | null;
|
|
34
|
+
};
|
|
35
|
+
/** Flow runs in flight, and the ceiling. Its own pool — separate from
|
|
36
|
+
* agent runs on purpose, so a flows burst cannot starve a chat. */
|
|
37
|
+
running: number;
|
|
38
|
+
max_concurrent: number;
|
|
39
|
+
}
|
|
23
40
|
interface CapabilitiesShape {
|
|
24
41
|
/** Version of @addai/node running. Lets the studio / CodeFlows
|
|
25
42
|
* UIs show "outdated daemon" hints and helps us debug field issues
|
|
@@ -49,6 +66,12 @@ interface CapabilitiesShape {
|
|
|
49
66
|
* installed. Studio renders an install card in place of the create
|
|
50
67
|
* button when this is null. */
|
|
51
68
|
container_engine?: EngineInfo | null;
|
|
69
|
+
/** What this machine can do about +Ai Flows, and what it is doing right now.
|
|
70
|
+
* The Studio's Flows tab is built entirely out of this — whether the pump
|
|
71
|
+
* is on, how much room is left, and crucially which sandbox the engine
|
|
72
|
+
* resolved, because a UI that implies isolated-vm when the weaker `vm`
|
|
73
|
+
* fallback is running is worse than one that says nothing. */
|
|
74
|
+
flows?: FlowsCapability;
|
|
52
75
|
/** CPU / memory / disk / runs at the moment of this probe. The AiNode page
|
|
53
76
|
* has read this key since it was written; nothing produced it until now. */
|
|
54
77
|
machine?: MachineSample;
|
package/dist/capabilities.js
CHANGED
|
@@ -56,6 +56,8 @@ const harness_registry_1 = require("./harness-registry");
|
|
|
56
56
|
const autostart_1 = require("./autostart");
|
|
57
57
|
const auto_update_1 = require("./auto-update");
|
|
58
58
|
const machine_metrics_1 = require("./machine-metrics");
|
|
59
|
+
const pump_1 = require("./flows/pump");
|
|
60
|
+
const engine_loader_1 = require("./flows/engine-loader");
|
|
59
61
|
function readDaemonVersion() {
|
|
60
62
|
try {
|
|
61
63
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -368,6 +370,51 @@ async function probeGit() {
|
|
|
368
370
|
authed: true,
|
|
369
371
|
};
|
|
370
372
|
}
|
|
373
|
+
/** Describe this machine's flows support.
|
|
374
|
+
*
|
|
375
|
+
* Deliberately does NOT force the engine to load. The engine is ESM with a
|
|
376
|
+
* top-level probe of the optional native sandbox, and loading it on every
|
|
377
|
+
* heartbeat — on machines that never run a flow — would be a cost paid by
|
|
378
|
+
* everyone for the benefit of a few. Once flows are on and the engine is
|
|
379
|
+
* loaded, the real sandbox is reported; until then it says so honestly
|
|
380
|
+
* rather than guessing.
|
|
381
|
+
*
|
|
382
|
+
* Never throws: a capabilities probe that could fail would take the whole
|
|
383
|
+
* heartbeat with it, and the machine would read offline because it could not
|
|
384
|
+
* describe its flows support.
|
|
385
|
+
*/
|
|
386
|
+
async function probeFlows() {
|
|
387
|
+
let engineVersion = null;
|
|
388
|
+
try {
|
|
389
|
+
// JSON, so plain require is safe here — this is not the ESM graph.
|
|
390
|
+
engineVersion = require('@addai/node-flows/package.json').version ?? null;
|
|
391
|
+
}
|
|
392
|
+
catch { /* not installed; reported as null below */ }
|
|
393
|
+
let sandbox = {
|
|
394
|
+
kind: 'not-loaded',
|
|
395
|
+
isolated: false,
|
|
396
|
+
note: 'The engine loads when flows are switched on for this machine.',
|
|
397
|
+
};
|
|
398
|
+
if ((0, engine_loader_1.engineLoaded)()) {
|
|
399
|
+
try {
|
|
400
|
+
const { sandboxAvailability } = await (0, engine_loader_1.loadEngine)();
|
|
401
|
+
sandbox = sandboxAvailability();
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
sandbox = {
|
|
405
|
+
kind: 'unavailable', isolated: false,
|
|
406
|
+
note: `The flows engine failed to load: ${err.message}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
enabled: (0, pump_1.flowsEnabled)(),
|
|
412
|
+
engine_version: engineVersion,
|
|
413
|
+
sandbox,
|
|
414
|
+
running: (0, pump_1.inflightFlowCount)(),
|
|
415
|
+
max_concurrent: (0, pump_1.maxConcurrentFlows)(),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
371
418
|
async function probeCapabilities() {
|
|
372
419
|
// Binary lookups cache misses; a CLI installed mid-daemon-life (by the
|
|
373
420
|
// command runner or by hand) must show up on the next probe, not after
|
|
@@ -403,6 +450,7 @@ async function probeCapabilities() {
|
|
|
403
450
|
daemon_version: readDaemonVersion(),
|
|
404
451
|
autostart: (0, autostart_1.status)(),
|
|
405
452
|
container_engine: containerEngine,
|
|
453
|
+
flows: await probeFlows(),
|
|
406
454
|
auto_update: (0, auto_update_1.autoUpdateState)(),
|
|
407
455
|
machine: (0, machine_metrics_1.sampleMachine)(),
|
|
408
456
|
claude: deco('claude', claude),
|
package/dist/codex-spawn.js
CHANGED
|
@@ -52,7 +52,6 @@ const codex_binary_1 = require("./codex-binary");
|
|
|
52
52
|
const diskguard_1 = require("./diskguard");
|
|
53
53
|
const win_1 = require("./win");
|
|
54
54
|
const events_1 = require("./events");
|
|
55
|
-
const mcp_headers_1 = require("./mcp-headers");
|
|
56
55
|
/** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
|
|
57
56
|
function tomlString(s) {
|
|
58
57
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
|
@@ -61,11 +60,6 @@ function tomlString(s) {
|
|
|
61
60
|
* Only the NAME goes in config.toml — the value rides on the child env, so
|
|
62
61
|
* the token never lands on disk. */
|
|
63
62
|
const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
|
|
64
|
-
/** Env var name carrying one HTTP header's value into the codex process.
|
|
65
|
-
* config.toml references it by name via env_http_headers, so the value
|
|
66
|
-
* itself never lands on disk. */
|
|
67
|
-
const headerEnvVar = (slug, header) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}` +
|
|
68
|
-
`_H_${header.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`;
|
|
69
63
|
/** Build a config.toml that declares only the supplied MCP servers and
|
|
70
64
|
* return the CODEX_HOME directory plus any env vars the child needs
|
|
71
65
|
* (bearer tokens for remote servers). Caller is responsible for cleanup
|
|
@@ -80,16 +74,12 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
80
74
|
// Remote MCP servers. Registry rows store command='http'|'sse' with
|
|
81
75
|
// args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
|
|
82
76
|
// the row verbatim made codex try to exec a binary called `http`, so the
|
|
83
|
-
// server never started and its tools silently vanished
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// codex could not send custom headers; it can, and a server needing more
|
|
90
|
-
// than one header (addai-drive carries three) was arriving half-authorised.
|
|
91
|
-
if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
|
|
92
|
-
const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
|
|
77
|
+
// server never started and its tools silently vanished — which is how a
|
|
78
|
+
// provider-ladder hop to codex dropped an entity's GitHub access without
|
|
79
|
+
// any error anywhere. The claude path (install.ts) always handled this;
|
|
80
|
+
// codex and grok did not.
|
|
81
|
+
if (s.command === 'http' || s.command === 'sse') {
|
|
82
|
+
const url = Array.isArray(s.args) && typeof s.args[0] === 'string' ? s.args[0] : null;
|
|
93
83
|
if (!url) {
|
|
94
84
|
console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
95
85
|
continue;
|
|
@@ -97,24 +87,22 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
97
87
|
const name = tomlString(s.slug).slice(1, -1);
|
|
98
88
|
toml += `[mcp_servers.${name}]\n`;
|
|
99
89
|
toml += `url = ${tomlString(url)}\n`;
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
90
|
+
// codex reads the bearer from its OWN environment (--env is stdio-only),
|
|
91
|
+
// so the value goes on the child env and only the var name in the file.
|
|
92
|
+
const creds = Object.entries(s.env || {})
|
|
93
|
+
.filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
|
|
94
|
+
const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
|
|
104
95
|
if (bearer) {
|
|
105
|
-
delete headers.Authorization;
|
|
106
96
|
const varName = bearerEnvVar(s.slug);
|
|
107
|
-
extraEnv[varName] = bearer
|
|
97
|
+
extraEnv[varName] = String(bearer[1]);
|
|
108
98
|
toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
extraEnv[varName] = value;
|
|
117
|
-
toml += `${tomlString(header)} = ${tomlString(varName)}\n`;
|
|
99
|
+
if (creds.length > 1) {
|
|
100
|
+
// codex carries one bearer and no arbitrary headers. Anything that
|
|
101
|
+
// authenticates with several custom headers is only partly wired,
|
|
102
|
+
// and saying so beats a server that 401s for no visible reason.
|
|
103
|
+
console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
|
|
104
|
+
`codex cannot send custom headers, so ${creds.length - 1} other credential ` +
|
|
105
|
+
`field(s) were dropped`);
|
|
118
106
|
}
|
|
119
107
|
}
|
|
120
108
|
toml += '\n';
|
package/dist/command-runner.js
CHANGED
|
@@ -577,6 +577,7 @@ async function runSetAutostart(cmd) {
|
|
|
577
577
|
const DESKTOP_KINDS = [
|
|
578
578
|
'desktop_create', 'desktop_start', 'desktop_stop',
|
|
579
579
|
'desktop_delete', 'desktop_rebuild', 'desktop_sync_logins',
|
|
580
|
+
'desktop_exec',
|
|
580
581
|
];
|
|
581
582
|
/** Setup scripts run as a FILE, never as a `sh -c` string: a script with
|
|
582
583
|
* quotes or newlines must not need escaping to survive. */
|
|
@@ -592,7 +593,12 @@ async function runDesktopCommand(cmd) {
|
|
|
592
593
|
}
|
|
593
594
|
// force: a user who just installed Docker should not have to restart the
|
|
594
595
|
// daemon for their first desktop to build.
|
|
595
|
-
|
|
596
|
+
// Re-detect the engine for the RARE lifecycle commands: someone who has
|
|
597
|
+
// just installed Docker should not have to restart the daemon for a create
|
|
598
|
+
// or a start to find it. desktop_exec is the opposite — an entity driving a
|
|
599
|
+
// remote desktop makes dozens of these, and `docker info` costs seconds on
|
|
600
|
+
// Docker Desktop (measured: 4.2s on a Mac), so it uses the cached engine.
|
|
601
|
+
const provider = await (0, docker_1.getProvider)(cmd.kind !== 'desktop_exec');
|
|
596
602
|
if (!provider) {
|
|
597
603
|
const why = 'No container engine on this machine. Install Docker Desktop or Podman, then try again.';
|
|
598
604
|
await update(cmd.id, 'failed', undefined, why);
|
|
@@ -650,11 +656,18 @@ async function runDesktopCommand(cmd) {
|
|
|
650
656
|
}
|
|
651
657
|
break;
|
|
652
658
|
}
|
|
653
|
-
case 'desktop_start':
|
|
659
|
+
case 'desktop_start': {
|
|
660
|
+
// Someone pressed start, so this one fails loudly rather than
|
|
661
|
+
// deferring quietly the way autostart does. They are owed an answer
|
|
662
|
+
// they can act on: which limit, and where to change it.
|
|
663
|
+
const room = await (0, manager_1.desktopStartAllowed)(await (0, manager_1.listDesktops)(), row.id);
|
|
664
|
+
if (!room.allowed)
|
|
665
|
+
throw new Error(room.reason);
|
|
654
666
|
await (0, manager_1.setStatus)(row.id, { status: 'starting' });
|
|
655
667
|
await provider.start(row);
|
|
656
668
|
await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
|
|
657
669
|
break;
|
|
670
|
+
}
|
|
658
671
|
case 'desktop_stop':
|
|
659
672
|
await provider.stop(row);
|
|
660
673
|
await (0, manager_1.setStatus)(row.id, { status: 'stopped', status_message: null });
|
|
@@ -671,6 +684,26 @@ async function runDesktopCommand(cmd) {
|
|
|
671
684
|
case 'desktop_sync_logins':
|
|
672
685
|
await (0, creds_1.syncCredentials)(provider, row, onLog);
|
|
673
686
|
break;
|
|
687
|
+
case 'desktop_exec': {
|
|
688
|
+
// An entity on ANOTHER machine driving this desktop. The MCP used to
|
|
689
|
+
// run `docker exec` locally, so an entity could only ever touch a
|
|
690
|
+
// desktop that happened to live on the node it was dispatched to.
|
|
691
|
+
// This is that same exec, arriving over the wire.
|
|
692
|
+
const argv = Array.isArray(cmd.input?.argv) ? cmd.input.argv : null;
|
|
693
|
+
if (!argv || argv.length === 0)
|
|
694
|
+
throw new Error('desktop_exec needs an argv array');
|
|
695
|
+
const timeoutMs = Math.min(Math.max(Number(cmd.input?.timeout_ms) || 60_000, 1_000), 10 * 60_000);
|
|
696
|
+
const res = await (0, creds_1.execInDesktopCapture)(provider, row, argv, timeoutMs);
|
|
697
|
+
// stdout is base64 because it is frequently a PNG. A non-zero exit is
|
|
698
|
+
// reported as a completed command carrying that code — the command
|
|
699
|
+
// ran; what it returned is the entity's problem to read.
|
|
700
|
+
await update(cmd.id, 'completed', {
|
|
701
|
+
stdout_b64: res.stdout.toString('base64'),
|
|
702
|
+
stderr: res.stderr.slice(0, 8000),
|
|
703
|
+
exit_code: res.exitCode,
|
|
704
|
+
});
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
674
707
|
}
|
|
675
708
|
await update(cmd.id, 'completed', { log });
|
|
676
709
|
}
|
package/dist/desktop/creds.d.ts
CHANGED
|
@@ -5,3 +5,18 @@ export declare function stripMcpServers(raw: string): string;
|
|
|
5
5
|
/** Run a command inside a desktop, streaming output to onLog. */
|
|
6
6
|
export declare function execInDesktop(provider: DesktopProvider, row: DesktopRow, cmd: string[], onLog: (c: string) => void): Promise<void>;
|
|
7
7
|
export declare function syncCredentials(provider: DesktopProvider, row: DesktopRow, onLog: (c: string) => void): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Run a command inside a desktop and CAPTURE its output.
|
|
10
|
+
*
|
|
11
|
+
* execInDesktop streams to a log, which is right for an install and useless
|
|
12
|
+
* for a screenshot: the caller needs the bytes back, exactly, and a PNG does
|
|
13
|
+
* not survive being treated as UTF-8. This is the remote arm of the desktop
|
|
14
|
+
* MCP — an entity running on one machine driving a desktop on another — so
|
|
15
|
+
* the result has to cross the wire intact and a non-zero exit is a RESULT,
|
|
16
|
+
* not a transport failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function execInDesktopCapture(provider: DesktopProvider, row: DesktopRow, cmd: string[], timeoutMs?: number): Promise<{
|
|
19
|
+
stdout: Buffer;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}>;
|
package/dist/desktop/creds.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.stripMcpServers = stripMcpServers;
|
|
37
37
|
exports.execInDesktop = execInDesktop;
|
|
38
38
|
exports.syncCredentials = syncCredentials;
|
|
39
|
+
exports.execInDesktopCapture = execInDesktopCapture;
|
|
39
40
|
// Harness logins are COPIED into a desktop, never bind-mounted.
|
|
40
41
|
//
|
|
41
42
|
// Bind-mounting ~/.claude would hand the box write access to every transcript
|
|
@@ -132,3 +133,30 @@ async function syncCredentials(provider, row, onLog) {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Run a command inside a desktop and CAPTURE its output.
|
|
138
|
+
*
|
|
139
|
+
* execInDesktop streams to a log, which is right for an install and useless
|
|
140
|
+
* for a screenshot: the caller needs the bytes back, exactly, and a PNG does
|
|
141
|
+
* not survive being treated as UTF-8. This is the remote arm of the desktop
|
|
142
|
+
* MCP — an entity running on one machine driving a desktop on another — so
|
|
143
|
+
* the result has to cross the wire intact and a non-zero exit is a RESULT,
|
|
144
|
+
* not a transport failure.
|
|
145
|
+
*/
|
|
146
|
+
function execInDesktopCapture(provider, row, cmd, timeoutMs = 60_000) {
|
|
147
|
+
const args = (0, provider_1.buildExecArgs)(row, cmd, { cwd: '/work', env: {}, tty: false });
|
|
148
|
+
const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const child = (0, child_process_1.spawn)(inv.file, inv.args, {
|
|
151
|
+
env: process.env, windowsHide: true, timeout: timeoutMs,
|
|
152
|
+
});
|
|
153
|
+
const out = [];
|
|
154
|
+
let err = '';
|
|
155
|
+
child.stdout?.on('data', (b) => out.push(b));
|
|
156
|
+
child.stderr?.on('data', (b) => { err += b.toString('utf8'); });
|
|
157
|
+
child.on('error', reject);
|
|
158
|
+
child.on('close', code => resolve({
|
|
159
|
+
stdout: Buffer.concat(out), stderr: err, exitCode: code ?? -1,
|
|
160
|
+
}));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -24,6 +24,25 @@ export declare function ensureDirs(desktopId: string): {
|
|
|
24
24
|
confDir: string;
|
|
25
25
|
workDir: string;
|
|
26
26
|
};
|
|
27
|
+
export declare const DEFAULT_MAX_CONCURRENT_DESKTOPS = 2;
|
|
28
|
+
export declare function clampMaxDesktops(value: unknown): number;
|
|
29
|
+
/** Pushed in by the heartbeat. Lowering it never stops a running desktop —
|
|
30
|
+
* killing someone's live session to satisfy a number they just typed would
|
|
31
|
+
* be a worse surprise than being one over the cap for a while. */
|
|
32
|
+
export declare function setMaxConcurrentDesktops(value: unknown): void;
|
|
33
|
+
export declare function maxConcurrentDesktops(): number;
|
|
34
|
+
/** How many desktops are running right now, asked of the engine rather than
|
|
35
|
+
* tracked in a counter. A counter would drift the moment somebody stopped a
|
|
36
|
+
* container by hand, and drift upward means refusing to start anything. */
|
|
37
|
+
export declare function runningDesktopCount(rows: DesktopRow[]): Promise<number>;
|
|
38
|
+
/** Whether one more may start. Returns the reason when it may not, so the
|
|
39
|
+
* caller can say something better than "failed". */
|
|
40
|
+
export declare function desktopStartAllowed(rows: DesktopRow[], excludeId?: string): Promise<{
|
|
41
|
+
allowed: true;
|
|
42
|
+
} | {
|
|
43
|
+
allowed: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
}>;
|
|
27
46
|
export declare function startDesktopManager(): void;
|
|
28
47
|
export declare function stopDesktopManager(): void;
|
|
29
48
|
/** Bring a desktop up and wait for it, for the run path. Returns the row when
|
package/dist/desktop/manager.js
CHANGED
|
@@ -33,11 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_MAX_CONCURRENT_DESKTOPS = void 0;
|
|
36
37
|
exports.reconcileAction = reconcileAction;
|
|
37
38
|
exports.listDesktops = listDesktops;
|
|
38
39
|
exports.setStatus = setStatus;
|
|
39
40
|
exports.allocateVncPort = allocateVncPort;
|
|
40
41
|
exports.ensureDirs = ensureDirs;
|
|
42
|
+
exports.clampMaxDesktops = clampMaxDesktops;
|
|
43
|
+
exports.setMaxConcurrentDesktops = setMaxConcurrentDesktops;
|
|
44
|
+
exports.maxConcurrentDesktops = maxConcurrentDesktops;
|
|
45
|
+
exports.runningDesktopCount = runningDesktopCount;
|
|
46
|
+
exports.desktopStartAllowed = desktopStartAllowed;
|
|
41
47
|
exports.startDesktopManager = startDesktopManager;
|
|
42
48
|
exports.stopDesktopManager = stopDesktopManager;
|
|
43
49
|
exports.ensureRunning = ensureRunning;
|
|
@@ -118,6 +124,70 @@ function ensureDirs(desktopId) {
|
|
|
118
124
|
fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
119
125
|
return { confDir, workDir };
|
|
120
126
|
}
|
|
127
|
+
// How many desktops may be RUNNING on this machine at once.
|
|
128
|
+
//
|
|
129
|
+
// There was never a ceiling here, and the failure it allows is not subtle:
|
|
130
|
+
// four containers each sized for a browser will take a Mac mini down, and
|
|
131
|
+
// when they do they take the agent runs and the flow runs with them. Its own
|
|
132
|
+
// pool, like flows and agent runs, because the three compete for the same box
|
|
133
|
+
// but answer to different people.
|
|
134
|
+
//
|
|
135
|
+
// Absent means "keep what you had", never 0 — a server that has not had the
|
|
136
|
+
// migration must not be able to stop every desktop on every machine.
|
|
137
|
+
exports.DEFAULT_MAX_CONCURRENT_DESKTOPS = 2;
|
|
138
|
+
const MAX_MAX_CONCURRENT_DESKTOPS = 16;
|
|
139
|
+
let maxDesktops = exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
140
|
+
function clampMaxDesktops(value) {
|
|
141
|
+
if (typeof value !== 'number' && typeof value !== 'string')
|
|
142
|
+
return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
143
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
144
|
+
return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
145
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
146
|
+
if (!Number.isFinite(n))
|
|
147
|
+
return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
148
|
+
return Math.min(MAX_MAX_CONCURRENT_DESKTOPS, Math.max(1, Math.floor(n)));
|
|
149
|
+
}
|
|
150
|
+
/** Pushed in by the heartbeat. Lowering it never stops a running desktop —
|
|
151
|
+
* killing someone's live session to satisfy a number they just typed would
|
|
152
|
+
* be a worse surprise than being one over the cap for a while. */
|
|
153
|
+
function setMaxConcurrentDesktops(value) {
|
|
154
|
+
const next = clampMaxDesktops(value);
|
|
155
|
+
if (next === maxDesktops)
|
|
156
|
+
return;
|
|
157
|
+
console.log(`[desktops] desktops at once: ${maxDesktops} -> ${next}`);
|
|
158
|
+
maxDesktops = next;
|
|
159
|
+
}
|
|
160
|
+
function maxConcurrentDesktops() { return maxDesktops; }
|
|
161
|
+
/** How many desktops are running right now, asked of the engine rather than
|
|
162
|
+
* tracked in a counter. A counter would drift the moment somebody stopped a
|
|
163
|
+
* container by hand, and drift upward means refusing to start anything. */
|
|
164
|
+
async function runningDesktopCount(rows) {
|
|
165
|
+
const provider = await (0, docker_1.getProvider)();
|
|
166
|
+
if (!provider)
|
|
167
|
+
return 0;
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
try {
|
|
171
|
+
const actual = await provider.inspect(row);
|
|
172
|
+
if (actual?.running)
|
|
173
|
+
count++;
|
|
174
|
+
}
|
|
175
|
+
catch { /* unknown state counts as not running; the reconcile will fix it */ }
|
|
176
|
+
}
|
|
177
|
+
return count;
|
|
178
|
+
}
|
|
179
|
+
/** Whether one more may start. Returns the reason when it may not, so the
|
|
180
|
+
* caller can say something better than "failed". */
|
|
181
|
+
async function desktopStartAllowed(rows, excludeId) {
|
|
182
|
+
const running = await runningDesktopCount(rows.filter(r => r.id !== excludeId));
|
|
183
|
+
if (running < maxDesktops)
|
|
184
|
+
return { allowed: true };
|
|
185
|
+
return {
|
|
186
|
+
allowed: false,
|
|
187
|
+
reason: `This machine is already running ${running} desktop${running === 1 ? '' : 's'}, `
|
|
188
|
+
+ `its limit. Stop one, or raise the limit on the machine's page.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
121
191
|
let timer = null;
|
|
122
192
|
let inflight = false;
|
|
123
193
|
async function tick() {
|
|
@@ -136,6 +206,15 @@ async function tick() {
|
|
|
136
206
|
continue;
|
|
137
207
|
try {
|
|
138
208
|
if (action === 'start') {
|
|
209
|
+
// Autostart is the quiet path to blowing the cap: a machine that
|
|
210
|
+
// reboots would bring every desktop back at once regardless of what
|
|
211
|
+
// the box can take. Over the line, it defers and says so rather than
|
|
212
|
+
// failing — nothing is wrong with the desktop, there is just no room.
|
|
213
|
+
const room = await desktopStartAllowed(rows, row.id);
|
|
214
|
+
if (!room.allowed) {
|
|
215
|
+
await setStatus(row.id, { status: 'stopped', status_message: room.reason });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
139
218
|
await provider.start(row);
|
|
140
219
|
await setStatus(row.id, { status: 'running', status_message: null });
|
|
141
220
|
}
|
|
@@ -196,6 +275,14 @@ async function ensureRunning(desktopId, timeoutMs = 60_000) {
|
|
|
196
275
|
return row;
|
|
197
276
|
if (!actual)
|
|
198
277
|
return null; // never created / pruned: cannot start
|
|
278
|
+
// At the cap, the run falls back to the host rather than starting an
|
|
279
|
+
// eleventh container. Returning null is how this function already says
|
|
280
|
+
// "could not start it" and the caller already handles it.
|
|
281
|
+
const room = await desktopStartAllowed(rows, row.id);
|
|
282
|
+
if (!room.allowed) {
|
|
283
|
+
console.warn(`[desktops] not starting ${row.id} for a run — ${room.reason}`);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
199
286
|
const deadline = Date.now() + timeoutMs;
|
|
200
287
|
try {
|
|
201
288
|
await provider.start(row);
|
|
@@ -164,10 +164,24 @@ async function connect() {
|
|
|
164
164
|
catch {
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
|
+
// Every way a bridge can fail used to fail SILENTLY: the viewer's socket
|
|
168
|
+
// stayed open, noVNC went on reporting 'connected', and the last frame sat
|
|
169
|
+
// there looking live while every keystroke was dropped on the floor. A
|
|
170
|
+
// frozen picture you cannot tell from a working one is the worst possible
|
|
171
|
+
// failure, so each of these now says so and the viewer is disconnected.
|
|
172
|
+
const gone = (viewerId, reason) => {
|
|
173
|
+
bridges.get(viewerId)?.destroy();
|
|
174
|
+
bridges.delete(viewerId);
|
|
175
|
+
if (sock.readyState === ws_1.default.OPEN) {
|
|
176
|
+
sock.send(JSON.stringify({ type: 'gone', viewerId, reason }));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
167
179
|
if (msg.type === 'attach') {
|
|
168
180
|
const row = (await (0, manager_1.listDesktops)()).find(d => d.id === msg.desktopId);
|
|
169
|
-
if (!row?.vnc_port)
|
|
181
|
+
if (!row?.vnc_port) {
|
|
182
|
+
gone(msg.viewerId, 'desktop is not running');
|
|
170
183
|
return;
|
|
184
|
+
}
|
|
171
185
|
// Loopback only - the container published its RFB on 127.0.0.1 and this
|
|
172
186
|
// process is the only thing on the machine that reaches it.
|
|
173
187
|
const tcp = net.connect(row.vnc_port, '127.0.0.1');
|
|
@@ -178,15 +192,25 @@ async function connect() {
|
|
|
178
192
|
}));
|
|
179
193
|
}
|
|
180
194
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
// Refused connection (container up, x11vnc not listening) and a bridge
|
|
196
|
+
// that dies mid-stream are different words but the same outcome for the
|
|
197
|
+
// person watching: there is no screen any more, and they must be told.
|
|
198
|
+
tcp.on('error', () => gone(msg.viewerId, 'lost the desktop connection'));
|
|
199
|
+
tcp.on('close', () => gone(msg.viewerId, 'the desktop closed the connection'));
|
|
184
200
|
bridges.get(msg.viewerId)?.destroy();
|
|
185
201
|
bridges.set(msg.viewerId, tcp);
|
|
186
202
|
return;
|
|
187
203
|
}
|
|
188
204
|
if (msg.type === 'data' && msg.b64) {
|
|
189
|
-
bridges.get(msg.viewerId)
|
|
205
|
+
const bridge = bridges.get(msg.viewerId);
|
|
206
|
+
// Input for a bridge that no longer exists. Dropping it quietly is how
|
|
207
|
+
// "take control does nothing" happens: the pointer never moves and the
|
|
208
|
+
// viewer has no idea why.
|
|
209
|
+
if (!bridge) {
|
|
210
|
+
gone(msg.viewerId, 'no longer connected to the desktop');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
bridge.write(Buffer.from(msg.b64, 'base64'));
|
|
190
214
|
return;
|
|
191
215
|
}
|
|
192
216
|
if (msg.type === 'wake') {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type EngineModule = typeof import('@addai/node-flows');
|
|
2
|
+
/** Load the engine once and reuse it. The promise is cached rather than the
|
|
3
|
+
* module, so concurrent first calls share one load instead of racing. */
|
|
4
|
+
export declare function loadEngine(): Promise<EngineModule>;
|
|
5
|
+
/** Has the engine been loaded already? Lets the capabilities probe report the
|
|
6
|
+
* sandbox without forcing a load on a machine that does not run flows. */
|
|
7
|
+
export declare function engineLoaded(): boolean;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Loading an ESM package from a CommonJS daemon.
|
|
3
|
+
//
|
|
4
|
+
// @addai/node-flows is ESM and its sandbox does a top-level `await
|
|
5
|
+
// import('isolated-vm')` — the optional native isolate, probed at load. This
|
|
6
|
+
// daemon compiles to CommonJS, and `require()` refuses an ESM graph with
|
|
7
|
+
// top-level await outright (ERR_REQUIRE_ASYNC_MODULE). So the engine can only
|
|
8
|
+
// ever be loaded asynchronously.
|
|
9
|
+
//
|
|
10
|
+
// The `new Function` is not a trick for its own sake: TypeScript with
|
|
11
|
+
// `module: CommonJS` rewrites a plain `import()` into `require()`, which is
|
|
12
|
+
// precisely the call that fails. Hiding the import inside a function body the
|
|
13
|
+
// compiler will not touch is what keeps it a real dynamic import at runtime.
|
|
14
|
+
//
|
|
15
|
+
// Loading is also lazy on purpose. A machine that never runs flows should
|
|
16
|
+
// never pay to load the engine, and on a machine where the native sandbox
|
|
17
|
+
// failed to build, the failure should surface when someone turns flows on —
|
|
18
|
+
// not as a daemon that will not start.
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.loadEngine = loadEngine;
|
|
21
|
+
exports.engineLoaded = engineLoaded;
|
|
22
|
+
const dynamicImport = new Function('specifier', 'return import(specifier);');
|
|
23
|
+
let cached = null;
|
|
24
|
+
/** Load the engine once and reuse it. The promise is cached rather than the
|
|
25
|
+
* module, so concurrent first calls share one load instead of racing. */
|
|
26
|
+
function loadEngine() {
|
|
27
|
+
if (!cached) {
|
|
28
|
+
cached = dynamicImport('@addai/node-flows').catch((err) => {
|
|
29
|
+
// Clear the cache so a later attempt can retry — a transient failure
|
|
30
|
+
// (a half-written install, say) must not poison the process for good.
|
|
31
|
+
cached = null;
|
|
32
|
+
throw new Error(`The flows engine could not be loaded: ${err.message}. `
|
|
33
|
+
+ 'Reinstall @addai/node, or turn flows off for this machine.');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
/** Has the engine been loaded already? Lets the capabilities probe report the
|
|
39
|
+
* sandbox without forcing a load on a machine that does not run flows. */
|
|
40
|
+
function engineLoaded() { return cached !== null; }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HandlerRegistry } from '@addai/node-flows';
|
|
2
|
+
/** One flow run, as the claim RPC hands it over. */
|
|
3
|
+
export interface FlowRunRow {
|
|
4
|
+
id: string;
|
|
5
|
+
flow_id: string | null;
|
|
6
|
+
aiflow_version_id: string | null;
|
|
7
|
+
workspace_id: string | null;
|
|
8
|
+
status: string;
|
|
9
|
+
nodes: any[] | null;
|
|
10
|
+
edges: any[] | null;
|
|
11
|
+
trigger_data: Record<string, unknown> | null;
|
|
12
|
+
node_outputs: Record<string, unknown> | null;
|
|
13
|
+
current_node_id: string | null;
|
|
14
|
+
execution_target: string;
|
|
15
|
+
is_test: boolean | null;
|
|
16
|
+
/** Set when the cloud handed back a run that had paused. Null on a fresh
|
|
17
|
+
* run, which starts at its trigger. */
|
|
18
|
+
resume_from_node_id: string | null;
|
|
19
|
+
priority: number | null;
|
|
20
|
+
resolved_variables: unknown;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The handler registry this machine runs a flow with.
|
|
24
|
+
*
|
|
25
|
+
* Built once per run so the proxy closes over that run's id — the server
|
|
26
|
+
* authorises each proxied node by checking this daemon still holds the claim
|
|
27
|
+
* on it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildRegistry(run: FlowRunRow): Promise<HandlerRegistry>;
|
|
30
|
+
/**
|
|
31
|
+
* Write one node event to the same place a cloud run writes it.
|
|
32
|
+
*
|
|
33
|
+
* Not optional polish. If a run on this machine is not as legible in the run
|
|
34
|
+
* viewer as a cloud run, self-hosting becomes the option nobody can debug, and
|
|
35
|
+
* people go back to the cloud for reasons that have nothing to do with where
|
|
36
|
+
* the work belongs.
|
|
37
|
+
*
|
|
38
|
+
* Fire-and-forget on purpose: a lost log line must never fail a flow.
|
|
39
|
+
*/
|
|
40
|
+
export declare function reportNodeLog(run: FlowRunRow, node: any, status: 'success' | 'error', output: unknown): void;
|