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.
- package/.agents/skills/mlclaw/SKILL.md +214 -0
- package/.agents/skills/mlclaw/agents/openai.yaml +4 -0
- package/.gitattributes +35 -0
- package/Dockerfile +45 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/assets/mlclaw.svg +143 -0
- package/dist/hf-state-sync.js +9532 -0
- package/dist/mlclaw-space-runtime.js +5010 -0
- package/dist/mlclaw.mjs +16502 -0
- package/entrypoint.sh +87 -0
- package/mlclaw.ps1 +108 -0
- package/mlclaw.sh +117 -0
- package/openclaw.default.json +67 -0
- package/package.json +66 -0
- package/scripts/configure-huggingface-model.mjs +86 -0
- package/scripts/configure-telegram.mjs +55 -0
- package/scripts/report-telegram-probe.mjs +18 -0
- package/space/README.md +42 -0
- package/src/hf-bucket-client/client.ts +217 -0
- package/src/hf-state-sync/archive.ts +137 -0
- package/src/hf-state-sync/cli.ts +92 -0
- package/src/hf-state-sync/hub.ts +81 -0
- package/src/hf-state-sync/manifest.ts +67 -0
- package/src/hf-state-sync/paths.ts +73 -0
- package/src/hf-state-sync/restore.ts +109 -0
- package/src/hf-state-sync/snapshot.ts +133 -0
- package/src/hf-state-sync/sqlite.ts +57 -0
- package/src/hf-state-sync/supervise.ts +256 -0
- package/src/vendor/hfjs-xet/error.ts +52 -0
- package/src/vendor/hfjs-xet/types/public.ts +207 -0
- package/src/vendor/hfjs-xet/utils/ChunkCache.ts +102 -0
- package/src/vendor/hfjs-xet/utils/RangeList.ts +182 -0
- package/src/vendor/hfjs-xet/utils/SplicedBlob.ts +249 -0
- package/src/vendor/hfjs-xet/utils/XetBlob.ts +732 -0
- package/src/vendor/hfjs-xet/utils/checkCredentials.ts +21 -0
- package/src/vendor/hfjs-xet/utils/combineUint8Arrays.ts +13 -0
- package/src/vendor/hfjs-xet/utils/createXorbs.ts +782 -0
- package/src/vendor/hfjs-xet/utils/shardParser.ts +152 -0
- package/src/vendor/hfjs-xet/utils/sum.ts +9 -0
- package/src/vendor/hfjs-xet/utils/uploadShards.ts +443 -0
- package/src/vendor/hfjs-xet/utils/xetWriteToken.ts +101 -0
- package/src/vendor/hfjs-xet/vendor/lz4js/index.ts +540 -0
- package/src/vendor/hfjs-xet/vendor/lz4js/util.ts +57 -0
- package/src/vendor/hfjs-xet/vendor/lz4js/xxh32.ts +99 -0
- package/src/vendor/hfjs-xet/vendor/type-fest/basic.ts +34 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Hugging Face Storage Bucket client.
|
|
3
|
+
*
|
|
4
|
+
* Protocol reference: huggingface_hub (Python) `HfApi._batch_bucket_files`.
|
|
5
|
+
* Buckets are non-versioned object storage; file content travels the Xet
|
|
6
|
+
* protocol (vendored from huggingface.js), then files are registered with a
|
|
7
|
+
* single NDJSON `POST /api/buckets/{id}/batch` call. Reads are plain HTTP.
|
|
8
|
+
*/
|
|
9
|
+
import { uploadShards } from "../vendor/hfjs-xet/utils/uploadShards.js";
|
|
10
|
+
|
|
11
|
+
export const HUB_URL = "https://huggingface.co";
|
|
12
|
+
|
|
13
|
+
export type BucketClientOptions = {
|
|
14
|
+
bucket: string;
|
|
15
|
+
accessToken: string;
|
|
16
|
+
hubUrl?: string;
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BucketEntry = {
|
|
21
|
+
path: string;
|
|
22
|
+
size: number;
|
|
23
|
+
type: "file" | "directory";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type BatchOperation =
|
|
27
|
+
| { type: "addFile"; path: string; xetHash: string; mtime: number; contentType?: string }
|
|
28
|
+
| { type: "deleteFile"; path: string };
|
|
29
|
+
|
|
30
|
+
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
/** RFC 5988 Link header, GitHub pagination style (per the Python reference). */
|
|
34
|
+
function nextPageUrl(linkHeader: string | null): string | null {
|
|
35
|
+
if (!linkHeader) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
for (const part of linkHeader.split(",")) {
|
|
39
|
+
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
|
|
40
|
+
if (match?.[1]) {
|
|
41
|
+
return match[1];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class BucketHttpError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly status: number,
|
|
50
|
+
readonly url: string,
|
|
51
|
+
body: string,
|
|
52
|
+
) {
|
|
53
|
+
super(`bucket request failed: ${status} ${url}: ${body.slice(0, 500)}`);
|
|
54
|
+
this.name = "BucketHttpError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class BucketClient {
|
|
59
|
+
private readonly bucket: string;
|
|
60
|
+
private readonly hubUrl: string;
|
|
61
|
+
private readonly accessToken: string;
|
|
62
|
+
private readonly fetchImpl: typeof fetch;
|
|
63
|
+
|
|
64
|
+
constructor(options: BucketClientOptions) {
|
|
65
|
+
this.bucket = options.bucket;
|
|
66
|
+
this.hubUrl = options.hubUrl ?? HUB_URL;
|
|
67
|
+
this.accessToken = options.accessToken;
|
|
68
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private apiUrl(suffix: string): string {
|
|
72
|
+
return `${this.hubUrl}/api/buckets/${this.bucket}${suffix}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private authHeaders(): Record<string, string> {
|
|
76
|
+
return { Authorization: `Bearer ${this.accessToken}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async request(url: string, init?: RequestInit): Promise<Response> {
|
|
80
|
+
const response = await this.fetchWithRetry(url, init);
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new BucketHttpError(response.status, url, await response.text());
|
|
83
|
+
}
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
|
|
88
|
+
const attempts = 4;
|
|
89
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
90
|
+
let response: Response;
|
|
91
|
+
try {
|
|
92
|
+
response = await this.fetchImpl(url, {
|
|
93
|
+
...init,
|
|
94
|
+
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
95
|
+
headers: { ...this.authHeaders(), ...init?.headers },
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (
|
|
99
|
+
attempt < attempts - 1 &&
|
|
100
|
+
err instanceof Error &&
|
|
101
|
+
(err.name === "AbortError" || err.name === "TimeoutError")
|
|
102
|
+
) {
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
if (!RETRY_STATUSES.has(response.status) || attempt === attempts - 1) {
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
|
|
112
|
+
}
|
|
113
|
+
throw new Error("unreachable retry state");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Upload file contents via Xet, then register them in one batch call. */
|
|
117
|
+
async uploadFiles(files: Array<{ path: string; content: Blob }>): Promise<void> {
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const hashes = new Map<string, string>();
|
|
122
|
+
const source = (async function* () {
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
yield { content: file.content, path: file.path };
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
for await (const event of uploadShards(source, {
|
|
128
|
+
accessToken: this.accessToken,
|
|
129
|
+
hubUrl: this.hubUrl,
|
|
130
|
+
// All upload traffic goes to the CAS endpoint from the write token;
|
|
131
|
+
// repo/rev are unused by the network path for buckets.
|
|
132
|
+
repo: { type: "model", name: this.bucket },
|
|
133
|
+
rev: "main",
|
|
134
|
+
xetParams: {
|
|
135
|
+
refreshWriteTokenUrl: this.apiUrl("/xet-write-token"),
|
|
136
|
+
},
|
|
137
|
+
fetch: this.fetchImpl,
|
|
138
|
+
})) {
|
|
139
|
+
if (event.event === "file") {
|
|
140
|
+
hashes.set(event.path, event.xetHash);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const missing = files.filter((file) => !hashes.has(file.path));
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
throw new Error(`xet upload returned no hash for: ${missing.map((f) => f.path).join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
await this.batch(
|
|
148
|
+
files.map((file) => ({
|
|
149
|
+
type: "addFile",
|
|
150
|
+
path: file.path,
|
|
151
|
+
xetHash: hashes.get(file.path) as string,
|
|
152
|
+
// Milliseconds, per the Python reference (`int(time.time() * 1000)`).
|
|
153
|
+
mtime: Date.now(),
|
|
154
|
+
})),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async deleteFiles(paths: string[]): Promise<void> {
|
|
159
|
+
if (paths.length === 0) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await this.batch(paths.map((path) => ({ type: "deleteFile", path })));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async batch(operations: BatchOperation[]): Promise<void> {
|
|
166
|
+
const body = `${operations.map((op) => JSON.stringify(op)).join("\n")}\n`;
|
|
167
|
+
await this.request(this.apiUrl("/batch"), {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/x-ndjson" },
|
|
170
|
+
body,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Download a file. Returns null when the file does not exist; throws on
|
|
176
|
+
* any other failure (including bucket/auth errors), so a missing object is
|
|
177
|
+
* never conflated with an unreachable bucket.
|
|
178
|
+
*/
|
|
179
|
+
async downloadFile(path: string): Promise<Blob | null> {
|
|
180
|
+
// The reference fully quotes the path, slashes included (`quote(safe="")`).
|
|
181
|
+
const url = `${this.hubUrl}/buckets/${this.bucket}/resolve/${encodeURIComponent(path)}`;
|
|
182
|
+
const response = await this.fetchWithRetry(url);
|
|
183
|
+
if (response.status === 404) {
|
|
184
|
+
// Distinguish "object missing" from "bucket missing/no access".
|
|
185
|
+
await this.assertBucketAccessible();
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new BucketHttpError(response.status, url, await response.text());
|
|
190
|
+
}
|
|
191
|
+
return await response.blob();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** List files under a prefix (recursive), following Link-header pagination. */
|
|
195
|
+
async listFiles(prefix = ""): Promise<BucketEntry[]> {
|
|
196
|
+
const entries: BucketEntry[] = [];
|
|
197
|
+
const encodedPrefix = prefix ? `/${encodeURIComponent(prefix)}` : "";
|
|
198
|
+
let url: string | null = `${this.apiUrl(`/tree${encodedPrefix}`)}?recursive=true`;
|
|
199
|
+
while (url) {
|
|
200
|
+
const response: Response = await this.request(url);
|
|
201
|
+
const page = (await response.json()) as Array<{ type: string; path: string; size?: number }>;
|
|
202
|
+
for (const item of page) {
|
|
203
|
+
entries.push({
|
|
204
|
+
path: item.path,
|
|
205
|
+
size: item.size ?? 0,
|
|
206
|
+
type: item.type === "directory" ? "directory" : "file",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
url = nextPageUrl(response.headers.get("link"));
|
|
210
|
+
}
|
|
211
|
+
return entries;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async assertBucketAccessible(): Promise<void> {
|
|
215
|
+
await this.request(this.apiUrl(""));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { checkIntegrity, vacuumInto } from "./sqlite.js";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
// Secrets live in HF Space Secrets, never in snapshots; scratch state is
|
|
13
|
+
// rebuildable. These exclusions apply ONLY inside OpenClaw state dirs
|
|
14
|
+
// (`.openclaw`): user workspace content is durable data no matter what it is
|
|
15
|
+
// named, so a `workspace/logs/` directory must survive.
|
|
16
|
+
const STATE_EXCLUDED_NAMES = new Set([".env", "credentials", "tmp", "cache", "logs"]);
|
|
17
|
+
const STATE_EXCLUDED_SUFFIXES = [".log"];
|
|
18
|
+
// WAL/SHM sidecars are never a durable format anywhere; live .sqlite files
|
|
19
|
+
// are replaced by VACUUM INTO copies during staging.
|
|
20
|
+
const SIDECAR_SUFFIXES = [".sqlite-wal", ".sqlite-shm"];
|
|
21
|
+
const STATE_DIR_NAME = ".openclaw";
|
|
22
|
+
|
|
23
|
+
function isExcluded(name: string, inStateDir: boolean): boolean {
|
|
24
|
+
if (SIDECAR_SUFFIXES.some((suffix) => name.endsWith(suffix))) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (!inStateDir) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
STATE_EXCLUDED_NAMES.has(name) ||
|
|
32
|
+
STATE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Walk the live tree once: copy regular files, skip excluded names, and
|
|
38
|
+
* collect live SQLite DBs for consistent copying. Discovering DBs during the
|
|
39
|
+
* same filtered walk keeps DBs under excluded dirs (e.g. cache/) out of the
|
|
40
|
+
* snapshot.
|
|
41
|
+
*/
|
|
42
|
+
async function copyTreeFiltered(params: {
|
|
43
|
+
sourceDir: string;
|
|
44
|
+
destDir: string;
|
|
45
|
+
databases: string[];
|
|
46
|
+
rootDir: string;
|
|
47
|
+
inStateDir: boolean;
|
|
48
|
+
depth: number;
|
|
49
|
+
}): Promise<void> {
|
|
50
|
+
const { sourceDir, destDir, databases, rootDir, inStateDir, depth } = params;
|
|
51
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
52
|
+
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (isExcluded(entry.name, inStateDir)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const source = path.join(sourceDir, entry.name);
|
|
58
|
+
const dest = path.join(destDir, entry.name);
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
await copyTreeFiltered({
|
|
61
|
+
sourceDir: source,
|
|
62
|
+
destDir: dest,
|
|
63
|
+
databases,
|
|
64
|
+
rootDir,
|
|
65
|
+
// Only the top-level .openclaw dir is OpenClaw state; a workspace
|
|
66
|
+
// project may legitimately contain its own .openclaw directory.
|
|
67
|
+
inStateDir: inStateDir || (depth === 0 && entry.name === STATE_DIR_NAME),
|
|
68
|
+
depth: depth + 1,
|
|
69
|
+
});
|
|
70
|
+
} else if (entry.isFile()) {
|
|
71
|
+
if (entry.name.endsWith(".sqlite")) {
|
|
72
|
+
databases.push(path.relative(rootDir, source));
|
|
73
|
+
} else {
|
|
74
|
+
await fs.copyFile(source, dest);
|
|
75
|
+
}
|
|
76
|
+
} else if (entry.isSymbolicLink()) {
|
|
77
|
+
// Workspaces hold checked-out repos; dropping symlinks would restore
|
|
78
|
+
// them broken. The link target is copied verbatim, not followed.
|
|
79
|
+
await fs.symlink(await fs.readlink(source), dest);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type StageResult =
|
|
85
|
+
| { kind: "staged"; databases: string[] }
|
|
86
|
+
| { kind: "corrupt-database"; database: string; detail: string };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stage a snapshot of the live dir: filtered file copy plus a consistent,
|
|
90
|
+
* integrity-checked VACUUM INTO copy of every live SQLite DB.
|
|
91
|
+
*/
|
|
92
|
+
export async function stageLiveDir(liveDir: string, stagingDir: string): Promise<StageResult> {
|
|
93
|
+
const databases: string[] = [];
|
|
94
|
+
await copyTreeFiltered({
|
|
95
|
+
sourceDir: liveDir,
|
|
96
|
+
destDir: stagingDir,
|
|
97
|
+
databases,
|
|
98
|
+
rootDir: liveDir,
|
|
99
|
+
inStateDir: false,
|
|
100
|
+
depth: 0,
|
|
101
|
+
});
|
|
102
|
+
for (const relative of databases) {
|
|
103
|
+
const staged = path.join(stagingDir, relative);
|
|
104
|
+
await fs.mkdir(path.dirname(staged), { recursive: true });
|
|
105
|
+
vacuumInto(path.join(liveDir, relative), staged);
|
|
106
|
+
const integrity = checkIntegrity(staged);
|
|
107
|
+
if (integrity.kind === "corrupt") {
|
|
108
|
+
return { kind: "corrupt-database", database: relative, detail: integrity.detail };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { kind: "staged", databases };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Two-step tar + zstd keeps this portable across GNU tar and bsdtar; tar's
|
|
115
|
+
// --use-compress-program extraction semantics differ between them.
|
|
116
|
+
export async function createTarZst(sourceDir: string, outFile: string): Promise<void> {
|
|
117
|
+
const tarFile = `${outFile}.tar`;
|
|
118
|
+
await execFileAsync("tar", ["-cf", tarFile, "-C", sourceDir, "."]);
|
|
119
|
+
await execFileAsync("zstd", ["-q", "-f", "--rm", tarFile, "-o", outFile]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function extractTarZst(archiveFile: string, destDir: string): Promise<void> {
|
|
123
|
+
const tarFile = `${archiveFile}.extracted.tar`;
|
|
124
|
+
await execFileAsync("zstd", ["-q", "-f", "-d", archiveFile, "-o", tarFile]);
|
|
125
|
+
try {
|
|
126
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
127
|
+
await execFileAsync("tar", ["-xf", tarFile, "-C", destDir]);
|
|
128
|
+
} finally {
|
|
129
|
+
await fs.rm(tarFile, { force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function sha256File(file: string): Promise<string> {
|
|
134
|
+
const hash = createHash("sha256");
|
|
135
|
+
await pipeline(createReadStream(file), hash);
|
|
136
|
+
return hash.digest("hex");
|
|
137
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHfBucketHub } from "./hub.js";
|
|
2
|
+
import type { BucketHub } from "./hub.js";
|
|
3
|
+
import { log, logError, resolveSyncConfig } from "./paths.js";
|
|
4
|
+
import { runRestore } from "./restore.js";
|
|
5
|
+
import { runSnapshot } from "./snapshot.js";
|
|
6
|
+
import { supervise } from "./supervise.js";
|
|
7
|
+
|
|
8
|
+
const USAGE = `usage:
|
|
9
|
+
hf-state-sync restore
|
|
10
|
+
hf-state-sync snapshot
|
|
11
|
+
hf-state-sync supervise -- <command> [args...]`;
|
|
12
|
+
|
|
13
|
+
function makeHub(bucket: string | null): BucketHub | null {
|
|
14
|
+
return bucket ? createHfBucketHub({ bucket }) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main(argv: string[]): Promise<number> {
|
|
18
|
+
const config = resolveSyncConfig();
|
|
19
|
+
const hub = makeHub(config.bucket);
|
|
20
|
+
if (!hub) {
|
|
21
|
+
logError("OPENCLAW_HF_STATE_BUCKET is not set; state will NOT survive restarts");
|
|
22
|
+
}
|
|
23
|
+
const mode = argv[0];
|
|
24
|
+
|
|
25
|
+
switch (mode) {
|
|
26
|
+
case "restore": {
|
|
27
|
+
if (!hub) {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const outcome = await runRestore({ config, hub });
|
|
31
|
+
switch (outcome.kind) {
|
|
32
|
+
case "restored":
|
|
33
|
+
return 0;
|
|
34
|
+
case "fresh-start":
|
|
35
|
+
log(`fresh start (${outcome.reason})`);
|
|
36
|
+
return 0;
|
|
37
|
+
case "invalid-manifest":
|
|
38
|
+
// Refuse to silently lose a bucket that claims to have state.
|
|
39
|
+
logError(`manifest exists but is invalid, refusing fresh start: ${outcome.reason}`);
|
|
40
|
+
return 1;
|
|
41
|
+
case "all-snapshots-failed":
|
|
42
|
+
logError(`all snapshots failed verification: ${outcome.tried.join(", ")}`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "snapshot": {
|
|
48
|
+
if (!hub) {
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const outcome = await runSnapshot({ config, hub, bootTime: new Date().toISOString() });
|
|
52
|
+
if (outcome.kind === "failed") {
|
|
53
|
+
logError(outcome.detail);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
log(`snapshot outcome: ${outcome.kind}`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
case "supervise": {
|
|
60
|
+
const separator = argv.indexOf("--");
|
|
61
|
+
const command = separator >= 0 ? argv.slice(separator + 1) : [];
|
|
62
|
+
if (command.length === 0) {
|
|
63
|
+
logError(USAGE);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
if (!hub) {
|
|
67
|
+
// Still run the child: a Space without a bucket should serve, just
|
|
68
|
+
// without durability.
|
|
69
|
+
const { spawn } = await import("node:child_process");
|
|
70
|
+
const child = spawn(command[0] as string, command.slice(1), { stdio: "inherit" });
|
|
71
|
+
return await new Promise<number>((resolve) => {
|
|
72
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
73
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
74
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return await supervise({ config, hub, command });
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
logError(USAGE);
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main(process.argv.slice(2)).then(
|
|
87
|
+
(code) => process.exit(code),
|
|
88
|
+
(err) => {
|
|
89
|
+
logError(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { BucketClient, BucketHttpError } from "../hf-bucket-client/client.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transport to the durable bucket. Implementations must be safe to call with
|
|
6
|
+
* untrusted remote state; callers verify all downloaded content separately.
|
|
7
|
+
*/
|
|
8
|
+
export interface BucketHub {
|
|
9
|
+
download(remotePath: string, localPath: string): Promise<"downloaded" | "not-found">;
|
|
10
|
+
upload(localPath: string, remotePath: string): Promise<void>;
|
|
11
|
+
delete(remotePaths: string[]): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* BucketHub backed by the shared TypeScript Storage Bucket client. Auth comes
|
|
16
|
+
* from HF_TOKEN in Space Secrets.
|
|
17
|
+
*/
|
|
18
|
+
export function createHfBucketHub(params: { bucket: string; token?: string }): BucketHub {
|
|
19
|
+
const token = params.token ?? process.env.HF_TOKEN;
|
|
20
|
+
if (!token) {
|
|
21
|
+
throw new Error("HF_TOKEN is required when OPENCLAW_HF_STATE_BUCKET is set");
|
|
22
|
+
}
|
|
23
|
+
const client = new BucketClient({ bucket: params.bucket, accessToken: token });
|
|
24
|
+
|
|
25
|
+
// A mistyped bucket id or a token without access also surfaces as a
|
|
26
|
+
// not-found error from resolve URLs. Treating that as "object missing" would
|
|
27
|
+
// make restore fresh-start a Space with NO durability, so a not-found is
|
|
28
|
+
// only trusted after the bucket itself proves listable.
|
|
29
|
+
let bucketAccessible = false;
|
|
30
|
+
const assertBucketAccessible = async (): Promise<void> => {
|
|
31
|
+
if (bucketAccessible) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await client.assertBucketAccessible();
|
|
36
|
+
bucketAccessible = true;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
throw new Error(`bucket ${params.bucket} is not accessible: ${message}`);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
async download(remotePath, localPath) {
|
|
45
|
+
try {
|
|
46
|
+
const blob = await client.downloadFile(remotePath);
|
|
47
|
+
if (!blob) {
|
|
48
|
+
await assertBucketAccessible();
|
|
49
|
+
return "not-found";
|
|
50
|
+
}
|
|
51
|
+
await fs.writeFile(localPath, Buffer.from(await blob.arrayBuffer()));
|
|
52
|
+
return "downloaded";
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err instanceof BucketHttpError && err.status === 404) {
|
|
55
|
+
await assertBucketAccessible();
|
|
56
|
+
return "not-found";
|
|
57
|
+
}
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
throw new Error(`bucket download failed for ${remotePath}: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
async upload(localPath, remotePath) {
|
|
63
|
+
try {
|
|
64
|
+
await client.uploadFiles([{ path: remotePath, content: new Blob([await fs.readFile(localPath)]) }]);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
throw new Error(`bucket upload failed for ${remotePath}: ${message}`);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
async delete(remotePaths) {
|
|
71
|
+
try {
|
|
72
|
+
await client.deleteFiles(remotePaths);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Retention pruning is best-effort; a leftover file costs storage,
|
|
75
|
+
// not correctness. Never fail a snapshot over it.
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
console.error(`[hf-state-sync] prune failed for ${remotePaths.join(", ")}: ${message}`);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const snapshotEntrySchema = z.object({
|
|
4
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/),
|
|
5
|
+
path: z.string().min(1),
|
|
6
|
+
createdAt: z.string().datetime(),
|
|
7
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
8
|
+
sizeBytes: z.number().int().nonnegative(),
|
|
9
|
+
runId: z.string().min(1),
|
|
10
|
+
bootTime: z.string().datetime(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Buckets are non-versioned: this manifest plus the snapshot files it
|
|
14
|
+
// references are the ONLY rollback path. Never drop an entry whose file
|
|
15
|
+
// has not been deleted, and never delete a file that is still referenced.
|
|
16
|
+
export const manifestSchema = z.object({
|
|
17
|
+
version: z.literal(1),
|
|
18
|
+
current: snapshotEntrySchema,
|
|
19
|
+
previous: z.array(snapshotEntrySchema),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type SnapshotEntry = z.infer<typeof snapshotEntrySchema>;
|
|
23
|
+
export type Manifest = z.infer<typeof manifestSchema>;
|
|
24
|
+
|
|
25
|
+
export const MANIFEST_REMOTE_NAME = "manifest.json";
|
|
26
|
+
|
|
27
|
+
export type ManifestParseResult =
|
|
28
|
+
| { kind: "ok"; manifest: Manifest }
|
|
29
|
+
| { kind: "invalid"; reason: string };
|
|
30
|
+
|
|
31
|
+
export function parseManifest(raw: string): ManifestParseResult {
|
|
32
|
+
let json: unknown;
|
|
33
|
+
try {
|
|
34
|
+
json = JSON.parse(raw);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { kind: "invalid", reason: `not JSON: ${String(err)}` };
|
|
37
|
+
}
|
|
38
|
+
const result = manifestSchema.safeParse(json);
|
|
39
|
+
return result.success
|
|
40
|
+
? { kind: "ok", manifest: result.data }
|
|
41
|
+
: { kind: "invalid", reason: result.error.message };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function serializeManifest(manifest: Manifest): string {
|
|
45
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Promote a new snapshot: previous gets the old current, truncated so the
|
|
50
|
+
* total retained count is `keep`. Returns the next manifest and the entries
|
|
51
|
+
* whose files are no longer referenced and may be deleted from the bucket.
|
|
52
|
+
*/
|
|
53
|
+
export function promoteSnapshot(params: {
|
|
54
|
+
existing: Manifest | null;
|
|
55
|
+
entry: SnapshotEntry;
|
|
56
|
+
keep: number;
|
|
57
|
+
}): { manifest: Manifest; expired: SnapshotEntry[] } {
|
|
58
|
+
const retainedPrevious = params.existing
|
|
59
|
+
? [params.existing.current, ...params.existing.previous]
|
|
60
|
+
: [];
|
|
61
|
+
const previous = retainedPrevious.slice(0, Math.max(params.keep - 1, 0));
|
|
62
|
+
const expired = retainedPrevious.slice(Math.max(params.keep - 1, 0));
|
|
63
|
+
return {
|
|
64
|
+
manifest: { version: 1, current: params.entry, previous },
|
|
65
|
+
expired,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Resolved runtime configuration for the state sync layer. */
|
|
4
|
+
export type SyncConfig = {
|
|
5
|
+
/**
|
|
6
|
+
* Root of all durable runtime data and the snapshot/restore unit. Contains
|
|
7
|
+
* the OpenClaw state dir AND the agent workspace; snapshotting only the
|
|
8
|
+
* state dir would silently drop workspace files on every restart.
|
|
9
|
+
*/
|
|
10
|
+
liveDir: string;
|
|
11
|
+
/** Bucket repo id (`owner/name`), or null when no bucket is configured. */
|
|
12
|
+
bucket: string | null;
|
|
13
|
+
/** Path prefix inside the bucket for all sync objects. */
|
|
14
|
+
bucketPrefix: string;
|
|
15
|
+
intervalSeconds: number;
|
|
16
|
+
handoffPollSeconds: number;
|
|
17
|
+
keepSnapshots: number;
|
|
18
|
+
/** Identifies this container run in manifests, for observability. */
|
|
19
|
+
runId: string;
|
|
20
|
+
/** Stable logical runtime identity used for leases and handoff. */
|
|
21
|
+
runtimeId: string;
|
|
22
|
+
agentName: string;
|
|
23
|
+
gatewayLocation: "local" | "space" | "unknown";
|
|
24
|
+
runtimeImage: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const DEFAULT_LIVE_DIR = "/tmp/openclaw-live";
|
|
28
|
+
export const DEFAULT_BUCKET_PREFIX = "openclaw-state";
|
|
29
|
+
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
30
|
+
const DEFAULT_HANDOFF_POLL_SECONDS = 5;
|
|
31
|
+
const DEFAULT_KEEP = 5;
|
|
32
|
+
|
|
33
|
+
function positiveIntFromEnv(value: string | undefined, fallback: number): number {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveSyncConfig(env: NodeJS.ProcessEnv = process.env): SyncConfig {
|
|
39
|
+
const runId = env.MLCLAW_RUN_ID?.trim() || randomUUID();
|
|
40
|
+
return {
|
|
41
|
+
liveDir: env.OPENCLAW_LIVE_DIR?.trim() || DEFAULT_LIVE_DIR,
|
|
42
|
+
bucket: env.OPENCLAW_HF_STATE_BUCKET?.trim() || null,
|
|
43
|
+
bucketPrefix: normalizeBucketPrefix(env.OPENCLAW_HF_STATE_PREFIX),
|
|
44
|
+
intervalSeconds: positiveIntFromEnv(env.HF_STATE_SYNC_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS),
|
|
45
|
+
handoffPollSeconds: positiveIntFromEnv(env.HF_STATE_SYNC_HANDOFF_POLL_SECONDS, DEFAULT_HANDOFF_POLL_SECONDS),
|
|
46
|
+
keepSnapshots: positiveIntFromEnv(env.HF_STATE_SYNC_KEEP, DEFAULT_KEEP),
|
|
47
|
+
runId,
|
|
48
|
+
runtimeId: env.MLCLAW_RUNTIME_ID?.trim() || runId,
|
|
49
|
+
agentName: env.OPENCLAW_AGENT_NAME?.trim() || "openclaw",
|
|
50
|
+
gatewayLocation: env.MLCLAW_GATEWAY_LOCATION === "local" || env.MLCLAW_GATEWAY_LOCATION === "space"
|
|
51
|
+
? env.MLCLAW_GATEWAY_LOCATION
|
|
52
|
+
: "unknown",
|
|
53
|
+
runtimeImage: env.MLCLAW_RUNTIME_IMAGE?.trim() || "unknown",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Remote object path under the configured bucket prefix. */
|
|
58
|
+
export function remotePath(config: Pick<SyncConfig, "bucketPrefix">, name: string): string {
|
|
59
|
+
return `${normalizeBucketPrefix(config.bucketPrefix)}/${name.replace(/^\/+/, "")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeBucketPrefix(prefix: string | undefined): string {
|
|
63
|
+
const normalized = (prefix?.trim() || DEFAULT_BUCKET_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
64
|
+
return normalized || DEFAULT_BUCKET_PREFIX;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function log(message: string): void {
|
|
68
|
+
console.log(`[hf-state-sync] ${message}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function logError(message: string): void {
|
|
72
|
+
console.error(`[hf-state-sync] ${message}`);
|
|
73
|
+
}
|