@nowcrew/daemon 0.6.60 → 0.6.64
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 +9 -8
- package/dist/agent-ability/materializer.js +1 -20
- package/dist/automatic-install-target.js +10 -15
- package/dist/computer-cli.js +1 -10
- package/dist/computer-service.js +1 -41
- package/dist/config.js +0 -35
- package/dist/daemon-migration-controller.js +18 -16
- package/dist/execution-runner.js +13 -35
- package/dist/local-executor.js +1 -44
- package/dist/machine-info.js +2 -3
- package/dist/main.js +0 -83
- package/dist/managed-service-lifecycle.js +2 -47
- package/dist/managed-service-startup.js +0 -2
- package/dist/profile-layout.js +4 -10
- package/dist/runner.js +0 -1
- package/dist/serve.js +3 -64
- package/dist/workspace.js +1 -24
- package/package.json +10 -9
- package/dist/local-memory/index-budget.js +0 -74
- package/dist/local-memory/pending.js +0 -111
- package/dist/local-memory/runtime.js +0 -87
- package/dist/local-memory/types.js +0 -1
- package/dist/local-memory/write.js +0 -448
- package/dist/storage-config.js +0 -175
- package/dist/storage-control.js +0 -131
- package/dist/storage-copy.js +0 -444
- package/dist/storage-credential-guard.js +0 -21
- package/dist/storage-doctor.js +0 -175
- package/dist/storage-identity.js +0 -29
- package/dist/storage-layout.js +0 -143
- package/dist/storage-manifest.js +0 -440
- package/dist/storage-migration-controller.js +0 -417
- package/dist/storage-migration-manifest.js +0 -105
- package/dist/storage-migration-policy.js +0 -100
- package/dist/storage-runtime-controller.js +0 -313
- package/dist/storage-runtime-readiness.js +0 -184
- package/dist/storage-runtime.js +0 -39
- package/dist/storage-safe-fs.js +0 -293
- package/dist/storage-serve-adapter.js +0 -65
- package/dist/storage-status-delivery.js +0 -201
- package/dist/storage-status-replay.js +0 -125
- package/dist/storage-target-confirmation.js +0 -129
- package/dist/storage-worker-command.js +0 -27
- package/dist/storage-worker-executor.js +0 -383
- package/dist/storage-worker-handoff.js +0 -151
- package/dist/storage-worker-operation.js +0 -283
- package/dist/storage-worker-status.js +0 -105
package/dist/storage-layout.js
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
2
|
-
import { posix, win32 } from "node:path";
|
|
3
|
-
export const STORAGE_LAYOUT_VERSION = 2;
|
|
4
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
5
|
-
const ENVIRONMENT = /^[a-z0-9][a-z0-9_-]{0,31}$/u;
|
|
6
|
-
const CONTROL = /[\0\r\n]/u;
|
|
7
|
-
const pathsFor = (platform) => platform === "win32" ? win32 : posix;
|
|
8
|
-
function canonicalExistingAncestor(value, platform) {
|
|
9
|
-
if (platform !== process.platform)
|
|
10
|
-
return value;
|
|
11
|
-
const paths = pathsFor(platform);
|
|
12
|
-
const missing = [];
|
|
13
|
-
let ancestor = value;
|
|
14
|
-
for (;;) {
|
|
15
|
-
try {
|
|
16
|
-
return paths.join(realpathSync.native(ancestor), ...missing);
|
|
17
|
-
}
|
|
18
|
-
catch (error) {
|
|
19
|
-
const code = error.code;
|
|
20
|
-
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
23
|
-
const parent = paths.dirname(ancestor);
|
|
24
|
-
if (parent === ancestor)
|
|
25
|
-
return value;
|
|
26
|
-
missing.unshift(paths.basename(ancestor));
|
|
27
|
-
ancestor = parent;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
function assertSafeText(value, label) {
|
|
31
|
-
if (value.length === 0 || CONTROL.test(value))
|
|
32
|
-
throw new Error(`${label} contains empty or control characters`);
|
|
33
|
-
}
|
|
34
|
-
function normalizedAbsolutePath(value, userHome, platform) {
|
|
35
|
-
assertSafeText(value, "NOWWORK_HOME");
|
|
36
|
-
const paths = pathsFor(platform);
|
|
37
|
-
let expanded = value;
|
|
38
|
-
if (value === "~") {
|
|
39
|
-
if (userHome === undefined)
|
|
40
|
-
throw new Error("NOWWORK_HOME '~' requires user home");
|
|
41
|
-
expanded = userHome;
|
|
42
|
-
}
|
|
43
|
-
else if (value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))) {
|
|
44
|
-
if (userHome === undefined)
|
|
45
|
-
throw new Error("NOWWORK_HOME '~' requires user home");
|
|
46
|
-
expanded = paths.join(userHome, value.slice(2));
|
|
47
|
-
}
|
|
48
|
-
if (!paths.isAbsolute(expanded))
|
|
49
|
-
throw new Error("NOWWORK_HOME must be absolute or start with '~/'");
|
|
50
|
-
return canonicalExistingAncestor(paths.normalize(expanded), platform);
|
|
51
|
-
}
|
|
52
|
-
export function validateStorageEnvironment(value) {
|
|
53
|
-
assertSafeText(value, "storage environment");
|
|
54
|
-
const normalized = value.trim().toLowerCase();
|
|
55
|
-
if (normalized === "in" || normalized === "production")
|
|
56
|
-
return "production";
|
|
57
|
-
if (!ENVIRONMENT.test(normalized) || normalized.includes("..")) {
|
|
58
|
-
throw new Error("storage environment must use lowercase letters, digits, '_' or '-'");
|
|
59
|
-
}
|
|
60
|
-
return normalized;
|
|
61
|
-
}
|
|
62
|
-
function validateOpaqueId(value, label) {
|
|
63
|
-
if (!UUID.test(value))
|
|
64
|
-
throw new Error(`${label} id must be a UUID`);
|
|
65
|
-
return value.toLowerCase();
|
|
66
|
-
}
|
|
67
|
-
function assertRuntimeIsLocal(persistentRoot, runtimeRoot, platform) {
|
|
68
|
-
const paths = pathsFor(platform);
|
|
69
|
-
const relative = paths.relative(persistentRoot, runtimeRoot);
|
|
70
|
-
if (relative === "" || (!relative.startsWith("..") && !paths.isAbsolute(relative))) {
|
|
71
|
-
throw new Error("runtime root must not be inside NOWWORK_HOME");
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
export function resolveNowworkHome(input) {
|
|
75
|
-
const selected = input.explicit !== undefined
|
|
76
|
-
? { value: input.explicit, source: "explicit" }
|
|
77
|
-
: input.environment !== undefined
|
|
78
|
-
? { value: input.environment, source: "environment" }
|
|
79
|
-
: null;
|
|
80
|
-
if (selected !== null) {
|
|
81
|
-
return Object.freeze({
|
|
82
|
-
mode: "standard",
|
|
83
|
-
root: normalizedAbsolutePath(selected.value, input.userHome, input.platform),
|
|
84
|
-
source: selected.source,
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
return Object.freeze({
|
|
88
|
-
mode: "legacy",
|
|
89
|
-
root: normalizedAbsolutePath(pathsFor(input.platform).join(input.userHome, ".crew"), undefined, input.platform),
|
|
90
|
-
source: "legacy_default",
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
export function resolveEnvTag(input) {
|
|
94
|
-
if (input.explicit !== undefined) {
|
|
95
|
-
return Object.freeze({ environment: validateStorageEnvironment(input.explicit), source: "explicit" });
|
|
96
|
-
}
|
|
97
|
-
if (input.environment !== undefined) {
|
|
98
|
-
return Object.freeze({ environment: validateStorageEnvironment(input.environment), source: "environment" });
|
|
99
|
-
}
|
|
100
|
-
return Object.freeze({ environment: "production", source: "default" });
|
|
101
|
-
}
|
|
102
|
-
export function storageLayout(input) {
|
|
103
|
-
const paths = pathsFor(input.platform);
|
|
104
|
-
const persistentRoot = normalizedAbsolutePath(input.nowworkHome, undefined, input.platform);
|
|
105
|
-
const runtimeBase = normalizedAbsolutePath(input.runtimeBase, undefined, input.platform);
|
|
106
|
-
const environment = validateStorageEnvironment(input.environment);
|
|
107
|
-
const workspaceId = validateOpaqueId(input.workspaceId, "workspace");
|
|
108
|
-
const environmentRoot = environment === "production"
|
|
109
|
-
? persistentRoot
|
|
110
|
-
: paths.join(persistentRoot, "environments", environment);
|
|
111
|
-
const workspaceRoot = paths.join(environmentRoot, "workspaces", workspaceId);
|
|
112
|
-
const runtimeRoot = paths.join(runtimeBase, environment, workspaceId);
|
|
113
|
-
assertRuntimeIsLocal(persistentRoot, runtimeRoot, input.platform);
|
|
114
|
-
return Object.freeze({
|
|
115
|
-
layoutVersion: STORAGE_LAYOUT_VERSION,
|
|
116
|
-
environment,
|
|
117
|
-
workspaceId,
|
|
118
|
-
persistentRoot,
|
|
119
|
-
environmentRoot,
|
|
120
|
-
workspaceRoot,
|
|
121
|
-
agentItemsRoot: paths.join(workspaceRoot, "agent-items"),
|
|
122
|
-
daemonRoot: paths.join(workspaceRoot, "daemon"),
|
|
123
|
-
repoRoot: paths.join(workspaceRoot, "repos"),
|
|
124
|
-
suiteToolsRoot: paths.join(workspaceRoot, "suite-tools"),
|
|
125
|
-
migrationsRoot: paths.join(persistentRoot, "migrations"),
|
|
126
|
-
runtimeRoot,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
export function agentStorageRoot(layout, agentId) {
|
|
130
|
-
const platform = layout.workspaceRoot.includes("\\") ? "win32" : "linux";
|
|
131
|
-
return pathsFor(platform).join(layout.agentItemsRoot, validateOpaqueId(agentId, "agent"));
|
|
132
|
-
}
|
|
133
|
-
const COMPARABLE_FIELDS = [
|
|
134
|
-
"layoutVersion", "environment", "workspaceId", "persistentRoot", "environmentRoot", "workspaceRoot",
|
|
135
|
-
"agentItemsRoot", "daemonRoot", "repoRoot", "suiteToolsRoot", "migrationsRoot", "runtimeRoot",
|
|
136
|
-
];
|
|
137
|
-
export function compareStorageLayout(current, expected) {
|
|
138
|
-
if (current === null || current.layoutVersion !== STORAGE_LAYOUT_VERSION)
|
|
139
|
-
return "unknown";
|
|
140
|
-
return COMPARABLE_FIELDS.every((field) => current[field] === expected[field])
|
|
141
|
-
? "standard"
|
|
142
|
-
: "migration_required";
|
|
143
|
-
}
|
package/dist/storage-manifest.js
DELETED
|
@@ -1,440 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { createReadStream } from "node:fs";
|
|
3
|
-
import { lstat, readdir, readlink, realpath } from "node:fs/promises";
|
|
4
|
-
import { dirname, isAbsolute, posix, relative, resolve, win32 } from "node:path";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { validateStorageEnvironment } from "./storage-layout.js";
|
|
7
|
-
export const STORAGE_MANIFEST_SCHEMA = "nowwork.storage.migration-manifest";
|
|
8
|
-
export const STORAGE_MANIFEST_VERSION = 1;
|
|
9
|
-
export const MAX_STORAGE_MANIFEST_ENTRIES = 100_000;
|
|
10
|
-
const SourceKindSchema = z.enum(["daemon-home", "agents-root", "legacy-agents-root"]);
|
|
11
|
-
const HashSchema = z.string().regex(/^[0-9a-f]{64}$/u);
|
|
12
|
-
const ManifestEntrySchema = z.object({
|
|
13
|
-
sourceKind: SourceKindSchema,
|
|
14
|
-
sourcePath: z.string().min(1),
|
|
15
|
-
logicalDestination: z.string().min(1).nullable(),
|
|
16
|
-
type: z.enum(["file", "directory", "symlink", "unsupported"]),
|
|
17
|
-
disposition: z.enum(["copy", "skip", "transform"]),
|
|
18
|
-
reason: z.string().min(1),
|
|
19
|
-
mode: z.string().regex(/^[0-7]{4}$/u),
|
|
20
|
-
sizeBytes: z.number().int().nonnegative(),
|
|
21
|
-
sha256: HashSchema.optional(),
|
|
22
|
-
transform: z.literal("redact_live_like_tokens_v1").optional(),
|
|
23
|
-
targetSha256: HashSchema.optional(),
|
|
24
|
-
targetMode: z.string().regex(/^[0-7]{4}$/u).optional(),
|
|
25
|
-
link: z.object({
|
|
26
|
-
classification: z.enum(["internal", "external", "broken"]),
|
|
27
|
-
portable: z.boolean(),
|
|
28
|
-
targetFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
29
|
-
relativeTarget: z.string().min(1).optional(),
|
|
30
|
-
}).strict().optional(),
|
|
31
|
-
}).strict();
|
|
32
|
-
const StorageManifestSchema = z.object({
|
|
33
|
-
schema: z.literal(STORAGE_MANIFEST_SCHEMA),
|
|
34
|
-
formatVersion: z.literal(STORAGE_MANIFEST_VERSION),
|
|
35
|
-
migrationId: z.string().uuid(),
|
|
36
|
-
generatedAt: z.string().datetime({ offset: true }),
|
|
37
|
-
environment: z.string().min(1),
|
|
38
|
-
workspaceId: z.string().uuid(),
|
|
39
|
-
targetWorkspaceRoot: z.string().min(1),
|
|
40
|
-
sourceKinds: z.array(SourceKindSchema),
|
|
41
|
-
entries: z.array(ManifestEntrySchema).max(MAX_STORAGE_MANIFEST_ENTRIES),
|
|
42
|
-
summary: z.object({
|
|
43
|
-
records: z.number().int().nonnegative(),
|
|
44
|
-
files: z.number().int().nonnegative(),
|
|
45
|
-
directories: z.number().int().nonnegative(),
|
|
46
|
-
symlinks: z.number().int().nonnegative(),
|
|
47
|
-
copied: z.number().int().nonnegative(),
|
|
48
|
-
skipped: z.number().int().nonnegative(),
|
|
49
|
-
transformations: z.number().int().nonnegative(),
|
|
50
|
-
externalSymlinks: z.number().int().nonnegative(),
|
|
51
|
-
bytesToCopy: z.number().int().nonnegative(),
|
|
52
|
-
errors: z.number().int().nonnegative(),
|
|
53
|
-
}).strict(),
|
|
54
|
-
}).strict();
|
|
55
|
-
const CONTROL = /[\0\r\n]/u;
|
|
56
|
-
function normalizedRelativePath(value) {
|
|
57
|
-
const normalized = value.replaceAll("\\", "/");
|
|
58
|
-
if (normalized.length === 0 || CONTROL.test(normalized) || posix.isAbsolute(normalized) || win32.isAbsolute(normalized)) {
|
|
59
|
-
throw new Error(`migration path must be normalized relative path: ${value}`);
|
|
60
|
-
}
|
|
61
|
-
const segments = normalized.split("/");
|
|
62
|
-
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
63
|
-
throw new Error(`migration path must be normalized relative path: ${value}`);
|
|
64
|
-
}
|
|
65
|
-
if (posix.normalize(normalized) !== normalized)
|
|
66
|
-
throw new Error(`migration path must be normalized relative path: ${value}`);
|
|
67
|
-
return normalized;
|
|
68
|
-
}
|
|
69
|
-
function modeString(mode) {
|
|
70
|
-
return (mode & 0o7777).toString(8).padStart(4, "0");
|
|
71
|
-
}
|
|
72
|
-
function pathWithin(root, candidate) {
|
|
73
|
-
const child = relative(root, candidate);
|
|
74
|
-
return child === "" || (!child.startsWith("..") && !isAbsolute(child));
|
|
75
|
-
}
|
|
76
|
-
function pathReason(sourceKind, sourcePath) {
|
|
77
|
-
const wrapped = `/${sourcePath}/`;
|
|
78
|
-
if (sourceKind === "daemon-home" && wrapped.startsWith("/profiles/"))
|
|
79
|
-
return "credential_material";
|
|
80
|
-
if (wrapped.includes("/.home/.codex/auth.json/")
|
|
81
|
-
|| wrapped.includes("/.home/auth.json/")
|
|
82
|
-
|| wrapped.includes("/.home/.codex/sessions/")
|
|
83
|
-
|| wrapped.includes("/.home/.config/")
|
|
84
|
-
|| wrapped.includes("/.home/.local/share/"))
|
|
85
|
-
return "credential_material";
|
|
86
|
-
if (wrapped.includes("/.home/.cache/")
|
|
87
|
-
|| wrapped.includes("/.home/.codex/tmp/")
|
|
88
|
-
|| wrapped.includes("/.crew/ability-cache/")
|
|
89
|
-
|| wrapped.includes("/.crew/executions/")
|
|
90
|
-
|| wrapped.includes("/.execution-telemetry/")
|
|
91
|
-
|| wrapped.includes("/tmp/"))
|
|
92
|
-
return "local_runtime";
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
async function fileDigest(path) {
|
|
96
|
-
const before = await lstat(path);
|
|
97
|
-
const digest = createHash("sha256");
|
|
98
|
-
await new Promise((resolvePromise, reject) => {
|
|
99
|
-
const stream = createReadStream(path);
|
|
100
|
-
stream.on("data", (chunk) => digest.update(chunk));
|
|
101
|
-
stream.once("end", resolvePromise);
|
|
102
|
-
stream.once("error", reject);
|
|
103
|
-
});
|
|
104
|
-
const after = await lstat(path);
|
|
105
|
-
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
106
|
-
throw new Error(`source changed while hashing: ${path}`);
|
|
107
|
-
}
|
|
108
|
-
return digest.digest("hex");
|
|
109
|
-
}
|
|
110
|
-
export function assertStorageManifestEntryLimit(count) {
|
|
111
|
-
if (!Number.isSafeInteger(count) || count < 0 || count > MAX_STORAGE_MANIFEST_ENTRIES) {
|
|
112
|
-
throw new Error(`storage manifest entry limit exceeded: ${count}`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
function fingerprint(value) {
|
|
116
|
-
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
117
|
-
}
|
|
118
|
-
class CanonicalHashWriter {
|
|
119
|
-
hash = createHash("sha256");
|
|
120
|
-
chunks = [];
|
|
121
|
-
bufferedBytes = 0;
|
|
122
|
-
append(value) {
|
|
123
|
-
this.chunks.push(value);
|
|
124
|
-
this.bufferedBytes += Buffer.byteLength(value);
|
|
125
|
-
if (this.bufferedBytes >= 64 * 1024)
|
|
126
|
-
this.flush();
|
|
127
|
-
}
|
|
128
|
-
flush() {
|
|
129
|
-
if (this.chunks.length === 0)
|
|
130
|
-
return;
|
|
131
|
-
this.hash.update(this.chunks.join(""));
|
|
132
|
-
this.chunks = [];
|
|
133
|
-
this.bufferedBytes = 0;
|
|
134
|
-
}
|
|
135
|
-
write(value) {
|
|
136
|
-
if (value === null) {
|
|
137
|
-
this.append("null");
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (typeof value === "string" || typeof value === "boolean") {
|
|
141
|
-
this.append(JSON.stringify(value));
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
if (typeof value === "number") {
|
|
145
|
-
if (!Number.isFinite(value))
|
|
146
|
-
throw new Error("storage manifest contains a non-finite number");
|
|
147
|
-
this.append(JSON.stringify(value));
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
if (Array.isArray(value)) {
|
|
151
|
-
this.append("[");
|
|
152
|
-
value.forEach((child, index) => {
|
|
153
|
-
if (index > 0)
|
|
154
|
-
this.append(",");
|
|
155
|
-
this.write(child);
|
|
156
|
-
});
|
|
157
|
-
this.append("]");
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (typeof value === "object") {
|
|
161
|
-
const record = value;
|
|
162
|
-
const keys = Object.keys(record)
|
|
163
|
-
.filter((key) => record[key] !== undefined)
|
|
164
|
-
.sort((left, right) => left.localeCompare(right));
|
|
165
|
-
this.append("{");
|
|
166
|
-
keys.forEach((key, index) => {
|
|
167
|
-
if (index > 0)
|
|
168
|
-
this.append(",");
|
|
169
|
-
this.append(JSON.stringify(key));
|
|
170
|
-
this.append(":");
|
|
171
|
-
this.write(record[key]);
|
|
172
|
-
});
|
|
173
|
-
this.append("}");
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
throw new Error("storage manifest contains a non-JSON value");
|
|
177
|
-
}
|
|
178
|
-
digest() {
|
|
179
|
-
this.flush();
|
|
180
|
-
return this.hash.digest("hex");
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
function validatePolicy(policy) {
|
|
184
|
-
const exclusions = policy.exclusions.map((entry) => ({
|
|
185
|
-
sourceKind: SourceKindSchema.parse(entry.sourceKind),
|
|
186
|
-
sourcePath: normalizedRelativePath(entry.sourcePath),
|
|
187
|
-
reason: z.string().min(1).max(256).parse(entry.reason),
|
|
188
|
-
}));
|
|
189
|
-
const transformations = policy.transformations.map((entry) => ({
|
|
190
|
-
sourceKind: SourceKindSchema.parse(entry.sourceKind),
|
|
191
|
-
sourcePath: normalizedRelativePath(entry.sourcePath),
|
|
192
|
-
logicalDestination: normalizedRelativePath(entry.logicalDestination),
|
|
193
|
-
sourceSha256: HashSchema.parse(entry.sourceSha256),
|
|
194
|
-
targetSha256: HashSchema.parse(entry.targetSha256),
|
|
195
|
-
targetMode: z.string().regex(/^[0-7]{4}$/u).parse(entry.targetMode),
|
|
196
|
-
transform: z.literal("redact_live_like_tokens_v1").parse(entry.transform),
|
|
197
|
-
}));
|
|
198
|
-
const identities = new Set();
|
|
199
|
-
for (const entry of [...exclusions, ...transformations]) {
|
|
200
|
-
const identity = `${entry.sourceKind}:${entry.sourcePath}`;
|
|
201
|
-
if (identities.has(identity))
|
|
202
|
-
throw new Error(`duplicate migration policy path: ${identity}`);
|
|
203
|
-
identities.add(identity);
|
|
204
|
-
}
|
|
205
|
-
return Object.freeze({ exclusions: Object.freeze(exclusions), transformations: Object.freeze(transformations) });
|
|
206
|
-
}
|
|
207
|
-
const policyKey = (sourceKind, sourcePath) => `${sourceKind}\0${sourcePath}`;
|
|
208
|
-
function indexPolicy(policy) {
|
|
209
|
-
return Object.freeze({
|
|
210
|
-
exclusions: new Map(policy.exclusions.map((entry) => [policyKey(entry.sourceKind, entry.sourcePath), entry])),
|
|
211
|
-
transformations: new Map(policy.transformations.map((entry) => [policyKey(entry.sourceKind, entry.sourcePath), entry])),
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
function excludedBy(index, sourceKind, sourcePath) {
|
|
215
|
-
let candidate = sourcePath;
|
|
216
|
-
for (;;) {
|
|
217
|
-
const exclusion = index.exclusions.get(policyKey(sourceKind, candidate));
|
|
218
|
-
if (exclusion !== undefined)
|
|
219
|
-
return exclusion;
|
|
220
|
-
const separator = candidate.lastIndexOf("/");
|
|
221
|
-
if (separator < 0)
|
|
222
|
-
return undefined;
|
|
223
|
-
candidate = candidate.slice(0, separator);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
function transformedBy(index, sourceKind, sourcePath) {
|
|
227
|
-
return index.transformations.get(policyKey(sourceKind, sourcePath));
|
|
228
|
-
}
|
|
229
|
-
async function linkEvidence(root, path) {
|
|
230
|
-
const rawTarget = await readlink(path);
|
|
231
|
-
const resolvedTarget = resolve(dirname(path), rawTarget);
|
|
232
|
-
const canonicalRoot = await realpath(root);
|
|
233
|
-
let canonicalTarget;
|
|
234
|
-
try {
|
|
235
|
-
canonicalTarget = await realpath(resolvedTarget);
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
if (error.code !== "ENOENT")
|
|
239
|
-
throw error;
|
|
240
|
-
return Object.freeze({ classification: "broken", portable: false, targetFingerprint: fingerprint(rawTarget) });
|
|
241
|
-
}
|
|
242
|
-
if (!pathWithin(canonicalRoot, canonicalTarget)) {
|
|
243
|
-
return Object.freeze({ classification: "external", portable: false, targetFingerprint: fingerprint(canonicalTarget) });
|
|
244
|
-
}
|
|
245
|
-
const portable = !isAbsolute(rawTarget);
|
|
246
|
-
return Object.freeze({
|
|
247
|
-
classification: "internal",
|
|
248
|
-
portable,
|
|
249
|
-
targetFingerprint: fingerprint(canonicalTarget),
|
|
250
|
-
...(portable ? { relativeTarget: rawTarget.replaceAll("\\", "/") } : {}),
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
async function scanSource(input) {
|
|
254
|
-
const entries = [];
|
|
255
|
-
const add = (entry) => {
|
|
256
|
-
if (entries.length >= input.remainingEntries) {
|
|
257
|
-
throw new Error(`storage manifest entry limit exceeded: ${entries.length + 1}`);
|
|
258
|
-
}
|
|
259
|
-
entries.push(entry);
|
|
260
|
-
};
|
|
261
|
-
const pending = [input.root];
|
|
262
|
-
while (pending.length > 0) {
|
|
263
|
-
const directory = pending.pop();
|
|
264
|
-
const children = await readdir(directory, { withFileTypes: true });
|
|
265
|
-
children.sort((left, right) => left.name.localeCompare(right.name));
|
|
266
|
-
for (const child of children) {
|
|
267
|
-
const path = resolve(directory, child.name);
|
|
268
|
-
const sourcePath = normalizedRelativePath(relative(input.root, path));
|
|
269
|
-
const stat = await lstat(path);
|
|
270
|
-
const exclusion = excludedBy(input.policy, input.sourceKind, sourcePath);
|
|
271
|
-
const transformation = transformedBy(input.policy, input.sourceKind, sourcePath);
|
|
272
|
-
const classifiedReason = pathReason(input.sourceKind, sourcePath);
|
|
273
|
-
const defaultDestination = normalizedRelativePath(`${input.logicalPrefix}/${sourcePath}`);
|
|
274
|
-
let type = "unsupported";
|
|
275
|
-
if (stat.isDirectory())
|
|
276
|
-
type = "directory";
|
|
277
|
-
else if (stat.isFile())
|
|
278
|
-
type = "file";
|
|
279
|
-
else if (stat.isSymbolicLink())
|
|
280
|
-
type = "symlink";
|
|
281
|
-
if (stat.isDirectory())
|
|
282
|
-
pending.push(path);
|
|
283
|
-
if (exclusion !== undefined) {
|
|
284
|
-
add(ManifestEntrySchema.parse({
|
|
285
|
-
sourceKind: input.sourceKind,
|
|
286
|
-
sourcePath,
|
|
287
|
-
logicalDestination: null,
|
|
288
|
-
type,
|
|
289
|
-
disposition: "skip",
|
|
290
|
-
reason: exclusion.reason,
|
|
291
|
-
mode: modeString(stat.mode),
|
|
292
|
-
sizeBytes: stat.size,
|
|
293
|
-
}));
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
if (transformation !== undefined) {
|
|
297
|
-
if (type !== "file")
|
|
298
|
-
throw new Error(`transformation source is not a file: ${sourcePath}`);
|
|
299
|
-
const digest = await fileDigest(path);
|
|
300
|
-
if (digest !== transformation.sourceSha256)
|
|
301
|
-
throw new Error(`transformation source drift: ${sourcePath}`);
|
|
302
|
-
add(ManifestEntrySchema.parse({
|
|
303
|
-
sourceKind: input.sourceKind,
|
|
304
|
-
sourcePath,
|
|
305
|
-
logicalDestination: transformation.logicalDestination,
|
|
306
|
-
type,
|
|
307
|
-
disposition: "transform",
|
|
308
|
-
reason: "approved_target_only_transformation",
|
|
309
|
-
mode: modeString(stat.mode),
|
|
310
|
-
sizeBytes: stat.size,
|
|
311
|
-
sha256: digest,
|
|
312
|
-
transform: transformation.transform,
|
|
313
|
-
targetSha256: transformation.targetSha256,
|
|
314
|
-
targetMode: transformation.targetMode,
|
|
315
|
-
}));
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
if (type === "symlink") {
|
|
319
|
-
const link = await linkEvidence(input.root, path);
|
|
320
|
-
const reason = classifiedReason
|
|
321
|
-
?? (link.classification === "internal" && link.portable ? "portable_internal_symlink" : "unsupported_symlink");
|
|
322
|
-
const copy = reason === "portable_internal_symlink";
|
|
323
|
-
add(ManifestEntrySchema.parse({
|
|
324
|
-
sourceKind: input.sourceKind,
|
|
325
|
-
sourcePath,
|
|
326
|
-
logicalDestination: copy ? defaultDestination : null,
|
|
327
|
-
type,
|
|
328
|
-
disposition: copy ? "copy" : "skip",
|
|
329
|
-
reason,
|
|
330
|
-
mode: modeString(stat.mode),
|
|
331
|
-
sizeBytes: stat.size,
|
|
332
|
-
link,
|
|
333
|
-
}));
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
if (classifiedReason !== null || type === "unsupported") {
|
|
337
|
-
add(ManifestEntrySchema.parse({
|
|
338
|
-
sourceKind: input.sourceKind,
|
|
339
|
-
sourcePath,
|
|
340
|
-
logicalDestination: null,
|
|
341
|
-
type,
|
|
342
|
-
disposition: "skip",
|
|
343
|
-
reason: classifiedReason ?? "unsupported_file_type",
|
|
344
|
-
mode: modeString(stat.mode),
|
|
345
|
-
sizeBytes: stat.size,
|
|
346
|
-
}));
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
add(ManifestEntrySchema.parse({
|
|
350
|
-
sourceKind: input.sourceKind,
|
|
351
|
-
sourcePath,
|
|
352
|
-
logicalDestination: defaultDestination,
|
|
353
|
-
type,
|
|
354
|
-
disposition: "copy",
|
|
355
|
-
reason: "durable_workspace_data",
|
|
356
|
-
mode: modeString(stat.mode),
|
|
357
|
-
sizeBytes: stat.size,
|
|
358
|
-
...(type === "file" ? { sha256: await fileDigest(path) } : {}),
|
|
359
|
-
}));
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return entries;
|
|
363
|
-
}
|
|
364
|
-
export async function generateStorageManifest(input) {
|
|
365
|
-
z.string().uuid().parse(input.migrationId);
|
|
366
|
-
z.string().datetime({ offset: true }).parse(input.generatedAt);
|
|
367
|
-
z.string().uuid().parse(input.workspaceId);
|
|
368
|
-
if (!isAbsolute(input.targetWorkspaceRoot) || CONTROL.test(input.targetWorkspaceRoot)) {
|
|
369
|
-
throw new Error("target workspace root must be an absolute single-line path");
|
|
370
|
-
}
|
|
371
|
-
const environment = validateStorageEnvironment(input.environment);
|
|
372
|
-
const policy = validatePolicy(input.policy);
|
|
373
|
-
const policyIndex = indexPolicy(policy);
|
|
374
|
-
const entryLimit = z.number().int().positive().max(MAX_STORAGE_MANIFEST_ENTRIES)
|
|
375
|
-
.parse(input.entryLimit ?? MAX_STORAGE_MANIFEST_ENTRIES);
|
|
376
|
-
const sourceKinds = input.sources.map((source) => SourceKindSchema.parse(source.kind));
|
|
377
|
-
if (new Set(sourceKinds).size !== sourceKinds.length)
|
|
378
|
-
throw new Error("duplicate migration source kind");
|
|
379
|
-
const entries = [];
|
|
380
|
-
for (const source of input.sources) {
|
|
381
|
-
if (!isAbsolute(source.root))
|
|
382
|
-
throw new Error(`migration source root must be absolute: ${source.kind}`);
|
|
383
|
-
entries.push(...await scanSource({
|
|
384
|
-
sourceKind: source.kind,
|
|
385
|
-
root: source.root,
|
|
386
|
-
logicalPrefix: normalizedRelativePath(source.logicalPrefix),
|
|
387
|
-
policy: policyIndex,
|
|
388
|
-
remainingEntries: entryLimit - entries.length,
|
|
389
|
-
}));
|
|
390
|
-
if (entries.length > entryLimit)
|
|
391
|
-
throw new Error(`storage manifest entry limit exceeded: ${entries.length}`);
|
|
392
|
-
}
|
|
393
|
-
entries.sort((left, right) => (left.sourceKind.localeCompare(right.sourceKind) || left.sourcePath.localeCompare(right.sourcePath)));
|
|
394
|
-
const destinations = new Set();
|
|
395
|
-
for (const entry of entries) {
|
|
396
|
-
if (entry.logicalDestination === null)
|
|
397
|
-
continue;
|
|
398
|
-
if (destinations.has(entry.logicalDestination))
|
|
399
|
-
throw new Error(`duplicate migration destination: ${entry.logicalDestination}`);
|
|
400
|
-
destinations.add(entry.logicalDestination);
|
|
401
|
-
}
|
|
402
|
-
return StorageManifestSchema.parse({
|
|
403
|
-
schema: STORAGE_MANIFEST_SCHEMA,
|
|
404
|
-
formatVersion: STORAGE_MANIFEST_VERSION,
|
|
405
|
-
migrationId: input.migrationId,
|
|
406
|
-
generatedAt: input.generatedAt,
|
|
407
|
-
environment,
|
|
408
|
-
workspaceId: input.workspaceId.toLowerCase(),
|
|
409
|
-
targetWorkspaceRoot: input.targetWorkspaceRoot,
|
|
410
|
-
sourceKinds,
|
|
411
|
-
entries,
|
|
412
|
-
summary: {
|
|
413
|
-
records: entries.length,
|
|
414
|
-
files: entries.filter((entry) => entry.type === "file").length,
|
|
415
|
-
directories: entries.filter((entry) => entry.type === "directory").length,
|
|
416
|
-
symlinks: entries.filter((entry) => entry.type === "symlink").length,
|
|
417
|
-
copied: entries.filter((entry) => entry.disposition === "copy").length,
|
|
418
|
-
skipped: entries.filter((entry) => entry.disposition === "skip").length,
|
|
419
|
-
transformations: entries.filter((entry) => entry.disposition === "transform").length,
|
|
420
|
-
externalSymlinks: entries.filter((entry) => entry.link?.classification === "external").length,
|
|
421
|
-
bytesToCopy: entries.filter((entry) => entry.type === "file" && entry.disposition !== "skip")
|
|
422
|
-
.reduce((total, entry) => total + entry.sizeBytes, 0),
|
|
423
|
-
errors: 0,
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
export function parseStorageManifest(raw) {
|
|
428
|
-
try {
|
|
429
|
-
return StorageManifestSchema.parse(JSON.parse(raw));
|
|
430
|
-
}
|
|
431
|
-
catch (error) {
|
|
432
|
-
throw new Error("Invalid storage migration manifest", { cause: error });
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
export function storageManifestDigest(manifest) {
|
|
436
|
-
const { generatedAt: _generatedAt, ...stable } = manifest;
|
|
437
|
-
const writer = new CanonicalHashWriter();
|
|
438
|
-
writer.write(stable);
|
|
439
|
-
return writer.digest();
|
|
440
|
-
}
|