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.
Files changed (147) hide show
  1. package/README.md +45 -6
  2. package/dist/cli/dataRecovery.js +38 -0
  3. package/dist/cli/dataRecovery.js.map +1 -0
  4. package/dist/cli/deployment.js +61 -0
  5. package/dist/cli/deployment.js.map +1 -0
  6. package/dist/cli/main.js +35 -1
  7. package/dist/cli/main.js.map +1 -1
  8. package/dist/config/globalConfig.js +8 -0
  9. package/dist/config/globalConfig.js.map +1 -1
  10. package/dist/errors.js +1 -1
  11. package/dist/errors.js.map +1 -1
  12. package/dist/ipc/server.js +2 -0
  13. package/dist/ipc/server.js.map +1 -1
  14. package/dist/ipc/timeouts.js +5 -0
  15. package/dist/ipc/timeouts.js.map +1 -0
  16. package/dist/platform/privateFs.js +32 -3
  17. package/dist/platform/privateFs.js.map +1 -1
  18. package/dist/platform/windowsPrivateAcl.js +90 -0
  19. package/dist/platform/windowsPrivateAcl.js.map +1 -0
  20. package/dist/security/outboundAddress.js +20 -0
  21. package/dist/security/outboundAddress.js.map +1 -0
  22. package/dist/server/aiAdmin.js +35 -10
  23. package/dist/server/aiAdmin.js.map +1 -1
  24. package/dist/server/appServer.js +11 -3
  25. package/dist/server/appServer.js.map +1 -1
  26. package/dist/server/bootstrap.js +14 -0
  27. package/dist/server/bootstrap.js.map +1 -1
  28. package/dist/server/configUi/index.html +3 -0
  29. package/dist/server/configUi/ui/dataRecovery.js +90 -0
  30. package/dist/server/configUi/ui/deployment.js +111 -0
  31. package/dist/server/configUi/ui/recovery.js +167 -0
  32. package/dist/server/configUi/workbench.css +28 -1
  33. package/dist/server/configUi/workbench.js +136 -25
  34. package/dist/server/installationRuntime.js +3 -1
  35. package/dist/server/installationRuntime.js.map +1 -1
  36. package/dist/server/instructions.js +6 -2
  37. package/dist/server/instructions.js.map +1 -1
  38. package/dist/server/ipcDispatch.js +219 -0
  39. package/dist/server/ipcDispatch.js.map +1 -1
  40. package/dist/server/localConfig.js +1 -1
  41. package/dist/server/localConfig.js.map +1 -1
  42. package/dist/server/stdioServer.js +11 -3
  43. package/dist/server/stdioServer.js.map +1 -1
  44. package/dist/services/ai/network.js +1 -18
  45. package/dist/services/ai/network.js.map +1 -1
  46. package/dist/services/ai/subagents.js +5 -2
  47. package/dist/services/ai/subagents.js.map +1 -1
  48. package/dist/services/assistance/verification.js +43 -10
  49. package/dist/services/assistance/verification.js.map +1 -1
  50. package/dist/services/changes/applier.js +472 -297
  51. package/dist/services/changes/applier.js.map +1 -1
  52. package/dist/services/git/gitService.js +150 -34
  53. package/dist/services/git/gitService.js.map +1 -1
  54. package/dist/services/jobs/jobManager.js +59 -2
  55. package/dist/services/jobs/jobManager.js.map +1 -1
  56. package/dist/services/jobs/spool.js +9 -3
  57. package/dist/services/jobs/spool.js.map +1 -1
  58. package/dist/services/multimodal/mediaService.js +6 -6
  59. package/dist/services/multimodal/mediaService.js.map +1 -1
  60. package/dist/services/recovery/configKeys.js +115 -0
  61. package/dist/services/recovery/configKeys.js.map +1 -0
  62. package/dist/services/recovery/configVault.js +361 -0
  63. package/dist/services/recovery/configVault.js.map +1 -0
  64. package/dist/services/recovery/contracts.js +24 -0
  65. package/dist/services/recovery/contracts.js.map +1 -0
  66. package/dist/services/recovery/databaseAwareness.js +179 -0
  67. package/dist/services/recovery/databaseAwareness.js.map +1 -0
  68. package/dist/services/recovery/deploymentArchive.js +129 -0
  69. package/dist/services/recovery/deploymentArchive.js.map +1 -0
  70. package/dist/services/recovery/deploymentContracts.js +70 -0
  71. package/dist/services/recovery/deploymentContracts.js.map +1 -0
  72. package/dist/services/recovery/deploymentHealth.js +143 -0
  73. package/dist/services/recovery/deploymentHealth.js.map +1 -0
  74. package/dist/services/recovery/deploymentMaintenance.js +197 -0
  75. package/dist/services/recovery/deploymentMaintenance.js.map +1 -0
  76. package/dist/services/recovery/deploymentService.js +429 -0
  77. package/dist/services/recovery/deploymentService.js.map +1 -0
  78. package/dist/services/recovery/deploymentSource.js +36 -0
  79. package/dist/services/recovery/deploymentSource.js.map +1 -0
  80. package/dist/services/recovery/dockerDeployment.js +172 -0
  81. package/dist/services/recovery/dockerDeployment.js.map +1 -0
  82. package/dist/services/recovery/drift.js +178 -0
  83. package/dist/services/recovery/drift.js.map +1 -0
  84. package/dist/services/recovery/evidence.js +127 -0
  85. package/dist/services/recovery/evidence.js.map +1 -0
  86. package/dist/services/recovery/gitCopies.js +219 -0
  87. package/dist/services/recovery/gitCopies.js.map +1 -0
  88. package/dist/services/recovery/history.js +423 -0
  89. package/dist/services/recovery/history.js.map +1 -0
  90. package/dist/services/recovery/recoveryService.js +521 -0
  91. package/dist/services/recovery/recoveryService.js.map +1 -0
  92. package/dist/services/recovery/storage.js +344 -0
  93. package/dist/services/recovery/storage.js.map +1 -0
  94. package/dist/services/schedules/scheduleService.js +9 -10
  95. package/dist/services/schedules/scheduleService.js.map +1 -1
  96. package/dist/store/db.js +71 -0
  97. package/dist/store/db.js.map +1 -1
  98. package/dist/tools/catalog.js +4 -0
  99. package/dist/tools/catalog.js.map +1 -1
  100. package/dist/tools/changeTools.js +1 -1
  101. package/dist/tools/changeTools.js.map +1 -1
  102. package/dist/tools/context.js +23 -3
  103. package/dist/tools/context.js.map +1 -1
  104. package/dist/tools/deploymentTools.js +58 -0
  105. package/dist/tools/deploymentTools.js.map +1 -0
  106. package/dist/tools/directTools.js +9 -5
  107. package/dist/tools/directTools.js.map +1 -1
  108. package/dist/tools/gitTools.js +7 -2
  109. package/dist/tools/gitTools.js.map +1 -1
  110. package/dist/tools/jobTools.js +4 -4
  111. package/dist/tools/jobTools.js.map +1 -1
  112. package/dist/tools/projectTools.js +1 -0
  113. package/dist/tools/projectTools.js.map +1 -1
  114. package/dist/tools/recoveryTools.js +79 -0
  115. package/dist/tools/recoveryTools.js.map +1 -0
  116. package/dist/tools/runtimeTools.js +1 -1
  117. package/dist/tools/runtimeTools.js.map +1 -1
  118. package/dist/tools/surface.js +101 -41
  119. package/dist/tools/surface.js.map +1 -1
  120. package/dist/tunnel/credentials.js +1 -1
  121. package/dist/tunnel/credentials.js.map +1 -1
  122. package/docs/AI_PROVIDERS.md +6 -3
  123. package/docs/ARCHITECTURE.md +88 -4
  124. package/docs/CI.md +118 -0
  125. package/docs/COMPATIBILITY.md +7 -6
  126. package/docs/EVALUATION.md +17 -12
  127. package/docs/MANUAL_ACCEPTANCE.md +59 -2
  128. package/docs/RECOVERY.md +448 -0
  129. package/docs/RELEASE_1.2.1.md +47 -0
  130. package/docs/RELEASE_1.3.0.md +64 -0
  131. package/docs/RELEASE_NOTES.md +41 -0
  132. package/docs/SECURITY.md +122 -0
  133. package/docs/TEST_REPORT.md +327 -5
  134. package/docs/WEB_CLIENTS.md +6 -1
  135. package/docs/adr/051-source-recovery-foundation.md +45 -0
  136. package/docs/adr/052-reviewed-source-restore.md +50 -0
  137. package/docs/adr/053-content-drift-and-private-git-recovery.md +29 -0
  138. package/docs/adr/054-verified-source-checkpoints.md +58 -0
  139. package/docs/adr/055-reviewed-docker-deployments.md +42 -0
  140. package/docs/adr/056-owner-data-recovery.md +47 -0
  141. package/docs/adr/README.md +7 -0
  142. package/native/private-state-windows.cs +105 -0
  143. package/package.json +7 -1
  144. package/schemas/global-config.schema.json +44 -0
  145. package/schemas/tools.compact.json +175 -15
  146. package/schemas/tools.hybrid.json +350 -16
  147. 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 { renameWithRetry, retryWindowsFs } from '../../platform/fsRetry.js';
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(async () => {
42
- const blocked = this.recoveryBlocked();
43
- if (blocked) {
44
- throw new DodoError('RECOVERY_REQUIRED', `a previous change (${blocked.id}) needs manual recovery before new mutations`, {
45
- recovery: 'run `dodo recover` on the server terminal',
46
- detail: { changesetId: blocked.id },
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 row = this.store.getPlan(opts.planId);
50
- if (!row)
51
- throw new DodoError('NOT_FOUND', 'unknown planId', { detail: { planId: opts.planId } });
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
- const plan = JSON.parse(row.payload);
67
- // Recheck EVERY path and expected raw-byte hash before touching anything.
68
- const conflicts = [];
69
- for (const f of plan.files) {
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
- conflicts.push(conflict);
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
- if (conflicts.length > 0) {
75
- throw new DodoError('FILE_CHANGED', 'workspace changed since preview; preview again', {
76
- detail: { conflicts: conflicts.slice(0, 20) },
77
- recovery: 'read the affected files and create a fresh plan',
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
- const changesetId = newId('cs');
81
- const csBackupDir = path.join(this.backupsDir, changesetId);
82
- fs.mkdirSync(csBackupDir, { recursive: true, mode: 0o700 });
83
- const tx = this.store.db.transaction(() => {
84
- this.store.createChangeset({
85
- id: changesetId,
86
- workspaceId: opts.workspaceId,
87
- epoch: opts.epoch,
88
- planId: opts.planId,
89
- principal: opts.principal,
90
- kind: 'apply',
91
- summary: plan.summary,
92
- });
93
- plan.files.forEach((f, seq) => {
94
- this.store.addJournalStep({
95
- changesetId,
96
- seq,
97
- op: f.action,
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
- for (let seq = 0; seq < plan.files.length; seq += 1) {
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 (err) {
121
- this.store.setChangesetStatus(changesetId, 'failed', `backup failed: ${err.message}`);
122
- throw new DodoError('RESOURCE_LIMIT', 'could not persist backups; nothing was changed', {
123
- recovery: 'check disk space and permissions for the DODO state directory',
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
- // Phase 2: apply each file with per-file atomic rename where possible.
127
- const applied = [];
128
- for (let seq = 0; seq < plan.files.length; seq += 1) {
129
- const f = plan.files[seq];
130
- try {
131
- this.applyOne(f);
132
- this.store.setJournalStepState(changesetId, seq, 'written');
133
- applied.push(seq);
134
- }
135
- catch (err) {
136
- const revert = this.revertSteps(plan, applied, changesetId);
137
- if (revert.conflicts.length === 0) {
138
- this.store.setChangesetStatus(changesetId, 'failed', `apply failed at ${f.path}: ${err.message}`);
139
- throw err instanceof DodoError
140
- ? err
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
- const done = this.store.db.transaction(() => {
151
- for (let seq = 0; seq < plan.files.length; seq += 1)
152
- this.store.setJournalStepState(changesetId, seq, 'done');
153
- this.store.setChangesetStatus(changesetId, 'committed');
154
- this.store.invalidatePlan(opts.planId);
155
- });
156
- done();
157
- return {
158
- changesetId,
159
- files: plan.files.map((f) => {
160
- const out = { path: f.path, action: f.action, afterHash: f.afterHash };
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
- const current = this.wfs.readFileBytes(f.path, this.limits.readFileBytes * 2);
181
- const hash = sha256Bytes(current.bytes);
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(() => fs.unlinkSync(abs));
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
- renameWithRetry(abs, dest);
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
- this.assertGuardedFile(f.path);
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 (hashFileAt(abs) === f.afterHash) {
242
- retryWindowsFs(() => fs.unlinkSync(abs));
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 (hashFileAt(abs) === f.afterHash) {
259
- copyFileSyncDurable(backupPath, abs, f.mode);
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 (hashFileAt(abs) === null) {
268
- copyFileSyncDurable(backupPath, abs, f.mode);
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 (hashFileAt(dest) === f.afterHash && hashFileAt(abs) === null) {
278
- renameWithRetry(dest, abs);
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: `revert failed: ${err.message}` });
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(async () => {
302
- const blocked = this.recoveryBlocked();
303
- if (blocked) {
304
- throw new DodoError('RECOVERY_REQUIRED', `a previous change (${blocked.id}) needs manual recovery before new mutations`, {
305
- recovery: 'run `dodo recover` on the server terminal',
306
- });
307
- }
308
- const cs = this.store.getChangeset(opts.changesetId);
309
- if (!cs || cs.workspaceId !== opts.workspaceId)
310
- throw new DodoError('NOT_FOUND', 'unknown changesetId');
311
- if (cs.kind !== 'apply')
312
- throw new DodoError('INVALID_INPUT', 'only apply changesets can be rolled back');
313
- if (cs.status !== 'committed') {
314
- throw new DodoError('CONFLICT', `changeset is ${cs.status}, not committed`, { detail: { changesetId: cs.id } });
315
- }
316
- const steps = this.store.listJournalSteps(cs.id);
317
- // Verify phase — refuse on any conflict, preserve human work (CHG-11).
318
- const conflicts = [];
319
- for (const s of steps) {
320
- const conflict = this.verifyRollbackable(s);
321
- if (conflict)
322
- conflicts.push(conflict);
323
- }
324
- if (conflicts.length > 0) {
325
- throw new DodoError('CONFLICT', 'files changed after this changeset; rollback refused to protect newer edits', {
326
- detail: { conflicts: conflicts.slice(0, 20) },
327
- recovery: 'inspect the conflicting files; roll back manually or create a new plan',
328
- });
329
- }
330
- const rbId = newId('cs');
331
- this.store.createChangeset({
332
- id: rbId,
333
- workspaceId: opts.workspaceId,
334
- epoch: opts.epoch,
335
- planId: null,
336
- principal: opts.principal,
337
- kind: 'rollback',
338
- summary: `rollback of ${cs.id}`,
339
- });
340
- const restored = [];
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
- this.assertGuardedFile(s.path);
389
- if (s.destPath)
390
- this.assertGuardedFile(s.destPath);
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: 'path policy changed after apply; rollback refused' };
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 written = steps.filter((s) => s.state === 'written' || s.state === 'done');
434
- if (written.length === 0) {
435
- this.store.setChangesetStatus(cs.id, 'failed', 'interrupted before any write');
436
- out.failed.push(cs.id);
437
- continue;
438
- }
439
- const allWrittenVerify = steps.every((s) => {
440
- if (s.state !== 'written' && s.state !== 'done')
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
- this.assertGuardedFile(rel);
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 (allWrittenVerify) {
452
- this.store.setChangesetStatus(cs.id, 'committed');
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 mid-apply; on-disk state is mixed');
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 statMode(abs) {
464
- try {
465
- return fs.statSync(abs).mode & 0o777;
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.writeFileSync(fd, bytes);
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.unlinkSync(tmp);
673
+ fs.writeFileSync(fd, bytes);
674
+ if (process.platform !== 'win32')
675
+ fs.fchmodSync(fd, mode);
676
+ fs.fsyncSync(fd);
502
677
  }
503
- catch {
504
- /* ignore */
678
+ finally {
679
+ fs.closeSync(fd);
505
680
  }
506
- throw err;
507
- }
508
- fsyncDir(dir);
509
- }
510
- function copyFileSyncDurable(src, dest, mode) {
511
- const bytes = fs.readFileSync(src);
512
- const dir = path.dirname(dest);
513
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
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
- fs.closeSync(fd);
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
- /* best effort */
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