@addai/node 0.24.0 → 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/claude-print.d.ts +14 -0
- package/dist/claude-print.js +37 -1
- 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 -25
- package/dist/heartbeat.js +24 -0
- package/dist/index.js +22 -4
- package/dist/mcp-headers.d.ts +47 -0
- package/dist/mcp-headers.js +58 -0
- package/package.json +2 -1
|
@@ -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;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What the shared engine needs from this machine, and what it must ask the
|
|
3
|
+
// server for.
|
|
4
|
+
//
|
|
5
|
+
// The engine itself is host-agnostic: it walks the graph and calls handlers.
|
|
6
|
+
// This file is the half of the contract that only makes sense here — the
|
|
7
|
+
// twenty-five local handlers run in this process, and the fifteen privileged
|
|
8
|
+
// ones become a round trip to flow-node-host, because they need a
|
|
9
|
+
// service-role Supabase client or the credential encryption key and this
|
|
10
|
+
// machine has neither.
|
|
11
|
+
//
|
|
12
|
+
// It is worth being precise about why that split is not a limitation we are
|
|
13
|
+
// working around. `httpRequest` and `code` running HERE is the entire feature:
|
|
14
|
+
// they reach this machine's LAN, its VPN, its filesystem and its IP address,
|
|
15
|
+
// which is the thing no amount of cloud capacity substitutes for. Everything
|
|
16
|
+
// else is bookkeeping the server was always going to be better at.
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.buildRegistry = buildRegistry;
|
|
19
|
+
exports.reportNodeLog = reportNodeLog;
|
|
20
|
+
const engine_loader_1 = require("./engine-loader");
|
|
21
|
+
const supabase_client_1 = require("../supabase-client");
|
|
22
|
+
const store_1 = require("../store");
|
|
23
|
+
const config_1 = require("../config");
|
|
24
|
+
const PROXY_TIMEOUT_MS = 5 * 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* Ask the server to run one privileged node.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NOT retried in here. A privileged node is very often a write —
|
|
29
|
+
* enqueueing an agent request, creating a review task, sending a notification —
|
|
30
|
+
* and a transparent retry on a timeout would do it twice. The engine's own
|
|
31
|
+
* error handling decides what happens next, in the open, where the flow author
|
|
32
|
+
* can see it on the node.
|
|
33
|
+
*/
|
|
34
|
+
async function proxyOne(runId, node, inputs, parentOutputs, inputData, viaReturnEdge) {
|
|
35
|
+
const pairing = (0, store_1.readPairing)();
|
|
36
|
+
if (!pairing)
|
|
37
|
+
throw new Error('not paired — cannot reach flow-node-host');
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${config_1.SUPABASE_URL}/functions/v1/flow-node-host`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'Authorization': `Bearer ${config_1.SUPABASE_ANON_KEY}`,
|
|
46
|
+
'apikey': config_1.SUPABASE_ANON_KEY,
|
|
47
|
+
'x-daemon-token': pairing.daemonToken,
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
run_id: runId,
|
|
51
|
+
node,
|
|
52
|
+
inputs,
|
|
53
|
+
parent_outputs: parentOutputs,
|
|
54
|
+
input_data: inputData,
|
|
55
|
+
via_return_edge: viaReturnEdge,
|
|
56
|
+
}),
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
let body = null;
|
|
61
|
+
try {
|
|
62
|
+
body = text ? JSON.parse(text) : null;
|
|
63
|
+
}
|
|
64
|
+
catch { /* non-JSON below */ }
|
|
65
|
+
if (!res.ok || body?.success === false) {
|
|
66
|
+
// Carry the server's own message through. It is what lands on the failed
|
|
67
|
+
// node in the run view, and "proxy failed (500)" is the difference
|
|
68
|
+
// between a five-minute fix and an afternoon.
|
|
69
|
+
throw new Error(body?.error || `flow-node-host returned ${res.status}: ${text.slice(0, 300)}`);
|
|
70
|
+
}
|
|
71
|
+
return body?.output ?? {};
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err.name === 'AbortError') {
|
|
75
|
+
throw new Error(`"${node.type}" did not finish on the server within ${PROXY_TIMEOUT_MS / 60000} minutes.`);
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The handler registry this machine runs a flow with.
|
|
85
|
+
*
|
|
86
|
+
* Built once per run so the proxy closes over that run's id — the server
|
|
87
|
+
* authorises each proxied node by checking this daemon still holds the claim
|
|
88
|
+
* on it.
|
|
89
|
+
*/
|
|
90
|
+
async function buildRegistry(run) {
|
|
91
|
+
const { createHandlerRegistry, findNodeInputs } = await (0, engine_loader_1.loadEngine)();
|
|
92
|
+
return createHandlerRegistry({
|
|
93
|
+
proxyFactory: (type) => async (args) => {
|
|
94
|
+
const node = args?.node ?? {};
|
|
95
|
+
const parentOutputs = args?.parentOutputs ?? {};
|
|
96
|
+
// The server's handlers take the `from_<sourceId>` input map that
|
|
97
|
+
// Engine A builds, not the whole outputs bag. Computing it here with the
|
|
98
|
+
// engine's own function is what keeps a proxied node seeing exactly what
|
|
99
|
+
// it would see running inline.
|
|
100
|
+
const inputs = findNodeInputs(node.id, run.edges || [], parentOutputs);
|
|
101
|
+
return proxyOne(run.id, { ...node, type }, inputs, parentOutputs, args?.inputData ?? {}, args?.context?.viaReturnEdge === true);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Write one node event to the same place a cloud run writes it.
|
|
107
|
+
*
|
|
108
|
+
* Not optional polish. If a run on this machine is not as legible in the run
|
|
109
|
+
* viewer as a cloud run, self-hosting becomes the option nobody can debug, and
|
|
110
|
+
* people go back to the cloud for reasons that have nothing to do with where
|
|
111
|
+
* the work belongs.
|
|
112
|
+
*
|
|
113
|
+
* Fire-and-forget on purpose: a lost log line must never fail a flow.
|
|
114
|
+
*/
|
|
115
|
+
function reportNodeLog(run, node, status, output) {
|
|
116
|
+
void (0, supabase_client_1.rpc)('insert_node_log', {
|
|
117
|
+
p_run_id: run.id,
|
|
118
|
+
p_node_id: node?.id ?? null,
|
|
119
|
+
p_node_type: node?.type ?? null,
|
|
120
|
+
p_node_label: node?.data?.label ?? null,
|
|
121
|
+
p_status: status,
|
|
122
|
+
p_output: output ?? null,
|
|
123
|
+
p_workspace_id: run.workspace_id,
|
|
124
|
+
}).catch(() => { });
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const DEFAULT_MAX_CONCURRENT_FLOWS = 4;
|
|
2
|
+
/** Clamp whatever the server sent into something the pump can act on. */
|
|
3
|
+
export declare function clampMaxConcurrentFlows(value: unknown): number;
|
|
4
|
+
/** Called by the heartbeat. Lowering it never interrupts a run in flight —
|
|
5
|
+
* those finish; the pump just stops claiming until it is back under the line. */
|
|
6
|
+
export declare function setMaxConcurrentFlows(value: unknown): void;
|
|
7
|
+
/** Called by the heartbeat. Turning flows on starts claiming immediately;
|
|
8
|
+
* turning it off stops claiming but lets what is running finish. */
|
|
9
|
+
export declare function setFlowsEnabled(value: unknown): void;
|
|
10
|
+
export declare function flowsEnabled(): boolean;
|
|
11
|
+
export declare function maxConcurrentFlows(): number;
|
|
12
|
+
export declare function inflightFlowCount(): number;
|
|
13
|
+
/** Runs this daemon is holding right now. Reported every 30s so the server's
|
|
14
|
+
* reclaim never requeues a healthy run that simply has nothing to say — a
|
|
15
|
+
* flow parked in a slow HTTP call is quiet, not dead. */
|
|
16
|
+
export declare function activeFlowRunIds(): string[];
|
|
17
|
+
export declare function start(): void;
|
|
18
|
+
export declare function stop(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Claim right now, because something said there is work.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately does not clear the backoff window: if the pump is backing off
|
|
23
|
+
* because Supabase is unhealthy, a nudge is no reason to start hammering it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pumpNow(): void;
|
|
26
|
+
/** Wait for in-flight runs, with a ceiling so a wedged run cannot block
|
|
27
|
+
* shutdown forever. */
|
|
28
|
+
export declare function drain(timeoutMs: number): Promise<{
|
|
29
|
+
drained: number;
|
|
30
|
+
timedOut: number;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Claims +Ai Flows runs targeted at this machine and executes them.
|
|
3
|
+
//
|
|
4
|
+
// A deliberate sibling of request-pump, not a variation on it. The two solve
|
|
5
|
+
// the same problem — claim work from Supabase, hold a bounded number of it,
|
|
6
|
+
// heartbeat what you are holding, drain on shutdown — and every difference
|
|
7
|
+
// between them would be a difference in how the box behaves under load that
|
|
8
|
+
// nobody chose. Where the shapes match, they match on purpose.
|
|
9
|
+
//
|
|
10
|
+
// The one thing that is genuinely different is the ceiling. Flow runs and
|
|
11
|
+
// agent runs count separately, because they compete for the same machine but
|
|
12
|
+
// answer to different people: a burst of flows must never mean a chat goes
|
|
13
|
+
// unanswered, and a long agent session must never stall the flows queue.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.DEFAULT_MAX_CONCURRENT_FLOWS = void 0;
|
|
16
|
+
exports.clampMaxConcurrentFlows = clampMaxConcurrentFlows;
|
|
17
|
+
exports.setMaxConcurrentFlows = setMaxConcurrentFlows;
|
|
18
|
+
exports.setFlowsEnabled = setFlowsEnabled;
|
|
19
|
+
exports.flowsEnabled = flowsEnabled;
|
|
20
|
+
exports.maxConcurrentFlows = maxConcurrentFlows;
|
|
21
|
+
exports.inflightFlowCount = inflightFlowCount;
|
|
22
|
+
exports.activeFlowRunIds = activeFlowRunIds;
|
|
23
|
+
exports.start = start;
|
|
24
|
+
exports.stop = stop;
|
|
25
|
+
exports.pumpNow = pumpNow;
|
|
26
|
+
exports.drain = drain;
|
|
27
|
+
const supabase_client_1 = require("../supabase-client");
|
|
28
|
+
const store_1 = require("../store");
|
|
29
|
+
const node_health_1 = require("../node-health");
|
|
30
|
+
const diskguard_1 = require("../diskguard");
|
|
31
|
+
const run_1 = require("./run");
|
|
32
|
+
// Same 2s cadence as request-pump, for the same reason: 250ms polling from a
|
|
33
|
+
// handful of daemons was enough to saturate the project's connection pool.
|
|
34
|
+
// Successful claims re-tick immediately so a queued burst still drains fast.
|
|
35
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
36
|
+
// How many flow runs this machine holds at once. Mirrors max_concurrent_runs:
|
|
37
|
+
// the server's number, pushed in by the heartbeat, defaulting to 4 and
|
|
38
|
+
// specifically to 4 when the server says NOTHING. A server without the
|
|
39
|
+
// migration must read as "the behaviour you had yesterday", never as 0 (a node
|
|
40
|
+
// that accepts nothing) or unbounded.
|
|
41
|
+
exports.DEFAULT_MAX_CONCURRENT_FLOWS = 4;
|
|
42
|
+
const MAX_MAX_CONCURRENT_FLOWS = 32;
|
|
43
|
+
let maxConcurrent = exports.DEFAULT_MAX_CONCURRENT_FLOWS;
|
|
44
|
+
// Off until the server says otherwise. Unlike desktops — where "can this
|
|
45
|
+
// machine?" is answered by whether Docker is installed — the engine ships
|
|
46
|
+
// inside the daemon, so capability is never the question and consent is.
|
|
47
|
+
let enabled = false;
|
|
48
|
+
/** Clamp whatever the server sent into something the pump can act on. */
|
|
49
|
+
function clampMaxConcurrentFlows(value) {
|
|
50
|
+
// Absent is NOT zero. Number(null) and Number('') are both 0, which would
|
|
51
|
+
// quietly throttle the node to a single run — or with a naive clamp, shut it.
|
|
52
|
+
if (typeof value !== 'number' && typeof value !== 'string')
|
|
53
|
+
return exports.DEFAULT_MAX_CONCURRENT_FLOWS;
|
|
54
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
55
|
+
return exports.DEFAULT_MAX_CONCURRENT_FLOWS;
|
|
56
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
57
|
+
if (!Number.isFinite(n))
|
|
58
|
+
return exports.DEFAULT_MAX_CONCURRENT_FLOWS;
|
|
59
|
+
return Math.min(MAX_MAX_CONCURRENT_FLOWS, Math.max(1, Math.floor(n)));
|
|
60
|
+
}
|
|
61
|
+
/** Called by the heartbeat. Lowering it never interrupts a run in flight —
|
|
62
|
+
* those finish; the pump just stops claiming until it is back under the line. */
|
|
63
|
+
function setMaxConcurrentFlows(value) {
|
|
64
|
+
const next = clampMaxConcurrentFlows(value);
|
|
65
|
+
if (next === maxConcurrent)
|
|
66
|
+
return;
|
|
67
|
+
console.log(`[flows-pump] flows at once: ${maxConcurrent} -> ${next}`);
|
|
68
|
+
maxConcurrent = next;
|
|
69
|
+
// Raising it should take effect now, not in two seconds — the usual reason
|
|
70
|
+
// to raise it is that work is queued up behind it.
|
|
71
|
+
if (enabled && !stopped && inflight < maxConcurrent)
|
|
72
|
+
queueMicrotask(() => { void tick(); });
|
|
73
|
+
}
|
|
74
|
+
/** Called by the heartbeat. Turning flows on starts claiming immediately;
|
|
75
|
+
* turning it off stops claiming but lets what is running finish. */
|
|
76
|
+
function setFlowsEnabled(value) {
|
|
77
|
+
const next = value === true;
|
|
78
|
+
if (next === enabled)
|
|
79
|
+
return;
|
|
80
|
+
enabled = next;
|
|
81
|
+
console.log(`[flows-pump] flows ${next ? 'enabled' : 'disabled'} for this machine`);
|
|
82
|
+
if (next && !stopped)
|
|
83
|
+
queueMicrotask(() => { void tick(); });
|
|
84
|
+
}
|
|
85
|
+
function flowsEnabled() { return enabled; }
|
|
86
|
+
function maxConcurrentFlows() { return maxConcurrent; }
|
|
87
|
+
function inflightFlowCount() { return inflight; }
|
|
88
|
+
/** Runs this daemon is holding right now. Reported every 30s so the server's
|
|
89
|
+
* reclaim never requeues a healthy run that simply has nothing to say — a
|
|
90
|
+
* flow parked in a slow HTTP call is quiet, not dead. */
|
|
91
|
+
function activeFlowRunIds() { return [...activeRunIds]; }
|
|
92
|
+
// Anti-leak backstop ONLY: how long the pump holds a slot for a run whose
|
|
93
|
+
// promise never settles. It does not stop the run and is not a time limit.
|
|
94
|
+
const SLOT_LEAK_BACKSTOP_MS = 12 * 60 * 60 * 1000;
|
|
95
|
+
let timer = null;
|
|
96
|
+
let stopped = false;
|
|
97
|
+
let inflight = 0;
|
|
98
|
+
const activeRunIds = new Set();
|
|
99
|
+
const inflightPromises = new Set();
|
|
100
|
+
// Failure backoff, deduped logging — same shape as request-pump so an outage
|
|
101
|
+
// produces ten log lines rather than a quarter of a million.
|
|
102
|
+
let consecutiveFailures = 0;
|
|
103
|
+
let lastLoggedError = '';
|
|
104
|
+
let lastLoggedAt = 0;
|
|
105
|
+
let nextClaimAllowedAt = 0;
|
|
106
|
+
let lastHealthBlock = '';
|
|
107
|
+
let lastHealthBlockAt = 0;
|
|
108
|
+
async function tick() {
|
|
109
|
+
if (stopped || !enabled)
|
|
110
|
+
return;
|
|
111
|
+
if (inflight >= maxConcurrent)
|
|
112
|
+
return;
|
|
113
|
+
if (Date.now() < nextClaimAllowedAt)
|
|
114
|
+
return;
|
|
115
|
+
// The daemon-wide circuit breaker: when Supabase is in distress every poller
|
|
116
|
+
// pauses, not just this one.
|
|
117
|
+
if ((0, supabase_client_1.rpcShouldSkip)())
|
|
118
|
+
return;
|
|
119
|
+
const pairing = (0, store_1.readPairing)();
|
|
120
|
+
if (!pairing)
|
|
121
|
+
return;
|
|
122
|
+
// Don't accept work this machine cannot finish. A full scratch disk fails
|
|
123
|
+
// every code node and every attachment write; refusing the claim leaves the
|
|
124
|
+
// run queued for a healthy machine instead of burning one of its three
|
|
125
|
+
// attempts here.
|
|
126
|
+
const health = (0, node_health_1.evaluateNodeHealth)({ diskUsedPct: (0, node_health_1.readDiskUsedPct)((0, diskguard_1.getDiskGuard)().config.tempRoot) });
|
|
127
|
+
if (!health.healthy) {
|
|
128
|
+
const msg = health.blockers.map(b => b.message).join('; ');
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
if (msg !== lastHealthBlock || (now - lastHealthBlockAt) > 60_000) {
|
|
131
|
+
console.warn(`[flows-pump] refusing claim — node unhealthy: ${msg}`);
|
|
132
|
+
lastHealthBlock = msg;
|
|
133
|
+
lastHealthBlockAt = now;
|
|
134
|
+
}
|
|
135
|
+
void (0, diskguard_1.getDiskGuard)().sweep('flows-health-gate').catch(() => { });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let row = null;
|
|
139
|
+
try {
|
|
140
|
+
const result = await (0, supabase_client_1.rpc)('flow_run_claim_next', {
|
|
141
|
+
p_token: pairing.daemonToken,
|
|
142
|
+
});
|
|
143
|
+
row = (result && result.id) ? result : null;
|
|
144
|
+
if (consecutiveFailures > 0) {
|
|
145
|
+
console.log(`[flows-pump] recovered after ${consecutiveFailures} consecutive failures`);
|
|
146
|
+
consecutiveFailures = 0;
|
|
147
|
+
lastLoggedError = '';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof supabase_client_1.RpcError && err.status === 401) {
|
|
152
|
+
console.error('[flows-pump] unauthorized; will retry on next heartbeat.');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
consecutiveFailures++;
|
|
156
|
+
const backoffMs = Math.min(60_000, POLL_INTERVAL_MS * Math.pow(2, Math.min(consecutiveFailures, 8)));
|
|
157
|
+
nextClaimAllowedAt = Date.now() + backoffMs;
|
|
158
|
+
const msg = err.message;
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (msg !== lastLoggedError || (now - lastLoggedAt) > 60_000) {
|
|
161
|
+
console.error(`[flows-pump] claim failed (#${consecutiveFailures}, backing off ${backoffMs}ms):`, msg);
|
|
162
|
+
lastLoggedError = msg;
|
|
163
|
+
lastLoggedAt = now;
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!row)
|
|
168
|
+
return;
|
|
169
|
+
// Never run the same run twice at once. The claim RPC should make this
|
|
170
|
+
// impossible, but a duplicate execution of a flow means duplicate writes to
|
|
171
|
+
// whatever it touches — the one failure here that is not recoverable by
|
|
172
|
+
// trying again.
|
|
173
|
+
if (activeRunIds.has(row.id)) {
|
|
174
|
+
console.warn(`[flows-pump] skipping re-claim of in-flight run [run=${row.id}]`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
inflight++;
|
|
178
|
+
const runId = row.id;
|
|
179
|
+
activeRunIds.add(runId);
|
|
180
|
+
// Per-run liveness, on its own timer. The 30s fleet heartbeat covers the
|
|
181
|
+
// normal case; this one exists so a single run that outlives a heartbeat
|
|
182
|
+
// hiccup still refreshes its own claim.
|
|
183
|
+
const touch = setInterval(() => {
|
|
184
|
+
void (0, supabase_client_1.rpc)('flow_run_touch', {
|
|
185
|
+
p_token: pairing.daemonToken,
|
|
186
|
+
p_run_id: runId,
|
|
187
|
+
}).catch(() => { });
|
|
188
|
+
}, 60_000);
|
|
189
|
+
touch.unref?.();
|
|
190
|
+
let resolveInflight;
|
|
191
|
+
const inflightPromise = new Promise((r) => { resolveInflight = r; });
|
|
192
|
+
inflightPromises.add(inflightPromise);
|
|
193
|
+
let backstopFired = false;
|
|
194
|
+
const backstop = new Promise((resolve) => {
|
|
195
|
+
const t = setTimeout(() => {
|
|
196
|
+
backstopFired = true;
|
|
197
|
+
console.error(`[flows-pump] run exceeded the ${SLOT_LEAK_BACKSTOP_MS / 3600000}h slot backstop `
|
|
198
|
+
+ `[run=${runId}] — freeing the slot. The run continues; the server's reclaim `
|
|
199
|
+
+ 'handles the row only if it really is dead.');
|
|
200
|
+
resolve();
|
|
201
|
+
}, SLOT_LEAK_BACKSTOP_MS);
|
|
202
|
+
t.unref?.();
|
|
203
|
+
});
|
|
204
|
+
const runPromise = (0, run_1.runFlow)(row).catch(err => console.error(`[flows-pump] runFlow threw [run=${runId}]: ${err.message}`));
|
|
205
|
+
// Drain tracking follows the REAL run, not the slot: a run that outlives its
|
|
206
|
+
// backstop must still hold up a graceful shutdown, or a restart would hand
|
|
207
|
+
// over mid-flow.
|
|
208
|
+
void runPromise.finally(() => {
|
|
209
|
+
clearInterval(touch);
|
|
210
|
+
activeRunIds.delete(runId);
|
|
211
|
+
inflightPromises.delete(inflightPromise);
|
|
212
|
+
resolveInflight();
|
|
213
|
+
});
|
|
214
|
+
Promise.race([runPromise, backstop]).finally(() => {
|
|
215
|
+
inflight = backstopFired ? Math.max(0, inflight - 1) : inflight - 1;
|
|
216
|
+
if (!stopped && enabled && inflight < maxConcurrent) {
|
|
217
|
+
queueMicrotask(() => { void tick(); });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
// Back-to-back claims should not wait for the next interval.
|
|
221
|
+
if (!stopped && enabled && inflight < maxConcurrent) {
|
|
222
|
+
queueMicrotask(() => { void tick(); });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function start() {
|
|
226
|
+
if (timer || stopped)
|
|
227
|
+
return;
|
|
228
|
+
void tick();
|
|
229
|
+
timer = setInterval(() => { void tick(); }, POLL_INTERVAL_MS);
|
|
230
|
+
}
|
|
231
|
+
function stop() {
|
|
232
|
+
stopped = true;
|
|
233
|
+
if (timer) {
|
|
234
|
+
clearInterval(timer);
|
|
235
|
+
timer = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Claim right now, because something said there is work.
|
|
240
|
+
*
|
|
241
|
+
* Deliberately does not clear the backoff window: if the pump is backing off
|
|
242
|
+
* because Supabase is unhealthy, a nudge is no reason to start hammering it.
|
|
243
|
+
*/
|
|
244
|
+
function pumpNow() {
|
|
245
|
+
if (stopped || !enabled)
|
|
246
|
+
return;
|
|
247
|
+
void tick();
|
|
248
|
+
}
|
|
249
|
+
/** Wait for in-flight runs, with a ceiling so a wedged run cannot block
|
|
250
|
+
* shutdown forever. */
|
|
251
|
+
async function drain(timeoutMs) {
|
|
252
|
+
if (inflightPromises.size === 0)
|
|
253
|
+
return { drained: 0, timedOut: 0 };
|
|
254
|
+
const started = inflightPromises.size;
|
|
255
|
+
const deadline = new Promise((resolve) => {
|
|
256
|
+
const t = setTimeout(resolve, timeoutMs);
|
|
257
|
+
t.unref?.();
|
|
258
|
+
});
|
|
259
|
+
await Promise.race([
|
|
260
|
+
Promise.allSettled([...inflightPromises]).then(() => undefined),
|
|
261
|
+
deadline,
|
|
262
|
+
]);
|
|
263
|
+
return { drained: started - inflightPromises.size, timedOut: inflightPromises.size };
|
|
264
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type FlowRunRow } from './host';
|
|
2
|
+
/**
|
|
3
|
+
* Run it. Resolves when the run has reached a terminal or paused state and
|
|
4
|
+
* that state has been reported; never throws, because the pump's slot
|
|
5
|
+
* accounting is built on this settling exactly once.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runFlow(run: FlowRunRow): Promise<void>;
|