dodo-mcp 1.2.0 → 1.3.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/README.md +45 -6
- package/dist/cli/dataRecovery.js +38 -0
- package/dist/cli/dataRecovery.js.map +1 -0
- package/dist/cli/deployment.js +61 -0
- package/dist/cli/deployment.js.map +1 -0
- package/dist/cli/main.js +35 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/config/globalConfig.js +8 -0
- package/dist/config/globalConfig.js.map +1 -1
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/ipc/server.js +2 -0
- package/dist/ipc/server.js.map +1 -1
- package/dist/ipc/timeouts.js +5 -0
- package/dist/ipc/timeouts.js.map +1 -0
- package/dist/platform/privateFs.js +32 -3
- package/dist/platform/privateFs.js.map +1 -1
- package/dist/platform/windowsPrivateAcl.js +90 -0
- package/dist/platform/windowsPrivateAcl.js.map +1 -0
- package/dist/security/outboundAddress.js +20 -0
- package/dist/security/outboundAddress.js.map +1 -0
- package/dist/server/aiAdmin.js +35 -10
- package/dist/server/aiAdmin.js.map +1 -1
- package/dist/server/appServer.js +11 -3
- package/dist/server/appServer.js.map +1 -1
- package/dist/server/bootstrap.js +14 -0
- package/dist/server/bootstrap.js.map +1 -1
- package/dist/server/configUi/index.html +3 -0
- package/dist/server/configUi/ui/dataRecovery.js +90 -0
- package/dist/server/configUi/ui/deployment.js +111 -0
- package/dist/server/configUi/ui/recovery.js +167 -0
- package/dist/server/configUi/workbench.css +28 -1
- package/dist/server/configUi/workbench.js +136 -25
- package/dist/server/installationRuntime.js +3 -1
- package/dist/server/installationRuntime.js.map +1 -1
- package/dist/server/instructions.js +6 -2
- package/dist/server/instructions.js.map +1 -1
- package/dist/server/ipcDispatch.js +219 -0
- package/dist/server/ipcDispatch.js.map +1 -1
- package/dist/server/localConfig.js +1 -1
- package/dist/server/localConfig.js.map +1 -1
- package/dist/server/stdioServer.js +11 -3
- package/dist/server/stdioServer.js.map +1 -1
- package/dist/services/ai/network.js +1 -18
- package/dist/services/ai/network.js.map +1 -1
- package/dist/services/ai/subagents.js +5 -2
- package/dist/services/ai/subagents.js.map +1 -1
- package/dist/services/assistance/verification.js +43 -10
- package/dist/services/assistance/verification.js.map +1 -1
- package/dist/services/changes/applier.js +472 -297
- package/dist/services/changes/applier.js.map +1 -1
- package/dist/services/git/gitService.js +150 -34
- package/dist/services/git/gitService.js.map +1 -1
- package/dist/services/jobs/jobManager.js +59 -2
- package/dist/services/jobs/jobManager.js.map +1 -1
- package/dist/services/jobs/spool.js +9 -3
- package/dist/services/jobs/spool.js.map +1 -1
- package/dist/services/multimodal/mediaService.js +6 -6
- package/dist/services/multimodal/mediaService.js.map +1 -1
- package/dist/services/recovery/configKeys.js +115 -0
- package/dist/services/recovery/configKeys.js.map +1 -0
- package/dist/services/recovery/configVault.js +361 -0
- package/dist/services/recovery/configVault.js.map +1 -0
- package/dist/services/recovery/contracts.js +24 -0
- package/dist/services/recovery/contracts.js.map +1 -0
- package/dist/services/recovery/databaseAwareness.js +179 -0
- package/dist/services/recovery/databaseAwareness.js.map +1 -0
- package/dist/services/recovery/deploymentArchive.js +129 -0
- package/dist/services/recovery/deploymentArchive.js.map +1 -0
- package/dist/services/recovery/deploymentContracts.js +70 -0
- package/dist/services/recovery/deploymentContracts.js.map +1 -0
- package/dist/services/recovery/deploymentHealth.js +143 -0
- package/dist/services/recovery/deploymentHealth.js.map +1 -0
- package/dist/services/recovery/deploymentMaintenance.js +197 -0
- package/dist/services/recovery/deploymentMaintenance.js.map +1 -0
- package/dist/services/recovery/deploymentService.js +429 -0
- package/dist/services/recovery/deploymentService.js.map +1 -0
- package/dist/services/recovery/deploymentSource.js +36 -0
- package/dist/services/recovery/deploymentSource.js.map +1 -0
- package/dist/services/recovery/dockerDeployment.js +172 -0
- package/dist/services/recovery/dockerDeployment.js.map +1 -0
- package/dist/services/recovery/drift.js +178 -0
- package/dist/services/recovery/drift.js.map +1 -0
- package/dist/services/recovery/evidence.js +127 -0
- package/dist/services/recovery/evidence.js.map +1 -0
- package/dist/services/recovery/gitCopies.js +219 -0
- package/dist/services/recovery/gitCopies.js.map +1 -0
- package/dist/services/recovery/history.js +423 -0
- package/dist/services/recovery/history.js.map +1 -0
- package/dist/services/recovery/recoveryService.js +521 -0
- package/dist/services/recovery/recoveryService.js.map +1 -0
- package/dist/services/recovery/storage.js +344 -0
- package/dist/services/recovery/storage.js.map +1 -0
- package/dist/services/schedules/scheduleService.js +9 -10
- package/dist/services/schedules/scheduleService.js.map +1 -1
- package/dist/store/db.js +71 -0
- package/dist/store/db.js.map +1 -1
- package/dist/tools/catalog.js +4 -0
- package/dist/tools/catalog.js.map +1 -1
- package/dist/tools/changeTools.js +1 -1
- package/dist/tools/changeTools.js.map +1 -1
- package/dist/tools/context.js +23 -3
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/deploymentTools.js +58 -0
- package/dist/tools/deploymentTools.js.map +1 -0
- package/dist/tools/directTools.js +9 -5
- package/dist/tools/directTools.js.map +1 -1
- package/dist/tools/gitTools.js +7 -2
- package/dist/tools/gitTools.js.map +1 -1
- package/dist/tools/jobTools.js +4 -4
- package/dist/tools/jobTools.js.map +1 -1
- package/dist/tools/projectTools.js +1 -0
- package/dist/tools/projectTools.js.map +1 -1
- package/dist/tools/recoveryTools.js +79 -0
- package/dist/tools/recoveryTools.js.map +1 -0
- package/dist/tools/runtimeTools.js +1 -1
- package/dist/tools/runtimeTools.js.map +1 -1
- package/dist/tools/surface.js +101 -41
- package/dist/tools/surface.js.map +1 -1
- package/dist/tunnel/credentials.js +1 -1
- package/dist/tunnel/credentials.js.map +1 -1
- package/docs/AI_PROVIDERS.md +6 -3
- package/docs/ARCHITECTURE.md +88 -4
- package/docs/CI.md +118 -0
- package/docs/COMPATIBILITY.md +7 -6
- package/docs/EVALUATION.md +17 -12
- package/docs/MANUAL_ACCEPTANCE.md +59 -2
- package/docs/RECOVERY.md +448 -0
- package/docs/RELEASE_1.2.1.md +47 -0
- package/docs/RELEASE_1.3.0.md +64 -0
- package/docs/RELEASE_NOTES.md +41 -0
- package/docs/SECURITY.md +122 -0
- package/docs/TEST_REPORT.md +327 -5
- package/docs/WEB_CLIENTS.md +6 -1
- package/docs/adr/051-source-recovery-foundation.md +45 -0
- package/docs/adr/052-reviewed-source-restore.md +50 -0
- package/docs/adr/053-content-drift-and-private-git-recovery.md +29 -0
- package/docs/adr/054-verified-source-checkpoints.md +58 -0
- package/docs/adr/055-reviewed-docker-deployments.md +42 -0
- package/docs/adr/056-owner-data-recovery.md +47 -0
- package/docs/adr/README.md +7 -0
- package/native/private-state-windows.cs +105 -0
- package/package.json +7 -1
- package/schemas/global-config.schema.json +44 -0
- package/schemas/tools.compact.json +175 -15
- package/schemas/tools.hybrid.json +350 -16
- package/schemas/tools.json +4883 -816
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { rootIdentity } from '../recovery/storage.js';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { DodoError } from '../../errors.js';
|
|
4
|
-
import { sha256Bytes, newId } from '../../util/hash.js';
|
|
5
|
-
import {
|
|
5
|
+
import { sha256Bytes, newId, digestOf } from '../../util/hash.js';
|
|
6
|
+
import { retryWindowsFs } from '../../platform/fsRetry.js';
|
|
7
|
+
import { assertPrivatePath, ensurePrivateDirectory } from '../../platform/privateFs.js';
|
|
6
8
|
export class Applier {
|
|
7
9
|
wfs;
|
|
8
10
|
limits;
|
|
@@ -10,6 +12,10 @@ export class Applier {
|
|
|
10
12
|
backupsDir;
|
|
11
13
|
workspaceId;
|
|
12
14
|
mutationLocked = false;
|
|
15
|
+
beforeMutation;
|
|
16
|
+
/** Invoked in the journal transaction, BEFORE a source write can happen. */
|
|
17
|
+
onChangeset;
|
|
18
|
+
onCommitted;
|
|
13
19
|
constructor(wfs, limits, store, backupsDir, workspaceId) {
|
|
14
20
|
this.wfs = wfs;
|
|
15
21
|
this.limits = limits;
|
|
@@ -38,137 +44,269 @@ export class Applier {
|
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
async apply(opts) {
|
|
41
|
-
return this.withMutationLock(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
return this.withMutationLock(() => this.applyLocked(opts));
|
|
48
|
+
}
|
|
49
|
+
async applyRecovery(opts) {
|
|
50
|
+
return this.withMutationLock(() => this.applyLocked(opts, undefined, true));
|
|
51
|
+
}
|
|
52
|
+
async applyLocked(opts, rollbackOf, recovery = false) {
|
|
53
|
+
const blocked = this.recoveryBlocked();
|
|
54
|
+
if (blocked) {
|
|
55
|
+
throw new DodoError('RECOVERY_REQUIRED', `a previous change (${blocked.id}) needs manual recovery before new mutations`, {
|
|
56
|
+
recovery: 'run `dodo recover` on the server terminal',
|
|
57
|
+
detail: { changesetId: blocked.id },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const row = this.store.getPlan(opts.planId);
|
|
61
|
+
if (!row)
|
|
62
|
+
throw new DodoError('NOT_FOUND', 'unknown planId', { detail: { planId: opts.planId } });
|
|
63
|
+
if (row.principal !== opts.principal)
|
|
64
|
+
throw new DodoError('FORBIDDEN', 'plan belongs to a different principal');
|
|
65
|
+
if (row.workspaceId !== opts.workspaceId)
|
|
66
|
+
throw new DodoError('WORKSPACE_MISMATCH', 'plan belongs to a different workspace');
|
|
67
|
+
if (row.epoch !== opts.epoch) {
|
|
68
|
+
throw new DodoError('PLAN_EXPIRED', 'plan was created before a server restart or workspace switch; preview again', { detail: { planId: opts.planId } });
|
|
69
|
+
}
|
|
70
|
+
if (row.invalidatedAt !== null)
|
|
71
|
+
throw new DodoError('PLAN_EXPIRED', 'plan was already applied or invalidated; preview again');
|
|
72
|
+
if (row.expiresAt < Date.now())
|
|
73
|
+
throw new DodoError('PLAN_EXPIRED', 'plan expired; preview again');
|
|
74
|
+
if (row.planHash !== opts.planHash || digestOf({ planId: row.id, payload: row.payload }) !== row.planHash) {
|
|
75
|
+
throw new DodoError('PLAN_HASH_MISMATCH', 'planHash does not match the stored immutable plan');
|
|
76
|
+
}
|
|
77
|
+
const plan = JSON.parse(row.payload);
|
|
78
|
+
if (plan.source === 'restore' && !recovery)
|
|
79
|
+
throw new DodoError('FORBIDDEN', 'use restore_apply for a reviewed recovery plan');
|
|
80
|
+
// Recheck EVERY path and expected raw-byte hash before touching anything.
|
|
81
|
+
const conflicts = [];
|
|
82
|
+
for (const f of plan.files) {
|
|
83
|
+
const conflict = this.verifyPreState(f);
|
|
84
|
+
if (conflict)
|
|
85
|
+
conflicts.push(conflict);
|
|
86
|
+
}
|
|
87
|
+
if (conflicts.length > 0) {
|
|
88
|
+
throw new DodoError('FILE_CHANGED', 'workspace changed since preview; preview again', {
|
|
89
|
+
detail: { conflicts: conflicts.slice(0, 20) },
|
|
90
|
+
recovery: 'read the affected files and create a fresh plan',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
await this.beforeMutation?.(opts, plan.files);
|
|
94
|
+
// Capture is asynchronous; hashes must still match before creating the journal.
|
|
95
|
+
if (plan.files.some(f => this.verifyPreState(f)))
|
|
96
|
+
throw new DodoError('FILE_CHANGED', 'workspace changed while backing up; preview again');
|
|
97
|
+
const changesetId = newId('cs');
|
|
98
|
+
const csBackupDir = path.join(this.backupsDir, changesetId);
|
|
99
|
+
ensurePrivateDirectory(this.backupsDir);
|
|
100
|
+
ensurePrivateDirectory(csBackupDir);
|
|
101
|
+
const tx = this.store.db.transaction(() => {
|
|
102
|
+
this.store.createChangeset({
|
|
103
|
+
id: changesetId,
|
|
104
|
+
workspaceId: opts.workspaceId,
|
|
105
|
+
epoch: opts.epoch,
|
|
106
|
+
planId: opts.planId,
|
|
107
|
+
principal: opts.principal,
|
|
108
|
+
kind: rollbackOf ? 'rollback' : 'apply',
|
|
109
|
+
summary: plan.summary,
|
|
110
|
+
});
|
|
111
|
+
this.store.setMeta(`journal-v2:${changesetId}`, rollbackOf ?? 'apply');
|
|
112
|
+
this.onChangeset?.(changesetId);
|
|
113
|
+
plan.files.forEach((f, seq) => {
|
|
114
|
+
this.store.addJournalStep({
|
|
115
|
+
changesetId,
|
|
116
|
+
seq,
|
|
117
|
+
op: f.action,
|
|
118
|
+
path: f.path,
|
|
119
|
+
destPath: f.destPath ?? null,
|
|
120
|
+
beforeHash: f.beforeHash,
|
|
121
|
+
afterHash: f.afterHash,
|
|
122
|
+
backupPath: f.beforeHash !== null ? path.join(csBackupDir, `${seq}.bin`) : null,
|
|
123
|
+
state: 'pending',
|
|
47
124
|
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
tx();
|
|
128
|
+
// Phase 1: durable backups of every pre-existing file.
|
|
129
|
+
try {
|
|
130
|
+
for (let seq = 0; seq < plan.files.length; seq += 1) {
|
|
131
|
+
const f = plan.files[seq];
|
|
132
|
+
if (f.beforeHash !== null && f.action !== 'rmdir') {
|
|
133
|
+
const backupPath = path.join(csBackupDir, `${seq}.bin`);
|
|
134
|
+
const current = this.readGuarded(f.path);
|
|
135
|
+
if (sha256Bytes(current) !== f.beforeHash)
|
|
136
|
+
throw new DodoError('FILE_CHANGED', 'file changed while preparing backup');
|
|
137
|
+
writeFileAtomic(backupPath, current, 0o600, { mustNotExist: true });
|
|
138
|
+
this.readBackup(this.store.listJournalSteps(changesetId)[seq], f.beforeHash);
|
|
139
|
+
}
|
|
140
|
+
this.store.setJournalStepState(changesetId, seq, 'backed_up');
|
|
48
141
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (row.principal !== opts.principal)
|
|
53
|
-
throw new DodoError('FORBIDDEN', 'plan belongs to a different principal');
|
|
54
|
-
if (row.workspaceId !== opts.workspaceId)
|
|
55
|
-
throw new DodoError('WORKSPACE_MISMATCH', 'plan belongs to a different workspace');
|
|
56
|
-
if (row.epoch !== opts.epoch) {
|
|
57
|
-
throw new DodoError('PLAN_EXPIRED', 'plan was created before a server restart or workspace switch; preview again', { detail: { planId: opts.planId } });
|
|
58
|
-
}
|
|
59
|
-
if (row.invalidatedAt !== null)
|
|
60
|
-
throw new DodoError('PLAN_EXPIRED', 'plan was already applied or invalidated; preview again');
|
|
61
|
-
if (row.expiresAt < Date.now())
|
|
62
|
-
throw new DodoError('PLAN_EXPIRED', 'plan expired; preview again');
|
|
63
|
-
if (row.planHash !== opts.planHash) {
|
|
64
|
-
throw new DodoError('PLAN_HASH_MISMATCH', 'planHash does not match the stored immutable plan');
|
|
142
|
+
for (const step of this.store.listJournalSteps(changesetId)) {
|
|
143
|
+
if (step.beforeHash !== null && step.op !== 'rmdir')
|
|
144
|
+
this.readBackup(step, step.beforeHash);
|
|
65
145
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
this.store.setChangesetStatus(changesetId, 'failed', 'backup preparation failed; no source writes started');
|
|
149
|
+
if (err instanceof DodoError)
|
|
150
|
+
throw err;
|
|
151
|
+
throw new DodoError('RESOURCE_LIMIT', 'could not persist backups; nothing was changed', {
|
|
152
|
+
recovery: 'check disk space and permissions for the DODO state directory',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Phase 2: apply each file with per-file atomic rename where possible.
|
|
156
|
+
const applied = [];
|
|
157
|
+
// Do not write the first file if any later file drifted during backup.
|
|
158
|
+
if (plan.files.some(f => this.verifyPreState(f))) {
|
|
159
|
+
this.store.setChangesetStatus(changesetId, 'failed', 'source changed during backup');
|
|
160
|
+
throw new DodoError('FILE_CHANGED', 'source changed during backup; preview again');
|
|
161
|
+
}
|
|
162
|
+
for (let seq = 0; seq < plan.files.length; seq += 1) {
|
|
163
|
+
const f = plan.files[seq];
|
|
164
|
+
try {
|
|
70
165
|
const conflict = this.verifyPreState(f);
|
|
71
166
|
if (conflict)
|
|
72
|
-
|
|
167
|
+
throw new DodoError('FILE_CHANGED', 'source changed before write', { detail: { path: f.path } });
|
|
168
|
+
if (f.beforeHash !== null && f.action !== 'rmdir')
|
|
169
|
+
this.readBackup(this.store.listJournalSteps(changesetId)[seq], f.beforeHash);
|
|
170
|
+
// The persisted written state denotes write INTENT; done verifies bytes.
|
|
171
|
+
// Reuse existing states so older readers fail closed on incomplete IO.
|
|
172
|
+
// Durable intent precedes the syscall: a crash can occur after rename
|
|
173
|
+
// but before the following SQLite update, including on the first file.
|
|
174
|
+
this.store.setJournalStepState(changesetId, seq, 'written');
|
|
175
|
+
applied.push(seq);
|
|
176
|
+
this.applyOne(f);
|
|
177
|
+
if (f.action === 'mkdir')
|
|
178
|
+
this.store.setMeta(`journal-directory:${changesetId}:${seq}`, f.createdDirectoryIdentity);
|
|
179
|
+
if (!this.matchesState(f, false))
|
|
180
|
+
throw new DodoError('FILE_CHANGED', 'post-write verification failed');
|
|
181
|
+
this.store.setJournalStepState(changesetId, seq, 'done');
|
|
73
182
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
183
|
+
catch (err) {
|
|
184
|
+
const revert = this.revertSteps(plan, applied, changesetId);
|
|
185
|
+
if (revert.conflicts.length === 0) {
|
|
186
|
+
this.store.setChangesetStatus(changesetId, 'failed', 'apply failed; attempted changes reverted');
|
|
187
|
+
throw err instanceof DodoError
|
|
188
|
+
? err
|
|
189
|
+
: new DodoError('INTERNAL_ERROR', `apply failed at ${f.path}`, { detail: { path: f.path }, cause: err });
|
|
190
|
+
}
|
|
191
|
+
this.store.setChangesetStatus(changesetId, 'recovery_required', `apply failed at ${f.path}; revert incomplete`);
|
|
192
|
+
throw new DodoError('PARTIAL_RECOVERY_REQUIRED', 'apply failed and automatic revert could not restore every file', {
|
|
193
|
+
detail: { changesetId, unrecovered: revert.conflicts.slice(0, 20) },
|
|
194
|
+
recovery: 'run `dodo recover` on the server terminal; backups are in the DODO state directory',
|
|
78
195
|
});
|
|
79
196
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
path: f.path,
|
|
99
|
-
destPath: f.destPath ?? null,
|
|
100
|
-
beforeHash: f.beforeHash,
|
|
101
|
-
afterHash: f.afterHash,
|
|
102
|
-
backupPath: f.beforeHash !== null ? path.join(csBackupDir, `${seq}.bin`) : null,
|
|
103
|
-
state: 'pending',
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
tx();
|
|
108
|
-
// Phase 1: durable backups of every pre-existing file.
|
|
197
|
+
}
|
|
198
|
+
if (plan.files.some(f => !this.matchesState(f, false))) {
|
|
199
|
+
this.store.setChangesetStatus(changesetId, 'recovery_required', 'source changed before final verification');
|
|
200
|
+
throw new DodoError('PARTIAL_RECOVERY_REQUIRED', 'final verification failed; preserve newer edits and inspect recovery');
|
|
201
|
+
}
|
|
202
|
+
const done = this.store.db.transaction(() => {
|
|
203
|
+
for (let seq = 0; seq < plan.files.length; seq += 1)
|
|
204
|
+
this.store.setJournalStepState(changesetId, seq, 'done');
|
|
205
|
+
this.onCommitted?.(plan.files);
|
|
206
|
+
this.store.setChangesetStatus(changesetId, 'committed');
|
|
207
|
+
this.store.invalidatePlan(opts.planId);
|
|
208
|
+
if (rollbackOf)
|
|
209
|
+
this.store.setChangesetStatus(rollbackOf, 'rolled_back');
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
done();
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
109
215
|
try {
|
|
110
|
-
|
|
111
|
-
const f = plan.files[seq];
|
|
112
|
-
if (f.beforeHash !== null) {
|
|
113
|
-
const src = this.wfs.absOf(f.path);
|
|
114
|
-
const backupPath = path.join(csBackupDir, `${seq}.bin`);
|
|
115
|
-
copyFileSyncDurable(src, backupPath);
|
|
116
|
-
}
|
|
117
|
-
this.store.setJournalStepState(changesetId, seq, 'backed_up');
|
|
118
|
-
}
|
|
216
|
+
this.store.setChangesetStatus(changesetId, 'recovery_required', 'final journal/expected-state transaction could not be confirmed');
|
|
119
217
|
}
|
|
120
|
-
catch
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
218
|
+
catch { /* a committing row also blocks further writes until reconciliation */ }
|
|
219
|
+
throw new DodoError('PARTIAL_RECOVERY_REQUIRED', 'source writes occurred but final journal persistence failed; inspect recovery before retrying', { detail: { changesetId } });
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
changesetId,
|
|
223
|
+
files: plan.files.map((f) => {
|
|
224
|
+
const out = { path: f.path, action: f.action, afterHash: f.afterHash };
|
|
225
|
+
if (f.destPath !== undefined)
|
|
226
|
+
out.destPath = f.destPath;
|
|
227
|
+
return out;
|
|
228
|
+
}),
|
|
229
|
+
rollbackAvailable: true,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** Stable, bounded read; final path and open descriptor must still agree. */
|
|
233
|
+
readGuarded(rel) {
|
|
234
|
+
const resolved = this.wfs.resolve(rel);
|
|
235
|
+
const before = this.wfs.assertRegularFileForDirectAccess(resolved);
|
|
236
|
+
const fd = fs.openSync(resolved.abs, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
237
|
+
try {
|
|
238
|
+
const opened = fs.fstatSync(fd);
|
|
239
|
+
if (!sameFile(before, opened) || !opened.isFile() || opened.nlink !== 1)
|
|
240
|
+
throw new DodoError('FILE_CHANGED', 'file identity changed while opening');
|
|
241
|
+
if (opened.size > this.limits.readFileBytes * 2)
|
|
242
|
+
throw new DodoError('FILE_TOO_LARGE', 'recovery file exceeds byte limit');
|
|
243
|
+
const bytes = fs.readFileSync(fd);
|
|
244
|
+
const after = this.wfs.assertRegularFileForDirectAccess(this.wfs.resolve(rel));
|
|
245
|
+
if (!sameFile(opened, fs.fstatSync(fd)) || !sameFile(opened, after))
|
|
246
|
+
throw new DodoError('FILE_CHANGED', 'file changed while reading');
|
|
247
|
+
return bytes;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
guardedHash(rel) {
|
|
254
|
+
const resolved = this.wfs.resolve(rel, { allowMissing: true });
|
|
255
|
+
if (!resolved.stat)
|
|
256
|
+
return null;
|
|
257
|
+
return sha256Bytes(this.readGuarded(rel));
|
|
258
|
+
}
|
|
259
|
+
readBackup(step, expected) {
|
|
260
|
+
if (!/^[\w-]+$/.test(step.changesetId))
|
|
261
|
+
throw new DodoError('CONFLICT', 'invalid backup identity');
|
|
262
|
+
const dir = path.join(this.backupsDir, step.changesetId);
|
|
263
|
+
const file = path.join(dir, `${step.seq}.bin`);
|
|
264
|
+
if (step.backupPath !== file)
|
|
265
|
+
throw new DodoError('CONFLICT', 'backup path does not match journal');
|
|
266
|
+
assertPrivatePath(this.backupsDir, true);
|
|
267
|
+
assertPrivatePath(dir, true);
|
|
268
|
+
const st = assertPrivatePath(file);
|
|
269
|
+
if (st.size > this.limits.readFileBytes * 2)
|
|
270
|
+
throw new DodoError('CONFLICT', 'backup exceeds byte limit');
|
|
271
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
272
|
+
try {
|
|
273
|
+
if (!sameFile(st, fs.fstatSync(fd)))
|
|
274
|
+
throw new DodoError('CONFLICT', 'backup identity changed');
|
|
275
|
+
const bytes = fs.readFileSync(fd);
|
|
276
|
+
if (!sameFile(st, fs.fstatSync(fd)) || !sameFile(st, assertPrivatePath(file)) || sha256Bytes(bytes) !== expected) {
|
|
277
|
+
throw new DodoError('CONFLICT', 'backup missing, changed or damaged');
|
|
125
278
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
: new DodoError('INTERNAL_ERROR', `apply failed at ${f.path}`, { detail: { path: f.path } });
|
|
142
|
-
}
|
|
143
|
-
this.store.setChangesetStatus(changesetId, 'recovery_required', `apply failed at ${f.path}; revert incomplete`);
|
|
144
|
-
throw new DodoError('PARTIAL_RECOVERY_REQUIRED', 'apply failed and automatic revert could not restore every file', {
|
|
145
|
-
detail: { changesetId, unrecovered: revert.conflicts.slice(0, 20) },
|
|
146
|
-
recovery: 'run `dodo recover` on the server terminal; backups are in the DODO state directory',
|
|
147
|
-
});
|
|
148
|
-
}
|
|
279
|
+
return bytes;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
fs.closeSync(fd);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
matchesState(f, before) {
|
|
286
|
+
try {
|
|
287
|
+
if (f.action === 'mkdir' || f.action === 'rmdir') {
|
|
288
|
+
const r = this.wfs.resolve(f.path, { allowMissing: true });
|
|
289
|
+
const absent = before ? f.action === 'mkdir' : f.action === 'rmdir';
|
|
290
|
+
if (absent)
|
|
291
|
+
return !r.stat;
|
|
292
|
+
const identity = f.action === 'mkdir' ? f.createdDirectoryIdentity : f.directoryIdentity;
|
|
293
|
+
return !!r.stat?.isDirectory() && !!identity && rootIdentity(r.abs) === identity && (process.platform === 'win32' || f.mode === undefined || (r.stat.mode & 0o777) === (before ? (f.beforeMode ?? f.mode) : f.mode));
|
|
149
294
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
this.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (f.destPath !== undefined)
|
|
162
|
-
out.destPath = f.destPath;
|
|
163
|
-
return out;
|
|
164
|
-
}),
|
|
165
|
-
rollbackAvailable: true,
|
|
166
|
-
};
|
|
167
|
-
});
|
|
295
|
+
if (f.action === 'move')
|
|
296
|
+
return this.guardedHash(f.path) === (before ? f.beforeHash : null)
|
|
297
|
+
&& this.guardedHash(f.destPath) === (before ? null : f.afterHash);
|
|
298
|
+
const mode = before ? f.beforeMode : f.mode;
|
|
299
|
+
if (mode !== undefined && process.platform !== 'win32' && (before ? f.beforeHash : f.afterHash) !== null && (this.wfs.resolve(f.path).stat.mode & 0o777) !== mode)
|
|
300
|
+
return false;
|
|
301
|
+
return this.guardedHash(f.path) === (before ? f.beforeHash : f.afterHash);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
168
306
|
}
|
|
169
307
|
verifyPreState(f) {
|
|
170
308
|
try {
|
|
171
|
-
if (f.action === 'create') {
|
|
309
|
+
if (f.action === 'create' || f.action === 'mkdir') {
|
|
172
310
|
const { missingParents } = this.wfs.resolveForCreate(f.path, (f.createParents?.length ?? 0) > 0);
|
|
173
311
|
const expected = new Set(f.createParents ?? []);
|
|
174
312
|
for (const p of missingParents) {
|
|
@@ -177,8 +315,19 @@ export class Applier {
|
|
|
177
315
|
}
|
|
178
316
|
return undefined;
|
|
179
317
|
}
|
|
180
|
-
|
|
181
|
-
|
|
318
|
+
if (f.action === 'rmdir') {
|
|
319
|
+
const r = this.wfs.resolve(f.path);
|
|
320
|
+
if (!r.stat?.isDirectory() || rootIdentity(r.abs) !== f.directoryIdentity)
|
|
321
|
+
return { path: f.path, reason: 'directory identity changed' };
|
|
322
|
+
if (process.platform !== 'win32' && f.mode !== undefined && (r.stat.mode & 0o777) !== (f.beforeMode ?? f.mode))
|
|
323
|
+
return { path: f.path, reason: 'directory mode changed since preview' };
|
|
324
|
+
if (fs.readdirSync(r.abs).some(n => !f.directoryChildren?.includes(n)))
|
|
325
|
+
return { path: f.path, reason: 'directory contains unrelated entries' };
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
if (f.beforeMode !== undefined && process.platform !== 'win32' && (this.wfs.resolve(f.path).stat.mode & 0o777) !== f.beforeMode)
|
|
329
|
+
return { path: f.path, reason: 'file mode changed since preview' };
|
|
330
|
+
const hash = sha256Bytes(this.readGuarded(f.path));
|
|
182
331
|
if (hash !== f.beforeHash)
|
|
183
332
|
return { path: f.path, reason: 'content changed since preview' };
|
|
184
333
|
if (f.action === 'move' && f.destPath) {
|
|
@@ -193,6 +342,21 @@ export class Applier {
|
|
|
193
342
|
applyOne(f) {
|
|
194
343
|
const abs = this.wfs.absOf(f.path);
|
|
195
344
|
switch (f.action) {
|
|
345
|
+
case 'mkdir': {
|
|
346
|
+
fs.mkdirSync(abs, { mode: f.mode ?? 0o755 });
|
|
347
|
+
f.createdDirectoryIdentity = rootIdentity(abs);
|
|
348
|
+
if (process.platform !== 'win32')
|
|
349
|
+
fs.chmodSync(abs, f.mode ?? 0o755);
|
|
350
|
+
fsyncDir(path.dirname(abs));
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'rmdir': {
|
|
354
|
+
if (fs.readdirSync(abs).length)
|
|
355
|
+
throw new DodoError('FILE_CHANGED', 'directory is no longer empty');
|
|
356
|
+
fs.rmdirSync(abs);
|
|
357
|
+
fsyncDir(path.dirname(abs));
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
196
360
|
case 'create': {
|
|
197
361
|
for (const dir of f.createParents ?? []) {
|
|
198
362
|
const parent = this.wfs.resolve(dir, { allowMissing: true });
|
|
@@ -203,21 +367,35 @@ export class Applier {
|
|
|
203
367
|
else
|
|
204
368
|
fs.mkdirSync(parent.abs, { mode: 0o755 });
|
|
205
369
|
}
|
|
206
|
-
writeFileAtomic(abs, Buffer.from(f.afterContentB64 ?? '', 'base64'), 0o644, { mustNotExist: true
|
|
370
|
+
writeFileAtomic(abs, Buffer.from(f.afterContentB64 ?? '', 'base64'), f.mode ?? 0o644, { mustNotExist: true, beforeReplace: () => {
|
|
371
|
+
if (this.verifyPreState(f))
|
|
372
|
+
throw new DodoError('FILE_CHANGED', 'create target changed before publish');
|
|
373
|
+
} });
|
|
207
374
|
break;
|
|
208
375
|
}
|
|
209
376
|
case 'modify': {
|
|
210
|
-
writeFileAtomic(abs, Buffer.from(f.afterContentB64 ?? '', 'base64'), f.mode ?? 0o644, { mustNotExist: false
|
|
377
|
+
writeFileAtomic(abs, Buffer.from(f.afterContentB64 ?? '', 'base64'), f.mode ?? 0o644, { mustNotExist: false, beforeReplace: () => {
|
|
378
|
+
if (this.verifyPreState(f))
|
|
379
|
+
throw new DodoError('FILE_CHANGED', 'file changed before atomic replace');
|
|
380
|
+
} });
|
|
211
381
|
break;
|
|
212
382
|
}
|
|
213
383
|
case 'delete': {
|
|
214
|
-
retryWindowsFs(() =>
|
|
384
|
+
retryWindowsFs(() => {
|
|
385
|
+
if (this.verifyPreState(f))
|
|
386
|
+
throw new DodoError('FILE_CHANGED', 'file changed before delete retry');
|
|
387
|
+
fs.unlinkSync(abs);
|
|
388
|
+
});
|
|
215
389
|
fsyncDir(path.dirname(abs));
|
|
216
390
|
break;
|
|
217
391
|
}
|
|
218
392
|
case 'move': {
|
|
219
393
|
const dest = this.wfs.absOf(f.destPath);
|
|
220
|
-
|
|
394
|
+
retryWindowsFs(() => {
|
|
395
|
+
if (this.verifyPreState(f))
|
|
396
|
+
throw new DodoError('FILE_CHANGED', 'file changed before move retry');
|
|
397
|
+
fs.renameSync(abs, dest);
|
|
398
|
+
});
|
|
221
399
|
fsyncDir(path.dirname(abs));
|
|
222
400
|
if (path.dirname(dest) !== path.dirname(abs))
|
|
223
401
|
fsyncDir(path.dirname(dest));
|
|
@@ -231,15 +409,51 @@ export class Applier {
|
|
|
231
409
|
for (const seq of [...appliedSeqs].reverse()) {
|
|
232
410
|
const f = plan.files[seq];
|
|
233
411
|
const abs = this.wfs.absOf(f.path);
|
|
234
|
-
const backupPath = path.join(this.backupsDir, changesetId, `${seq}.bin`);
|
|
235
412
|
try {
|
|
236
|
-
|
|
413
|
+
if (f.action !== 'mkdir' && f.action !== 'rmdir')
|
|
414
|
+
this.assertGuardedFile(f.path);
|
|
237
415
|
if (f.destPath)
|
|
238
416
|
this.assertGuardedFile(f.destPath);
|
|
417
|
+
// An attempted syscall may have failed before it changed anything.
|
|
418
|
+
if (this.matchesState(f, true)) {
|
|
419
|
+
this.store.setJournalStepState(changesetId, seq, 'reverted');
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const restore = () => {
|
|
423
|
+
const step = this.store.listJournalSteps(changesetId).find(s => s.seq === seq);
|
|
424
|
+
const bytes = this.readBackup(step, f.beforeHash);
|
|
425
|
+
writeFileAtomic(abs, bytes, f.beforeMode ?? f.mode ?? 0o644, { mustNotExist: f.action === 'delete', beforeReplace: () => {
|
|
426
|
+
if (!this.matchesState(f, false))
|
|
427
|
+
throw new DodoError('FILE_CHANGED', 'file changed before compensation');
|
|
428
|
+
} });
|
|
429
|
+
};
|
|
239
430
|
switch (f.action) {
|
|
431
|
+
case 'mkdir': {
|
|
432
|
+
if (!this.matchesState(f, false) || fs.readdirSync(abs).length)
|
|
433
|
+
throw new DodoError('FILE_CHANGED', 'created directory changed; preserved');
|
|
434
|
+
fs.rmdirSync(abs);
|
|
435
|
+
fsyncDir(path.dirname(abs));
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case 'rmdir': {
|
|
439
|
+
if (!this.matchesState(f, false))
|
|
440
|
+
throw new DodoError('FILE_CHANGED', 'directory reappeared; preserved');
|
|
441
|
+
fs.mkdirSync(abs, { mode: f.beforeMode ?? f.mode ?? 0o755 });
|
|
442
|
+
f.directoryIdentity = rootIdentity(abs);
|
|
443
|
+
this.store.setMeta(`journal-directory-reverted:${changesetId}:${seq}`, f.directoryIdentity);
|
|
444
|
+
if (process.platform !== 'win32')
|
|
445
|
+
fs.chmodSync(abs, f.beforeMode ?? f.mode ?? 0o755);
|
|
446
|
+
fsyncDir(path.dirname(abs));
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
240
449
|
case 'create': {
|
|
241
|
-
if (
|
|
242
|
-
retryWindowsFs(() =>
|
|
450
|
+
if (this.guardedHash(f.path) === f.afterHash) {
|
|
451
|
+
retryWindowsFs(() => {
|
|
452
|
+
if (!this.matchesState(f, false))
|
|
453
|
+
throw new DodoError('FILE_CHANGED', 'created file changed before compensation retry');
|
|
454
|
+
fs.unlinkSync(abs);
|
|
455
|
+
});
|
|
456
|
+
fsyncDir(path.dirname(abs));
|
|
243
457
|
for (const dir of [...(f.createParents ?? [])].reverse()) {
|
|
244
458
|
try {
|
|
245
459
|
fs.rmdirSync(this.wfs.absOf(dir));
|
|
@@ -255,8 +469,8 @@ export class Applier {
|
|
|
255
469
|
break;
|
|
256
470
|
}
|
|
257
471
|
case 'modify': {
|
|
258
|
-
if (
|
|
259
|
-
|
|
472
|
+
if (this.guardedHash(f.path) === f.afterHash) {
|
|
473
|
+
restore();
|
|
260
474
|
}
|
|
261
475
|
else {
|
|
262
476
|
conflicts.push({ path: f.path, reason: 'file changed before revert' });
|
|
@@ -264,8 +478,8 @@ export class Applier {
|
|
|
264
478
|
break;
|
|
265
479
|
}
|
|
266
480
|
case 'delete': {
|
|
267
|
-
if (
|
|
268
|
-
|
|
481
|
+
if (this.guardedHash(f.path) === null) {
|
|
482
|
+
restore();
|
|
269
483
|
}
|
|
270
484
|
else {
|
|
271
485
|
conflicts.push({ path: f.path, reason: 'a new file appeared at the deleted path' });
|
|
@@ -274,8 +488,15 @@ export class Applier {
|
|
|
274
488
|
}
|
|
275
489
|
case 'move': {
|
|
276
490
|
const dest = this.wfs.absOf(f.destPath);
|
|
277
|
-
if (
|
|
278
|
-
|
|
491
|
+
if (this.guardedHash(f.destPath) === f.afterHash && this.guardedHash(f.path) === null) {
|
|
492
|
+
retryWindowsFs(() => {
|
|
493
|
+
if (!this.matchesState(f, false))
|
|
494
|
+
throw new DodoError('FILE_CHANGED', 'moved file changed before compensation retry');
|
|
495
|
+
fs.renameSync(dest, abs);
|
|
496
|
+
});
|
|
497
|
+
fsyncDir(path.dirname(abs));
|
|
498
|
+
if (path.dirname(dest) !== path.dirname(abs))
|
|
499
|
+
fsyncDir(path.dirname(dest));
|
|
279
500
|
}
|
|
280
501
|
else {
|
|
281
502
|
conflicts.push({ path: f.path, reason: 'moved file changed before revert' });
|
|
@@ -283,10 +504,12 @@ export class Applier {
|
|
|
283
504
|
break;
|
|
284
505
|
}
|
|
285
506
|
}
|
|
507
|
+
if (!this.matchesState(f, true))
|
|
508
|
+
throw new DodoError('FILE_CHANGED', 'compensation verification failed');
|
|
286
509
|
this.store.setJournalStepState(changesetId, seq, 'reverted');
|
|
287
510
|
}
|
|
288
511
|
catch (err) {
|
|
289
|
-
conflicts.push({ path: f.path, reason:
|
|
512
|
+
conflicts.push({ path: f.path, reason: err instanceof DodoError ? err.message : 'revert failed' });
|
|
290
513
|
}
|
|
291
514
|
}
|
|
292
515
|
return { conflicts };
|
|
@@ -298,83 +521,54 @@ export class Applier {
|
|
|
298
521
|
* journaled changeset.
|
|
299
522
|
*/
|
|
300
523
|
async rollback(opts) {
|
|
301
|
-
return this.withMutationLock(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
try {
|
|
342
|
-
for (const s of [...steps].reverse()) {
|
|
343
|
-
const abs = this.wfs.absOf(s.path);
|
|
344
|
-
this.store.addJournalStep({ ...s, changesetId: rbId, state: 'pending' });
|
|
345
|
-
switch (s.op) {
|
|
346
|
-
case 'create':
|
|
347
|
-
retryWindowsFs(() => fs.unlinkSync(abs));
|
|
348
|
-
break;
|
|
349
|
-
case 'modify':
|
|
350
|
-
copyFileSyncDurable(s.backupPath, abs, statMode(abs) ?? 0o644);
|
|
351
|
-
break;
|
|
352
|
-
case 'delete':
|
|
353
|
-
copyFileSyncDurable(s.backupPath, abs, 0o644);
|
|
354
|
-
break;
|
|
355
|
-
case 'move': {
|
|
356
|
-
renameWithRetry(this.wfs.absOf(s.destPath), abs);
|
|
357
|
-
break;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
this.store.setJournalStepState(rbId, s.seq, 'done');
|
|
361
|
-
restored.push(s.path);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
catch (err) {
|
|
365
|
-
this.store.setChangesetStatus(rbId, 'recovery_required', `rollback interrupted: ${err.message}`);
|
|
366
|
-
throw new DodoError('PARTIAL_RECOVERY_REQUIRED', 'rollback failed part-way; manual recovery required', {
|
|
367
|
-
detail: { changesetId: rbId, restored },
|
|
368
|
-
recovery: 'run `dodo recover` on the server terminal',
|
|
369
|
-
});
|
|
524
|
+
return this.withMutationLock(() => this.rollbackLocked(opts, false));
|
|
525
|
+
}
|
|
526
|
+
/** Private owner administration only. Never expose an owner flag in MCP input. */
|
|
527
|
+
async rollbackAsOwner(opts) {
|
|
528
|
+
return this.withMutationLock(() => this.rollbackLocked(opts, true));
|
|
529
|
+
}
|
|
530
|
+
async rollbackLocked(opts, owner) {
|
|
531
|
+
if (this.recoveryBlocked())
|
|
532
|
+
throw new DodoError('RECOVERY_REQUIRED', 'resolve pending recovery before rollback');
|
|
533
|
+
const cs = this.store.getChangeset(opts.changesetId);
|
|
534
|
+
if (!cs || cs.workspaceId !== opts.workspaceId || cs.workspaceId !== this.workspaceId)
|
|
535
|
+
throw new DodoError('NOT_FOUND', 'unknown changesetId');
|
|
536
|
+
if (!owner && cs.principal !== opts.principal)
|
|
537
|
+
throw new DodoError('FORBIDDEN', 'changeset belongs to a different principal; ask the owner to recover it');
|
|
538
|
+
if (cs.kind !== 'apply')
|
|
539
|
+
throw new DodoError('INVALID_INPUT', 'only apply changesets can be rolled back');
|
|
540
|
+
if (cs.status !== 'committed')
|
|
541
|
+
throw new DodoError('CONFLICT', 'changeset is not committed');
|
|
542
|
+
const steps = this.store.listJournalSteps(cs.id);
|
|
543
|
+
if (steps.some(s => s.op === 'mkdir' || s.op === 'rmdir'))
|
|
544
|
+
throw new DodoError('NOT_SUPPORTED', 'preview the recovery session/checkpoint to restore directory changes');
|
|
545
|
+
if (steps.length === 0)
|
|
546
|
+
throw new DodoError('CONFLICT', 'changeset journal is empty');
|
|
547
|
+
const conflicts = steps.map(s => this.verifyRollbackable(s)).filter((c) => c !== undefined);
|
|
548
|
+
if (conflicts.length)
|
|
549
|
+
throw new DodoError('CONFLICT', 'files or backups changed; rollback refused', { detail: { conflicts: conflicts.slice(0, 20) } });
|
|
550
|
+
const original = cs.planId ? this.store.getPlan(cs.planId) : undefined;
|
|
551
|
+
const originalFiles = original ? JSON.parse(original.payload).files : [];
|
|
552
|
+
// Express restore as a NEW forward plan. Its journal records the actual
|
|
553
|
+
// restore direction and its backups contain the pre-restore bytes.
|
|
554
|
+
const files = [...steps].reverse().map(s => {
|
|
555
|
+
const bytes = s.beforeHash === null ? undefined : this.readBackup(s, s.beforeHash);
|
|
556
|
+
const mode = originalFiles.find(f => f.path === s.path)?.mode ?? 0o644;
|
|
557
|
+
const common = { diff: '', diffTruncated: false, mode, bytesBefore: 0, bytesAfter: bytes?.length ?? 0 };
|
|
558
|
+
switch (s.op) {
|
|
559
|
+
case 'create': return { ...common, path: s.path, action: 'delete', beforeHash: s.afterHash, afterHash: null };
|
|
560
|
+
case 'modify': return { ...common, path: s.path, action: 'modify', beforeHash: s.afterHash, afterHash: s.beforeHash, afterContentB64: bytes.toString('base64') };
|
|
561
|
+
case 'delete': return { ...common, path: s.path, action: 'create', beforeHash: null, afterHash: s.beforeHash, afterContentB64: bytes.toString('base64') };
|
|
562
|
+
case 'move': return { ...common, path: s.destPath, destPath: s.path, action: 'move', beforeHash: s.afterHash, afterHash: s.beforeHash };
|
|
563
|
+
default: throw new DodoError('CONFLICT', 'unknown journal operation');
|
|
370
564
|
}
|
|
371
|
-
const tx = this.store.db.transaction(() => {
|
|
372
|
-
this.store.setChangesetStatus(rbId, 'committed');
|
|
373
|
-
this.store.setChangesetStatus(cs.id, 'rolled_back');
|
|
374
|
-
});
|
|
375
|
-
tx();
|
|
376
|
-
return { changesetId: rbId, restored };
|
|
377
565
|
});
|
|
566
|
+
const payload = { version: 1, workspaceId: opts.workspaceId, epoch: opts.epoch, principal: opts.principal, source: 'direct', files, summary: `rollback of ${cs.id}` };
|
|
567
|
+
const planId = newId('plan');
|
|
568
|
+
const planHash = digestOf({ planId, payload: JSON.stringify(payload) });
|
|
569
|
+
this.store.putPlan({ id: planId, workspaceId: opts.workspaceId, epoch: opts.epoch, principal: opts.principal, planHash, payload: JSON.stringify(payload), ttlMs: 60_000 });
|
|
570
|
+
const result = await this.applyLocked({ ...opts, planId, planHash }, cs.id);
|
|
571
|
+
return { changesetId: result.changesetId, restored: steps.map(s => s.path) };
|
|
378
572
|
}
|
|
379
573
|
/** A saved journal is not permission to follow a newly introduced link,
|
|
380
574
|
* junction, hardlink or NTFS alias. Revalidate before reading rollback hashes. */
|
|
@@ -385,39 +579,17 @@ export class Applier {
|
|
|
385
579
|
}
|
|
386
580
|
verifyRollbackable(s) {
|
|
387
581
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
582
|
+
if (!['create', 'modify', 'delete', 'move'].includes(s.op))
|
|
583
|
+
return { path: s.path, reason: 'unknown journal operation' };
|
|
584
|
+
if (!this.matchesState({ path: s.path, beforeHash: s.beforeHash, afterHash: s.afterHash, action: s.op, ...(s.destPath ? { destPath: s.destPath } : {}) }, false)) {
|
|
585
|
+
return { path: s.path, reason: 'path policy or content changed after apply' };
|
|
586
|
+
}
|
|
587
|
+
if (s.beforeHash !== null)
|
|
588
|
+
this.readBackup(s, s.beforeHash);
|
|
589
|
+
return undefined;
|
|
391
590
|
}
|
|
392
591
|
catch {
|
|
393
|
-
return { path: s.path, reason: '
|
|
394
|
-
}
|
|
395
|
-
const abs = this.wfs.absOf(s.path);
|
|
396
|
-
switch (s.op) {
|
|
397
|
-
case 'create':
|
|
398
|
-
return hashFileAt(abs) === s.afterHash ? undefined : { path: s.path, reason: 'file changed after creation' };
|
|
399
|
-
case 'modify':
|
|
400
|
-
if (hashFileAt(abs) !== s.afterHash)
|
|
401
|
-
return { path: s.path, reason: 'file changed after apply' };
|
|
402
|
-
if (!s.backupPath || hashFileAt(s.backupPath) !== s.beforeHash)
|
|
403
|
-
return { path: s.path, reason: 'backup missing or damaged' };
|
|
404
|
-
return undefined;
|
|
405
|
-
case 'delete':
|
|
406
|
-
if (hashFileAt(abs) !== null)
|
|
407
|
-
return { path: s.path, reason: 'a new file exists at the deleted path' };
|
|
408
|
-
if (!s.backupPath || hashFileAt(s.backupPath) !== s.beforeHash)
|
|
409
|
-
return { path: s.path, reason: 'backup missing or damaged' };
|
|
410
|
-
return undefined;
|
|
411
|
-
case 'move': {
|
|
412
|
-
const dest = this.wfs.absOf(s.destPath);
|
|
413
|
-
if (hashFileAt(dest) !== s.afterHash)
|
|
414
|
-
return { path: s.destPath, reason: 'moved file changed after apply' };
|
|
415
|
-
if (hashFileAt(abs) !== null)
|
|
416
|
-
return { path: s.path, reason: 'a new file exists at the original path' };
|
|
417
|
-
return undefined;
|
|
418
|
-
}
|
|
419
|
-
default:
|
|
420
|
-
return { path: s.path, reason: `unknown op ${s.op}` };
|
|
592
|
+
return { path: s.path, reason: 'backup missing, damaged or inaccessible' };
|
|
421
593
|
}
|
|
422
594
|
}
|
|
423
595
|
/**
|
|
@@ -430,43 +602,56 @@ export class Applier {
|
|
|
430
602
|
const out = { failed: [], committed: [], recoveryRequired: [] };
|
|
431
603
|
for (const cs of this.store.listChangesetsByStatus('committing').filter(cs => cs.workspaceId === this.workspaceId)) {
|
|
432
604
|
const steps = this.store.listJournalSteps(cs.id);
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
605
|
+
const marker = this.store.getMeta(`journal-v2:${cs.id}`);
|
|
606
|
+
// Historical rollback rows used forward hashes; infer nothing from them.
|
|
607
|
+
const original = cs.kind === 'rollback' && marker ? this.store.getChangeset(marker) : undefined;
|
|
608
|
+
const knownDirection = cs.kind === 'apply' || (original?.workspaceId === cs.workspaceId && original?.kind === 'apply');
|
|
609
|
+
const originalPlan = cs.planId ? this.store.getPlan(cs.planId) : undefined;
|
|
610
|
+
const planned = originalPlan ? JSON.parse(originalPlan.payload).files : [];
|
|
611
|
+
const asFile = (s) => ({ ...planned[s.seq],
|
|
612
|
+
...(this.store.getMeta(`journal-directory:${cs.id}:${s.seq}`) ? { createdDirectoryIdentity: this.store.getMeta(`journal-directory:${cs.id}:${s.seq}`) } : {}),
|
|
613
|
+
...(this.store.getMeta(`journal-directory-reverted:${cs.id}:${s.seq}`) ? { directoryIdentity: this.store.getMeta(`journal-directory-reverted:${cs.id}:${s.seq}`) } : {}), path: s.path, action: s.op, beforeHash: s.beforeHash, afterHash: s.afterHash, ...(s.destPath ? { destPath: s.destPath } : {}) });
|
|
614
|
+
const allBefore = knownDirection && steps.length > 0 && steps.every(s => this.matchesState(asFile(s), true));
|
|
615
|
+
const allAfter = knownDirection && steps.length > 0 && steps.every(s => {
|
|
616
|
+
if (!['written', 'done'].includes(s.state) || !this.matchesState(asFile(s), false))
|
|
441
617
|
return false;
|
|
442
|
-
const rel = s.op === 'move' ? s.destPath : s.path;
|
|
443
618
|
try {
|
|
444
|
-
|
|
619
|
+
if (s.beforeHash !== null && s.op !== 'rmdir')
|
|
620
|
+
this.readBackup(s, s.beforeHash);
|
|
621
|
+
return true;
|
|
445
622
|
}
|
|
446
623
|
catch {
|
|
447
624
|
return false;
|
|
448
625
|
}
|
|
449
|
-
return hashFileAt(this.wfs.absOf(rel)) === s.afterHash;
|
|
450
626
|
});
|
|
451
|
-
if (
|
|
452
|
-
this.store.
|
|
627
|
+
if (allAfter) {
|
|
628
|
+
this.store.db.transaction(() => {
|
|
629
|
+
for (const s of steps)
|
|
630
|
+
this.store.setJournalStepState(cs.id, s.seq, 'done');
|
|
631
|
+
this.store.setChangesetStatus(cs.id, 'committed');
|
|
632
|
+
if (cs.planId)
|
|
633
|
+
this.store.invalidatePlan(cs.planId);
|
|
634
|
+
if (cs.kind === 'rollback' && marker) {
|
|
635
|
+
this.store.setChangesetStatus(marker, 'rolled_back');
|
|
636
|
+
}
|
|
637
|
+
})();
|
|
453
638
|
out.committed.push(cs.id);
|
|
454
639
|
}
|
|
640
|
+
else if (allBefore || (steps.length === 0 && cs.kind === 'apply')) {
|
|
641
|
+
this.store.setChangesetStatus(cs.id, 'failed', 'interrupted; verified original state');
|
|
642
|
+
out.failed.push(cs.id);
|
|
643
|
+
}
|
|
455
644
|
else {
|
|
456
|
-
this.store.setChangesetStatus(cs.id, 'recovery_required', 'interrupted
|
|
645
|
+
this.store.setChangesetStatus(cs.id, 'recovery_required', 'interrupted; state is mixed, changed or unverifiable');
|
|
457
646
|
out.recoveryRequired.push(cs.id);
|
|
458
647
|
}
|
|
459
648
|
}
|
|
460
649
|
return out;
|
|
461
650
|
}
|
|
462
651
|
}
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
return undefined;
|
|
469
|
-
}
|
|
652
|
+
function sameFile(a, b) {
|
|
653
|
+
return a.dev === b.dev && a.ino === b.ino && a.nlink === b.nlink && a.size === b.size
|
|
654
|
+
&& a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
470
655
|
}
|
|
471
656
|
export function hashFileAt(abs) {
|
|
472
657
|
try {
|
|
@@ -480,48 +665,34 @@ export function hashFileAt(abs) {
|
|
|
480
665
|
}
|
|
481
666
|
}
|
|
482
667
|
function writeFileAtomic(abs, bytes, mode, opts) {
|
|
483
|
-
if (opts.mustNotExist && fs.existsSync(abs)) {
|
|
484
|
-
throw new DodoError('CONFLICT', 'target appeared before write', { detail: { path: abs } });
|
|
485
|
-
}
|
|
486
668
|
const dir = path.dirname(abs);
|
|
487
669
|
const tmp = path.join(dir, `.dodo-tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`);
|
|
488
|
-
const fd = fs.openSync(tmp, 'wx', mode);
|
|
489
670
|
try {
|
|
490
|
-
fs.
|
|
491
|
-
fs.fsyncSync(fd);
|
|
492
|
-
}
|
|
493
|
-
finally {
|
|
494
|
-
fs.closeSync(fd);
|
|
495
|
-
}
|
|
496
|
-
try {
|
|
497
|
-
renameWithRetry(tmp, abs);
|
|
498
|
-
}
|
|
499
|
-
catch (err) {
|
|
671
|
+
const fd = fs.openSync(tmp, 'wx', mode);
|
|
500
672
|
try {
|
|
501
|
-
fs.
|
|
673
|
+
fs.writeFileSync(fd, bytes);
|
|
674
|
+
if (process.platform !== 'win32')
|
|
675
|
+
fs.fchmodSync(fd, mode);
|
|
676
|
+
fs.fsyncSync(fd);
|
|
502
677
|
}
|
|
503
|
-
|
|
504
|
-
|
|
678
|
+
finally {
|
|
679
|
+
fs.closeSync(fd);
|
|
505
680
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const tmp = path.join(dir, `.dodo-tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`);
|
|
515
|
-
const fd = fs.openSync(tmp, 'wx', mode ?? 0o600);
|
|
516
|
-
try {
|
|
517
|
-
fs.writeFileSync(fd, bytes);
|
|
518
|
-
fs.fsyncSync(fd);
|
|
681
|
+
if (opts.mustNotExist) {
|
|
682
|
+
// Exclusive publication: rename would silently overwrite a new file.
|
|
683
|
+
retryWindowsFs(() => { opts.beforeReplace?.(); fs.linkSync(tmp, abs); });
|
|
684
|
+
retryWindowsFs(() => fs.unlinkSync(tmp));
|
|
685
|
+
}
|
|
686
|
+
else
|
|
687
|
+
retryWindowsFs(() => { opts.beforeReplace?.(); fs.renameSync(tmp, abs); });
|
|
688
|
+
fsyncDir(dir);
|
|
519
689
|
}
|
|
520
690
|
finally {
|
|
521
|
-
|
|
691
|
+
try {
|
|
692
|
+
fs.unlinkSync(tmp);
|
|
693
|
+
}
|
|
694
|
+
catch { /* already published or never created */ }
|
|
522
695
|
}
|
|
523
|
-
renameWithRetry(tmp, dest);
|
|
524
|
-
fsyncDir(dir);
|
|
525
696
|
}
|
|
526
697
|
function fsyncDir(dir) {
|
|
527
698
|
// Windows does not expose POSIX directory fsync through this Node adapter.
|
|
@@ -537,8 +708,12 @@ function fsyncDir(dir) {
|
|
|
537
708
|
fs.closeSync(fd);
|
|
538
709
|
}
|
|
539
710
|
}
|
|
540
|
-
catch {
|
|
541
|
-
|
|
711
|
+
catch (error) {
|
|
712
|
+
// Unsupported directory fsync is a documented platform limitation;
|
|
713
|
+
// disk-full and I/O failures must not be mistaken for durable success.
|
|
714
|
+
const code = error.code;
|
|
715
|
+
if (!['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'EISDIR'].includes(code ?? ''))
|
|
716
|
+
throw error;
|
|
542
717
|
}
|
|
543
718
|
}
|
|
544
719
|
//# sourceMappingURL=applier.js.map
|