@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.
- package/README.md +46 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/manager.d.ts +150 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +408 -0
- package/dist/manager.js.map +1 -0
- package/dist/providers/runpod-rest.d.ts +151 -0
- package/dist/providers/runpod-rest.d.ts.map +1 -0
- package/dist/providers/runpod-rest.js +202 -0
- package/dist/providers/runpod-rest.js.map +1 -0
- package/dist/providers/runpod.d.ts +15 -0
- package/dist/providers/runpod.d.ts.map +1 -0
- package/dist/providers/runpod.js +100 -0
- package/dist/providers/runpod.js.map +1 -0
- package/dist/providers/types.d.ts +107 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +18 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/providers/vast.d.ts +27 -0
- package/dist/providers/vast.d.ts.map +1 -0
- package/dist/providers/vast.js +243 -0
- package/dist/providers/vast.js.map +1 -0
- package/dist/reaper.d.ts +37 -0
- package/dist/reaper.d.ts.map +1 -0
- package/dist/reaper.js +88 -0
- package/dist/reaper.js.map +1 -0
- package/dist/worker-heartbeat.d.ts +49 -0
- package/dist/worker-heartbeat.d.ts.map +1 -0
- package/dist/worker-heartbeat.js +68 -0
- package/dist/worker-heartbeat.js.map +1 -0
- package/package.json +61 -0
|
@@ -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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"}
|
package/dist/reaper.d.ts
ADDED
|
@@ -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
|
+
}
|