@inetafrica/open-claudia 2.14.8 → 3.0.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/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- package/web.js +159 -45
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const MIGRATION_ID = "provider-parity-v1.1";
|
|
8
|
+
const FORMAT_VERSION = 1;
|
|
9
|
+
const REQUIRED_ACTIVATION_COMPONENTS = Object.freeze(["state", "sessions", "jobs"]);
|
|
10
|
+
const MANIFEST_FILE = "manifest.json";
|
|
11
|
+
const MANIFEST_HASH_FILE = "manifest.sha256";
|
|
12
|
+
const JOURNAL_FILE = "journal.json";
|
|
13
|
+
const LOCK_FILE = ".lock";
|
|
14
|
+
const COMPONENT_SCHEMA_VERSIONS = Object.freeze({
|
|
15
|
+
state: 3,
|
|
16
|
+
sessions: 3,
|
|
17
|
+
jobs: 2,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function migrationError(code, message, details = {}) {
|
|
21
|
+
const error = new Error(message);
|
|
22
|
+
error.name = "MigrationBackupError";
|
|
23
|
+
error.code = code;
|
|
24
|
+
error.details = details;
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sha256(value) {
|
|
29
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function migrationRoot(configDir) {
|
|
33
|
+
if (!configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
34
|
+
return path.join(path.resolve(configDir), "migration-backups", MIGRATION_ID);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function journalPath(configDir) {
|
|
38
|
+
return path.join(migrationRoot(configDir), JOURNAL_FILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensureDirectory(directory, mode = 0o700) {
|
|
42
|
+
fs.mkdirSync(directory, { recursive: true, mode });
|
|
43
|
+
const stat = fs.lstatSync(directory);
|
|
44
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
45
|
+
throw migrationError("UNSAFE_MIGRATION_DIRECTORY", "migration directory must be a real directory");
|
|
46
|
+
}
|
|
47
|
+
try { fs.chmodSync(directory, mode); } catch (_) {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureTargetParent(directory) {
|
|
51
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
52
|
+
const stat = fs.lstatSync(directory);
|
|
53
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
54
|
+
throw migrationError("UNSAFE_ROLLBACK_TARGET", "rollback parent must be a real directory");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fsyncDirectory(directory) {
|
|
59
|
+
let fd;
|
|
60
|
+
try {
|
|
61
|
+
fd = fs.openSync(directory, "r");
|
|
62
|
+
fs.fsyncSync(fd);
|
|
63
|
+
} catch (_) {
|
|
64
|
+
// Some platforms/filesystems cannot fsync directories. File fsync and
|
|
65
|
+
// atomic sibling rename still protect the contents there.
|
|
66
|
+
} finally {
|
|
67
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch (_) {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeSecureFile(filePath, data, { exclusive = false, privateParent = true } = {}) {
|
|
72
|
+
if (privateParent) ensureDirectory(path.dirname(filePath));
|
|
73
|
+
else ensureTargetParent(path.dirname(filePath));
|
|
74
|
+
const flags = exclusive ? "wx" : "w";
|
|
75
|
+
const fd = fs.openSync(filePath, flags, 0o600);
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(fd, data);
|
|
78
|
+
fs.fsyncSync(fd);
|
|
79
|
+
} finally {
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
try { fs.chmodSync(filePath, 0o600); } catch (_) {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function atomicWriteSecure(filePath, data) {
|
|
86
|
+
ensureDirectory(path.dirname(filePath));
|
|
87
|
+
const temp = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
88
|
+
try {
|
|
89
|
+
writeSecureFile(temp, data, { exclusive: true });
|
|
90
|
+
fs.renameSync(temp, filePath);
|
|
91
|
+
try { fs.chmodSync(filePath, 0o600); } catch (_) {}
|
|
92
|
+
fsyncDirectory(path.dirname(filePath));
|
|
93
|
+
} finally {
|
|
94
|
+
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (_) {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stableJson(value) {
|
|
99
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertRegularFile(filePath, code, label) {
|
|
103
|
+
const stat = fs.lstatSync(filePath);
|
|
104
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
105
|
+
throw migrationError(code, `${label} must be a regular file`);
|
|
106
|
+
}
|
|
107
|
+
return stat;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertPrivateMode(stat, expectedMask, code, label) {
|
|
111
|
+
if (process.platform !== "win32" && (stat.mode & 0o777) !== expectedMask) {
|
|
112
|
+
throw migrationError(code, `${label} has unsafe permissions`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeLogicalPath(value) {
|
|
117
|
+
if (typeof value !== "string" || !value || value.includes("\\") || value.includes("\0")) {
|
|
118
|
+
throw migrationError("UNSAFE_MANIFEST_PATH", "migration path is invalid");
|
|
119
|
+
}
|
|
120
|
+
const normalized = path.posix.normalize(value);
|
|
121
|
+
if (normalized !== value || normalized === "." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
|
122
|
+
throw migrationError("UNSAFE_MANIFEST_PATH", `unsafe migration path: ${value}`);
|
|
123
|
+
}
|
|
124
|
+
return normalized;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sourceRecord(logicalPath, sourcePath, kind) {
|
|
128
|
+
return {
|
|
129
|
+
path: normalizeLogicalPath(logicalPath),
|
|
130
|
+
sourcePath: path.resolve(sourcePath),
|
|
131
|
+
kind,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function defaultMigrationSources(options = {}) {
|
|
136
|
+
const configDir = path.resolve(options.configDir || "");
|
|
137
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
138
|
+
const stateFile = path.resolve(options.stateFile || path.join(configDir, "state.json"));
|
|
139
|
+
const sessionsFile = path.resolve(options.sessionsFile || path.join(configDir, "sessions.json"));
|
|
140
|
+
const jobsFile = path.resolve(options.jobsFile || path.join(configDir, "jobs.json"));
|
|
141
|
+
const cronsFile = path.resolve(options.cronsFile || path.join(configDir, "crons.json"));
|
|
142
|
+
return normalizeSources([
|
|
143
|
+
sourceRecord("state.json", stateFile, "state"),
|
|
144
|
+
sourceRecord("state.json.bak", `${stateFile}.bak`, "state"),
|
|
145
|
+
sourceRecord("sessions.json", sessionsFile, "sessions"),
|
|
146
|
+
sourceRecord("sessions.json.bak", `${sessionsFile}.bak`, "sessions"),
|
|
147
|
+
sourceRecord("jobs.json", jobsFile, "jobs"),
|
|
148
|
+
sourceRecord("jobs.json.bak", `${jobsFile}.bak`, "jobs"),
|
|
149
|
+
sourceRecord("crons.json", cronsFile, "crons"),
|
|
150
|
+
sourceRecord("crons.json.migrated", `${cronsFile}.migrated`, "crons"),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeSources(sources) {
|
|
155
|
+
if (!Array.isArray(sources) || sources.length === 0) {
|
|
156
|
+
throw migrationError("INVALID_MIGRATION_CONFIG", "at least one migration source is required");
|
|
157
|
+
}
|
|
158
|
+
const logical = new Set();
|
|
159
|
+
const physical = new Map();
|
|
160
|
+
const normalized = sources.map((entry) => {
|
|
161
|
+
const record = sourceRecord(entry.path, entry.sourcePath, entry.kind || "unknown");
|
|
162
|
+
if (logical.has(record.path)) throw migrationError("MIGRATION_PATH_COLLISION", `duplicate migration path: ${record.path}`);
|
|
163
|
+
logical.add(record.path);
|
|
164
|
+
const previous = physical.get(record.sourcePath);
|
|
165
|
+
if (previous && previous !== record.path) {
|
|
166
|
+
throw migrationError("MIGRATION_SOURCE_COLLISION", `one source is mapped to both ${previous} and ${record.path}`);
|
|
167
|
+
}
|
|
168
|
+
physical.set(record.sourcePath, record.path);
|
|
169
|
+
return record;
|
|
170
|
+
});
|
|
171
|
+
return normalized.sort((left, right) => left.path.localeCompare(right.path));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stateValues(value) {
|
|
175
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
176
|
+
if (value.users && typeof value.users === "object" && !Array.isArray(value.users)) return Object.values(value.users);
|
|
177
|
+
return [value];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function schemaGuess(buffer, source) {
|
|
181
|
+
if (buffer === null) return { schemaGuess: "missing", resolution: "missing" };
|
|
182
|
+
let value;
|
|
183
|
+
try { value = JSON.parse(buffer.toString("utf8")); }
|
|
184
|
+
catch (_) { return { schemaGuess: "malformed-json", resolution: "unresolved" }; }
|
|
185
|
+
|
|
186
|
+
const suffix = source.path.endsWith(".bak") ? "-fallback" : "";
|
|
187
|
+
if (source.kind === "state") {
|
|
188
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
189
|
+
return { schemaGuess: `unknown-state${suffix}`, resolution: "unresolved" };
|
|
190
|
+
}
|
|
191
|
+
const values = stateValues(value);
|
|
192
|
+
const multi = !!(value && typeof value === "object" && value.users && typeof value.users === "object");
|
|
193
|
+
const cursor = values.some((entry) => (
|
|
194
|
+
entry?.settings?.backend === "cursor"
|
|
195
|
+
|| !!entry?.cursorSessionId
|
|
196
|
+
|| !!entry?.providerSettings?.cursor
|
|
197
|
+
|| Object.values(entry?.activeSessions || {}).some((providers) => !!providers?.cursor)
|
|
198
|
+
));
|
|
199
|
+
const dual = values.some((entry) => !!entry?.lastSessionId && !!entry?.codexSessionId);
|
|
200
|
+
const codex = values.some((entry) => entry?.settings?.backend === "codex" || !!entry?.codexSessionId);
|
|
201
|
+
const claude = values.some((entry) => entry?.settings?.backend === "claude" || !!entry?.lastSessionId);
|
|
202
|
+
const missingProject = values.some((entry) => !entry?.currentSession && (!!entry?.lastSessionId || !!entry?.codexSessionId || !!entry?.cursorSessionId));
|
|
203
|
+
if (cursor) return { schemaGuess: `${multi ? "multi-user-" : "legacy-"}state-cursor${suffix}`, resolution: "unresolved" };
|
|
204
|
+
if (missingProject) return { schemaGuess: `${multi ? "multi-user-" : "legacy-"}state-missing-project${suffix}`, resolution: "unresolved" };
|
|
205
|
+
if (dual || (multi && codex && claude)) return { schemaGuess: `${multi ? "multi-user-" : "legacy-"}state-claude-codex${suffix}`, resolution: "archived" };
|
|
206
|
+
if (codex) return { schemaGuess: `${multi ? "multi-user-" : "legacy-"}state-codex${suffix}`, resolution: "archived" };
|
|
207
|
+
if (claude) return { schemaGuess: `${multi ? "multi-user-" : "legacy-"}state-claude${suffix}`, resolution: "archived" };
|
|
208
|
+
return { schemaGuess: `${multi ? "multi-user-" : "legacy-"}state-unknown${suffix}`, resolution: "unresolved" };
|
|
209
|
+
}
|
|
210
|
+
if (source.kind === "sessions") {
|
|
211
|
+
const records = [];
|
|
212
|
+
const walk = (node) => {
|
|
213
|
+
if (Array.isArray(node)) records.push(...node.filter((entry) => entry && typeof entry === "object"));
|
|
214
|
+
else if (node && typeof node === "object") Object.values(node).forEach(walk);
|
|
215
|
+
};
|
|
216
|
+
walk(value);
|
|
217
|
+
const cursor = records.some((entry) => entry?.provider === "cursor" || entry?.archivedProvider === "cursor");
|
|
218
|
+
const providerAware = records.length > 0 && records.every((entry) => typeof entry.provider === "string" && entry.provider);
|
|
219
|
+
return {
|
|
220
|
+
schemaGuess: `${cursor ? "provider-session-history-cursor" : (providerAware ? "provider-session-history" : "legacy-provider-blind-sessions")}${suffix}`,
|
|
221
|
+
resolution: cursor || !providerAware ? "unresolved" : "archived",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (source.kind === "jobs") {
|
|
225
|
+
if (!Array.isArray(value)) return { schemaGuess: `unknown-jobs${suffix}`, resolution: "unresolved" };
|
|
226
|
+
const cursor = value.some((entry) => entry?.provider === "cursor" || entry?.sessionKey === "cursorSessionId");
|
|
227
|
+
const missingProject = value.some((entry) => entry?.sessionId && !entry?.project);
|
|
228
|
+
const ambiguous = value.some((entry) => entry?.sessionId && !entry?.provider && !entry?.sessionKey);
|
|
229
|
+
return {
|
|
230
|
+
schemaGuess: `${cursor ? "legacy-jobs-cursor" : "legacy-jobs"}${suffix}`,
|
|
231
|
+
resolution: cursor || missingProject || ambiguous ? "unresolved" : "archived",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (source.kind === "crons") {
|
|
235
|
+
return { schemaGuess: `legacy-provider-blind-crons${suffix}`, resolution: "unresolved" };
|
|
236
|
+
}
|
|
237
|
+
return { schemaGuess: `unknown-json${suffix}`, resolution: "unresolved" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function fault(options, point, context = {}) {
|
|
241
|
+
if (typeof options.faultInjector === "function") options.faultInjector(point, context);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function acquireLock(root) {
|
|
245
|
+
ensureDirectory(root);
|
|
246
|
+
const filePath = path.join(root, LOCK_FILE);
|
|
247
|
+
let fd;
|
|
248
|
+
const open = () => fs.openSync(filePath, "wx", 0o600);
|
|
249
|
+
try { fd = open(); }
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (error.code === "EEXIST") {
|
|
252
|
+
let stale = false;
|
|
253
|
+
try {
|
|
254
|
+
const stat = assertRegularFile(filePath, "MIGRATION_LOCKED", "migration lock");
|
|
255
|
+
const pid = Number(fs.readFileSync(filePath, "utf8").trim());
|
|
256
|
+
if (Number.isSafeInteger(pid) && pid > 0 && Date.now() - stat.mtimeMs > 1000) {
|
|
257
|
+
try { process.kill(pid, 0); }
|
|
258
|
+
catch (probeError) { stale = probeError.code === "ESRCH"; }
|
|
259
|
+
}
|
|
260
|
+
} catch (_) {}
|
|
261
|
+
if (stale) {
|
|
262
|
+
try { fs.unlinkSync(filePath); } catch (_) {}
|
|
263
|
+
try { fd = open(); }
|
|
264
|
+
catch (_) { throw migrationError("MIGRATION_LOCKED", "provider migration snapshot is already locked"); }
|
|
265
|
+
} else {
|
|
266
|
+
throw migrationError("MIGRATION_LOCKED", "provider migration snapshot is already locked");
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
fs.writeFileSync(fd, `${process.pid}\n`);
|
|
273
|
+
fs.fsyncSync(fd);
|
|
274
|
+
return () => {
|
|
275
|
+
try { fs.closeSync(fd); } catch (_) {}
|
|
276
|
+
try { fs.unlinkSync(filePath); } catch (_) {}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function snapshotPathFromJournal(root, journal) {
|
|
281
|
+
if (!journal?.snapshot) throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal has no snapshot pointer");
|
|
282
|
+
const resolved = path.resolve(root, journal.snapshot);
|
|
283
|
+
const prefix = `${path.resolve(root)}${path.sep}`;
|
|
284
|
+
if (!resolved.startsWith(prefix)) throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal snapshot path is unsafe");
|
|
285
|
+
return resolved;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readJsonFile(filePath, code, label) {
|
|
289
|
+
assertRegularFile(filePath, code, label);
|
|
290
|
+
try { return JSON.parse(fs.readFileSync(filePath, "utf8")); }
|
|
291
|
+
catch (_) { throw migrationError(code, `${label} is invalid JSON`); }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readMigrationJournal(configDir) {
|
|
295
|
+
const filePath = journalPath(configDir);
|
|
296
|
+
if (!fs.existsSync(filePath)) return null;
|
|
297
|
+
const journal = readJsonFile(filePath, "INVALID_MIGRATION_JOURNAL", "migration journal");
|
|
298
|
+
if (journal.migrationId !== MIGRATION_ID || journal.formatVersion !== FORMAT_VERSION) {
|
|
299
|
+
throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal identity is invalid");
|
|
300
|
+
}
|
|
301
|
+
if (!new Set(["prepared", "validated", "activated"]).has(journal.stage)
|
|
302
|
+
|| typeof journal.snapshot !== "string"
|
|
303
|
+
|| !/^[a-f0-9]{64}$/.test(journal.manifestSha256 || "")
|
|
304
|
+
|| !journal.stages?.prepared) {
|
|
305
|
+
throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal stage metadata is invalid");
|
|
306
|
+
}
|
|
307
|
+
if (journal.stage !== "prepared" && !journal.stages.validated) {
|
|
308
|
+
throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal is missing validation metadata");
|
|
309
|
+
}
|
|
310
|
+
if (journal.stage === "activated" && (
|
|
311
|
+
!journal.stages.activated
|
|
312
|
+
|| !REQUIRED_ACTIVATION_COMPONENTS.every((component) => !!journal.components?.[component])
|
|
313
|
+
)) {
|
|
314
|
+
throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal activation is incomplete");
|
|
315
|
+
}
|
|
316
|
+
if (journal.pendingComponents !== undefined && (
|
|
317
|
+
!journal.pendingComponents
|
|
318
|
+
|| typeof journal.pendingComponents !== "object"
|
|
319
|
+
|| Array.isArray(journal.pendingComponents)
|
|
320
|
+
|| Object.keys(journal.pendingComponents).some((component) => !REQUIRED_ACTIVATION_COMPONENTS.includes(component))
|
|
321
|
+
)) {
|
|
322
|
+
throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal pending-component metadata is invalid");
|
|
323
|
+
}
|
|
324
|
+
return journal;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function writeJournal(configDir, journal) {
|
|
328
|
+
atomicWriteSecure(journalPath(configDir), stableJson(journal));
|
|
329
|
+
return journal;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function sourceMap(sources) {
|
|
333
|
+
return new Map(normalizeSources(sources).map((entry) => [entry.path, entry]));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function readSource(source) {
|
|
337
|
+
if (!fs.existsSync(source.sourcePath)) return null;
|
|
338
|
+
let stat;
|
|
339
|
+
try { stat = assertRegularFile(source.sourcePath, "UNSAFE_MIGRATION_SOURCE", `migration source ${source.path}`); }
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (error.code === "ENOENT") return null;
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
const data = fs.readFileSync(source.sourcePath);
|
|
345
|
+
const after = fs.lstatSync(source.sourcePath);
|
|
346
|
+
if (!after.isFile() || after.isSymbolicLink() || stat.dev !== after.dev || stat.ino !== after.ino || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs) {
|
|
347
|
+
throw migrationError("MIGRATION_SOURCE_CHANGED", `migration source changed while reading: ${source.path}`);
|
|
348
|
+
}
|
|
349
|
+
return data;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isRecord(value) {
|
|
353
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function componentNotMigrated(component, reason, details = {}) {
|
|
357
|
+
return migrationError(
|
|
358
|
+
"MIGRATION_COMPONENT_NOT_MIGRATED",
|
|
359
|
+
`${component} migration output is not provider-aware: ${reason}`,
|
|
360
|
+
{ component, ...details },
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseMigratedComponent(component, raw) {
|
|
365
|
+
if (raw === null) throw componentNotMigrated(component, `${component}.json is missing`);
|
|
366
|
+
let document;
|
|
367
|
+
try { document = JSON.parse(raw.toString("utf8")); }
|
|
368
|
+
catch (_) { throw componentNotMigrated(component, `${component}.json is invalid JSON`); }
|
|
369
|
+
const expectedSchema = COMPONENT_SCHEMA_VERSIONS[component];
|
|
370
|
+
if (!isRecord(document) || document.schemaVersion !== expectedSchema) {
|
|
371
|
+
throw componentNotMigrated(component, `expected schema version ${expectedSchema}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (component === "state") {
|
|
375
|
+
if (!isRecord(document.users)) throw componentNotMigrated(component, "users must be an object");
|
|
376
|
+
for (const user of Object.values(document.users)) {
|
|
377
|
+
if (!isRecord(user)) throw componentNotMigrated(component, "every user state must be an object");
|
|
378
|
+
const backend = user.settings?.backend;
|
|
379
|
+
if (backend !== undefined && backend !== null && !["claude", "codex"].includes(backend)) {
|
|
380
|
+
throw componentNotMigrated(component, `unsupported active provider ${String(backend)}`);
|
|
381
|
+
}
|
|
382
|
+
if (Object.prototype.hasOwnProperty.call(user, "cursorSessionId")) {
|
|
383
|
+
throw componentNotMigrated(component, "removed-provider session pointer is still active");
|
|
384
|
+
}
|
|
385
|
+
if (isRecord(user.providerSettings) && Object.prototype.hasOwnProperty.call(user.providerSettings, "cursor")) {
|
|
386
|
+
throw componentNotMigrated(component, "removed-provider settings are still active");
|
|
387
|
+
}
|
|
388
|
+
for (const providers of Object.values(isRecord(user.activeSessions) ? user.activeSessions : {})) {
|
|
389
|
+
if (!isRecord(providers)) throw componentNotMigrated(component, "active session buckets must be objects");
|
|
390
|
+
if (Object.prototype.hasOwnProperty.call(providers, "cursor")) {
|
|
391
|
+
throw componentNotMigrated(component, "removed-provider session is still selectable");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} else if (component === "sessions") {
|
|
396
|
+
if (!isRecord(document.users)) throw componentNotMigrated(component, "users must be an object");
|
|
397
|
+
for (const projects of Object.values(document.users)) {
|
|
398
|
+
if (!isRecord(projects)) throw componentNotMigrated(component, "project histories must be objects");
|
|
399
|
+
for (const records of Object.values(projects)) {
|
|
400
|
+
if (!Array.isArray(records)) throw componentNotMigrated(component, "session histories must be arrays");
|
|
401
|
+
for (const record of records) {
|
|
402
|
+
if (!isRecord(record) || typeof record.provider !== "string" || !record.provider) {
|
|
403
|
+
throw componentNotMigrated(component, "session records must declare a provider");
|
|
404
|
+
}
|
|
405
|
+
if (record.provider === "cursor") {
|
|
406
|
+
throw componentNotMigrated(component, "removed-provider history is still selectable");
|
|
407
|
+
}
|
|
408
|
+
if (record.archivedProvider === "cursor" && (record.provider !== "legacy" || record.selectable !== false)) {
|
|
409
|
+
throw componentNotMigrated(component, "removed-provider history is not a disabled legacy record");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else if (component === "jobs") {
|
|
415
|
+
if (!Array.isArray(document.jobs) || !Array.isArray(document.archived)) {
|
|
416
|
+
throw componentNotMigrated(component, "jobs and archived must be arrays");
|
|
417
|
+
}
|
|
418
|
+
for (const job of document.jobs) {
|
|
419
|
+
if (!isRecord(job) || !["claude", "codex"].includes(job.provider)) {
|
|
420
|
+
throw componentNotMigrated(component, "runnable jobs must use a supported provider");
|
|
421
|
+
}
|
|
422
|
+
if (job.sessionKey === "cursorSessionId") {
|
|
423
|
+
throw componentNotMigrated(component, "removed-provider session pointer is still runnable");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const job of document.archived) {
|
|
427
|
+
if (!isRecord(job) || job.disabled !== true || job.status !== "archived") {
|
|
428
|
+
throw componentNotMigrated(component, "archived jobs must be disabled archives");
|
|
429
|
+
}
|
|
430
|
+
if (job.sessionKey === "cursorSessionId") {
|
|
431
|
+
throw componentNotMigrated(component, "removed-provider session pointer was not archived");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const cron = document.legacyCronMigration;
|
|
435
|
+
if (!isRecord(cron)
|
|
436
|
+
|| cron.path !== "crons.json"
|
|
437
|
+
|| typeof cron.present !== "boolean"
|
|
438
|
+
|| !new Set(["missing", "archived", "malformed-archived"]).has(cron.status)
|
|
439
|
+
|| !Number.isSafeInteger(cron.recordCount)
|
|
440
|
+
|| cron.recordCount < 0
|
|
441
|
+
|| !Array.isArray(cron.recordHashes)
|
|
442
|
+
|| cron.recordHashes.length !== cron.recordCount) {
|
|
443
|
+
throw componentNotMigrated(component, "legacy cron migration evidence is missing or invalid");
|
|
444
|
+
}
|
|
445
|
+
if (cron.present) {
|
|
446
|
+
if (!Number.isSafeInteger(cron.size) || cron.size < 0 || !/^[a-f0-9]{64}$/.test(cron.sha256 || "")) {
|
|
447
|
+
throw componentNotMigrated(component, "legacy cron source evidence is invalid");
|
|
448
|
+
}
|
|
449
|
+
} else if (cron.size !== null || cron.sha256 !== null || cron.status !== "missing") {
|
|
450
|
+
throw componentNotMigrated(component, "missing legacy cron source evidence is invalid");
|
|
451
|
+
}
|
|
452
|
+
const archivedCronHashes = new Set(document.archived.map((job) => job.legacyCronHash).filter(Boolean));
|
|
453
|
+
if (cron.recordHashes.some((hash) => !/^[a-f0-9]{64}$/.test(hash) || !archivedCronHashes.has(hash))) {
|
|
454
|
+
throw componentNotMigrated(component, "a legacy cron record was not archived");
|
|
455
|
+
}
|
|
456
|
+
if (cron.status === "malformed-archived"
|
|
457
|
+
&& !document.archived.some((job) => job.id === cron.archiveId && job.legacyCronSourceHash === cron.sha256)) {
|
|
458
|
+
throw componentNotMigrated(component, "malformed legacy cron source was not archived");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return document;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function migratedComponentProof(sources, component) {
|
|
465
|
+
const source = normalizeSources(sources).find((entry) => entry.path === `${component}.json` && entry.kind === component);
|
|
466
|
+
if (!source) {
|
|
467
|
+
throw migrationError("INVALID_MIGRATION_CONFIG", `migration source is missing: ${component}.json`);
|
|
468
|
+
}
|
|
469
|
+
const raw = readSource(source);
|
|
470
|
+
const document = parseMigratedComponent(component, raw);
|
|
471
|
+
if (component === "jobs") {
|
|
472
|
+
const cronSource = normalizeSources(sources).find((entry) => entry.path === "crons.json" && entry.kind === "crons");
|
|
473
|
+
if (!cronSource) throw migrationError("INVALID_MIGRATION_CONFIG", "migration source is missing: crons.json");
|
|
474
|
+
const cronRaw = readSource(cronSource);
|
|
475
|
+
const cron = document.legacyCronMigration;
|
|
476
|
+
const matches = cron.path === cronSource.path
|
|
477
|
+
&& cron.present === (cronRaw !== null)
|
|
478
|
+
&& cron.size === (cronRaw === null ? null : cronRaw.length)
|
|
479
|
+
&& cron.sha256 === (cronRaw === null ? null : sha256(cronRaw));
|
|
480
|
+
if (!matches) throw componentNotMigrated(component, "legacy cron certification is stale");
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
path: source.path,
|
|
484
|
+
size: raw.length,
|
|
485
|
+
sha256: sha256(raw),
|
|
486
|
+
schemaVersion: document.schemaVersion,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function proofMatches(record, proof) {
|
|
491
|
+
return isRecord(record)
|
|
492
|
+
&& record.path === proof.path
|
|
493
|
+
&& record.size === proof.size
|
|
494
|
+
&& record.sha256 === proof.sha256
|
|
495
|
+
&& record.schemaVersion === proof.schemaVersion;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function backupPath(snapshotDir, logicalPath) {
|
|
499
|
+
const safe = normalizeLogicalPath(logicalPath);
|
|
500
|
+
const root = path.resolve(snapshotDir, "originals");
|
|
501
|
+
const resolved = path.resolve(root, ...safe.split("/"));
|
|
502
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
503
|
+
throw migrationError("UNSAFE_MANIFEST_PATH", `unsafe backup path: ${logicalPath}`);
|
|
504
|
+
}
|
|
505
|
+
return resolved;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function compareSources(manifest, sources) {
|
|
509
|
+
const byPath = sourceMap(sources);
|
|
510
|
+
const manifestPaths = new Set(manifest.files.map((entry) => entry.path));
|
|
511
|
+
if (byPath.size !== manifestPaths.size) return { ok: false, path: "source-set" };
|
|
512
|
+
for (const logicalPath of byPath.keys()) {
|
|
513
|
+
if (!manifestPaths.has(logicalPath)) return { ok: false, path: logicalPath };
|
|
514
|
+
}
|
|
515
|
+
for (const entry of manifest.files) {
|
|
516
|
+
const source = byPath.get(entry.path);
|
|
517
|
+
if (!source) return { ok: false, path: entry.path };
|
|
518
|
+
const raw = readSource(source);
|
|
519
|
+
if (!entry.present) {
|
|
520
|
+
if (raw !== null) return { ok: false, path: entry.path };
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (raw === null || raw.length !== entry.size || sha256(raw) !== entry.sha256) return { ok: false, path: entry.path };
|
|
524
|
+
}
|
|
525
|
+
return { ok: true };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function activationComponentForKind(kind) {
|
|
529
|
+
if (kind === "crons") return "jobs";
|
|
530
|
+
return REQUIRED_ACTIVATION_COMPONENTS.includes(kind) ? kind : null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function compareComponentSources(manifest, sources, component) {
|
|
534
|
+
if (!REQUIRED_ACTIVATION_COMPONENTS.includes(component)) {
|
|
535
|
+
throw migrationError("INVALID_MIGRATION_COMPONENT", `unknown migration component: ${component}`);
|
|
536
|
+
}
|
|
537
|
+
const byPath = sourceMap(sources);
|
|
538
|
+
for (const entry of manifest.files) {
|
|
539
|
+
const source = byPath.get(entry.path);
|
|
540
|
+
if (!source || activationComponentForKind(source.kind) !== component) continue;
|
|
541
|
+
const raw = readSource(source);
|
|
542
|
+
if (!entry.present) {
|
|
543
|
+
if (raw !== null) return { ok: false, path: entry.path };
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (raw === null || raw.length !== entry.size || sha256(raw) !== entry.sha256) {
|
|
547
|
+
return { ok: false, path: entry.path };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return { ok: true };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function assertMigrationComponentSourcesMatch(manifest, sources, component) {
|
|
554
|
+
const comparison = compareComponentSources(manifest, sources, component);
|
|
555
|
+
if (!comparison.ok) {
|
|
556
|
+
throw migrationError(
|
|
557
|
+
"MIGRATION_SOURCE_CHANGED",
|
|
558
|
+
`${component} migration source changed after snapshot: ${comparison.path}`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function assertSourcesMatch(manifest, sources) {
|
|
564
|
+
const comparison = compareSources(manifest, sources);
|
|
565
|
+
if (!comparison.ok) {
|
|
566
|
+
throw migrationError("MIGRATION_SOURCE_CHANGED", `migration source changed after snapshot: ${comparison.path}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function validateManifestShape(manifest) {
|
|
571
|
+
if (!manifest || manifest.formatVersion !== FORMAT_VERSION || manifest.migrationId !== MIGRATION_ID || !Array.isArray(manifest.files)) {
|
|
572
|
+
throw migrationError("INVALID_MIGRATION_MANIFEST", "migration manifest identity is invalid");
|
|
573
|
+
}
|
|
574
|
+
const paths = manifest.files.map((entry) => normalizeLogicalPath(entry.path));
|
|
575
|
+
if (new Set(paths).size !== paths.length) throw migrationError("INVALID_MIGRATION_MANIFEST", "migration manifest contains duplicate paths");
|
|
576
|
+
if (paths.some((value, index) => index > 0 && paths[index - 1].localeCompare(value) > 0)) {
|
|
577
|
+
throw migrationError("INVALID_MIGRATION_MANIFEST", "migration manifest paths are not sorted");
|
|
578
|
+
}
|
|
579
|
+
for (const entry of manifest.files) {
|
|
580
|
+
if (typeof entry.present !== "boolean" || typeof entry.schemaGuess !== "string") {
|
|
581
|
+
throw migrationError("INVALID_MIGRATION_MANIFEST", `migration manifest entry is invalid: ${entry.path}`);
|
|
582
|
+
}
|
|
583
|
+
if (entry.present) {
|
|
584
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256 || "") || !Number.isSafeInteger(entry.mode)) {
|
|
585
|
+
throw migrationError("INVALID_MIGRATION_MANIFEST", `migration manifest checksum is invalid: ${entry.path}`);
|
|
586
|
+
}
|
|
587
|
+
} else if (entry.size !== null || entry.sha256 !== null || entry.mode !== null) {
|
|
588
|
+
throw migrationError("INVALID_MIGRATION_MANIFEST", `missing migration entry has data metadata: ${entry.path}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function validateMigrationSnapshot(snapshotDir, options = {}) {
|
|
594
|
+
const resolvedSnapshot = path.resolve(snapshotDir);
|
|
595
|
+
const snapshotStat = fs.lstatSync(resolvedSnapshot);
|
|
596
|
+
if (!snapshotStat.isDirectory() || snapshotStat.isSymbolicLink()) {
|
|
597
|
+
throw migrationError("INVALID_MIGRATION_SNAPSHOT", "migration snapshot must be a real directory");
|
|
598
|
+
}
|
|
599
|
+
assertPrivateMode(snapshotStat, 0o700, "INVALID_MIGRATION_SNAPSHOT", "migration snapshot");
|
|
600
|
+
const originalsStat = fs.lstatSync(path.join(resolvedSnapshot, "originals"));
|
|
601
|
+
if (!originalsStat.isDirectory() || originalsStat.isSymbolicLink()) {
|
|
602
|
+
throw migrationError("INVALID_MIGRATION_SNAPSHOT", "migration originals directory is invalid");
|
|
603
|
+
}
|
|
604
|
+
assertPrivateMode(originalsStat, 0o700, "INVALID_MIGRATION_SNAPSHOT", "migration originals directory");
|
|
605
|
+
const manifestPath = path.join(resolvedSnapshot, MANIFEST_FILE);
|
|
606
|
+
const hashPath = path.join(resolvedSnapshot, MANIFEST_HASH_FILE);
|
|
607
|
+
const manifestStat = assertRegularFile(manifestPath, "INVALID_MIGRATION_MANIFEST", "migration manifest");
|
|
608
|
+
const manifestHashStat = assertRegularFile(hashPath, "INVALID_MIGRATION_MANIFEST", "migration manifest checksum");
|
|
609
|
+
assertPrivateMode(manifestStat, 0o600, "INVALID_MIGRATION_MANIFEST", "migration manifest");
|
|
610
|
+
assertPrivateMode(manifestHashStat, 0o600, "INVALID_MIGRATION_MANIFEST", "migration manifest checksum");
|
|
611
|
+
const manifestBytes = fs.readFileSync(manifestPath);
|
|
612
|
+
const expectedManifestHash = fs.readFileSync(hashPath, "utf8").trim();
|
|
613
|
+
if (!/^[a-f0-9]{64}$/.test(expectedManifestHash) || sha256(manifestBytes) !== expectedManifestHash) {
|
|
614
|
+
throw migrationError("MANIFEST_CHECKSUM_MISMATCH", "migration manifest checksum verification failed");
|
|
615
|
+
}
|
|
616
|
+
let manifest;
|
|
617
|
+
try { manifest = JSON.parse(manifestBytes.toString("utf8")); }
|
|
618
|
+
catch (_) { throw migrationError("INVALID_MIGRATION_MANIFEST", "migration manifest is invalid JSON"); }
|
|
619
|
+
validateManifestShape(manifest);
|
|
620
|
+
for (const entry of manifest.files) {
|
|
621
|
+
const filePath = backupPath(resolvedSnapshot, entry.path);
|
|
622
|
+
if (!entry.present) {
|
|
623
|
+
if (fs.existsSync(filePath)) throw migrationError("BACKUP_CHECKSUM_MISMATCH", `unexpected backup data exists for ${entry.path}`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (!fs.existsSync(filePath)) throw migrationError("BACKUP_CHECKSUM_MISMATCH", `backup file is missing: ${entry.path}`);
|
|
627
|
+
const stat = assertRegularFile(filePath, "BACKUP_CHECKSUM_MISMATCH", `backup file ${entry.path}`);
|
|
628
|
+
assertPrivateMode(stat, 0o600, "BACKUP_CHECKSUM_MISMATCH", `backup file ${entry.path}`);
|
|
629
|
+
const bytes = fs.readFileSync(filePath);
|
|
630
|
+
if (stat.size !== entry.size || bytes.length !== entry.size || sha256(bytes) !== entry.sha256) {
|
|
631
|
+
throw migrationError("BACKUP_CHECKSUM_MISMATCH", `backup checksum verification failed: ${entry.path}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (options.verifySources) assertSourcesMatch(manifest, options.sources || []);
|
|
635
|
+
return { ok: true, manifest, manifestSha256: expectedManifestHash, snapshotDir: resolvedSnapshot };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function uniqueSnapshotDir(root, now, idFactory) {
|
|
639
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
640
|
+
const snapshotsRoot = path.join(root, "snapshots");
|
|
641
|
+
ensureDirectory(snapshotsRoot);
|
|
642
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
643
|
+
const suffix = String(idFactory()).replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 48) || crypto.randomBytes(6).toString("hex");
|
|
644
|
+
const snapshotDir = path.join(snapshotsRoot, `${stamp}-${suffix}`);
|
|
645
|
+
try {
|
|
646
|
+
fs.mkdirSync(snapshotDir, { mode: 0o700 });
|
|
647
|
+
fs.chmodSync(snapshotDir, 0o700);
|
|
648
|
+
ensureDirectory(path.join(snapshotDir, "originals"));
|
|
649
|
+
return snapshotDir;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error.code !== "EEXIST") throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
throw migrationError("MIGRATION_SNAPSHOT_COLLISION", "could not allocate a unique migration snapshot directory");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function initialJournal(snapshotDir, root, manifestHash, createdAt, previous) {
|
|
658
|
+
const components = Object.fromEntries(REQUIRED_ACTIVATION_COMPONENTS.map((component) => [component, null]));
|
|
659
|
+
const prior = Array.isArray(previous?.previousSnapshots) ? [...previous.previousSnapshots] : [];
|
|
660
|
+
if (previous?.snapshot) prior.push(previous.snapshot);
|
|
661
|
+
return {
|
|
662
|
+
formatVersion: FORMAT_VERSION,
|
|
663
|
+
migrationId: MIGRATION_ID,
|
|
664
|
+
snapshot: path.relative(root, snapshotDir).split(path.sep).join("/"),
|
|
665
|
+
manifestSha256: manifestHash,
|
|
666
|
+
stage: "prepared",
|
|
667
|
+
stages: {
|
|
668
|
+
prepared: { at: createdAt },
|
|
669
|
+
validated: null,
|
|
670
|
+
activated: null,
|
|
671
|
+
},
|
|
672
|
+
components,
|
|
673
|
+
pendingComponents: {},
|
|
674
|
+
previousSnapshots: [...new Set(prior)],
|
|
675
|
+
rollbacks: [],
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function createSnapshot(configDir, sources, options, previousJournal) {
|
|
680
|
+
const root = migrationRoot(configDir);
|
|
681
|
+
const nowValue = options.now ? options.now() : new Date();
|
|
682
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
683
|
+
if (!Number.isFinite(now.getTime())) throw migrationError("INVALID_MIGRATION_CONFIG", "migration clock returned an invalid date");
|
|
684
|
+
const idFactory = options.idFactory || (() => crypto.randomBytes(6).toString("hex"));
|
|
685
|
+
const snapshotDir = uniqueSnapshotDir(root, now, idFactory);
|
|
686
|
+
const files = [];
|
|
687
|
+
for (const source of sources) {
|
|
688
|
+
const raw = readSource(source);
|
|
689
|
+
const guess = schemaGuess(raw, source);
|
|
690
|
+
const entry = {
|
|
691
|
+
path: source.path,
|
|
692
|
+
present: raw !== null,
|
|
693
|
+
size: raw === null ? null : raw.length,
|
|
694
|
+
sha256: raw === null ? null : sha256(raw),
|
|
695
|
+
mode: raw === null ? null : (fs.lstatSync(source.sourcePath).mode & 0o777),
|
|
696
|
+
schemaGuess: guess.schemaGuess,
|
|
697
|
+
resolution: guess.resolution,
|
|
698
|
+
};
|
|
699
|
+
if (raw !== null) {
|
|
700
|
+
const destination = backupPath(snapshotDir, source.path);
|
|
701
|
+
writeSecureFile(destination, raw, { exclusive: true });
|
|
702
|
+
fault(options, `before-copy-verify:${source.path}`, { backupPath: destination, path: source.path });
|
|
703
|
+
const copied = fs.readFileSync(destination);
|
|
704
|
+
if (copied.length !== entry.size || sha256(copied) !== entry.sha256) {
|
|
705
|
+
throw migrationError("BACKUP_VERIFICATION_FAILED", `backup copy verification failed: ${source.path}`);
|
|
706
|
+
}
|
|
707
|
+
const current = readSource(source);
|
|
708
|
+
if (current === null || current.length !== entry.size || sha256(current) !== entry.sha256) {
|
|
709
|
+
throw migrationError("MIGRATION_SOURCE_CHANGED", `migration source changed while copying: ${source.path}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
files.push(entry);
|
|
713
|
+
fault(options, `after-copy:${source.path}`, { path: source.path });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const manifest = {
|
|
717
|
+
formatVersion: FORMAT_VERSION,
|
|
718
|
+
migrationId: MIGRATION_ID,
|
|
719
|
+
snapshotId: path.basename(snapshotDir),
|
|
720
|
+
createdAt: now.toISOString(),
|
|
721
|
+
creation: {
|
|
722
|
+
tool: "open-claudia-provider-migration",
|
|
723
|
+
purpose: "pre-provider-schema byte snapshot",
|
|
724
|
+
hashAlgorithm: "sha256",
|
|
725
|
+
sourceSet: "state-sessions-jobs-and-legacy-crons-v1",
|
|
726
|
+
fileCount: files.filter((entry) => entry.present).length,
|
|
727
|
+
},
|
|
728
|
+
files,
|
|
729
|
+
};
|
|
730
|
+
const manifestBytes = Buffer.from(stableJson(manifest));
|
|
731
|
+
const manifestHash = sha256(manifestBytes);
|
|
732
|
+
writeSecureFile(path.join(snapshotDir, MANIFEST_FILE), manifestBytes, { exclusive: true });
|
|
733
|
+
writeSecureFile(path.join(snapshotDir, MANIFEST_HASH_FILE), `${manifestHash}\n`, { exclusive: true });
|
|
734
|
+
fsyncDirectory(snapshotDir);
|
|
735
|
+
fault(options, "after-manifest", { snapshotDir });
|
|
736
|
+
|
|
737
|
+
let journal = initialJournal(snapshotDir, root, manifestHash, now.toISOString(), previousJournal);
|
|
738
|
+
writeJournal(configDir, journal);
|
|
739
|
+
fault(options, "after-prepared", { snapshotDir });
|
|
740
|
+
fault(options, "before-source-validation", { snapshotDir });
|
|
741
|
+
assertSourcesMatch(manifest, sources);
|
|
742
|
+
validateMigrationSnapshot(snapshotDir);
|
|
743
|
+
journal = {
|
|
744
|
+
...journal,
|
|
745
|
+
stage: "validated",
|
|
746
|
+
stages: { ...journal.stages, validated: { at: now.toISOString() } },
|
|
747
|
+
};
|
|
748
|
+
writeJournal(configDir, journal);
|
|
749
|
+
fault(options, "after-validated", { snapshotDir });
|
|
750
|
+
return { snapshotDir, manifest, journal, reused: false };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function ensureMigrationSnapshot(options = {}) {
|
|
754
|
+
const configDir = path.resolve(options.configDir || "");
|
|
755
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
756
|
+
const sources = normalizeSources(options.sources || defaultMigrationSources({ configDir }));
|
|
757
|
+
const root = migrationRoot(configDir);
|
|
758
|
+
ensureDirectory(root);
|
|
759
|
+
const release = acquireLock(root);
|
|
760
|
+
try {
|
|
761
|
+
fault(options, "after-lock", {});
|
|
762
|
+
const current = readMigrationJournal(configDir);
|
|
763
|
+
if (current) {
|
|
764
|
+
const currentSnapshot = snapshotPathFromJournal(root, current);
|
|
765
|
+
const validated = validateMigrationSnapshot(currentSnapshot);
|
|
766
|
+
if (current.manifestSha256 !== validated.manifestSha256) {
|
|
767
|
+
throw migrationError("MANIFEST_CHECKSUM_MISMATCH", "migration journal and manifest checksums differ");
|
|
768
|
+
}
|
|
769
|
+
const comparison = compareSources(validated.manifest, sources);
|
|
770
|
+
if (comparison.ok) {
|
|
771
|
+
let journal = current;
|
|
772
|
+
if (journal.stage === "prepared") {
|
|
773
|
+
const nowValue = options.now ? options.now() : new Date();
|
|
774
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
775
|
+
journal = {
|
|
776
|
+
...journal,
|
|
777
|
+
stage: "validated",
|
|
778
|
+
stages: { ...journal.stages, validated: { at: now.toISOString() } },
|
|
779
|
+
};
|
|
780
|
+
writeJournal(configDir, journal);
|
|
781
|
+
fault(options, "after-validated", { snapshotDir: currentSnapshot });
|
|
782
|
+
}
|
|
783
|
+
return { snapshotDir: currentSnapshot, manifest: validated.manifest, journal, reused: true };
|
|
784
|
+
}
|
|
785
|
+
if (current.stage === "activated") {
|
|
786
|
+
return { snapshotDir: currentSnapshot, manifest: validated.manifest, journal: current, reused: true, sourceChangedAfterActivation: true };
|
|
787
|
+
}
|
|
788
|
+
const changingComponents = REQUIRED_ACTIVATION_COMPONENTS.filter((component) => (
|
|
789
|
+
!!current.components?.[component] || !!current.pendingComponents?.[component]
|
|
790
|
+
));
|
|
791
|
+
if (changingComponents.length > 0) {
|
|
792
|
+
// A component migration intentionally changes its live source. Keep the
|
|
793
|
+
// one pre-migration rollback point across restarts, while still
|
|
794
|
+
// refusing any unexpected change to a component that has not migrated.
|
|
795
|
+
for (const component of REQUIRED_ACTIVATION_COMPONENTS) {
|
|
796
|
+
if (!current.components?.[component] && !current.pendingComponents?.[component]) {
|
|
797
|
+
assertMigrationComponentSourcesMatch(validated.manifest, sources, component);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
snapshotDir: currentSnapshot,
|
|
802
|
+
manifest: validated.manifest,
|
|
803
|
+
journal: current,
|
|
804
|
+
reused: true,
|
|
805
|
+
sourceChangedAfterPartialActivation: true,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return createSnapshot(configDir, sources, options, current);
|
|
810
|
+
} finally {
|
|
811
|
+
release();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function markMigrationComponentPrepared(snapshotDir, component, options = {}) {
|
|
816
|
+
if (!REQUIRED_ACTIVATION_COMPONENTS.includes(component)) {
|
|
817
|
+
throw migrationError("INVALID_MIGRATION_COMPONENT", `unknown migration component: ${component}`);
|
|
818
|
+
}
|
|
819
|
+
const configDir = path.resolve(options.configDir || "");
|
|
820
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
821
|
+
const sources = normalizeSources(options.sources || defaultMigrationSources({ configDir }));
|
|
822
|
+
const root = migrationRoot(configDir);
|
|
823
|
+
const release = acquireLock(root);
|
|
824
|
+
try {
|
|
825
|
+
const validated = validateMigrationSnapshot(snapshotDir);
|
|
826
|
+
const journal = readMigrationJournal(configDir);
|
|
827
|
+
if (!journal) throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal is missing");
|
|
828
|
+
assertSnapshotMatchesJournal(snapshotDir, configDir, journal, validated);
|
|
829
|
+
if (!journal.stages?.validated) throw migrationError("MIGRATION_NOT_VALIDATED", "migration snapshot has not been validated");
|
|
830
|
+
if (journal.components?.[component] || journal.pendingComponents?.[component]) return journal;
|
|
831
|
+
if (options.requireSourceMatch !== false) {
|
|
832
|
+
assertMigrationComponentSourcesMatch(validated.manifest, sources, component);
|
|
833
|
+
}
|
|
834
|
+
const nowValue = options.now ? options.now() : new Date();
|
|
835
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
836
|
+
const next = {
|
|
837
|
+
...journal,
|
|
838
|
+
pendingComponents: {
|
|
839
|
+
...(journal.pendingComponents || {}),
|
|
840
|
+
[component]: { at: now.toISOString() },
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
writeJournal(configDir, next);
|
|
844
|
+
fault(options, `after-prepare:${component}`, { component });
|
|
845
|
+
return next;
|
|
846
|
+
} finally {
|
|
847
|
+
release();
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function requireMigrationSnapshotForComponent(component, options = {}) {
|
|
852
|
+
if (!REQUIRED_ACTIVATION_COMPONENTS.includes(component)) {
|
|
853
|
+
throw migrationError("INVALID_MIGRATION_COMPONENT", `unknown migration component: ${component}`);
|
|
854
|
+
}
|
|
855
|
+
const configDir = path.resolve(options.configDir || "");
|
|
856
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
857
|
+
const sources = normalizeSources(options.sources || defaultMigrationSources({ configDir }));
|
|
858
|
+
const existing = readMigrationJournal(configDir);
|
|
859
|
+
if (!existing) return ensureMigrationSnapshot({ ...options, configDir, sources });
|
|
860
|
+
|
|
861
|
+
const snapshotDir = snapshotPathFromJournal(migrationRoot(configDir), existing);
|
|
862
|
+
const validated = validateMigrationSnapshot(snapshotDir);
|
|
863
|
+
if (existing.manifestSha256 !== validated.manifestSha256 || !existing.stages?.validated) {
|
|
864
|
+
throw migrationError("MIGRATION_NOT_VALIDATED", "migration snapshot has not been validated");
|
|
865
|
+
}
|
|
866
|
+
if (!existing.components?.[component] && !options.allowSourceMismatch) {
|
|
867
|
+
assertMigrationComponentSourcesMatch(validated.manifest, sources, component);
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
snapshotDir,
|
|
871
|
+
manifest: validated.manifest,
|
|
872
|
+
journal: existing,
|
|
873
|
+
reused: true,
|
|
874
|
+
componentAlreadyActivated: !!existing.components?.[component],
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function assertSnapshotMatchesJournal(snapshotDir, configDir, journal, validated) {
|
|
879
|
+
const root = migrationRoot(configDir);
|
|
880
|
+
const expected = snapshotPathFromJournal(root, journal);
|
|
881
|
+
if (path.resolve(snapshotDir) !== expected || journal.manifestSha256 !== validated.manifestSha256) {
|
|
882
|
+
throw migrationError("MIGRATION_SNAPSHOT_MISMATCH", "migration snapshot does not match the active journal");
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function markMigrationComponentActivated(snapshotDir, component, options = {}) {
|
|
887
|
+
if (!REQUIRED_ACTIVATION_COMPONENTS.includes(component)) {
|
|
888
|
+
throw migrationError("INVALID_MIGRATION_COMPONENT", `unknown migration component: ${component}`);
|
|
889
|
+
}
|
|
890
|
+
const configDir = path.resolve(options.configDir || "");
|
|
891
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
892
|
+
const sources = normalizeSources(options.sources || defaultMigrationSources({ configDir }));
|
|
893
|
+
const root = migrationRoot(configDir);
|
|
894
|
+
const release = acquireLock(root);
|
|
895
|
+
try {
|
|
896
|
+
const validated = validateMigrationSnapshot(snapshotDir);
|
|
897
|
+
const journal = readMigrationJournal(configDir);
|
|
898
|
+
if (!journal) throw migrationError("INVALID_MIGRATION_JOURNAL", "migration journal is missing");
|
|
899
|
+
assertSnapshotMatchesJournal(snapshotDir, configDir, journal, validated);
|
|
900
|
+
if (!journal.stages?.validated) throw migrationError("MIGRATION_NOT_VALIDATED", "migration snapshot has not been validated");
|
|
901
|
+
if (options.requireSourceMatch) assertSourcesMatch(validated.manifest, sources);
|
|
902
|
+
const proof = migratedComponentProof(sources, component);
|
|
903
|
+
const previous = journal.components?.[component];
|
|
904
|
+
if (proofMatches(previous, proof)) return journal;
|
|
905
|
+
if (!previous && !journal.pendingComponents?.[component]) {
|
|
906
|
+
throw migrationError("MIGRATION_COMPONENT_NOT_PREPARED", `${component} migration has not been prepared`);
|
|
907
|
+
}
|
|
908
|
+
const nowValue = options.now ? options.now() : new Date();
|
|
909
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
910
|
+
const components = {
|
|
911
|
+
...(journal.components || {}),
|
|
912
|
+
[component]: { at: previous?.at || now.toISOString(), ...proof },
|
|
913
|
+
};
|
|
914
|
+
const pendingComponents = { ...(journal.pendingComponents || {}) };
|
|
915
|
+
delete pendingComponents[component];
|
|
916
|
+
const allActivated = REQUIRED_ACTIVATION_COMPONENTS.every((name) => !!components[name]);
|
|
917
|
+
const next = {
|
|
918
|
+
...journal,
|
|
919
|
+
stage: allActivated ? "activated" : "validated",
|
|
920
|
+
components,
|
|
921
|
+
pendingComponents,
|
|
922
|
+
stages: {
|
|
923
|
+
...journal.stages,
|
|
924
|
+
activated: allActivated ? (journal.stages.activated || { at: now.toISOString() }) : null,
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
writeJournal(configDir, next);
|
|
928
|
+
fault(options, `after-activation:${component}`, { component });
|
|
929
|
+
return next;
|
|
930
|
+
} finally {
|
|
931
|
+
release();
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function requireProviderRemovalReady(options = {}) {
|
|
936
|
+
const configDir = path.resolve(options.configDir || "");
|
|
937
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
938
|
+
const sources = normalizeSources(options.sources || defaultMigrationSources({ configDir }));
|
|
939
|
+
const journal = readMigrationJournal(configDir);
|
|
940
|
+
if (!journal) throw migrationError("PROVIDER_REMOVAL_NOT_READY", "provider removal requires a validated migration journal");
|
|
941
|
+
const snapshotDir = snapshotPathFromJournal(migrationRoot(configDir), journal);
|
|
942
|
+
const validated = validateMigrationSnapshot(snapshotDir);
|
|
943
|
+
if (!journal.stages?.validated || journal.manifestSha256 !== validated.manifestSha256) {
|
|
944
|
+
throw migrationError("PROVIDER_REMOVAL_NOT_READY", "provider removal requires a valid migration snapshot");
|
|
945
|
+
}
|
|
946
|
+
const missing = REQUIRED_ACTIVATION_COMPONENTS.filter((component) => !journal.components?.[component]);
|
|
947
|
+
if (journal.stage !== "activated" || missing.length) {
|
|
948
|
+
throw migrationError(
|
|
949
|
+
"PROVIDER_REMOVAL_NOT_READY",
|
|
950
|
+
`provider removal requires completed state, sessions, and jobs migrations${missing.length ? `; pending: ${missing.join(", ")}` : ""}`,
|
|
951
|
+
{ missing },
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
for (const component of REQUIRED_ACTIVATION_COMPONENTS) {
|
|
955
|
+
let proof;
|
|
956
|
+
try { proof = migratedComponentProof(sources, component); }
|
|
957
|
+
catch (error) {
|
|
958
|
+
throw migrationError(
|
|
959
|
+
"PROVIDER_REMOVAL_NOT_READY",
|
|
960
|
+
`provider removal requires valid ${component} migration output`,
|
|
961
|
+
{ component, cause: error.code || error.name },
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
if (!proofMatches(journal.components[component], proof)) {
|
|
965
|
+
throw migrationError(
|
|
966
|
+
"PROVIDER_REMOVAL_NOT_READY",
|
|
967
|
+
`provider removal certification is stale for ${component}`,
|
|
968
|
+
{ component },
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return { snapshotDir, manifest: validated.manifest, journal };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function targetMapForManifest(manifest, sources) {
|
|
976
|
+
const byPath = sourceMap(sources);
|
|
977
|
+
for (const entry of manifest.files) {
|
|
978
|
+
if (!byPath.has(entry.path)) throw migrationError("INVALID_MIGRATION_CONFIG", `rollback target is missing: ${entry.path}`);
|
|
979
|
+
}
|
|
980
|
+
return byPath;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function rollbackMigrationSnapshot(snapshotDir, options = {}) {
|
|
984
|
+
const configDir = path.resolve(options.configDir || "");
|
|
985
|
+
if (!options.configDir) throw migrationError("INVALID_MIGRATION_CONFIG", "configDir is required");
|
|
986
|
+
const root = migrationRoot(configDir);
|
|
987
|
+
const release = acquireLock(root);
|
|
988
|
+
const staged = [];
|
|
989
|
+
try {
|
|
990
|
+
// Full snapshot verification deliberately precedes every target mutation.
|
|
991
|
+
const validated = validateMigrationSnapshot(snapshotDir);
|
|
992
|
+
const targets = targetMapForManifest(validated.manifest, options.sources || []);
|
|
993
|
+
const journal = readMigrationJournal(configDir);
|
|
994
|
+
if (journal) assertSnapshotMatchesJournal(snapshotDir, configDir, journal, validated);
|
|
995
|
+
fault(options, "before-rollback", {});
|
|
996
|
+
|
|
997
|
+
for (const entry of validated.manifest.files) {
|
|
998
|
+
const target = targets.get(entry.path).sourcePath;
|
|
999
|
+
if (fs.existsSync(target)) assertRegularFile(target, "UNSAFE_ROLLBACK_TARGET", `rollback target ${entry.path}`);
|
|
1000
|
+
if (!entry.present) {
|
|
1001
|
+
staged.push({ entry, target, temp: null });
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
ensureDirectory(path.dirname(target));
|
|
1005
|
+
const temp = `${target}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.rollback.tmp`;
|
|
1006
|
+
writeSecureFile(temp, fs.readFileSync(backupPath(snapshotDir, entry.path)), { exclusive: true, privateParent: false });
|
|
1007
|
+
staged.push({ entry, target, temp });
|
|
1008
|
+
fault(options, `after-rollback-stage:${entry.path}`, { path: entry.path });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
for (const item of staged) {
|
|
1012
|
+
if (item.entry.present) {
|
|
1013
|
+
fs.renameSync(item.temp, item.target);
|
|
1014
|
+
item.temp = null;
|
|
1015
|
+
try { fs.chmodSync(item.target, item.entry.mode); } catch (_) {}
|
|
1016
|
+
} else if (fs.existsSync(item.target)) {
|
|
1017
|
+
fs.unlinkSync(item.target);
|
|
1018
|
+
}
|
|
1019
|
+
fsyncDirectory(path.dirname(item.target));
|
|
1020
|
+
fault(options, `after-rollback:${item.entry.path}`, { path: item.entry.path });
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
assertSourcesMatch(validated.manifest, options.sources || []);
|
|
1024
|
+
if (journal) {
|
|
1025
|
+
const nowValue = options.now ? options.now() : new Date();
|
|
1026
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
1027
|
+
writeJournal(configDir, {
|
|
1028
|
+
...journal,
|
|
1029
|
+
rollbacks: [...(journal.rollbacks || []), { at: now.toISOString(), verified: true }],
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return { ok: true, restored: validated.manifest.files.length, snapshotDir: path.resolve(snapshotDir) };
|
|
1033
|
+
} finally {
|
|
1034
|
+
for (const item of staged) {
|
|
1035
|
+
try { if (item.temp && fs.existsSync(item.temp)) fs.unlinkSync(item.temp); } catch (_) {}
|
|
1036
|
+
}
|
|
1037
|
+
release();
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
module.exports = {
|
|
1042
|
+
FORMAT_VERSION,
|
|
1043
|
+
MIGRATION_ID,
|
|
1044
|
+
REQUIRED_ACTIVATION_COMPONENTS,
|
|
1045
|
+
assertMigrationComponentSourcesMatch,
|
|
1046
|
+
assertSourcesMatch,
|
|
1047
|
+
defaultMigrationSources,
|
|
1048
|
+
ensureMigrationSnapshot,
|
|
1049
|
+
markMigrationComponentActivated,
|
|
1050
|
+
markMigrationComponentPrepared,
|
|
1051
|
+
migrationRoot,
|
|
1052
|
+
normalizeLogicalPath,
|
|
1053
|
+
normalizeSources,
|
|
1054
|
+
readMigrationJournal,
|
|
1055
|
+
requireProviderRemovalReady,
|
|
1056
|
+
requireMigrationSnapshotForComponent,
|
|
1057
|
+
rollbackMigrationSnapshot,
|
|
1058
|
+
sha256,
|
|
1059
|
+
validateMigrationSnapshot,
|
|
1060
|
+
};
|