mlclaw 0.1.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.
Files changed (47) hide show
  1. package/.agents/skills/mlclaw/SKILL.md +214 -0
  2. package/.agents/skills/mlclaw/agents/openai.yaml +4 -0
  3. package/.gitattributes +35 -0
  4. package/Dockerfile +45 -0
  5. package/LICENSE +21 -0
  6. package/README.md +206 -0
  7. package/assets/mlclaw.svg +143 -0
  8. package/dist/hf-state-sync.js +9532 -0
  9. package/dist/mlclaw-space-runtime.js +5010 -0
  10. package/dist/mlclaw.mjs +16502 -0
  11. package/entrypoint.sh +87 -0
  12. package/mlclaw.ps1 +108 -0
  13. package/mlclaw.sh +117 -0
  14. package/openclaw.default.json +67 -0
  15. package/package.json +66 -0
  16. package/scripts/configure-huggingface-model.mjs +86 -0
  17. package/scripts/configure-telegram.mjs +55 -0
  18. package/scripts/report-telegram-probe.mjs +18 -0
  19. package/space/README.md +42 -0
  20. package/src/hf-bucket-client/client.ts +217 -0
  21. package/src/hf-state-sync/archive.ts +137 -0
  22. package/src/hf-state-sync/cli.ts +92 -0
  23. package/src/hf-state-sync/hub.ts +81 -0
  24. package/src/hf-state-sync/manifest.ts +67 -0
  25. package/src/hf-state-sync/paths.ts +73 -0
  26. package/src/hf-state-sync/restore.ts +109 -0
  27. package/src/hf-state-sync/snapshot.ts +133 -0
  28. package/src/hf-state-sync/sqlite.ts +57 -0
  29. package/src/hf-state-sync/supervise.ts +256 -0
  30. package/src/vendor/hfjs-xet/error.ts +52 -0
  31. package/src/vendor/hfjs-xet/types/public.ts +207 -0
  32. package/src/vendor/hfjs-xet/utils/ChunkCache.ts +102 -0
  33. package/src/vendor/hfjs-xet/utils/RangeList.ts +182 -0
  34. package/src/vendor/hfjs-xet/utils/SplicedBlob.ts +249 -0
  35. package/src/vendor/hfjs-xet/utils/XetBlob.ts +732 -0
  36. package/src/vendor/hfjs-xet/utils/checkCredentials.ts +21 -0
  37. package/src/vendor/hfjs-xet/utils/combineUint8Arrays.ts +13 -0
  38. package/src/vendor/hfjs-xet/utils/createXorbs.ts +782 -0
  39. package/src/vendor/hfjs-xet/utils/shardParser.ts +152 -0
  40. package/src/vendor/hfjs-xet/utils/sum.ts +9 -0
  41. package/src/vendor/hfjs-xet/utils/uploadShards.ts +443 -0
  42. package/src/vendor/hfjs-xet/utils/xetWriteToken.ts +101 -0
  43. package/src/vendor/hfjs-xet/vendor/lz4js/index.ts +540 -0
  44. package/src/vendor/hfjs-xet/vendor/lz4js/util.ts +57 -0
  45. package/src/vendor/hfjs-xet/vendor/lz4js/xxh32.ts +99 -0
  46. package/src/vendor/hfjs-xet/vendor/type-fest/basic.ts +34 -0
  47. package/tsconfig.json +16 -0
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { extractTarZst, sha256File } from "./archive.js";
5
+ import type { BucketHub } from "./hub.js";
6
+ import { MANIFEST_REMOTE_NAME, type SnapshotEntry, parseManifest } from "./manifest.js";
7
+ import { type SyncConfig, log, logError, remotePath } from "./paths.js";
8
+ import { checkIntegrity, findSqliteFiles } from "./sqlite.js";
9
+
10
+ export type RestoreOutcome =
11
+ | { kind: "restored"; entry: SnapshotEntry }
12
+ | { kind: "fresh-start"; reason: "no-bucket" | "no-manifest" | "live-dir-exists" }
13
+ // Both failure kinds must stop the boot: starting fresh and snapshotting
14
+ // would overwrite the rollback index of a bucket that still holds state.
15
+ | { kind: "invalid-manifest"; reason: string }
16
+ | { kind: "all-snapshots-failed"; tried: string[] };
17
+
18
+ async function tryRestoreEntry(params: {
19
+ hub: BucketHub;
20
+ entry: SnapshotEntry;
21
+ workDir: string;
22
+ liveDir: string;
23
+ }): Promise<"restored" | "failed"> {
24
+ const { hub, entry, workDir, liveDir } = params;
25
+ const archivePath = path.join(workDir, `candidate-${entry.id}.tar.zst`);
26
+ const downloaded = await hub.download(entry.path, archivePath);
27
+ if (downloaded === "not-found") {
28
+ logError(`snapshot ${entry.id} missing from bucket`);
29
+ return "failed";
30
+ }
31
+ const digest = await sha256File(archivePath);
32
+ if (digest !== entry.sha256) {
33
+ logError(`snapshot ${entry.id} checksum mismatch`);
34
+ return "failed";
35
+ }
36
+ // Extract next to the live dir, not in tmpdir: the final rename into place
37
+ // must stay on one filesystem (rename across mounts fails with EXDEV).
38
+ const extractDir = `${liveDir}.restoring-${entry.id}`;
39
+ await fs.rm(extractDir, { recursive: true, force: true });
40
+ try {
41
+ try {
42
+ await extractTarZst(archivePath, extractDir);
43
+ } catch (err) {
44
+ // A checksum can match a manifest that was written for a broken upload;
45
+ // extraction failure must fall back to older entries, not abort restore.
46
+ const detail = err instanceof Error ? err.message : String(err);
47
+ logError(`snapshot ${entry.id} failed to extract: ${detail}`);
48
+ return "failed";
49
+ }
50
+ for (const database of await findSqliteFiles(extractDir)) {
51
+ const integrity = checkIntegrity(database);
52
+ if (integrity.kind === "corrupt") {
53
+ logError(`snapshot ${entry.id} db ${path.basename(database)} corrupt: ${integrity.detail}`);
54
+ return "failed";
55
+ }
56
+ }
57
+ // Verified: move into place atomically. The live dir must not exist yet
58
+ // (fresh container) — restore never overwrites live state.
59
+ await fs.mkdir(path.dirname(liveDir), { recursive: true });
60
+ await fs.rename(extractDir, liveDir);
61
+ return "restored";
62
+ } finally {
63
+ // No-op after a successful rename; clears partial extractions otherwise.
64
+ await fs.rm(extractDir, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ /** Restore the newest verified snapshot into the (not yet existing) live dir. */
69
+ export async function runRestore(params: {
70
+ config: SyncConfig;
71
+ hub: BucketHub;
72
+ }): Promise<RestoreOutcome> {
73
+ const { config, hub } = params;
74
+ if (!config.bucket) {
75
+ return { kind: "fresh-start", reason: "no-bucket" };
76
+ }
77
+ try {
78
+ await fs.access(config.liveDir);
79
+ return { kind: "fresh-start", reason: "live-dir-exists" };
80
+ } catch {
81
+ // expected: fresh container
82
+ }
83
+
84
+ const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-restore-"));
85
+ try {
86
+ const manifestPath = path.join(workDir, "manifest.json");
87
+ const downloaded = await hub.download(remotePath(config, MANIFEST_REMOTE_NAME), manifestPath);
88
+ if (downloaded === "not-found") {
89
+ return { kind: "fresh-start", reason: "no-manifest" };
90
+ }
91
+ const parsed = parseManifest(await fs.readFile(manifestPath, "utf8"));
92
+ if (parsed.kind === "invalid") {
93
+ return { kind: "invalid-manifest", reason: parsed.reason };
94
+ }
95
+
96
+ const candidates = [parsed.manifest.current, ...parsed.manifest.previous];
97
+ const tried: string[] = [];
98
+ for (const entry of candidates) {
99
+ tried.push(entry.id);
100
+ if ((await tryRestoreEntry({ hub, entry, workDir, liveDir: config.liveDir })) === "restored") {
101
+ log(`restored snapshot ${entry.id} (created ${entry.createdAt})`);
102
+ return { kind: "restored", entry };
103
+ }
104
+ }
105
+ return { kind: "all-snapshots-failed", tried };
106
+ } finally {
107
+ await fs.rm(workDir, { recursive: true, force: true });
108
+ }
109
+ }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createTarZst, sha256File, stageLiveDir } from "./archive.js";
5
+ import type { BucketHub } from "./hub.js";
6
+ import {
7
+ MANIFEST_REMOTE_NAME,
8
+ type Manifest,
9
+ type SnapshotEntry,
10
+ parseManifest,
11
+ promoteSnapshot,
12
+ serializeManifest,
13
+ } from "./manifest.js";
14
+ import { type SyncConfig, log, remotePath } from "./paths.js";
15
+
16
+ export type SnapshotOutcome =
17
+ | { kind: "uploaded"; entry: SnapshotEntry }
18
+ | { kind: "skipped"; reason: "no-bucket" | "empty-state" }
19
+ | { kind: "failed"; detail: string };
20
+
21
+ // Object names must be unique across overlapping containers (run-id suffix)
22
+ // AND within one run (counter): a final snapshot can land in the same second
23
+ // as the last interval snapshot, and an overwritten tarball behind a distinct
24
+ // manifest entry would poison rollback and confuse retention pruning.
25
+ let snapshotCounter = 0;
26
+ function snapshotId(now: Date, runId: string): string {
27
+ snapshotCounter += 1;
28
+ const stamp = now.toISOString().replaceAll(":", "-").replace(".", "-");
29
+ return `${stamp}-${runId.slice(0, 8)}-${snapshotCounter}`;
30
+ }
31
+
32
+ type FetchManifestResult =
33
+ | { kind: "none" }
34
+ | { kind: "ok"; manifest: Manifest }
35
+ | { kind: "invalid"; reason: string };
36
+
37
+ async function fetchManifest(
38
+ config: SyncConfig,
39
+ hub: BucketHub,
40
+ workDir: string,
41
+ ): Promise<FetchManifestResult> {
42
+ const localPath = path.join(workDir, "manifest.remote.json");
43
+ const result = await hub.download(remotePath(config, MANIFEST_REMOTE_NAME), localPath);
44
+ if (result === "not-found") {
45
+ return { kind: "none" };
46
+ }
47
+ const parsed = parseManifest(await fs.readFile(localPath, "utf8"));
48
+ return parsed.kind === "ok"
49
+ ? { kind: "ok", manifest: parsed.manifest }
50
+ : { kind: "invalid", reason: parsed.reason };
51
+ }
52
+
53
+ /**
54
+ * Stage, verify, upload, then promote: the manifest object is only
55
+ * overwritten after the tarball upload succeeded, so it never references a
56
+ * partial archive. Object writes are atomic; no locking (last verified
57
+ * writer wins across overlapping containers).
58
+ */
59
+ export async function runSnapshot(params: {
60
+ config: SyncConfig;
61
+ hub: BucketHub;
62
+ bootTime: string;
63
+ now?: () => Date;
64
+ }): Promise<SnapshotOutcome> {
65
+ const { config, hub } = params;
66
+ if (!config.bucket) {
67
+ return { kind: "skipped", reason: "no-bucket" };
68
+ }
69
+ try {
70
+ await fs.access(config.liveDir);
71
+ } catch {
72
+ return { kind: "skipped", reason: "empty-state" };
73
+ }
74
+
75
+ const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-snapshot-"));
76
+ try {
77
+ const stagingDir = path.join(workDir, "stage");
78
+ const staged = await stageLiveDir(config.liveDir, stagingDir);
79
+ if (staged.kind === "corrupt-database") {
80
+ return {
81
+ kind: "failed",
82
+ detail: `live database ${staged.database} failed integrity check: ${staged.detail}`,
83
+ };
84
+ }
85
+
86
+ const now = (params.now ?? (() => new Date()))();
87
+ const id = snapshotId(now, config.runId);
88
+ const archiveName = `state-${id}.tar.zst`;
89
+ const archivePath = path.join(workDir, archiveName);
90
+ await createTarZst(stagingDir, archivePath);
91
+
92
+ const entry: SnapshotEntry = {
93
+ id,
94
+ path: remotePath(config, `snapshots/${archiveName}`),
95
+ createdAt: now.toISOString(),
96
+ sha256: await sha256File(archivePath),
97
+ sizeBytes: (await fs.stat(archivePath)).size,
98
+ runId: config.runId,
99
+ bootTime: params.bootTime,
100
+ };
101
+
102
+ await hub.upload(archivePath, entry.path);
103
+
104
+ const existing = await fetchManifest(config, hub, workDir);
105
+ if (existing.kind === "invalid") {
106
+ // The manifest is the only rollback index (buckets are non-versioned).
107
+ // Never overwrite an index we cannot read — its snapshots may be the
108
+ // only good copy of the user's data.
109
+ return {
110
+ kind: "failed",
111
+ detail: `remote manifest is invalid, refusing to overwrite it: ${existing.reason}`,
112
+ };
113
+ }
114
+ const { manifest, expired } = promoteSnapshot({
115
+ existing: existing.kind === "ok" ? existing.manifest : null,
116
+ entry,
117
+ keep: config.keepSnapshots,
118
+ });
119
+ const manifestPath = path.join(workDir, "manifest.json");
120
+ await fs.writeFile(manifestPath, serializeManifest(manifest));
121
+ await hub.upload(manifestPath, remotePath(config, MANIFEST_REMOTE_NAME));
122
+
123
+ if (expired.length > 0) {
124
+ await hub.delete(expired.map((e) => e.path));
125
+ }
126
+ log(`snapshot ${entry.id} uploaded (${entry.sizeBytes} bytes, ${staged.databases.length} dbs)`);
127
+ return { kind: "uploaded", entry };
128
+ } catch (err) {
129
+ return { kind: "failed", detail: err instanceof Error ? err.message : String(err) };
130
+ } finally {
131
+ await fs.rm(workDir, { recursive: true, force: true });
132
+ }
133
+ }
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+
5
+ /** Recursively find live SQLite database files under a directory. */
6
+ export async function findSqliteFiles(root: string): Promise<string[]> {
7
+ const found: string[] = [];
8
+ let entries;
9
+ try {
10
+ entries = await fs.readdir(root, { withFileTypes: true, recursive: true });
11
+ } catch {
12
+ return found;
13
+ }
14
+ for (const entry of entries) {
15
+ if (entry.isFile() && entry.name.endsWith(".sqlite")) {
16
+ found.push(path.join(entry.parentPath, entry.name));
17
+ }
18
+ }
19
+ return found.sort();
20
+ }
21
+
22
+ /**
23
+ * Produce a consistent standalone copy of a live SQLite DB. VACUUM INTO takes
24
+ * its own read transaction, so this is safe while OpenClaw is writing; raw
25
+ * file copies of a WAL-mode DB are not.
26
+ */
27
+ export function vacuumInto(sourceDb: string, destDb: string): void {
28
+ const db = new DatabaseSync(sourceDb);
29
+ try {
30
+ db.prepare("VACUUM INTO ?").run(destDb);
31
+ } finally {
32
+ db.close();
33
+ }
34
+ }
35
+
36
+ export type IntegrityResult = { kind: "ok" } | { kind: "corrupt"; detail: string };
37
+
38
+ export function checkIntegrity(dbPath: string): IntegrityResult {
39
+ let db: DatabaseSync;
40
+ try {
41
+ db = new DatabaseSync(dbPath, { readOnly: true });
42
+ } catch (err) {
43
+ return { kind: "corrupt", detail: `cannot open: ${String(err)}` };
44
+ }
45
+ try {
46
+ const row = db.prepare("PRAGMA integrity_check").get() as
47
+ | { integrity_check?: unknown }
48
+ | undefined;
49
+ return row?.integrity_check === "ok"
50
+ ? { kind: "ok" }
51
+ : { kind: "corrupt", detail: String(row?.integrity_check ?? "no result") };
52
+ } catch (err) {
53
+ return { kind: "corrupt", detail: String(err) };
54
+ } finally {
55
+ db.close();
56
+ }
57
+ }
@@ -0,0 +1,256 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import type { BucketHub } from "./hub.js";
7
+ import { type SyncConfig, log, logError, remotePath } from "./paths.js";
8
+ import { runSnapshot, type SnapshotOutcome } from "./snapshot.js";
9
+
10
+ const LEASE_HEARTBEAT_MS = 60_000;
11
+ const HANDOFF_REQUEST_TTL_MS = 10 * 60 * 1000;
12
+
13
+ /**
14
+ * Run OpenClaw as a child process with a periodic snapshot loop. On SIGTERM/
15
+ * SIGINT (Space shutdown/rebuild) the signal is forwarded and a best-effort
16
+ * final snapshot runs after the child exits, so at most one interval of state
17
+ * is lost. If the platform hard-kills us first, the interval loop has already
18
+ * bounded the loss the same way.
19
+ */
20
+ export async function supervise(params: {
21
+ config: SyncConfig;
22
+ hub: BucketHub;
23
+ command: string[];
24
+ }): Promise<number> {
25
+ const { config, hub, command } = params;
26
+ const [binary, ...args] = command;
27
+ if (!binary) {
28
+ throw new Error("supervise: missing child command");
29
+ }
30
+ const bootTime = new Date().toISOString();
31
+ let lastSnapshotId: string | undefined;
32
+ const handoffState: { request: RuntimeHandoffRequest | null } = { request: null };
33
+
34
+ const writeLease = async () => {
35
+ const status = {
36
+ schemaVersion: 1,
37
+ agent: config.agentName,
38
+ runtimeId: config.runtimeId,
39
+ gatewayLocation: config.gatewayLocation,
40
+ runtimeImage: config.runtimeImage,
41
+ startedAt: bootTime,
42
+ lastHeartbeatAt: new Date().toISOString(),
43
+ ...(lastSnapshotId ? { lastSnapshotId } : {}),
44
+ };
45
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-lease-"));
46
+ try {
47
+ const file = path.join(tmpDir, "status.json");
48
+ await fs.writeFile(file, JSON.stringify(status, null, 2) + "\n");
49
+ await hub.upload(file, remotePath(config, "runtime/status.json"));
50
+ } finally {
51
+ await fs.rm(tmpDir, { recursive: true, force: true });
52
+ }
53
+ };
54
+ const readHandoffRequest = async (): Promise<RuntimeHandoffRequest | null> => {
55
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-handoff-"));
56
+ try {
57
+ const file = path.join(tmpDir, "request.json");
58
+ const result = await hub.download(remotePath(config, "runtime/handoff-request.json"), file);
59
+ if (result === "not-found") {
60
+ return null;
61
+ }
62
+ const parsed = JSON.parse(await fs.readFile(file, "utf8")) as RuntimeHandoffRequest;
63
+ if (
64
+ parsed?.schemaVersion !== 1 ||
65
+ parsed.agent !== config.agentName ||
66
+ parsed.runtimeId !== config.runtimeId ||
67
+ typeof parsed.requestedAt !== "string" ||
68
+ typeof parsed.requestId !== "string" ||
69
+ !parsed.requestId
70
+ ) {
71
+ return null;
72
+ }
73
+ const requestedAt = Date.parse(parsed.requestedAt);
74
+ if (!Number.isFinite(requestedAt) || Date.now() - requestedAt > HANDOFF_REQUEST_TTL_MS) {
75
+ log(`ignoring expired handoff request ${parsed.requestId}`);
76
+ await hub.delete([remotePath(config, "runtime/handoff-request.json")]).catch((err) => {
77
+ logError(`failed to clear expired handoff request: ${err instanceof Error ? err.message : String(err)}`);
78
+ });
79
+ return null;
80
+ }
81
+ return parsed;
82
+ } finally {
83
+ await fs.rm(tmpDir, { recursive: true, force: true });
84
+ }
85
+ };
86
+ const writeHandoffAck = async (request: RuntimeHandoffRequest) => {
87
+ const ack = {
88
+ schemaVersion: 1,
89
+ requestId: request.requestId,
90
+ agent: config.agentName,
91
+ runtimeId: config.runtimeId,
92
+ gatewayLocation: config.gatewayLocation,
93
+ completedAt: new Date().toISOString(),
94
+ ...(lastSnapshotId ? { lastSnapshotId } : {}),
95
+ };
96
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-handoff-ack-"));
97
+ try {
98
+ const file = path.join(tmpDir, "ack.json");
99
+ await fs.writeFile(file, JSON.stringify(ack, null, 2) + "\n");
100
+ await hub.upload(file, remotePath(config, "runtime/handoff-ack.json"));
101
+ await hub.delete([remotePath(config, "runtime/handoff-request.json")]);
102
+ } finally {
103
+ await fs.rm(tmpDir, { recursive: true, force: true });
104
+ }
105
+ };
106
+
107
+ const child: ChildProcess = spawn(binary, args, { stdio: "inherit" });
108
+ const childExit = new Promise<number>((resolve) => {
109
+ child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
110
+ child.on("error", (err) => {
111
+ logError(`child failed to start: ${err.message}`);
112
+ resolve(1);
113
+ });
114
+ });
115
+
116
+ let stopping = false;
117
+ let inFlight: Promise<SnapshotOutcome> | null = null;
118
+ const runOnce = async (label: string): Promise<SnapshotOutcome> => {
119
+ try {
120
+ const outcome = await runSnapshot({ config, hub, bootTime });
121
+ if (outcome.kind === "failed") {
122
+ logError(`${label}: snapshot failed: ${outcome.detail}`);
123
+ } else if (outcome.kind === "uploaded") {
124
+ lastSnapshotId = outcome.entry.path;
125
+ }
126
+ await writeLease().catch((err) => {
127
+ logError(`${label}: lease heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
128
+ });
129
+ return outcome;
130
+ } finally {
131
+ inFlight = null;
132
+ }
133
+ };
134
+ const snapshotInterval = async () => {
135
+ if (inFlight) {
136
+ log("interval: previous snapshot still running, skipping");
137
+ return;
138
+ }
139
+ inFlight = runOnce("interval");
140
+ await inFlight;
141
+ };
142
+ // The final snapshot must neither be skipped nor kill an in-flight upload:
143
+ // wait the in-flight one out, then take a fresh snapshot of the quiesced
144
+ // state before the process is allowed to exit.
145
+ const snapshotFinal = async (): Promise<SnapshotOutcome> => {
146
+ if (inFlight) {
147
+ await inFlight;
148
+ }
149
+ inFlight = runOnce("final");
150
+ return await inFlight;
151
+ };
152
+
153
+ const snapshotLoop = (async () => {
154
+ while (!stopping) {
155
+ await delay(config.intervalSeconds * 1000);
156
+ if (stopping) {
157
+ return;
158
+ }
159
+ await snapshotInterval();
160
+ }
161
+ })();
162
+ void snapshotLoop;
163
+
164
+ const heartbeatLoop = (async () => {
165
+ await writeLease().catch((err) => logError(`initial lease failed: ${err instanceof Error ? err.message : String(err)}`));
166
+ while (!stopping) {
167
+ await delay(LEASE_HEARTBEAT_MS);
168
+ if (stopping) {
169
+ return;
170
+ }
171
+ await writeLease().catch((err) => {
172
+ logError(`lease heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
173
+ });
174
+ }
175
+ })();
176
+ void heartbeatLoop;
177
+
178
+ const handoffLoop = (async () => {
179
+ while (!stopping && !handoffState.request) {
180
+ await delay(config.handoffPollSeconds * 1000);
181
+ if (stopping || handoffState.request) {
182
+ return;
183
+ }
184
+ try {
185
+ const request = await readHandoffRequest();
186
+ if (!request) {
187
+ continue;
188
+ }
189
+ handoffState.request = request;
190
+ log(`handoff ${request.requestId} requested for ${request.targetRuntimeId}`);
191
+ stopping = true;
192
+ child.kill("SIGTERM");
193
+ } catch (err) {
194
+ logError(`handoff poll failed: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ }
197
+ })();
198
+ void handoffLoop;
199
+
200
+ const forwardSignal = (signal: NodeJS.Signals) => {
201
+ log(`received ${signal}, shutting down`);
202
+ stopping = true;
203
+ child.kill(signal);
204
+ };
205
+ process.on("SIGTERM", forwardSignal);
206
+ process.on("SIGINT", forwardSignal);
207
+
208
+ const exitCode = await childExit;
209
+ stopping = true;
210
+
211
+ log(`child exited with code ${exitCode}, taking final snapshot`);
212
+ if (!handoffState.request) {
213
+ try {
214
+ const shutdownRequest = await readHandoffRequest();
215
+ if (shutdownRequest) {
216
+ handoffState.request = shutdownRequest;
217
+ log(`handoff ${shutdownRequest.requestId} requested for ${shutdownRequest.targetRuntimeId}`);
218
+ }
219
+ } catch (err) {
220
+ logError(`shutdown handoff check failed: ${err instanceof Error ? err.message : String(err)}`);
221
+ }
222
+ }
223
+ const finalOutcome = await snapshotFinal();
224
+ if (handoffState.request) {
225
+ const request = handoffState.request;
226
+ if (finalOutcome.kind !== "uploaded") {
227
+ throw new Error(`handoff ${request.requestId} final snapshot did not upload: ${snapshotFailureDetail(finalOutcome)}`);
228
+ }
229
+ await writeHandoffAck(request).catch((err) => {
230
+ throw new Error(`handoff ${request.requestId} snapshot completed but ack failed: ${err instanceof Error ? err.message : String(err)}`);
231
+ });
232
+ log(`handoff ${request.requestId} acknowledged`);
233
+ return 0;
234
+ }
235
+ return exitCode;
236
+ }
237
+
238
+ function snapshotFailureDetail(outcome: SnapshotOutcome): string {
239
+ switch (outcome.kind) {
240
+ case "uploaded":
241
+ return "uploaded";
242
+ case "failed":
243
+ return outcome.detail;
244
+ case "skipped":
245
+ return outcome.reason;
246
+ }
247
+ }
248
+
249
+ type RuntimeHandoffRequest = {
250
+ schemaVersion: 1;
251
+ requestId: string;
252
+ agent: string;
253
+ runtimeId: string;
254
+ requestedAt: string;
255
+ targetRuntimeId: string;
256
+ };
@@ -0,0 +1,52 @@
1
+ // @ts-nocheck -- vendored upstream code, kept verbatim; our strict tsconfig does not apply.
2
+ // Vendored from huggingface/huggingface.js@f8fdf6be (packages/hub/src/error.ts), MIT License.
3
+ // Delete this directory when bucket support is upstreamed to @huggingface/hub.
4
+ import type { JsonObject } from "./vendor/type-fest/basic";
5
+
6
+ export async function createApiError(
7
+ response: Response,
8
+ opts?: { requestId?: string; message?: string },
9
+ ): Promise<never> {
10
+ const error = new HubApiError(response.url, response.status, response.headers.get("X-Request-Id") ?? opts?.requestId);
11
+
12
+ error.message = `Api error with status ${error.statusCode}${opts?.message ? `. ${opts.message}` : ""}`;
13
+
14
+ const trailer = [`URL: ${error.url}`, error.requestId ? `Request ID: ${error.requestId}` : undefined]
15
+ .filter(Boolean)
16
+ .join(". ");
17
+
18
+ if (response.headers.get("Content-Type")?.startsWith("application/json")) {
19
+ const json = await response.json();
20
+ error.message = json.error || json.message || error.message;
21
+ if (json.error_description) {
22
+ error.message = error.message ? error.message + `: ${json.error_description}` : json.error_description;
23
+ }
24
+ error.data = json;
25
+ } else {
26
+ error.data = { message: await response.text() };
27
+ }
28
+
29
+ error.message += `. ${trailer}`;
30
+
31
+ throw error;
32
+ }
33
+
34
+ /**
35
+ * Error thrown when an API call to the Hugging Face Hub fails.
36
+ */
37
+ export class HubApiError extends Error {
38
+ statusCode: number;
39
+ url: string;
40
+ requestId?: string;
41
+ data?: JsonObject;
42
+
43
+ constructor(url: string, statusCode: number, requestId?: string, message?: string) {
44
+ super(message);
45
+
46
+ this.statusCode = statusCode;
47
+ this.requestId = requestId;
48
+ this.url = url;
49
+ }
50
+ }
51
+
52
+ export class InvalidApiResponseFormatError extends Error {}