mlclaw 0.2.3 → 0.3.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/.agents/skills/mlclaw/SKILL.md +58 -19
- package/Dockerfile +46 -8
- package/README.md +75 -17
- package/assets/mlclaw-control-ui/assets/index-92PjW54g.js +9 -0
- package/assets/mlclaw-control-ui/assets/index-D1bHaYpf.css +1 -0
- package/assets/mlclaw-control-ui/index.html +2 -2
- package/dist/hf-state-sync.js +438 -76
- package/dist/hf-tooling-seed.js +64 -11
- package/dist/mlclaw-space-runtime.js +8403 -5935
- package/dist/mlclaw.mjs +406 -42
- package/entrypoint.sh +167 -17
- package/hf-broker.scope.json +38 -0
- package/package.json +26 -2
- package/space/README.md +14 -4
- package/src/hf-state-sync/archive.ts +15 -8
- package/src/hf-state-sync/cli.ts +33 -6
- package/src/hf-state-sync/hub.ts +82 -1
- package/src/hf-state-sync/paths.ts +23 -4
- package/src/hf-state-sync/prepare.ts +79 -0
- package/src/hf-state-sync/snapshot.ts +31 -9
- package/src/hf-state-sync/stage-worker.ts +165 -0
- package/src/hf-state-sync/supervise.ts +29 -10
- package/src/vendor/hfjs-xet/utils/ChunkCache.ts +1 -1
- package/src/vendor/hfjs-xet/utils/XetBlob.ts +2 -2
- package/src/vendor/hfjs-xet/utils/createXorbs.ts +2 -2
- package/assets/mlclaw-control-ui/assets/index-D2TFes32.js +0 -9
- package/assets/mlclaw-control-ui/assets/index-DP72PFuv.css +0 -1
|
@@ -10,6 +10,8 @@ export type SyncConfig = {
|
|
|
10
10
|
liveDir: string;
|
|
11
11
|
/** Bucket repo id (`owner/name`), or null when no bucket is configured. */
|
|
12
12
|
bucket: string | null;
|
|
13
|
+
/** Mounted bucket directory for Space runtimes, or null to use the Hub API. */
|
|
14
|
+
stateMountDir: string | null;
|
|
13
15
|
/** Path prefix inside the bucket for all sync objects. */
|
|
14
16
|
bucketPrefix: string;
|
|
15
17
|
intervalSeconds: number;
|
|
@@ -22,9 +24,14 @@ export type SyncConfig = {
|
|
|
22
24
|
agentName: string;
|
|
23
25
|
gatewayLocation: "local" | "space" | "unknown";
|
|
24
26
|
runtimeImage: string;
|
|
27
|
+
/** UID/GID used by the secret-free snapshot traversal worker. */
|
|
28
|
+
snapshotUid?: number;
|
|
29
|
+
snapshotGid?: number;
|
|
30
|
+
/** Root-owned runtime state overlaid by the trusted snapshot supervisor. */
|
|
31
|
+
protectedStateDir?: string;
|
|
25
32
|
};
|
|
26
33
|
|
|
27
|
-
const DEFAULT_LIVE_DIR = "/
|
|
34
|
+
const DEFAULT_LIVE_DIR = "/home/node/.local/share/mlclaw/live";
|
|
28
35
|
export const DEFAULT_BUCKET_PREFIX = "openclaw-state";
|
|
29
36
|
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
30
37
|
const DEFAULT_HANDOFF_POLL_SECONDS = 5;
|
|
@@ -37,9 +44,12 @@ function positiveIntFromEnv(value: string | undefined, fallback: number): number
|
|
|
37
44
|
|
|
38
45
|
export function resolveSyncConfig(env: NodeJS.ProcessEnv = process.env): SyncConfig {
|
|
39
46
|
const runId = env.MLCLAW_RUN_ID?.trim() || randomUUID();
|
|
47
|
+
const snapshotUid = nonNegativeIntFromEnv(env.MLCLAW_OPENCLAW_UID);
|
|
48
|
+
const snapshotGid = nonNegativeIntFromEnv(env.MLCLAW_OPENCLAW_GID);
|
|
40
49
|
return {
|
|
41
50
|
liveDir: env.OPENCLAW_LIVE_DIR?.trim() || DEFAULT_LIVE_DIR,
|
|
42
51
|
bucket: env.OPENCLAW_HF_STATE_BUCKET?.trim() || null,
|
|
52
|
+
stateMountDir: env.MLCLAW_STATE_MOUNT_DIR?.trim() || null,
|
|
43
53
|
bucketPrefix: normalizeBucketPrefix(env.OPENCLAW_HF_STATE_PREFIX),
|
|
44
54
|
intervalSeconds: positiveIntFromEnv(env.HF_STATE_SYNC_INTERVAL_SECONDS, DEFAULT_INTERVAL_SECONDS),
|
|
45
55
|
handoffPollSeconds: positiveIntFromEnv(env.HF_STATE_SYNC_HANDOFF_POLL_SECONDS, DEFAULT_HANDOFF_POLL_SECONDS),
|
|
@@ -47,13 +57,22 @@ export function resolveSyncConfig(env: NodeJS.ProcessEnv = process.env): SyncCon
|
|
|
47
57
|
runId,
|
|
48
58
|
runtimeId: env.MLCLAW_RUNTIME_ID?.trim() || runId,
|
|
49
59
|
agentName: env.OPENCLAW_AGENT_NAME?.trim() || "openclaw",
|
|
50
|
-
gatewayLocation:
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
gatewayLocation:
|
|
61
|
+
env.MLCLAW_GATEWAY_LOCATION === "local" || env.MLCLAW_GATEWAY_LOCATION === "space"
|
|
62
|
+
? env.MLCLAW_GATEWAY_LOCATION
|
|
63
|
+
: "unknown",
|
|
53
64
|
runtimeImage: env.MLCLAW_RUNTIME_IMAGE?.trim() || "unknown",
|
|
65
|
+
...(snapshotUid !== undefined ? { snapshotUid } : {}),
|
|
66
|
+
...(snapshotGid !== undefined ? { snapshotGid } : {}),
|
|
67
|
+
...(env.MLCLAW_PROTECTED_STATE_DIR?.trim() ? { protectedStateDir: env.MLCLAW_PROTECTED_STATE_DIR.trim() } : {}),
|
|
54
68
|
};
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
function nonNegativeIntFromEnv(value: string | undefined): number | undefined {
|
|
72
|
+
const parsed = Number(value);
|
|
73
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/** Remote object path under the configured bucket prefix. */
|
|
58
77
|
export function remotePath(config: Pick<SyncConfig, "bucketPrefix">, name: string): string {
|
|
59
78
|
return `${normalizeBucketPrefix(config.bucketPrefix)}/${name.replace(/^\/+/, "")}`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { SyncConfig } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
export async function prepareRestore(config: SyncConfig): Promise<void> {
|
|
6
|
+
if (config.snapshotUid === undefined || config.snapshotGid === undefined) {
|
|
7
|
+
throw new Error("restore preparation requires MLCLAW_OPENCLAW_UID and MLCLAW_OPENCLAW_GID");
|
|
8
|
+
}
|
|
9
|
+
const liveParent = path.dirname(config.liveDir);
|
|
10
|
+
await fs.mkdir(liveParent, { recursive: true });
|
|
11
|
+
await fs.chown(liveParent, config.snapshotUid, config.snapshotGid);
|
|
12
|
+
|
|
13
|
+
if (!config.stateMountDir) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
await makeTraversableDirectory(config.stateMountDir);
|
|
17
|
+
const prefixRoot = confinedPath(config.stateMountDir, config.bucketPrefix);
|
|
18
|
+
let prefixPart = config.stateMountDir;
|
|
19
|
+
for (const part of config.bucketPrefix.split("/").filter(Boolean)) {
|
|
20
|
+
prefixPart = path.join(prefixPart, part);
|
|
21
|
+
await makeTraversableDirectory(prefixPart);
|
|
22
|
+
}
|
|
23
|
+
await makeReadableIfFile(path.join(prefixRoot, "manifest.json"));
|
|
24
|
+
const snapshotsDir = path.join(prefixRoot, "snapshots");
|
|
25
|
+
await makeTraversableDirectory(snapshotsDir);
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = await fs.readdir(snapshotsDir, { withFileTypes: true });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (isNotFound(err)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (entry.isFile()) {
|
|
37
|
+
await fs.chmod(path.join(snapshotsDir, entry.name), 0o644);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function makeTraversableDirectory(directory: string): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const stat = await fs.lstat(directory);
|
|
45
|
+
if (stat.isDirectory()) {
|
|
46
|
+
await fs.chmod(directory, 0o711);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (!isNotFound(err)) {
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function confinedPath(root: string, relative: string): string {
|
|
56
|
+
const resolvedRoot = path.resolve(root);
|
|
57
|
+
const resolved = path.resolve(resolvedRoot, relative);
|
|
58
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
59
|
+
throw new Error(`invalid state prefix outside mounted bucket: ${relative}`);
|
|
60
|
+
}
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function makeReadableIfFile(file: string): Promise<void> {
|
|
65
|
+
try {
|
|
66
|
+
const stat = await fs.lstat(file);
|
|
67
|
+
if (stat.isFile()) {
|
|
68
|
+
await fs.chmod(file, 0o644);
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (!isNotFound(err)) {
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isNotFound(err: unknown): boolean {
|
|
78
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
79
|
+
}
|
|
@@ -18,6 +18,15 @@ export type SnapshotOutcome =
|
|
|
18
18
|
| { kind: "skipped"; reason: "no-bucket" | "empty-state" }
|
|
19
19
|
| { kind: "failed"; detail: string };
|
|
20
20
|
|
|
21
|
+
export type StagedArchiveOutcome =
|
|
22
|
+
| { kind: "staged"; databaseCount: number }
|
|
23
|
+
| { kind: "corrupt-database"; database: string; detail: string };
|
|
24
|
+
|
|
25
|
+
export type StageArchive = (params: {
|
|
26
|
+
liveDir: string;
|
|
27
|
+
archivePath: string;
|
|
28
|
+
}) => Promise<StagedArchiveOutcome>;
|
|
29
|
+
|
|
21
30
|
// Object names must be unique across overlapping containers (run-id suffix)
|
|
22
31
|
// AND within one run (counter): a final snapshot can land in the same second
|
|
23
32
|
// as the last interval snapshot, and an overwritten tarball behind a distinct
|
|
@@ -61,6 +70,7 @@ export async function runSnapshot(params: {
|
|
|
61
70
|
hub: BucketHub;
|
|
62
71
|
bootTime: string;
|
|
63
72
|
now?: () => Date;
|
|
73
|
+
stageArchive?: StageArchive;
|
|
64
74
|
}): Promise<SnapshotOutcome> {
|
|
65
75
|
const { config, hub } = params;
|
|
66
76
|
if (!config.bucket) {
|
|
@@ -74,8 +84,13 @@ export async function runSnapshot(params: {
|
|
|
74
84
|
|
|
75
85
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-snapshot-"));
|
|
76
86
|
try {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
87
|
+
const now = (params.now ?? (() => new Date()))();
|
|
88
|
+
const id = snapshotId(now, config.runId);
|
|
89
|
+
const archiveName = `state-${id}.tar.zst`;
|
|
90
|
+
const archivePath = path.join(workDir, archiveName);
|
|
91
|
+
const staged = params.stageArchive
|
|
92
|
+
? await params.stageArchive({ liveDir: config.liveDir, archivePath })
|
|
93
|
+
: await stageArchiveInProcess(config.liveDir, path.join(workDir, "stage"), archivePath);
|
|
79
94
|
if (staged.kind === "corrupt-database") {
|
|
80
95
|
return {
|
|
81
96
|
kind: "failed",
|
|
@@ -83,12 +98,6 @@ export async function runSnapshot(params: {
|
|
|
83
98
|
};
|
|
84
99
|
}
|
|
85
100
|
|
|
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
101
|
const entry: SnapshotEntry = {
|
|
93
102
|
id,
|
|
94
103
|
path: remotePath(config, `snapshots/${archiveName}`),
|
|
@@ -123,7 +132,7 @@ export async function runSnapshot(params: {
|
|
|
123
132
|
if (expired.length > 0) {
|
|
124
133
|
await hub.delete(expired.map((e) => e.path));
|
|
125
134
|
}
|
|
126
|
-
log(`snapshot ${entry.id} uploaded (${entry.sizeBytes} bytes, ${staged.
|
|
135
|
+
log(`snapshot ${entry.id} uploaded (${entry.sizeBytes} bytes, ${staged.databaseCount} dbs)`);
|
|
127
136
|
return { kind: "uploaded", entry };
|
|
128
137
|
} catch (err) {
|
|
129
138
|
return { kind: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
@@ -131,3 +140,16 @@ export async function runSnapshot(params: {
|
|
|
131
140
|
await fs.rm(workDir, { recursive: true, force: true });
|
|
132
141
|
}
|
|
133
142
|
}
|
|
143
|
+
|
|
144
|
+
async function stageArchiveInProcess(
|
|
145
|
+
liveDir: string,
|
|
146
|
+
stagingDir: string,
|
|
147
|
+
archivePath: string,
|
|
148
|
+
): Promise<StagedArchiveOutcome> {
|
|
149
|
+
const staged = await stageLiveDir(liveDir, stagingDir);
|
|
150
|
+
if (staged.kind === "corrupt-database") {
|
|
151
|
+
return staged;
|
|
152
|
+
}
|
|
153
|
+
await createTarZst(stagingDir, archivePath);
|
|
154
|
+
return { kind: "staged", databaseCount: staged.databases.length };
|
|
155
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream, createWriteStream, writeFileSync } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { PROTECTED_STATE_DIR_NAME, createTarZst, extractTarZst, stageLiveDir } from "./archive.js";
|
|
8
|
+
import type { SyncConfig } from "./paths.js";
|
|
9
|
+
import type { StageArchive, StagedArchiveOutcome } from "./snapshot.js";
|
|
10
|
+
|
|
11
|
+
type WorkerMessage = StagedArchiveOutcome | { kind: "failed"; detail: string };
|
|
12
|
+
|
|
13
|
+
export function unprivilegedStageArchive(params: { uid: number; gid: number; scriptPath: string }): StageArchive {
|
|
14
|
+
return async ({ liveDir, archivePath }) => {
|
|
15
|
+
const child = spawn(process.execPath, [params.scriptPath, "stage-worker", liveDir], {
|
|
16
|
+
uid: params.uid,
|
|
17
|
+
gid: params.gid,
|
|
18
|
+
env: snapshotWorkerEnvironment(process.env),
|
|
19
|
+
stdio: ["ignore", "pipe", "inherit", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
if (!child.stdout || !child.stdio[3]) {
|
|
22
|
+
child.kill("SIGKILL");
|
|
23
|
+
throw new Error("snapshot staging worker pipes are unavailable");
|
|
24
|
+
}
|
|
25
|
+
const archiveOutput = createWriteStream(archivePath, { flags: "wx", mode: 0o600 });
|
|
26
|
+
const metadata = collect(child.stdio[3] as NodeJS.ReadableStream);
|
|
27
|
+
const archive = pipeline(child.stdout, archiveOutput);
|
|
28
|
+
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
29
|
+
child.once("error", reject);
|
|
30
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
31
|
+
});
|
|
32
|
+
await archive;
|
|
33
|
+
const message = parseWorkerMessage(await metadata);
|
|
34
|
+
if (exitCode !== 0 || message.kind === "failed") {
|
|
35
|
+
throw new Error(
|
|
36
|
+
message.kind === "failed" ? message.detail : `snapshot staging worker exited with code ${exitCode}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return message;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function protectedStageArchive(params: {
|
|
44
|
+
base: StageArchive;
|
|
45
|
+
sourceDir: string;
|
|
46
|
+
archiveName: string;
|
|
47
|
+
}): StageArchive {
|
|
48
|
+
return async (request) => {
|
|
49
|
+
const outcome = await params.base(request);
|
|
50
|
+
if (outcome.kind !== "staged") {
|
|
51
|
+
return outcome;
|
|
52
|
+
}
|
|
53
|
+
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-protected-stage-"));
|
|
54
|
+
try {
|
|
55
|
+
const stagingDir = path.join(workDir, "stage");
|
|
56
|
+
await extractTarZst(request.archivePath, stagingDir);
|
|
57
|
+
const destination = path.join(stagingDir, params.archiveName);
|
|
58
|
+
await fs.cp(params.sourceDir, destination, {
|
|
59
|
+
recursive: true,
|
|
60
|
+
force: false,
|
|
61
|
+
preserveTimestamps: true,
|
|
62
|
+
filter: (source) => includeProtectedSnapshotPath(params.sourceDir, source),
|
|
63
|
+
});
|
|
64
|
+
await fs.chmod(destination, 0o700);
|
|
65
|
+
await fs.rm(request.archivePath, { force: true });
|
|
66
|
+
await createTarZst(stagingDir, request.archivePath);
|
|
67
|
+
await fs.chmod(request.archivePath, 0o600);
|
|
68
|
+
return outcome;
|
|
69
|
+
} finally {
|
|
70
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function includeProtectedSnapshotPath(sourceDir: string, source: string): boolean {
|
|
76
|
+
const relative = path.relative(sourceDir, source);
|
|
77
|
+
return relative !== "hf-broker/mirrors" && !relative.startsWith(`hf-broker/mirrors${path.sep}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function trustedStageArchive(config: SyncConfig, scriptPath: string | undefined): StageArchive | undefined {
|
|
81
|
+
const canStageAsOpenClaw =
|
|
82
|
+
process.getuid?.() === 0 &&
|
|
83
|
+
Boolean(scriptPath) &&
|
|
84
|
+
config.snapshotUid !== undefined &&
|
|
85
|
+
config.snapshotGid !== undefined;
|
|
86
|
+
if (!canStageAsOpenClaw) {
|
|
87
|
+
if (config.protectedStateDir) {
|
|
88
|
+
throw new Error("protected runtime state requires root snapshot staging with an OpenClaw UID and GID");
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let stageArchive = unprivilegedStageArchive({
|
|
94
|
+
uid: config.snapshotUid as number,
|
|
95
|
+
gid: config.snapshotGid as number,
|
|
96
|
+
scriptPath: scriptPath as string,
|
|
97
|
+
});
|
|
98
|
+
if (config.protectedStateDir) {
|
|
99
|
+
stageArchive = protectedStageArchive({
|
|
100
|
+
base: stageArchive,
|
|
101
|
+
sourceDir: config.protectedStateDir,
|
|
102
|
+
archiveName: PROTECTED_STATE_DIR_NAME,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return stageArchive;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runStageWorker(liveDir: string): Promise<number> {
|
|
109
|
+
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "hf-state-stage-worker-"));
|
|
110
|
+
try {
|
|
111
|
+
const stagingDir = path.join(workDir, "stage");
|
|
112
|
+
const archivePath = path.join(workDir, "snapshot.tar.zst");
|
|
113
|
+
const staged = await stageLiveDir(liveDir, stagingDir, { excludeProtectedState: true });
|
|
114
|
+
if (staged.kind === "corrupt-database") {
|
|
115
|
+
writeWorkerMessage(staged);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
await createTarZst(stagingDir, archivePath);
|
|
119
|
+
writeWorkerMessage({ kind: "staged", databaseCount: staged.databases.length });
|
|
120
|
+
await pipeline(createReadStream(archivePath), process.stdout);
|
|
121
|
+
return 0;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
writeWorkerMessage({ kind: "failed", detail: err instanceof Error ? err.message : String(err) });
|
|
124
|
+
return 1;
|
|
125
|
+
} finally {
|
|
126
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function snapshotWorkerEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
131
|
+
return {
|
|
132
|
+
HOME: "/home/node",
|
|
133
|
+
PATH: env.PATH,
|
|
134
|
+
TMPDIR: env.TMPDIR,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeWorkerMessage(message: WorkerMessage): void {
|
|
139
|
+
writeFileSync(3, `${JSON.stringify(message)}\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function collect(stream: NodeJS.ReadableStream | null): Promise<string> {
|
|
143
|
+
if (!stream) {
|
|
144
|
+
throw new Error("snapshot staging worker metadata pipe is unavailable");
|
|
145
|
+
}
|
|
146
|
+
const chunks: Buffer[] = [];
|
|
147
|
+
for await (const chunk of stream) {
|
|
148
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseWorkerMessage(raw: string): WorkerMessage {
|
|
154
|
+
const parsed = JSON.parse(raw) as WorkerMessage;
|
|
155
|
+
if (parsed.kind === "staged" && Number.isInteger(parsed.databaseCount) && parsed.databaseCount >= 0) {
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
if (parsed.kind === "corrupt-database" && parsed.database && parsed.detail) {
|
|
159
|
+
return parsed;
|
|
160
|
+
}
|
|
161
|
+
if (parsed.kind === "failed" && parsed.detail) {
|
|
162
|
+
return parsed;
|
|
163
|
+
}
|
|
164
|
+
throw new Error("snapshot staging worker returned invalid metadata");
|
|
165
|
+
}
|
|
@@ -6,6 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
6
6
|
import type { BucketHub } from "./hub.js";
|
|
7
7
|
import { type SyncConfig, log, logError, remotePath } from "./paths.js";
|
|
8
8
|
import { runSnapshot, type SnapshotOutcome } from "./snapshot.js";
|
|
9
|
+
import { trustedStageArchive } from "./stage-worker.js";
|
|
9
10
|
|
|
10
11
|
const LEASE_HEARTBEAT_MS = 60_000;
|
|
11
12
|
const HANDOFF_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
@@ -17,17 +18,15 @@ const HANDOFF_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
|
17
18
|
* is lost. If the platform hard-kills us first, the interval loop has already
|
|
18
19
|
* bounded the loss the same way.
|
|
19
20
|
*/
|
|
20
|
-
export async function supervise(params: {
|
|
21
|
-
config: SyncConfig;
|
|
22
|
-
hub: BucketHub;
|
|
23
|
-
command: string[];
|
|
24
|
-
}): Promise<number> {
|
|
21
|
+
export async function supervise(params: { config: SyncConfig; hub: BucketHub; command: string[] }): Promise<number> {
|
|
25
22
|
const { config, hub, command } = params;
|
|
26
23
|
const [binary, ...args] = command;
|
|
27
24
|
if (!binary) {
|
|
28
25
|
throw new Error("supervise: missing child command");
|
|
29
26
|
}
|
|
30
27
|
const bootTime = new Date().toISOString();
|
|
28
|
+
const scriptPath = process.argv[1];
|
|
29
|
+
const stageArchive = trustedStageArchive(config, scriptPath);
|
|
31
30
|
let lastSnapshotId: string | undefined;
|
|
32
31
|
const handoffState: { request: RuntimeHandoffRequest | null } = { request: null };
|
|
33
32
|
|
|
@@ -104,7 +103,10 @@ export async function supervise(params: {
|
|
|
104
103
|
}
|
|
105
104
|
};
|
|
106
105
|
|
|
107
|
-
const child: ChildProcess = spawn(binary, args, {
|
|
106
|
+
const child: ChildProcess = spawn(binary, args, {
|
|
107
|
+
stdio: "inherit",
|
|
108
|
+
env: supervisedChildEnvironment(process.env),
|
|
109
|
+
});
|
|
108
110
|
const childExit = new Promise<number>((resolve) => {
|
|
109
111
|
child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
110
112
|
child.on("error", (err) => {
|
|
@@ -117,7 +119,12 @@ export async function supervise(params: {
|
|
|
117
119
|
let inFlight: Promise<SnapshotOutcome> | null = null;
|
|
118
120
|
const runOnce = async (label: string): Promise<SnapshotOutcome> => {
|
|
119
121
|
try {
|
|
120
|
-
const outcome = await runSnapshot({
|
|
122
|
+
const outcome = await runSnapshot({
|
|
123
|
+
config,
|
|
124
|
+
hub,
|
|
125
|
+
bootTime,
|
|
126
|
+
...(stageArchive ? { stageArchive } : {}),
|
|
127
|
+
});
|
|
121
128
|
if (outcome.kind === "failed") {
|
|
122
129
|
logError(`${label}: snapshot failed: ${outcome.detail}`);
|
|
123
130
|
} else if (outcome.kind === "uploaded") {
|
|
@@ -162,7 +169,9 @@ export async function supervise(params: {
|
|
|
162
169
|
void snapshotLoop;
|
|
163
170
|
|
|
164
171
|
const heartbeatLoop = (async () => {
|
|
165
|
-
await writeLease().catch((err) =>
|
|
172
|
+
await writeLease().catch((err) =>
|
|
173
|
+
logError(`initial lease failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
174
|
+
);
|
|
166
175
|
while (!stopping) {
|
|
167
176
|
await delay(LEASE_HEARTBEAT_MS);
|
|
168
177
|
if (stopping) {
|
|
@@ -224,10 +233,14 @@ export async function supervise(params: {
|
|
|
224
233
|
if (handoffState.request) {
|
|
225
234
|
const request = handoffState.request;
|
|
226
235
|
if (finalOutcome.kind !== "uploaded") {
|
|
227
|
-
throw new Error(
|
|
236
|
+
throw new Error(
|
|
237
|
+
`handoff ${request.requestId} final snapshot did not upload: ${snapshotFailureDetail(finalOutcome)}`,
|
|
238
|
+
);
|
|
228
239
|
}
|
|
229
240
|
await writeHandoffAck(request).catch((err) => {
|
|
230
|
-
throw new Error(
|
|
241
|
+
throw new Error(
|
|
242
|
+
`handoff ${request.requestId} snapshot completed but ack failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
243
|
+
);
|
|
231
244
|
});
|
|
232
245
|
log(`handoff ${request.requestId} acknowledged`);
|
|
233
246
|
return 0;
|
|
@@ -235,6 +248,12 @@ export async function supervise(params: {
|
|
|
235
248
|
return exitCode;
|
|
236
249
|
}
|
|
237
250
|
|
|
251
|
+
export function supervisedChildEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
252
|
+
const env = { ...source };
|
|
253
|
+
delete env.MLCLAW_STATE_HF_TOKEN;
|
|
254
|
+
return env;
|
|
255
|
+
}
|
|
256
|
+
|
|
238
257
|
function snapshotFailureDetail(outcome: SnapshotOutcome): string {
|
|
239
258
|
switch (outcome.kind) {
|
|
240
259
|
case "uploaded":
|
|
@@ -35,7 +35,7 @@ export class ChunkCache {
|
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
if (this.map.values().next().value === this.index) {
|
|
38
|
-
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The equality check proves the oldest key exists.
|
|
39
39
|
this.map.delete(this.map.keys().next().value!);
|
|
40
40
|
}
|
|
41
41
|
this.map.set(hash, this.index);
|
|
@@ -137,7 +137,7 @@ export class XetBlob extends Blob {
|
|
|
137
137
|
fetch: this.fetch,
|
|
138
138
|
hash: this.hash,
|
|
139
139
|
refreshUrl: this.refreshUrl,
|
|
140
|
-
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Clone is called only after reconstruction URL initialization.
|
|
141
141
|
reconstructionUrl: this.reconstructionUrl!,
|
|
142
142
|
size: this.size,
|
|
143
143
|
});
|
|
@@ -258,7 +258,7 @@ export class XetBlob extends Blob {
|
|
|
258
258
|
if (termRanges.every((range) => range.data)) {
|
|
259
259
|
log("all data available for term", term.hash, readBytesToSkip);
|
|
260
260
|
rangeLoop: for (const range of termRanges) {
|
|
261
|
-
|
|
261
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The preceding every check proves range data is present.
|
|
262
262
|
for (let chunk of range.data!) {
|
|
263
263
|
if (readBytesToSkip) {
|
|
264
264
|
const skipped = Math.min(readBytesToSkip, chunk.byteLength);
|
|
@@ -32,7 +32,7 @@ const INTERVAL_BETWEEN_REMOTE_DEDUP = 4_000_000; // 4MB
|
|
|
32
32
|
* 0.5 = show progress when uploading the xorb and when processing the file
|
|
33
33
|
*/
|
|
34
34
|
const PROCESSING_PROGRESS_RATIO = 0.1;
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Kept in sync with the vendored upstream progress constants.
|
|
36
36
|
const UPLOADING_PROGRESS_RATIO = 1 - PROCESSING_PROGRESS_RATIO;
|
|
37
37
|
|
|
38
38
|
function computeXorbHashHex(chunks: { hash: string; length: number }[]): string {
|
|
@@ -703,7 +703,7 @@ async function loadDedupInfoToCache(
|
|
|
703
703
|
const chunker = createChunker(TARGET_CHUNK_SIZE);
|
|
704
704
|
const cache = chunkCache;
|
|
705
705
|
|
|
706
|
-
|
|
706
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Preserved for parity with upstream deduplication accounting.
|
|
707
707
|
let dedupedBytes = 0;
|
|
708
708
|
let chunksProcessed = 0;
|
|
709
709
|
let totalBytes = 0;
|