@nodetool-ai/compute 0.8.1

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.
@@ -0,0 +1,18 @@
1
+ // Shared provider contract for the worker provisioning subsystem.
2
+ //
3
+ // A `WorkerProvider` is the per-target driver (RunPod pod, Vast.ai) that the
4
+ // `WorkerManager` delegates to. It deals only in provider-native handles
5
+ // (`providerRef`) and the attach handoff (`wsUrl` + optional `token`); all
6
+ // persistence and identity (profiles, instances) live above it in the manager.
7
+ /**
8
+ * Persistent-volume conventions shared by all providers. The volume survives a
9
+ * stop/resume (only a terminate deletes it), so HF model downloads cached under
10
+ * `WORKER_HF_HOME` persist across pauses — that's the whole point of feature.
11
+ */
12
+ export const WORKER_VOLUME_MOUNT = "/workspace";
13
+ export const WORKER_HF_HOME = `${WORKER_VOLUME_MOUNT}/huggingface`;
14
+ /** Default persistent volume size — big enough for several HF image models. */
15
+ export const DEFAULT_VOLUME_GB = 100;
16
+ /** Default ephemeral container disk (image + temp); not persisted. */
17
+ export const DEFAULT_CONTAINER_DISK_GB = 30;
18
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/providers/types.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,6EAA6E;AAC7E,yEAAyE;AACzE,2EAA2E;AAC3E,+EAA+E;AAqB/E;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAAC;AAChD,MAAM,CAAC,MAAM,cAAc,GAAG,GAAG,mBAAmB,cAAc,CAAC;AACnE,+EAA+E;AAC/E,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAC;AACrC,sEAAsE;AACtE,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { type ProviderInstance, type ProvisionResult, type WorkerProvider, type WorkerSpec, type WorkerStatus } from "./types.js";
2
+ export declare class VastProvider implements WorkerProvider {
3
+ private readonly apiKey;
4
+ constructor(apiKey: string);
5
+ provision(spec: WorkerSpec): Promise<ProvisionResult>;
6
+ status(ref: string): Promise<WorkerStatus>;
7
+ /** Pause: stop the instance but keep its disk (and model cache). */
8
+ stop(ref: string): Promise<void>;
9
+ /** Resume a stopped instance and re-derive its (possibly new) endpoint. */
10
+ resume(ref: string): Promise<ProvisionResult>;
11
+ /** Destroy the instance and its disk — real teardown, stops all billing. */
12
+ terminate(ref: string): Promise<void>;
13
+ list(): Promise<ProviderInstance[]>;
14
+ /**
15
+ * Search the marketplace for the cheapest rentable offer matching the spec's
16
+ * GPU, GPU count and vCPU floor. Returns the offer id plus its `dph_total`
17
+ * (total dollars-per-hour), the estimated cost the cost guard records on the
18
+ * instance.
19
+ */
20
+ private findOffer;
21
+ /** Launch the worker image on the chosen offer; return the new contract id. */
22
+ private launch;
23
+ /** Poll GET /instances/{id}/ until `running`. */
24
+ private waitForReady;
25
+ private getInstance;
26
+ }
27
+ //# sourceMappingURL=vast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vast.d.ts","sourceRoot":"","sources":["../../src/providers/vast.ts"],"names":[],"mappings":"AAaA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,YAAY,EAClB,MAAM,YAAY,CAAC;AA4EpB,qBAAa,YAAa,YAAW,cAAc;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAAnC,YAA6B,MAAM,EAAE,MAAM,EAAI;IAEzC,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CAiC1D;IAEK,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAG/C;IAED,oEAAoE;IAC9D,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIrC;IAED,2EAA2E;IACrE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAiBlD;IAED,4EAA4E;IACtE,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1C;IAEK,IAAI,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAQxC;IAID;;;;;OAKG;YACW,SAAS;IAwCvB,+EAA+E;YACjE,MAAM;IA0CpB,iDAAiD;YACnC,YAAY;YAyBZ,WAAW;CAK1B"}
@@ -0,0 +1,243 @@
1
+ // Vast.ai-backed `WorkerProvider`.
2
+ //
3
+ // Launches the NodeTool worker image on a Vast.ai GPU offer over the Vast HTTP
4
+ // API (https://console.vast.ai/api/v0), polls the instance to `running`, and
5
+ // derives the direct `ws://<ip>:<port>` URL the bridge attaches to.
6
+ //
7
+ // Lifecycle mirrors RunPod: `stop` PAUSES the instance (state→stopped, its disk
8
+ // and model cache are retained), `resume` brings it back (state→running), and
9
+ // `terminate` DELETEs it for good — the real teardown that stops all billing.
10
+ //
11
+ // The API key is injected via the constructor (sourced from the secret store /
12
+ // env by the `WorkerManager`); this provider never reads `process.env`.
13
+ import { DEFAULT_VOLUME_GB, WORKER_HF_HOME, } from "./types.js";
14
+ const VAST_API_BASE_URL = "https://console.vast.ai/api/v0";
15
+ /** Internal port the worker serves on. */
16
+ const WORKER_PORT = 7777;
17
+ /** Internal sshd port, published only when the spec carries a public key. */
18
+ const SSH_PORT = 22;
19
+ /**
20
+ * Call the Vast HTTP API with an explicitly-supplied key. Mirrors the RunPod
21
+ * REST transport: explicit key (never `process.env`), bearer auth, JSON body.
22
+ */
23
+ async function vastApi(apiKey, endpoint, method = "GET", data) {
24
+ if (!apiKey)
25
+ throw new Error("Vast.ai API key is required");
26
+ const init = {
27
+ method,
28
+ headers: {
29
+ Authorization: `Bearer ${apiKey}`,
30
+ "Content-Type": "application/json",
31
+ Accept: "application/json",
32
+ },
33
+ signal: AbortSignal.timeout(60_000),
34
+ };
35
+ if (data && method !== "GET")
36
+ init.body = JSON.stringify(data);
37
+ const response = await fetch(`${VAST_API_BASE_URL}/${endpoint}`, init);
38
+ if (!response.ok) {
39
+ const body = await response.text().catch(() => "");
40
+ throw new Error(`Vast.ai ${method} /${endpoint} failed (${response.status}): ${body}`);
41
+ }
42
+ const text = await response.text();
43
+ return text ? JSON.parse(text) : {};
44
+ }
45
+ /** Map a Vast instance's `actual_status` to the shared `WorkerStatus`. */
46
+ function mapInstanceStatus(status) {
47
+ switch (status) {
48
+ case "running":
49
+ return "running";
50
+ case "exited":
51
+ case "stopped":
52
+ return "stopped";
53
+ default:
54
+ return "provisioning";
55
+ }
56
+ }
57
+ /** Resolve the host port mapped to the worker's internal port, if exposed. */
58
+ function externalPort(instance) {
59
+ const binding = instance.ports?.[`${WORKER_PORT}/tcp`];
60
+ return binding?.[0]?.HostPort ?? null;
61
+ }
62
+ export class VastProvider {
63
+ apiKey;
64
+ constructor(apiKey) {
65
+ this.apiKey = apiKey;
66
+ }
67
+ async provision(spec) {
68
+ const offer = await this.findOffer(spec);
69
+ const instanceId = await this.launch(offer.id, spec);
70
+ // `launch` created a billing instance. Any failure past this point must
71
+ // DESTROY it, otherwise the orphaned GPU bills until the reconcile scan
72
+ // detects it — its `provider_ref` is never persisted, so the manager can't
73
+ // tear it down.
74
+ try {
75
+ const instance = await this.waitForReady(instanceId);
76
+ const ip = instance.public_ipaddr;
77
+ const port = externalPort(instance);
78
+ if (!ip || !port) {
79
+ throw new Error(`Vast instance ${instanceId} became running without a reachable ` +
80
+ `${WORKER_PORT} port mapping`);
81
+ }
82
+ return {
83
+ providerRef: instanceId,
84
+ wsUrl: `ws://${ip}:${port}`,
85
+ token: spec.token,
86
+ status: "running",
87
+ costUsd: offer.dphTotal,
88
+ };
89
+ }
90
+ catch (err) {
91
+ await this.terminate(instanceId).catch(() => {
92
+ // Best-effort teardown; surface the original provision failure below.
93
+ });
94
+ throw err;
95
+ }
96
+ }
97
+ async status(ref) {
98
+ const instance = await this.getInstance(ref);
99
+ return mapInstanceStatus(instance.actual_status);
100
+ }
101
+ /** Pause: stop the instance but keep its disk (and model cache). */
102
+ async stop(ref) {
103
+ await vastApi(this.apiKey, `instances/${ref}/`, "PUT", {
104
+ state: "stopped",
105
+ });
106
+ }
107
+ /** Resume a stopped instance and re-derive its (possibly new) endpoint. */
108
+ async resume(ref) {
109
+ await vastApi(this.apiKey, `instances/${ref}/`, "PUT", {
110
+ state: "running",
111
+ });
112
+ const instance = await this.waitForReady(ref);
113
+ const ip = instance.public_ipaddr;
114
+ const port = externalPort(instance);
115
+ if (!ip || !port) {
116
+ throw new Error(`Vast instance ${ref} resumed without a reachable ${WORKER_PORT} port`);
117
+ }
118
+ return {
119
+ providerRef: ref,
120
+ wsUrl: `ws://${ip}:${port}`,
121
+ status: "running",
122
+ };
123
+ }
124
+ /** Destroy the instance and its disk — real teardown, stops all billing. */
125
+ async terminate(ref) {
126
+ await vastApi(this.apiKey, `instances/${ref}/`, "DELETE");
127
+ }
128
+ async list() {
129
+ const res = await vastApi(this.apiKey, "instances/");
130
+ const arr = res.instances;
131
+ const instances = Array.isArray(arr) ? arr : [];
132
+ return instances.map((instance) => ({
133
+ providerRef: String(instance.id),
134
+ status: mapInstanceStatus(instance.actual_status),
135
+ }));
136
+ }
137
+ // --- Internals ----------------------------------------------------------
138
+ /**
139
+ * Search the marketplace for the cheapest rentable offer matching the spec's
140
+ * GPU, GPU count and vCPU floor. Returns the offer id plus its `dph_total`
141
+ * (total dollars-per-hour), the estimated cost the cost guard records on the
142
+ * instance.
143
+ */
144
+ async findOffer(spec) {
145
+ // Require the offer to have at least the disk we intend to allocate;
146
+ // otherwise the cheapest offer may not fit the requested disk and the
147
+ // subsequent launch is rejected.
148
+ const diskGb = spec.disk ?? DEFAULT_VOLUME_GB;
149
+ const query = {
150
+ rentable: { eq: true },
151
+ verified: { eq: true },
152
+ rented: { eq: false },
153
+ disk_space: { gte: diskGb },
154
+ };
155
+ if (spec.gpu) {
156
+ query.gpu_name = { eq: spec.gpu };
157
+ }
158
+ // A multi-GPU request must constrain the search: the cheapest offer is a
159
+ // single-GPU machine, so without this the launch silently under-delivers.
160
+ if (spec.gpuCount !== undefined) {
161
+ query.num_gpus = { gte: spec.gpuCount };
162
+ }
163
+ if (spec.vcpu !== undefined) {
164
+ query.cpu_cores_effective = { gte: spec.vcpu };
165
+ }
166
+ const res = await vastApi(this.apiKey, "bundles/", "PUT", {
167
+ q: query,
168
+ order: [["dph_total", "asc"]],
169
+ });
170
+ const offers = res.offers;
171
+ const first = Array.isArray(offers)
172
+ ? offers[0]
173
+ : undefined;
174
+ if (!first?.id) {
175
+ throw new Error(`No rentable Vast.ai offer found for GPU "${spec.gpu ?? "any"}"`);
176
+ }
177
+ return { id: String(first.id), dphTotal: first.dph_total };
178
+ }
179
+ /** Launch the worker image on the chosen offer; return the new contract id. */
180
+ async launch(offerId, spec) {
181
+ const env = { ...(spec.env ?? {}) };
182
+ if (spec.token)
183
+ env.NODETOOL_WORKER_TOKEN = spec.token;
184
+ // Cache HF models on the instance disk so they survive a stop/resume.
185
+ env.HF_HOME = WORKER_HF_HOME;
186
+ // Vast takes port mappings as Docker flags *inside* `env`, keyed by the
187
+ // flag with a dummy value — `args` goes to the image entrypoint instead and
188
+ // would never publish the port. Map the worker port out for the ws attach.
189
+ env[`-p ${WORKER_PORT}:${WORKER_PORT}`] = "1";
190
+ const sshKey = spec.sshPublicKey?.trim();
191
+ if (sshKey) {
192
+ // Same contract as RunPod: the key is an *image* convention — the worker
193
+ // entrypoint installs PUBLIC_KEY and starts sshd — so it carries over to
194
+ // Vast unchanged. Vast's account-level keys only apply to its own `ssh`
195
+ // runtypes, and we run the image's entrypoint (`args`).
196
+ env.PUBLIC_KEY = sshKey;
197
+ env[`-p ${SSH_PORT}:${SSH_PORT}`] = "1";
198
+ }
199
+ const res = await vastApi(this.apiKey, `asks/${offerId}/`, "PUT", {
200
+ image: spec.image,
201
+ // Names the instance in the Vast console, so a rented worker can be
202
+ // matched to its profile by hand during a reconcile.
203
+ label: spec.name,
204
+ disk: spec.disk ?? DEFAULT_VOLUME_GB,
205
+ env,
206
+ // Run the image's own entrypoint (the worker), not Vast's ssh/jupyter.
207
+ runtype: "args",
208
+ });
209
+ // The launch response is `{success, new_contract}` per the Vast HTTP API
210
+ // (https://console.vast.ai/api/v0). On failure it carries `error`/`message`
211
+ // instead, so include the whole body to keep the failure debuggable.
212
+ const contract = res.new_contract;
213
+ if (!contract) {
214
+ throw new Error(`Vast.ai launch returned no instance id: ${JSON.stringify(res)}`);
215
+ }
216
+ return String(contract);
217
+ }
218
+ /** Poll GET /instances/{id}/ until `running`. */
219
+ async waitForReady(id, opts = {}) {
220
+ const deadline = Date.now() + (opts.timeoutMs ?? 240_000);
221
+ const intervalMs = opts.intervalMs ?? 5_000;
222
+ let last;
223
+ while (Date.now() < deadline) {
224
+ const instance = await this.getInstance(id);
225
+ last = instance;
226
+ const status = mapInstanceStatus(instance.actual_status);
227
+ if (status === "running")
228
+ return instance;
229
+ if (status === "stopped") {
230
+ throw new Error(`Vast instance ${id} reached terminal status ${instance.actual_status}`);
231
+ }
232
+ await new Promise((r) => setTimeout(r, intervalMs));
233
+ }
234
+ throw new Error(`Vast instance ${id} did not become reachable within the timeout ` +
235
+ `(last status: ${last?.actual_status ?? "unknown"})`);
236
+ }
237
+ async getInstance(id) {
238
+ const res = await vastApi(this.apiKey, `instances/${id}/`);
239
+ const instance = res.instances;
240
+ return (instance ?? {});
241
+ }
242
+ }
243
+ //# sourceMappingURL=vast.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vast.js","sourceRoot":"","sources":["../../src/providers/vast.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ import type { WorkerInstance, WorkerProfile } from "@nodetool-ai/models";
2
+ /**
3
+ * The slice of `WorkerManager` the reaper drives: it reads the registry and
4
+ * tears instances down through the manager (never the provider directly).
5
+ */
6
+ export interface ReaperManager {
7
+ list(): Promise<WorkerInstance[]>;
8
+ /** Pause (idle path) — frees the GPU, keeps the volume. */
9
+ stop(instanceId: string): Promise<WorkerInstance>;
10
+ /** Destroy (hard-TTL path) — deletes the pod and its volume. */
11
+ terminate(instanceId: string): Promise<WorkerInstance>;
12
+ }
13
+ /**
14
+ * Reaper dependencies. `manager` reads/stops instances; `getProfile` resolves
15
+ * an instance's profile (for its limits); `now` returns the current epoch ms
16
+ * (injected so idle/TTL windows are deterministic in tests).
17
+ */
18
+ export interface ReaperDeps {
19
+ manager: ReaperManager;
20
+ getProfile: (name: string) => Promise<WorkerProfile | null>;
21
+ now: () => number;
22
+ }
23
+ /** A handle that cancels a running `startReaper` timer. */
24
+ export type ReaperHandle = () => void;
25
+ /**
26
+ * Run one reaping pass: stop every non-stopped instance that is past its
27
+ * profile's idle window or hard TTL. Instances whose profile is missing or
28
+ * sets neither limit are left untouched.
29
+ */
30
+ export declare function runReaperOnce(deps: ReaperDeps): Promise<void>;
31
+ /**
32
+ * Schedule `runReaperOnce` on a fixed interval (used by the server). The timer
33
+ * itself is not unit-tested; the returned handle clears it. A pass that throws
34
+ * is swallowed so a transient provider/DB error doesn't kill the loop.
35
+ */
36
+ export declare function startReaper(deps: ReaperDeps, intervalMs: number): ReaperHandle;
37
+ //# sourceMappingURL=reaper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reaper.d.ts","sourceRoot":"","sources":["../src/reaper.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzE;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IAClC,2DAA2D;IAC3D,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAClD,gEAAgE;IAChE,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CACxD;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAC5D,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,2DAA2D;AAC3D,MAAM,MAAM,YAAY,GAAG,MAAM,IAAI,CAAC;AAItC;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBnE;AA+BD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,YAAY,CAS9E"}
package/dist/reaper.js ADDED
@@ -0,0 +1,88 @@
1
+ // Reaper — the cost guard's time-based teardown loop.
2
+ //
3
+ // GPU pods/Vast instances bill continuously, the laptop sleeps, the app
4
+ // crashes. The reaper enforces two profile-declared limits so a forgotten
5
+ // worker can't quietly bill for hours, with deliberately different actions:
6
+ //
7
+ // - idle auto-stop: PAUSE an instance after `idle_timeout_minutes` of bridge
8
+ // inactivity (the bridge touches `last_activity_at`). Pausing frees the
9
+ // expensive GPU but keeps the volume, so resuming is fast and the model
10
+ // cache survives — the common, recoverable case.
11
+ // - hard TTL: TERMINATE an instance older than `max_lifetime_minutes`
12
+ // regardless of activity — an absolute kill switch that destroys the volume
13
+ // too, so a forgotten pod can't bill volume storage forever.
14
+ //
15
+ // Both go through the manager (`stop`/`terminate`), so the real provider work
16
+ // and the status update happen as one. A profile that sets neither limit opts
17
+ // its instances out. The clock and the manager are injected so the windows can
18
+ // be exercised deterministically with a fake clock.
19
+ //
20
+ // Orphan reconcile (diffing the DB against `provider.list()`) is a separate
21
+ // concern handled by `WorkerManager.reconcile()` in Task D4.
22
+ const MINUTE_MS = 60_000;
23
+ /**
24
+ * Run one reaping pass: stop every non-stopped instance that is past its
25
+ * profile's idle window or hard TTL. Instances whose profile is missing or
26
+ * sets neither limit are left untouched.
27
+ */
28
+ export async function runReaperOnce(deps) {
29
+ const nowMs = deps.now();
30
+ const instances = await deps.manager.list();
31
+ for (const instance of instances) {
32
+ // Already-paused or already-destroyed instances are done.
33
+ if (instance.status === "stopped" || instance.status === "terminated") {
34
+ continue;
35
+ }
36
+ const profile = await deps.getProfile(instance.profile_name);
37
+ if (!profile) {
38
+ continue;
39
+ }
40
+ // Hard TTL wins over idle: if both fire, destroy (the stronger action)
41
+ // rather than merely pause.
42
+ const action = reapAction(instance, profile, nowMs);
43
+ if (action === "terminate") {
44
+ await deps.manager.terminate(instance.id);
45
+ }
46
+ else if (action === "stop") {
47
+ await deps.manager.stop(instance.id);
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Which teardown (if any) an instance is due for: `terminate` past its hard TTL
53
+ * (destroy, incl. volume), `stop` past its idle window (pause, keep volume), or
54
+ * `null` when neither limit has fired.
55
+ */
56
+ function reapAction(instance, profile, nowMs) {
57
+ const ttl = profile.max_lifetime_minutes;
58
+ if (ttl != null) {
59
+ const ageMs = nowMs - Date.parse(instance.created_at);
60
+ if (ageMs > ttl * MINUTE_MS) {
61
+ return "terminate";
62
+ }
63
+ }
64
+ const idle = profile.idle_timeout_minutes;
65
+ if (idle != null) {
66
+ const idleMs = nowMs - Date.parse(instance.last_activity_at);
67
+ if (idleMs > idle * MINUTE_MS) {
68
+ return "stop";
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ /**
74
+ * Schedule `runReaperOnce` on a fixed interval (used by the server). The timer
75
+ * itself is not unit-tested; the returned handle clears it. A pass that throws
76
+ * is swallowed so a transient provider/DB error doesn't kill the loop.
77
+ */
78
+ export function startReaper(deps, intervalMs) {
79
+ const timer = setInterval(() => {
80
+ void runReaperOnce(deps).catch(() => { });
81
+ }, intervalMs);
82
+ // Don't keep the process alive solely for the reaper.
83
+ if (typeof timer.unref === "function") {
84
+ timer.unref();
85
+ }
86
+ return () => clearInterval(timer);
87
+ }
88
+ //# sourceMappingURL=reaper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reaper.js","sourceRoot":"","sources":["../src/reaper.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ import { type WorkerInstance } from "@nodetool-ai/models";
2
+ /**
3
+ * The slice of a Python bridge the heartbeat needs: the `"activity"` event the
4
+ * remote WebSocket bridge emits on every frame it sends or receives. A local
5
+ * stdio bridge never emits it, so attaching to one is inert.
6
+ */
7
+ export interface WorkerActivitySource {
8
+ on(event: "activity", listener: () => void): void;
9
+ off?(event: "activity", listener: () => void): void;
10
+ }
11
+ /** Options for {@link attachWorkerActivityHeartbeat}. */
12
+ export interface WorkerActivityHeartbeatOptions {
13
+ /**
14
+ * Resolve the instance whose clock this bridge's traffic belongs to.
15
+ * Returning null skips the touch (no worker attached).
16
+ */
17
+ resolveInstanceId: () => Promise<string | null>;
18
+ /** Write the timestamp. Injected for tests; defaults to the model accessor. */
19
+ touch?: (id: string) => Promise<WorkerInstance | void>;
20
+ /** Current epoch ms. Injected for tests. */
21
+ now?: () => number;
22
+ /** Throttle window in ms. Injected for tests. */
23
+ throttleMs?: number;
24
+ }
25
+ /**
26
+ * Keep a worker's `last_activity_at` fresh from real bridge traffic.
27
+ *
28
+ * Returns a function that detaches the listener. Every touch is best-effort:
29
+ * a rejected resolve or write is swallowed, because keeping a cost-guard clock
30
+ * fresh must never break the execution it is observing.
31
+ */
32
+ export declare function attachWorkerActivityHeartbeat(bridge: WorkerActivitySource, options: WorkerActivityHeartbeatOptions): () => void;
33
+ /**
34
+ * A host's hook for observing every Python bridge it creates —
35
+ * `onPythonBridgeCreated` from `@nodetool-ai/runtime`. Taken as a parameter so
36
+ * this package keeps no runtime dependency.
37
+ */
38
+ export type BridgeCreationRegistrar = (observer: (bridge: WorkerActivitySource) => void) => () => void;
39
+ /**
40
+ * Install the heartbeat on every bridge the host creates from now on, resolving
41
+ * the attached worker through a lazily-built {@link WorkerManager}. Returns a
42
+ * function that uninstalls it.
43
+ *
44
+ * This is what a host without its own long-lived bridge — the CLI — calls once
45
+ * at startup. It opens no database and builds no bridge; the manager appears
46
+ * only when a bridge first reports traffic.
47
+ */
48
+ export declare function installWorkerActivityHeartbeat(register: BridgeCreationRegistrar, overrides?: Partial<WorkerActivityHeartbeatOptions>): () => void;
49
+ //# sourceMappingURL=worker-heartbeat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-heartbeat.d.ts","sourceRoot":"","sources":["../src/worker-heartbeat.ts"],"names":[],"mappings":"AAaA,OAAO,EAEL,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAI7B;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAClD,GAAG,CAAC,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CACrD;AASD,yDAAyD;AACzD,MAAM,WAAW,8BAA8B;IAC7C;;;OAGG;IACH,iBAAiB,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAChD,+EAA+E;IAC/E,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACvD,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,8BAA8B,GACtC,MAAM,IAAI,CAqBZ;AAED;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG,CACpC,QAAQ,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,KAC7C,MAAM,IAAI,CAAC;AAEhB;;;;;;;;GAQG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,uBAAuB,EACjC,SAAS,GAAE,OAAO,CAAC,8BAA8B,CAAM,GACtD,MAAM,IAAI,CASZ"}
@@ -0,0 +1,68 @@
1
+ // Worker idle-clock heartbeat.
2
+ //
3
+ // The reaper pauses an instance after `idle_timeout_minutes` of inactivity,
4
+ // measured from `last_activity_at`. That column only moves when something
5
+ // touches it, and the only thing that can is a host with database access that
6
+ // is watching the Python bridge. This module is that wiring, kept out of any
7
+ // single host so every DB-having host — the websocket server, the CLI —
8
+ // installs the same behaviour instead of each reimplementing it.
9
+ //
10
+ // The bridge is taken structurally (an event emitter with an `"activity"`
11
+ // event) so `@nodetool-ai/compute` stays free of a `@nodetool-ai/runtime`
12
+ // dependency, the same way `WorkerManager` does.
13
+ import { touchWorkerInstance, } from "@nodetool-ai/models";
14
+ import { WorkerManager } from "./manager.js";
15
+ /**
16
+ * How often a burst of bridge traffic is allowed to become a DB write. The
17
+ * reaper runs once a minute and its windows are measured in minutes, so
18
+ * second-level freshness is ample and one write per frame is waste.
19
+ */
20
+ const TOUCH_THROTTLE_MS = 10_000;
21
+ /**
22
+ * Keep a worker's `last_activity_at` fresh from real bridge traffic.
23
+ *
24
+ * Returns a function that detaches the listener. Every touch is best-effort:
25
+ * a rejected resolve or write is swallowed, because keeping a cost-guard clock
26
+ * fresh must never break the execution it is observing.
27
+ */
28
+ export function attachWorkerActivityHeartbeat(bridge, options) {
29
+ const now = options.now ?? Date.now;
30
+ const touch = options.touch ?? touchWorkerInstance;
31
+ const throttleMs = options.throttleMs ?? TOUCH_THROTTLE_MS;
32
+ // -Infinity, not 0: the first beat must fire whatever the clock reads.
33
+ let lastTouchAt = Number.NEGATIVE_INFINITY;
34
+ const listener = () => {
35
+ const nowMs = now();
36
+ if (nowMs - lastTouchAt < throttleMs)
37
+ return;
38
+ lastTouchAt = nowMs;
39
+ void options
40
+ .resolveInstanceId()
41
+ .then((id) => (id ? touch(id) : undefined))
42
+ .catch(() => {
43
+ // best-effort: the idle clock must not crash the run it measures
44
+ });
45
+ };
46
+ bridge.on("activity", listener);
47
+ return () => bridge.off?.("activity", listener);
48
+ }
49
+ /**
50
+ * Install the heartbeat on every bridge the host creates from now on, resolving
51
+ * the attached worker through a lazily-built {@link WorkerManager}. Returns a
52
+ * function that uninstalls it.
53
+ *
54
+ * This is what a host without its own long-lived bridge — the CLI — calls once
55
+ * at startup. It opens no database and builds no bridge; the manager appears
56
+ * only when a bridge first reports traffic.
57
+ */
58
+ export function installWorkerActivityHeartbeat(register, overrides = {}) {
59
+ let manager = null;
60
+ const resolveInstanceId = async () => {
61
+ manager ??= new WorkerManager();
62
+ return (await manager.getActiveWorker())?.id ?? null;
63
+ };
64
+ return register((bridge) => {
65
+ attachWorkerActivityHeartbeat(bridge, { resolveInstanceId, ...overrides });
66
+ });
67
+ }
68
+ //# sourceMappingURL=worker-heartbeat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-heartbeat.js","sourceRoot":"","sources":["../src/worker-heartbeat.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,8EAA8E;AAC9E,6EAA6E;AAC7E,wEAAwE;AACxE,iEAAiE;AACjE,EAAE;AACF,0EAA0E;AAC1E,0EAA0E;AAC1E,iDAAiD;AAEjD,OAAO,EACL,mBAAmB,GAEpB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAY7C;;;;GAIG;AACH,MAAM,iBAAiB,GAAG,MAAM,CAAC;AAiBjC;;;;;;GAMG;AACH,MAAM,UAAU,6BAA6B,CAC3C,MAA4B,EAC5B,OAAuC;IAEvC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACpC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,mBAAmB,CAAC;IACnD,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,iBAAiB,CAAC;IAE3D,uEAAuE;IACvE,IAAI,WAAW,GAAG,MAAM,CAAC,iBAAiB,CAAC;IAC3C,MAAM,QAAQ,GAAG,GAAS,EAAE;QAC1B,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC;QACpB,IAAI,KAAK,GAAG,WAAW,GAAG,UAAU;YAAE,OAAO;QAC7C,WAAW,GAAG,KAAK,CAAC;QACpB,KAAK,OAAO;aACT,iBAAiB,EAAE;aACnB,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;aAC1C,KAAK,CAAC,GAAG,EAAE;YACV,iEAAiE;QACnE,CAAC,CAAC,CAAC;IACP,CAAC,CAAC;IAEF,MAAM,CAAC,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IAChC,OAAO,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;AAClD,CAAC;AAWD;;;;;;;;GAQG;AACH,MAAM,UAAU,8BAA8B,CAC5C,QAAiC,EACjC,SAAS,GAA4C,EAAE;IAEvD,IAAI,OAAO,GAAyB,IAAI,CAAC;IACzC,MAAM,iBAAiB,GAAG,KAAK,IAA4B,EAAE;QAC3D,OAAO,KAAK,IAAI,aAAa,EAAE,CAAC;QAChC,OAAO,CAAC,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC;IACvD,CAAC,CAAC;IACF,OAAO,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE;QACzB,6BAA6B,CAAC,MAAM,EAAE,EAAE,iBAAiB,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC;IAC7E,CAAC,CAAC,CAAC;AACL,CAAC"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@nodetool-ai/compute",
3
+ "version": "0.8.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "node ../../scripts/build-typescript-workspace.mjs",
9
+ "test": "node ../../scripts/run-vitest.mjs run",
10
+ "test:watch": "node ../../scripts/run-vitest.mjs",
11
+ "lint": "node ../../scripts/run-tsc.mjs --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@nodetool-ai/config": "^0.8.1",
15
+ "@nodetool-ai/models": "^0.8.1",
16
+ "@nodetool-ai/security": "^0.8.1"
17
+ },
18
+ "devDependencies": {
19
+ "typescript": "npm:@typescript/typescript6@6.0.2",
20
+ "vitest": "^5.0.1"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "nodetool-dev": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/nodetool-ai/nodetool.git",
34
+ "directory": "packages/compute"
35
+ },
36
+ "homepage": "https://nodetool.ai",
37
+ "bugs": {
38
+ "url": "https://github.com/nodetool-ai/nodetool/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "README.md"
46
+ ],
47
+ "description": "Remote compute orchestration for NodeTool — provisions and reaps GPU workers on RunPod and Vast.ai",
48
+ "keywords": [
49
+ "nodetool",
50
+ "ai",
51
+ "workflow",
52
+ "workflow-automation",
53
+ "typescript",
54
+ "gpu",
55
+ "runpod",
56
+ "vast-ai",
57
+ "compute",
58
+ "gpu-workers",
59
+ "orchestration"
60
+ ]
61
+ }