@ran-sh/dsh-crew 1.9.0 → 1.10.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/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +3 -2
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -340
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +463 -455
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- package/zcode/commands/dsh-status.md +5 -5
|
@@ -1,272 +1,272 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
-
|
|
5
|
-
const WORKSPACE = 'harness/storages/workspace.json';
|
|
6
|
-
const LIMIT = 512 * 1024 * 1024;
|
|
7
|
-
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
-
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
|
|
9
|
-
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
10
|
-
const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
11
|
-
const validId = id => typeof id === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(id);
|
|
12
|
-
const fail = code => { throw new Error(`HISTORY_${code}`); };
|
|
13
|
-
|
|
14
|
-
function pathInside(root, relativePath) {
|
|
15
|
-
if (typeof relativePath !== 'string' || isAbsolute(relativePath) || relativePath.includes('\\')
|
|
16
|
-
|| relativePath.includes(':') || relativePath.split('/').some(p => !p || p === '.' || p === '..')) fail('INVALID_PATH');
|
|
17
|
-
let current = resolve(root);
|
|
18
|
-
if (!lstatSync(current).isDirectory() || lstatSync(current).isSymbolicLink()) fail('LINKED_ROOT');
|
|
19
|
-
const canonical = realpathSync(current);
|
|
20
|
-
if ((process.platform === 'win32' ? canonical.toLowerCase() !== current.toLowerCase() : canonical !== current)) fail('LINKED_ROOT');
|
|
21
|
-
for (const part of relativePath.split('/')) {
|
|
22
|
-
current = join(current, part);
|
|
23
|
-
try { if (lstatSync(current).isSymbolicLink()) fail('LINKED_PATH'); }
|
|
24
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
25
|
-
}
|
|
26
|
-
return current;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// One canonical session artifact path. The jsonl backend keeps one immutable
|
|
30
|
-
// file per Session format generation: `session.jsonl` for version 0 and
|
|
31
|
-
// `session.vN.jsonl` (N >= 1) after that, each optionally zstd-compressed.
|
|
32
|
-
// Sharing the shape between the inventory and the archive guard keeps both in
|
|
33
|
-
// step when the backend's naming changes.
|
|
34
|
-
export const SESSION_ARTIFACT_PATTERN = /^harness\/sessions\/[^/]+\/[^/]+\/session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/;
|
|
35
|
-
|
|
36
|
-
export function isSessionArtifactPath(relativePath, sessionId) {
|
|
37
|
-
return typeof relativePath === 'string'
|
|
38
|
-
&& SESSION_ARTIFACT_PATTERN.test(relativePath)
|
|
39
|
-
&& relativePath.split('/')[3] === sessionId;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function artifactPath(root, file) {
|
|
43
|
-
if (!validId(file?.sessionId) || !isSessionArtifactPath(file.relativePath, file.sessionId)) fail('INVALID_ARTIFACT');
|
|
44
|
-
return pathInside(root, file.relativePath);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function readBounded(file) {
|
|
48
|
-
const info = lstatSync(file);
|
|
49
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > LIMIT) fail('INVALID_FILE');
|
|
50
|
-
const bytes = readFileSync(file);
|
|
51
|
-
if (bytes.length > LIMIT || bytes.length !== info.size) fail('FILE_CHANGED');
|
|
52
|
-
return bytes;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function atomic(root, relativePath, bytes, exclusive = false) {
|
|
56
|
-
const file = pathInside(root, relativePath);
|
|
57
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
58
|
-
pathInside(root, relativePath);
|
|
59
|
-
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
60
|
-
let fd;
|
|
61
|
-
try {
|
|
62
|
-
fd = openSync(temporary, 'wx', 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
63
|
-
if (exclusive) {
|
|
64
|
-
try { linkSync(temporary, file); }
|
|
65
|
-
catch (error) { if (error.code === 'EEXIST') fail('RESTORE_CONFLICT'); throw error; }
|
|
66
|
-
} else renameSync(temporary, file);
|
|
67
|
-
} finally {
|
|
68
|
-
if (fd !== undefined) closeSync(fd);
|
|
69
|
-
if (existsSync(temporary)) unlinkSync(temporary);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function decodeStore(bytes) {
|
|
74
|
-
let store;
|
|
75
|
-
try { store = JSON.parse(bytes.toString('utf8')); } catch { fail('INVALID_STORAGE'); }
|
|
76
|
-
if (store?.unit?.name !== 'workspace' || store.unit.version !== 2 || store.global?.initialized !== true
|
|
77
|
-
|| store.global.pendingMutation || !Array.isArray(store.global.workspaceIds) || !Array.isArray(store.global.archivedSessionIds)
|
|
78
|
-
|| !store.tables?.workspaces || Array.isArray(store.tables.workspaces)) fail('UNSUPPORTED_STORAGE');
|
|
79
|
-
if (store.global.archivedSessionIds.some(id => !validId(id))
|
|
80
|
-
|| new Set(store.global.archivedSessionIds).size !== store.global.archivedSessionIds.length) fail('INVALID_STORAGE');
|
|
81
|
-
const table = store.tables.workspaces;
|
|
82
|
-
if (Object.keys(table).length > 10000 || store.global.workspaceIds.length !== Object.keys(table).length
|
|
83
|
-
|| new Set(store.global.workspaceIds).size !== store.global.workspaceIds.length
|
|
84
|
-
|| store.global.workspaceIds.some(id => !validId(id) || !Object.hasOwn(table, id))) fail('INVALID_STORAGE');
|
|
85
|
-
for (const record of Object.values(table)) if (!record || !Array.isArray(record.sessionIds)
|
|
86
|
-
|| record.sessionIds.some(id => !validId(id)) || typeof record.path !== 'string') fail('INVALID_STORAGE');
|
|
87
|
-
return store;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function batchPath(id) {
|
|
91
|
-
if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/.test(id)) fail('INVALID_ARCHIVE_ID');
|
|
92
|
-
return `history/transactions/${id}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function saveManifest(root, manifest) {
|
|
96
|
-
atomic(root, `${batchPath(manifest.id)}/manifest.json`, JSON.stringify(manifest));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function loadManifest(root, id) {
|
|
100
|
-
let manifest;
|
|
101
|
-
try { manifest = JSON.parse(readBounded(pathInside(root, `${batchPath(id)}/manifest.json`))); }
|
|
102
|
-
catch { fail('INVALID_MANIFEST'); }
|
|
103
|
-
if (manifest.id !== id || manifest.schemaVersion !== 1 || !['archive', 'delete'].includes(manifest.operation)
|
|
104
|
-
|| !Array.isArray(manifest.files) || manifest.files.length > 10000
|
|
105
|
-
|| !['PREPARING', 'PREPARED', 'APPLYING', 'APPLIED', 'RESTORING', 'RESTORED', 'ROLLED_BACK', 'DELETING', 'DELETED'].includes(manifest.state)
|
|
106
|
-
|| manifest.files.some(file => !file || !/^[a-f0-9]{64}$/.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0)
|
|
107
|
-
|| new Set(manifest.files.map(file => file.sessionId)).size !== manifest.files.length
|
|
108
|
-
|| new Set(manifest.files.map(file => file.relativePath)).size !== manifest.files.length
|
|
109
|
-
|| manifest.files.reduce((total, file) => total + file.size, 0) > LIMIT) fail('INVALID_MANIFEST');
|
|
110
|
-
decodeStore(Buffer.from(JSON.stringify(manifest.before)));
|
|
111
|
-
decodeStore(Buffer.from(JSON.stringify(manifest.after)));
|
|
112
|
-
for (const file of manifest.files) artifactPath(root, file);
|
|
113
|
-
if (manifest.state === 'RESTORING') {
|
|
114
|
-
decodeStore(Buffer.from(JSON.stringify(manifest.restoreBefore ?? null)));
|
|
115
|
-
decodeStore(Buffer.from(JSON.stringify(manifest.restoreAfter ?? null)));
|
|
116
|
-
}
|
|
117
|
-
return manifest;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function requireStopped(assertStopped) {
|
|
121
|
-
if (typeof assertStopped !== 'function' || await assertStopped() !== true) fail('BACKEND_NOT_STOPPED');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Internal offline primitive: caller must own the maintenance lease + update lock. */
|
|
125
|
-
export async function archiveHistory({ crewRoot, request, assertStopped, archiveId = randomUUID() }) {
|
|
126
|
-
await requireStopped(assertStopped);
|
|
127
|
-
if (!request || !['archive', 'delete'].includes(request.operation) || !Array.isArray(request.artifacts)
|
|
128
|
-
|| !Array.isArray(request.sessionIds) || !Array.isArray(request.workspaceIds)
|
|
129
|
-
|| request.artifacts.length > 10000 || request.workspaceIds.length > 10000) fail('INVALID_REQUEST');
|
|
130
|
-
const selectedSessions = new Set(request.sessionIds);
|
|
131
|
-
const selectedWorkspaces = new Set(request.workspaceIds);
|
|
132
|
-
if ([...selectedSessions, ...selectedWorkspaces].some(id => !validId(id))
|
|
133
|
-
|| selectedSessions.size !== request.sessionIds.length || selectedWorkspaces.size !== request.workspaceIds.length
|
|
134
|
-
|| request.artifacts.length !== selectedSessions.size
|
|
135
|
-
|| new Set(request.artifacts.map(f => f.sessionId)).size !== selectedSessions.size
|
|
136
|
-
|| request.artifacts.some(f => !selectedSessions.has(f.sessionId))) fail('INVALID_SELECTION');
|
|
137
|
-
const beforeBytes = readBounded(pathInside(crewRoot, WORKSPACE));
|
|
138
|
-
if (hash(beforeBytes) !== request.workspaceHash) fail('PREVIEW_CHANGED');
|
|
139
|
-
const before = decodeStore(beforeBytes);
|
|
140
|
-
const after = structuredClone(before);
|
|
141
|
-
for (const id of selectedWorkspaces) {
|
|
142
|
-
const record = before.tables.workspaces[id];
|
|
143
|
-
if (!record || record.sessionIds.some(sid => !selectedSessions.has(sid))) fail('PREVIEW_CHANGED');
|
|
144
|
-
delete after.tables.workspaces[id];
|
|
145
|
-
}
|
|
146
|
-
after.global.workspaceIds = after.global.workspaceIds.filter(id => !selectedWorkspaces.has(id));
|
|
147
|
-
after.global.archivedSessionIds = after.global.archivedSessionIds.filter(id => !selectedSessions.has(id));
|
|
148
|
-
for (const record of Object.values(after.tables.workspaces)) record.sessionIds = record.sessionIds.filter(id => !selectedSessions.has(id));
|
|
149
|
-
let size = 0;
|
|
150
|
-
const files = request.artifacts.map(file => {
|
|
151
|
-
const bytes = readBounded(artifactPath(crewRoot, file));
|
|
152
|
-
size += bytes.length;
|
|
153
|
-
if (size > LIMIT) fail('ARCHIVE_TOO_LARGE');
|
|
154
|
-
if (hash(bytes) !== file.sha256) fail('PREVIEW_CHANGED');
|
|
155
|
-
return { sessionId: file.sessionId, relativePath: file.relativePath, sha256: file.sha256, size: bytes.length };
|
|
156
|
-
});
|
|
157
|
-
if (existsSync(pathInside(crewRoot, `${batchPath(archiveId)}/manifest.json`))) fail('ARCHIVE_EXISTS');
|
|
158
|
-
const manifest = { schemaVersion: 1, id: archiveId, operation: request.operation,
|
|
159
|
-
createdAt: new Date().toISOString(), state: 'PREPARING', files, before, after };
|
|
160
|
-
saveManifest(crewRoot, manifest);
|
|
161
|
-
try {
|
|
162
|
-
for (const [index, file] of files.entries()) {
|
|
163
|
-
atomic(crewRoot, `${batchPath(manifest.id)}/files/${index}.bin`, readBounded(artifactPath(crewRoot, file)));
|
|
164
|
-
if (hash(readBounded(pathInside(crewRoot, `${batchPath(manifest.id)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
|
|
165
|
-
}
|
|
166
|
-
manifest.state = 'PREPARED'; saveManifest(crewRoot, manifest);
|
|
167
|
-
await requireStopped(assertStopped);
|
|
168
|
-
if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== request.workspaceHash) fail('PREVIEW_CHANGED');
|
|
169
|
-
for (const file of files) if (hash(readBounded(artifactPath(crewRoot, file))) !== file.sha256) fail('PREVIEW_CHANGED');
|
|
170
|
-
manifest.state = 'APPLYING'; saveManifest(crewRoot, manifest);
|
|
171
|
-
for (const file of files) unlinkSync(artifactPath(crewRoot, file));
|
|
172
|
-
atomic(crewRoot, WORKSPACE, JSON.stringify(after));
|
|
173
|
-
manifest.state = 'APPLIED'; saveManifest(crewRoot, manifest);
|
|
174
|
-
return { id: manifest.id, state: manifest.state, operation: manifest.operation, counts: { sessions: files.length, workspaces: selectedWorkspaces.size } };
|
|
175
|
-
} catch (error) {
|
|
176
|
-
// The caller retains the stopped lease. Never restart after this exception
|
|
177
|
-
// without explicitly recovering the referenced durable transaction.
|
|
178
|
-
error.archiveId = manifest.id;
|
|
179
|
-
throw error;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Shared internal IO for the controller; no paths are accepted from HTTP clients.
|
|
184
|
-
export { pathInside as historyPath, readBounded as readHistoryBytes, atomic as writeHistoryBytes,
|
|
185
|
-
decodeStore as decodeWorkspaceStore, loadManifest as readHistoryManifest, hash as historyHash };
|
|
186
|
-
|
|
187
|
-
export async function restoreHistory({ crewRoot, archiveId, assertStopped }) {
|
|
188
|
-
await requireStopped(assertStopped);
|
|
189
|
-
const manifest = loadManifest(crewRoot, archiveId);
|
|
190
|
-
if (manifest.operation !== 'archive' || manifest.state !== 'APPLIED') fail('ARCHIVE_NOT_RESTORABLE');
|
|
191
|
-
const currentBytes = readBounded(pathInside(crewRoot, WORKSPACE));
|
|
192
|
-
const current = decodeStore(currentBytes);
|
|
193
|
-
const next = structuredClone(current);
|
|
194
|
-
const restoredIds = [];
|
|
195
|
-
for (const [id, old] of Object.entries(manifest.before.tables.workspaces)) {
|
|
196
|
-
const after = manifest.after.tables.workspaces[id];
|
|
197
|
-
if (equal(old, after)) continue;
|
|
198
|
-
if (!equal(current.tables.workspaces[id], after)) fail('RESTORE_CONFLICT');
|
|
199
|
-
if (Object.entries(current.tables.workspaces).some(([other, record]) => other !== id && record.path === old.path)) fail('RESTORE_CONFLICT');
|
|
200
|
-
next.tables.workspaces[id] = old;
|
|
201
|
-
if (after === undefined) restoredIds.push(id);
|
|
202
|
-
}
|
|
203
|
-
next.global.workspaceIds = [...manifest.before.global.workspaceIds.filter(id => restoredIds.includes(id)), ...current.global.workspaceIds];
|
|
204
|
-
const selected = new Set(manifest.files.map(file => file.sessionId));
|
|
205
|
-
next.global.archivedSessionIds = [...new Set([...current.global.archivedSessionIds, ...manifest.before.global.archivedSessionIds.filter(id => selected.has(id))])];
|
|
206
|
-
for (const [index, file] of manifest.files.entries()) {
|
|
207
|
-
if (existsSync(artifactPath(crewRoot, file))) fail('RESTORE_CONFLICT');
|
|
208
|
-
if (hash(readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
|
|
209
|
-
}
|
|
210
|
-
manifest.restoreBefore = current; manifest.restoreAfter = next; manifest.state = 'RESTORING'; saveManifest(crewRoot, manifest);
|
|
211
|
-
await requireStopped(assertStopped);
|
|
212
|
-
if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== hash(currentBytes)) fail('RESTORE_CONFLICT');
|
|
213
|
-
for (const [index, file] of manifest.files.entries()) {
|
|
214
|
-
artifactPath(crewRoot, file);
|
|
215
|
-
// A durable temporary file is published exclusively. A crash cannot leave
|
|
216
|
-
// a partially written target that recovery mistakes for someone else's data.
|
|
217
|
-
atomic(crewRoot, file.relativePath, readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`)), true);
|
|
218
|
-
}
|
|
219
|
-
atomic(crewRoot, WORKSPACE, JSON.stringify(next));
|
|
220
|
-
manifest.state = 'RESTORED'; saveManifest(crewRoot, manifest);
|
|
221
|
-
return { id: archiveId, state: manifest.state };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Roll back an interrupted offline operation. The caller retains its stop lease. */
|
|
225
|
-
export async function recoverHistory({ crewRoot, archiveId, assertStopped }) {
|
|
226
|
-
await requireStopped(assertStopped);
|
|
227
|
-
const manifest = loadManifest(crewRoot, archiveId);
|
|
228
|
-
if (manifest.state === 'ROLLED_BACK') return { id: archiveId, state: manifest.state };
|
|
229
|
-
if (!['PREPARING', 'PREPARED', 'APPLYING', 'APPLIED', 'RESTORING'].includes(manifest.state)) fail('RECOVERY_UNAVAILABLE');
|
|
230
|
-
const restoring = manifest.state === 'RESTORING';
|
|
231
|
-
const desired = restoring ? manifest.restoreBefore : manifest.before;
|
|
232
|
-
const possible = restoring ? manifest.restoreAfter : manifest.after;
|
|
233
|
-
decodeStore(Buffer.from(JSON.stringify(desired)));
|
|
234
|
-
decodeStore(Buffer.from(JSON.stringify(possible)));
|
|
235
|
-
const currentBytes = readBounded(pathInside(crewRoot, WORKSPACE));
|
|
236
|
-
const current = decodeStore(currentBytes);
|
|
237
|
-
if (!equal(current, desired) && !equal(current, possible)) fail('RECOVERY_CONFLICT');
|
|
238
|
-
const touched = !['PREPARING', 'PREPARED'].includes(manifest.state);
|
|
239
|
-
for (const [index, file] of manifest.files.entries()) {
|
|
240
|
-
const target = artifactPath(crewRoot, file);
|
|
241
|
-
if (existsSync(target) && hash(readBounded(target)) !== file.sha256) fail('RECOVERY_CONFLICT');
|
|
242
|
-
if (!touched && !existsSync(target)) fail('RECOVERY_CONFLICT');
|
|
243
|
-
if (touched && hash(readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
|
|
244
|
-
}
|
|
245
|
-
await requireStopped(assertStopped);
|
|
246
|
-
if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== hash(currentBytes)) fail('RECOVERY_CONFLICT');
|
|
247
|
-
for (const [index, file] of manifest.files.entries()) {
|
|
248
|
-
const target = artifactPath(crewRoot, file);
|
|
249
|
-
if (restoring) { if (existsSync(target)) unlinkSync(target); }
|
|
250
|
-
else if (!existsSync(target)) {
|
|
251
|
-
atomic(crewRoot, file.relativePath, readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`)), true);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
atomic(crewRoot, WORKSPACE, JSON.stringify(desired));
|
|
255
|
-
manifest.state = restoring ? 'APPLIED' : 'ROLLED_BACK'; saveManifest(crewRoot, manifest);
|
|
256
|
-
return { id: archiveId, state: manifest.state };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** The private rollback artifacts are purged only after the new runtime is verified. */
|
|
260
|
-
export async function finalizeHistoryDeletion({ crewRoot, archiveId, assertRestarted }) {
|
|
261
|
-
if (typeof assertRestarted !== 'function' || await assertRestarted() !== true) fail('RESTART_NOT_VERIFIED');
|
|
262
|
-
const manifest = loadManifest(crewRoot, archiveId);
|
|
263
|
-
if (manifest.operation !== 'delete' || !['APPLIED', 'DELETING', 'DELETED'].includes(manifest.state)) fail('DELETE_UNAVAILABLE');
|
|
264
|
-
if (manifest.state === 'DELETED') return { id: archiveId, state: manifest.state };
|
|
265
|
-
manifest.state = 'DELETING'; saveManifest(crewRoot, manifest);
|
|
266
|
-
for (const [index] of manifest.files.entries()) {
|
|
267
|
-
const backup = pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`);
|
|
268
|
-
if (existsSync(backup)) unlinkSync(backup);
|
|
269
|
-
}
|
|
270
|
-
manifest.state = 'DELETED'; saveManifest(crewRoot, manifest);
|
|
271
|
-
return { id: archiveId, state: manifest.state };
|
|
272
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const WORKSPACE = 'harness/storages/workspace.json';
|
|
6
|
+
const LIMIT = 512 * 1024 * 1024;
|
|
7
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
|
|
9
|
+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
10
|
+
const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
11
|
+
const validId = id => typeof id === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(id);
|
|
12
|
+
const fail = code => { throw new Error(`HISTORY_${code}`); };
|
|
13
|
+
|
|
14
|
+
function pathInside(root, relativePath) {
|
|
15
|
+
if (typeof relativePath !== 'string' || isAbsolute(relativePath) || relativePath.includes('\\')
|
|
16
|
+
|| relativePath.includes(':') || relativePath.split('/').some(p => !p || p === '.' || p === '..')) fail('INVALID_PATH');
|
|
17
|
+
let current = resolve(root);
|
|
18
|
+
if (!lstatSync(current).isDirectory() || lstatSync(current).isSymbolicLink()) fail('LINKED_ROOT');
|
|
19
|
+
const canonical = realpathSync(current);
|
|
20
|
+
if ((process.platform === 'win32' ? canonical.toLowerCase() !== current.toLowerCase() : canonical !== current)) fail('LINKED_ROOT');
|
|
21
|
+
for (const part of relativePath.split('/')) {
|
|
22
|
+
current = join(current, part);
|
|
23
|
+
try { if (lstatSync(current).isSymbolicLink()) fail('LINKED_PATH'); }
|
|
24
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
25
|
+
}
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One canonical session artifact path. The jsonl backend keeps one immutable
|
|
30
|
+
// file per Session format generation: `session.jsonl` for version 0 and
|
|
31
|
+
// `session.vN.jsonl` (N >= 1) after that, each optionally zstd-compressed.
|
|
32
|
+
// Sharing the shape between the inventory and the archive guard keeps both in
|
|
33
|
+
// step when the backend's naming changes.
|
|
34
|
+
export const SESSION_ARTIFACT_PATTERN = /^harness\/sessions\/[^/]+\/[^/]+\/session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/;
|
|
35
|
+
|
|
36
|
+
export function isSessionArtifactPath(relativePath, sessionId) {
|
|
37
|
+
return typeof relativePath === 'string'
|
|
38
|
+
&& SESSION_ARTIFACT_PATTERN.test(relativePath)
|
|
39
|
+
&& relativePath.split('/')[3] === sessionId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function artifactPath(root, file) {
|
|
43
|
+
if (!validId(file?.sessionId) || !isSessionArtifactPath(file.relativePath, file.sessionId)) fail('INVALID_ARTIFACT');
|
|
44
|
+
return pathInside(root, file.relativePath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readBounded(file) {
|
|
48
|
+
const info = lstatSync(file);
|
|
49
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > LIMIT) fail('INVALID_FILE');
|
|
50
|
+
const bytes = readFileSync(file);
|
|
51
|
+
if (bytes.length > LIMIT || bytes.length !== info.size) fail('FILE_CHANGED');
|
|
52
|
+
return bytes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function atomic(root, relativePath, bytes, exclusive = false) {
|
|
56
|
+
const file = pathInside(root, relativePath);
|
|
57
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
58
|
+
pathInside(root, relativePath);
|
|
59
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
60
|
+
let fd;
|
|
61
|
+
try {
|
|
62
|
+
fd = openSync(temporary, 'wx', 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
63
|
+
if (exclusive) {
|
|
64
|
+
try { linkSync(temporary, file); }
|
|
65
|
+
catch (error) { if (error.code === 'EEXIST') fail('RESTORE_CONFLICT'); throw error; }
|
|
66
|
+
} else renameSync(temporary, file);
|
|
67
|
+
} finally {
|
|
68
|
+
if (fd !== undefined) closeSync(fd);
|
|
69
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function decodeStore(bytes) {
|
|
74
|
+
let store;
|
|
75
|
+
try { store = JSON.parse(bytes.toString('utf8')); } catch { fail('INVALID_STORAGE'); }
|
|
76
|
+
if (store?.unit?.name !== 'workspace' || store.unit.version !== 2 || store.global?.initialized !== true
|
|
77
|
+
|| store.global.pendingMutation || !Array.isArray(store.global.workspaceIds) || !Array.isArray(store.global.archivedSessionIds)
|
|
78
|
+
|| !store.tables?.workspaces || Array.isArray(store.tables.workspaces)) fail('UNSUPPORTED_STORAGE');
|
|
79
|
+
if (store.global.archivedSessionIds.some(id => !validId(id))
|
|
80
|
+
|| new Set(store.global.archivedSessionIds).size !== store.global.archivedSessionIds.length) fail('INVALID_STORAGE');
|
|
81
|
+
const table = store.tables.workspaces;
|
|
82
|
+
if (Object.keys(table).length > 10000 || store.global.workspaceIds.length !== Object.keys(table).length
|
|
83
|
+
|| new Set(store.global.workspaceIds).size !== store.global.workspaceIds.length
|
|
84
|
+
|| store.global.workspaceIds.some(id => !validId(id) || !Object.hasOwn(table, id))) fail('INVALID_STORAGE');
|
|
85
|
+
for (const record of Object.values(table)) if (!record || !Array.isArray(record.sessionIds)
|
|
86
|
+
|| record.sessionIds.some(id => !validId(id)) || typeof record.path !== 'string') fail('INVALID_STORAGE');
|
|
87
|
+
return store;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function batchPath(id) {
|
|
91
|
+
if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/.test(id)) fail('INVALID_ARCHIVE_ID');
|
|
92
|
+
return `history/transactions/${id}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveManifest(root, manifest) {
|
|
96
|
+
atomic(root, `${batchPath(manifest.id)}/manifest.json`, JSON.stringify(manifest));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function loadManifest(root, id) {
|
|
100
|
+
let manifest;
|
|
101
|
+
try { manifest = JSON.parse(readBounded(pathInside(root, `${batchPath(id)}/manifest.json`))); }
|
|
102
|
+
catch { fail('INVALID_MANIFEST'); }
|
|
103
|
+
if (manifest.id !== id || manifest.schemaVersion !== 1 || !['archive', 'delete'].includes(manifest.operation)
|
|
104
|
+
|| !Array.isArray(manifest.files) || manifest.files.length > 10000
|
|
105
|
+
|| !['PREPARING', 'PREPARED', 'APPLYING', 'APPLIED', 'RESTORING', 'RESTORED', 'ROLLED_BACK', 'DELETING', 'DELETED'].includes(manifest.state)
|
|
106
|
+
|| manifest.files.some(file => !file || !/^[a-f0-9]{64}$/.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0)
|
|
107
|
+
|| new Set(manifest.files.map(file => file.sessionId)).size !== manifest.files.length
|
|
108
|
+
|| new Set(manifest.files.map(file => file.relativePath)).size !== manifest.files.length
|
|
109
|
+
|| manifest.files.reduce((total, file) => total + file.size, 0) > LIMIT) fail('INVALID_MANIFEST');
|
|
110
|
+
decodeStore(Buffer.from(JSON.stringify(manifest.before)));
|
|
111
|
+
decodeStore(Buffer.from(JSON.stringify(manifest.after)));
|
|
112
|
+
for (const file of manifest.files) artifactPath(root, file);
|
|
113
|
+
if (manifest.state === 'RESTORING') {
|
|
114
|
+
decodeStore(Buffer.from(JSON.stringify(manifest.restoreBefore ?? null)));
|
|
115
|
+
decodeStore(Buffer.from(JSON.stringify(manifest.restoreAfter ?? null)));
|
|
116
|
+
}
|
|
117
|
+
return manifest;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function requireStopped(assertStopped) {
|
|
121
|
+
if (typeof assertStopped !== 'function' || await assertStopped() !== true) fail('BACKEND_NOT_STOPPED');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Internal offline primitive: caller must own the maintenance lease + update lock. */
|
|
125
|
+
export async function archiveHistory({ crewRoot, request, assertStopped, archiveId = randomUUID() }) {
|
|
126
|
+
await requireStopped(assertStopped);
|
|
127
|
+
if (!request || !['archive', 'delete'].includes(request.operation) || !Array.isArray(request.artifacts)
|
|
128
|
+
|| !Array.isArray(request.sessionIds) || !Array.isArray(request.workspaceIds)
|
|
129
|
+
|| request.artifacts.length > 10000 || request.workspaceIds.length > 10000) fail('INVALID_REQUEST');
|
|
130
|
+
const selectedSessions = new Set(request.sessionIds);
|
|
131
|
+
const selectedWorkspaces = new Set(request.workspaceIds);
|
|
132
|
+
if ([...selectedSessions, ...selectedWorkspaces].some(id => !validId(id))
|
|
133
|
+
|| selectedSessions.size !== request.sessionIds.length || selectedWorkspaces.size !== request.workspaceIds.length
|
|
134
|
+
|| request.artifacts.length !== selectedSessions.size
|
|
135
|
+
|| new Set(request.artifacts.map(f => f.sessionId)).size !== selectedSessions.size
|
|
136
|
+
|| request.artifacts.some(f => !selectedSessions.has(f.sessionId))) fail('INVALID_SELECTION');
|
|
137
|
+
const beforeBytes = readBounded(pathInside(crewRoot, WORKSPACE));
|
|
138
|
+
if (hash(beforeBytes) !== request.workspaceHash) fail('PREVIEW_CHANGED');
|
|
139
|
+
const before = decodeStore(beforeBytes);
|
|
140
|
+
const after = structuredClone(before);
|
|
141
|
+
for (const id of selectedWorkspaces) {
|
|
142
|
+
const record = before.tables.workspaces[id];
|
|
143
|
+
if (!record || record.sessionIds.some(sid => !selectedSessions.has(sid))) fail('PREVIEW_CHANGED');
|
|
144
|
+
delete after.tables.workspaces[id];
|
|
145
|
+
}
|
|
146
|
+
after.global.workspaceIds = after.global.workspaceIds.filter(id => !selectedWorkspaces.has(id));
|
|
147
|
+
after.global.archivedSessionIds = after.global.archivedSessionIds.filter(id => !selectedSessions.has(id));
|
|
148
|
+
for (const record of Object.values(after.tables.workspaces)) record.sessionIds = record.sessionIds.filter(id => !selectedSessions.has(id));
|
|
149
|
+
let size = 0;
|
|
150
|
+
const files = request.artifacts.map(file => {
|
|
151
|
+
const bytes = readBounded(artifactPath(crewRoot, file));
|
|
152
|
+
size += bytes.length;
|
|
153
|
+
if (size > LIMIT) fail('ARCHIVE_TOO_LARGE');
|
|
154
|
+
if (hash(bytes) !== file.sha256) fail('PREVIEW_CHANGED');
|
|
155
|
+
return { sessionId: file.sessionId, relativePath: file.relativePath, sha256: file.sha256, size: bytes.length };
|
|
156
|
+
});
|
|
157
|
+
if (existsSync(pathInside(crewRoot, `${batchPath(archiveId)}/manifest.json`))) fail('ARCHIVE_EXISTS');
|
|
158
|
+
const manifest = { schemaVersion: 1, id: archiveId, operation: request.operation,
|
|
159
|
+
createdAt: new Date().toISOString(), state: 'PREPARING', files, before, after };
|
|
160
|
+
saveManifest(crewRoot, manifest);
|
|
161
|
+
try {
|
|
162
|
+
for (const [index, file] of files.entries()) {
|
|
163
|
+
atomic(crewRoot, `${batchPath(manifest.id)}/files/${index}.bin`, readBounded(artifactPath(crewRoot, file)));
|
|
164
|
+
if (hash(readBounded(pathInside(crewRoot, `${batchPath(manifest.id)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
|
|
165
|
+
}
|
|
166
|
+
manifest.state = 'PREPARED'; saveManifest(crewRoot, manifest);
|
|
167
|
+
await requireStopped(assertStopped);
|
|
168
|
+
if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== request.workspaceHash) fail('PREVIEW_CHANGED');
|
|
169
|
+
for (const file of files) if (hash(readBounded(artifactPath(crewRoot, file))) !== file.sha256) fail('PREVIEW_CHANGED');
|
|
170
|
+
manifest.state = 'APPLYING'; saveManifest(crewRoot, manifest);
|
|
171
|
+
for (const file of files) unlinkSync(artifactPath(crewRoot, file));
|
|
172
|
+
atomic(crewRoot, WORKSPACE, JSON.stringify(after));
|
|
173
|
+
manifest.state = 'APPLIED'; saveManifest(crewRoot, manifest);
|
|
174
|
+
return { id: manifest.id, state: manifest.state, operation: manifest.operation, counts: { sessions: files.length, workspaces: selectedWorkspaces.size } };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// The caller retains the stopped lease. Never restart after this exception
|
|
177
|
+
// without explicitly recovering the referenced durable transaction.
|
|
178
|
+
error.archiveId = manifest.id;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Shared internal IO for the controller; no paths are accepted from HTTP clients.
|
|
184
|
+
export { pathInside as historyPath, readBounded as readHistoryBytes, atomic as writeHistoryBytes,
|
|
185
|
+
decodeStore as decodeWorkspaceStore, loadManifest as readHistoryManifest, hash as historyHash };
|
|
186
|
+
|
|
187
|
+
export async function restoreHistory({ crewRoot, archiveId, assertStopped }) {
|
|
188
|
+
await requireStopped(assertStopped);
|
|
189
|
+
const manifest = loadManifest(crewRoot, archiveId);
|
|
190
|
+
if (manifest.operation !== 'archive' || manifest.state !== 'APPLIED') fail('ARCHIVE_NOT_RESTORABLE');
|
|
191
|
+
const currentBytes = readBounded(pathInside(crewRoot, WORKSPACE));
|
|
192
|
+
const current = decodeStore(currentBytes);
|
|
193
|
+
const next = structuredClone(current);
|
|
194
|
+
const restoredIds = [];
|
|
195
|
+
for (const [id, old] of Object.entries(manifest.before.tables.workspaces)) {
|
|
196
|
+
const after = manifest.after.tables.workspaces[id];
|
|
197
|
+
if (equal(old, after)) continue;
|
|
198
|
+
if (!equal(current.tables.workspaces[id], after)) fail('RESTORE_CONFLICT');
|
|
199
|
+
if (Object.entries(current.tables.workspaces).some(([other, record]) => other !== id && record.path === old.path)) fail('RESTORE_CONFLICT');
|
|
200
|
+
next.tables.workspaces[id] = old;
|
|
201
|
+
if (after === undefined) restoredIds.push(id);
|
|
202
|
+
}
|
|
203
|
+
next.global.workspaceIds = [...manifest.before.global.workspaceIds.filter(id => restoredIds.includes(id)), ...current.global.workspaceIds];
|
|
204
|
+
const selected = new Set(manifest.files.map(file => file.sessionId));
|
|
205
|
+
next.global.archivedSessionIds = [...new Set([...current.global.archivedSessionIds, ...manifest.before.global.archivedSessionIds.filter(id => selected.has(id))])];
|
|
206
|
+
for (const [index, file] of manifest.files.entries()) {
|
|
207
|
+
if (existsSync(artifactPath(crewRoot, file))) fail('RESTORE_CONFLICT');
|
|
208
|
+
if (hash(readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
|
|
209
|
+
}
|
|
210
|
+
manifest.restoreBefore = current; manifest.restoreAfter = next; manifest.state = 'RESTORING'; saveManifest(crewRoot, manifest);
|
|
211
|
+
await requireStopped(assertStopped);
|
|
212
|
+
if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== hash(currentBytes)) fail('RESTORE_CONFLICT');
|
|
213
|
+
for (const [index, file] of manifest.files.entries()) {
|
|
214
|
+
artifactPath(crewRoot, file);
|
|
215
|
+
// A durable temporary file is published exclusively. A crash cannot leave
|
|
216
|
+
// a partially written target that recovery mistakes for someone else's data.
|
|
217
|
+
atomic(crewRoot, file.relativePath, readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`)), true);
|
|
218
|
+
}
|
|
219
|
+
atomic(crewRoot, WORKSPACE, JSON.stringify(next));
|
|
220
|
+
manifest.state = 'RESTORED'; saveManifest(crewRoot, manifest);
|
|
221
|
+
return { id: archiveId, state: manifest.state };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Roll back an interrupted offline operation. The caller retains its stop lease. */
|
|
225
|
+
export async function recoverHistory({ crewRoot, archiveId, assertStopped }) {
|
|
226
|
+
await requireStopped(assertStopped);
|
|
227
|
+
const manifest = loadManifest(crewRoot, archiveId);
|
|
228
|
+
if (manifest.state === 'ROLLED_BACK') return { id: archiveId, state: manifest.state };
|
|
229
|
+
if (!['PREPARING', 'PREPARED', 'APPLYING', 'APPLIED', 'RESTORING'].includes(manifest.state)) fail('RECOVERY_UNAVAILABLE');
|
|
230
|
+
const restoring = manifest.state === 'RESTORING';
|
|
231
|
+
const desired = restoring ? manifest.restoreBefore : manifest.before;
|
|
232
|
+
const possible = restoring ? manifest.restoreAfter : manifest.after;
|
|
233
|
+
decodeStore(Buffer.from(JSON.stringify(desired)));
|
|
234
|
+
decodeStore(Buffer.from(JSON.stringify(possible)));
|
|
235
|
+
const currentBytes = readBounded(pathInside(crewRoot, WORKSPACE));
|
|
236
|
+
const current = decodeStore(currentBytes);
|
|
237
|
+
if (!equal(current, desired) && !equal(current, possible)) fail('RECOVERY_CONFLICT');
|
|
238
|
+
const touched = !['PREPARING', 'PREPARED'].includes(manifest.state);
|
|
239
|
+
for (const [index, file] of manifest.files.entries()) {
|
|
240
|
+
const target = artifactPath(crewRoot, file);
|
|
241
|
+
if (existsSync(target) && hash(readBounded(target)) !== file.sha256) fail('RECOVERY_CONFLICT');
|
|
242
|
+
if (!touched && !existsSync(target)) fail('RECOVERY_CONFLICT');
|
|
243
|
+
if (touched && hash(readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
|
|
244
|
+
}
|
|
245
|
+
await requireStopped(assertStopped);
|
|
246
|
+
if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== hash(currentBytes)) fail('RECOVERY_CONFLICT');
|
|
247
|
+
for (const [index, file] of manifest.files.entries()) {
|
|
248
|
+
const target = artifactPath(crewRoot, file);
|
|
249
|
+
if (restoring) { if (existsSync(target)) unlinkSync(target); }
|
|
250
|
+
else if (!existsSync(target)) {
|
|
251
|
+
atomic(crewRoot, file.relativePath, readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`)), true);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
atomic(crewRoot, WORKSPACE, JSON.stringify(desired));
|
|
255
|
+
manifest.state = restoring ? 'APPLIED' : 'ROLLED_BACK'; saveManifest(crewRoot, manifest);
|
|
256
|
+
return { id: archiveId, state: manifest.state };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The private rollback artifacts are purged only after the new runtime is verified. */
|
|
260
|
+
export async function finalizeHistoryDeletion({ crewRoot, archiveId, assertRestarted }) {
|
|
261
|
+
if (typeof assertRestarted !== 'function' || await assertRestarted() !== true) fail('RESTART_NOT_VERIFIED');
|
|
262
|
+
const manifest = loadManifest(crewRoot, archiveId);
|
|
263
|
+
if (manifest.operation !== 'delete' || !['APPLIED', 'DELETING', 'DELETED'].includes(manifest.state)) fail('DELETE_UNAVAILABLE');
|
|
264
|
+
if (manifest.state === 'DELETED') return { id: archiveId, state: manifest.state };
|
|
265
|
+
manifest.state = 'DELETING'; saveManifest(crewRoot, manifest);
|
|
266
|
+
for (const [index] of manifest.files.entries()) {
|
|
267
|
+
const backup = pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`);
|
|
268
|
+
if (existsSync(backup)) unlinkSync(backup);
|
|
269
|
+
}
|
|
270
|
+
manifest.state = 'DELETED'; saveManifest(crewRoot, manifest);
|
|
271
|
+
return { id: archiveId, state: manifest.state };
|
|
272
|
+
}
|