@zktx.io/sui-mcp 0.5.0 → 0.6.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 (87) hide show
  1. package/README.md +6 -6
  2. package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +3 -3
  3. package/dist/core/activity/localDataService.js +71 -52
  4. package/dist/core/activity/localDataValidation.js +1 -3
  5. package/dist/core/activity/sqliteActivityStore.js +24 -16
  6. package/dist/core/activity/sqliteActivityStoreSchema.js +83 -12
  7. package/dist/core/preferences/sqlitePreferencesRepository.js +5 -3
  8. package/dist/core/session/cardSession.js +1 -1
  9. package/dist/core/session/cardSessionStore.js +53 -3
  10. package/dist/core/session/sessionStore.js +0 -17
  11. package/dist/core/session/settingsFileIdentity.js +11 -0
  12. package/dist/core/session/settingsFileProtocol.js +25 -0
  13. package/dist/core/session/settingsFileStore.js +346 -0
  14. package/dist/core/session/settingsInputError.js +34 -0
  15. package/dist/core/session/settingsOperation.js +224 -0
  16. package/dist/core/session/settingsPreparationSql.js +15 -0
  17. package/dist/core/session/settingsWorkflow.js +1219 -0
  18. package/dist/core/session/sqliteSessionStore.js +0 -40
  19. package/dist/core/session/sqliteSettingsWorkflowStore.js +463 -0
  20. package/dist/core/session/sqliteWalletWorkflowStore.js +164 -41
  21. package/dist/core/session/status.js +2 -2
  22. package/dist/core/session/walletConnection.js +27 -8
  23. package/dist/core/session/walletWorkflow.js +73 -15
  24. package/dist/core/session/workflowState.js +16 -8
  25. package/dist/core/session/workflowView.js +18 -2
  26. package/dist/mcp/responseGuidance.js +10 -5
  27. package/dist/mcp/serverInfo.js +3 -4
  28. package/dist/mcp/toolNames.js +2 -2
  29. package/dist/mcp/tools/account/index.js +1 -19
  30. package/dist/mcp/tools/read/readToolHelpers.js +5 -4
  31. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  32. package/dist/mcp/tools/session/shared.js +1 -0
  33. package/dist/mcp/tools/session/statusTools.js +4 -3
  34. package/dist/mcp/tools/settings/index.js +8 -28
  35. package/dist/mcp-app/account.html +14 -14
  36. package/dist/mcp-app/account.notices.txt +1 -1
  37. package/dist/mcp-app/chart.html +9 -9
  38. package/dist/mcp-app/chart.notices.txt +1 -1
  39. package/dist/mcp-app/connect.html +14 -14
  40. package/dist/mcp-app/connect.notices.txt +1 -1
  41. package/dist/mcp-app/receipt.html +1298 -1463
  42. package/dist/mcp-app/receipt.notices.txt +226 -1204
  43. package/dist/mcp-app/review.html +1298 -1463
  44. package/dist/mcp-app/review.notices.txt +226 -1204
  45. package/dist/mcp-app/settings.html +3133 -0
  46. package/dist/mcp-app/settings.notices.txt +973 -0
  47. package/dist/mcp-ui/contracts.js +8 -2
  48. package/dist/mcp-ui/readCards.js +1 -1
  49. package/dist/mcp-ui/tools.js +33 -2
  50. package/dist/review-server/server.js +5 -51
  51. package/dist/runtime/application.js +41 -14
  52. package/dist/runtime/localSettingsService.js +26 -2
  53. package/dist/runtime/shared/authenticatedFetch.js +6 -2
  54. package/dist/runtime/shared/control.js +5 -6
  55. package/dist/runtime/shared/http.js +14 -0
  56. package/dist/runtime/shared/mcpHttp.js +9 -4
  57. package/dist/runtime/shared/server.js +1 -1
  58. package/dist/runtime/smokeMainnetRead.js +0 -1
  59. package/dist/runtime/walletConnectStorage.js +2 -4
  60. package/dist/runtime/walletSdkChildRuntime.js +2 -1
  61. package/dist/runtime/walletSdkIpc.js +2 -1
  62. package/dist/runtime/walletSdkProcess.js +3 -2
  63. package/docs/AGENT_BEHAVIOR.md +44 -10
  64. package/docs/AGENT_DEVELOPMENT_POLICY.md +16 -12
  65. package/docs/FRONTEND_POLICY.md +105 -28
  66. package/docs/LOCAL_DB_ARCHITECTURE.md +143 -20
  67. package/docs/MCP_SETUP.md +99 -39
  68. package/docs/MCP_TOOLS.md +273 -24
  69. package/docs/SDK_API.md +51 -10
  70. package/docs/UTILITY_INDEX.md +9 -6
  71. package/docs/WALLET_CONNECTION.md +30 -9
  72. package/package.json +13 -7
  73. package/dist/core/activity/schemaVersion.js +0 -2
  74. package/dist/core/session/keyedRecordStore.js +0 -18
  75. package/dist/core/session/settingsSession.js +0 -1
  76. package/dist/core/session/settingsSessions.js +0 -45
  77. package/dist/review-app/brand-dark.svg +0 -1
  78. package/dist/review-app/brand-light.svg +0 -1
  79. package/dist/review-app/favicon.svg +0 -1
  80. package/dist/review-app/settings.css +0 -1
  81. package/dist/review-app/settings.js +0 -1
  82. package/dist/review-app/ui.css +0 -809
  83. package/dist/review-server/assets.js +0 -48
  84. package/dist/review-server/html.js +0 -45
  85. package/dist/review-server/http.js +0 -47
  86. package/dist/review-server/middleware/reviewToken.js +0 -7
  87. package/dist/review-server/settingsApi.js +0 -158
@@ -0,0 +1,1219 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename, dirname } from "node:path";
3
+ import { TextDecoder } from "node:util";
4
+ import { LocalDataError } from "../activity/localDataTypes.js";
5
+ import { settingsInputError, SETTINGS_REPLACEMENT_BLOCKED_MESSAGE } from "./settingsInputError.js";
6
+ import { SETTINGS_APPLIES_AFTER_RESTART } from "../preferences/preferencesStore.js";
7
+ import { parseGraphqlUrl, parseGrpcUrl } from "../suiEndpoint.js";
8
+ import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
9
+ import { DEFAULT_SESSION_TTL_MS } from "./localSession.js";
10
+ import { isSettingsDownloadFile, settingsDownloadPath } from "./settingsFileIdentity.js";
11
+ import { SettingsDownloadPathExists, settingsFileDigest, decodeSettingsChunk } from "./settingsFileStore.js";
12
+ import { settingsIdentity } from "./sqliteSettingsWorkflowStore.js";
13
+ import { SettingsConflict, settingsDownload, isSettingsDownloadFileAction, settingsViewSchema, endpointPreparationSchema, storedRestoreCheckSchema, retainedBackupRejection, permitsBackupRecheck } from "./settingsOperation.js";
14
+ // One existing product-session length from physical snapshot readiness.
15
+ export const DOWNLOAD_RETRY_WINDOW_MS = DEFAULT_SESSION_TTL_MS;
16
+ export class SettingsWorkflow {
17
+ options;
18
+ pendingResults = new Map();
19
+ finishedFileIO = new Set();
20
+ // This tracks actual pending I/O only; SQLite owns check results and permission.
21
+ checkingFiles = new Set();
22
+ downloadTimer;
23
+ closing = false;
24
+ downloadIO = new Map();
25
+ downloadTasks = new Set();
26
+ localSaveTasks = new Map();
27
+ localSaveResults = new Map();
28
+ constructor(options) {
29
+ this.options = options;
30
+ options.wallet.attachSettingsRecovery({ synchronize: () => this.synchronize(undefined, true),
31
+ runtimeEvent: (event) => this.runtimeEvent(event), readyPublished: (runId) => this.readyPublished(runId) });
32
+ }
33
+ get records() { this.options.assertCurrent(); return this.options.records; }
34
+ create(record) { this.records.create(record); }
35
+ operationKind(record) { return this.records.forCard(record).draft?.kind; }
36
+ recoverOwner() {
37
+ // Invalidate old input synchronously. Verifying a prior backup and removing
38
+ // temporary files must not delay unrelated MCP startup or completed reads.
39
+ this.records.transaction(() => {
40
+ const downloads = this.records.db.prepare("SELECT operation_id FROM settings_operations WHERE kind='download_backup' AND owner_id!=?")
41
+ .all(this.records.ownerId);
42
+ for (const row of downloads) {
43
+ const operation = this.records.get(row.operation_id);
44
+ if (settingsDownload(operation)?.localSave?.status === "saving") {
45
+ // Inherit settlement, never the departed card's input permission.
46
+ // Keep its final/file reservation until the existing artifact is checked.
47
+ this.records.db.prepare("UPDATE settings_operations SET owner_id=? WHERE operation_id=?").run(this.records.ownerId, operation.operationId);
48
+ }
49
+ else
50
+ this.records.endDownload(row.operation_id, "server_restarted");
51
+ }
52
+ const rows = this.records.db.prepare("SELECT operation_id FROM settings_operations WHERE owner_id!=? AND phase!='terminal'")
53
+ .all(this.records.ownerId);
54
+ for (const row of rows) {
55
+ const operation = this.records.get(row.operation_id);
56
+ const file = operation.fileId ? this.records.file(operation.fileId) : undefined;
57
+ const card = this.records.cards.get(operation.cardId);
58
+ if (!card)
59
+ continue;
60
+ const verifyExport = operation.draft?.kind === "export_local_data" && file?.role === "export" && !!operation.admittedAt;
61
+ const result = { outcome: "interrupted", recordedAt: this.now().toISOString(),
62
+ message: operation.admittedAt
63
+ ? "Sui MCP restarted before this confirmed change finished. It will not be repeated. Check the result of the original request separately."
64
+ : "Sui MCP restarted before you confirmed this change. It will not be repeated." };
65
+ this.records.db.prepare(`UPDATE settings_operations SET owner_id=?,phase=?,result_json=?,checkpoint=? WHERE operation_id=?`)
66
+ .run(this.records.ownerId, verifyExport ? "executing" : "terminal", verifyExport ? null : JSON.stringify(result), verifyExport ? "verify_export_after_owner_change" : null, operation.operationId);
67
+ const reason = card.state.reason ?? (card.state.state === "ready" && Date.parse(card.state.expiresAt) <= this.now().getTime() ? "expired" : "server_restarted");
68
+ if (this.records.db.prepare(`UPDATE live_read_cards SET owner_id=?,state=?,reason=?,revision=revision+1
69
+ WHERE id=? AND owner_id=? AND token_hash=? AND revision=?`).run(this.records.ownerId, verifyExport ? "running" : "closed", verifyExport ? null : reason, card.state.cardId, card.ownerId, card.tokenHash, card.state.revision).changes !== 1) {
70
+ throw new SettingsConflict("The interrupted Settings card changed while its status was being updated.");
71
+ }
72
+ if (file?.role === "upload")
73
+ this.records.writeFile({ ...file, invalidated: true });
74
+ this.records.db.prepare(`UPDATE settings_control SET active_operation_id=NULL,wallet_input_blocked=0,wallet_block_operation_id=NULL
75
+ WHERE id=1 AND active_operation_id=?`).run(operation.operationId);
76
+ if (file)
77
+ this.records.releaseFileIO(file.fileId);
78
+ }
79
+ // The departed owner may have committed a terminal result and finished
80
+ // physical I/O, but failed to persist its final I/O release. Terminal
81
+ // operations were intentionally not reopened above. Release that stale
82
+ // reservation without replaying the operation or touching a current owner.
83
+ const ioId = this.records.control().file_io_id;
84
+ if (ioId) {
85
+ const file = this.records.file(ioId);
86
+ const cleanupOwner = file && this.records.get(file.cleanupOperationId ?? file.operationId);
87
+ if (!cleanupOwner || cleanupOwner.ownerId !== this.records.ownerId)
88
+ this.records.releaseFileIO(ioId);
89
+ }
90
+ });
91
+ }
92
+ async cleanupAfterOwnerChange() {
93
+ for (const operation of this.records.downloads())
94
+ this.observeLocalSave(operation);
95
+ await Promise.allSettled([...this.localSaveTasks.values()]);
96
+ await this.maintainDownloads();
97
+ }
98
+ maintenance(work) {
99
+ return this.options.runMaintenance ? this.options.runMaintenance(work) : work();
100
+ }
101
+ async trackDownloadIO(fileId, work) {
102
+ const task = Promise.resolve().then(work), active = this.downloadIO.get(fileId) ?? new Set();
103
+ active.add(task);
104
+ this.downloadIO.set(fileId, active);
105
+ try {
106
+ return await task;
107
+ }
108
+ finally {
109
+ active.delete(task);
110
+ if (!active.size)
111
+ this.downloadIO.delete(fileId);
112
+ }
113
+ }
114
+ scheduleDownloadExpiry(skipElapsed = false) {
115
+ if (this.downloadTimer !== undefined)
116
+ clearTimeout(this.downloadTimer);
117
+ this.downloadTimer = undefined;
118
+ if (this.closing)
119
+ return;
120
+ const deadlines = this.records.downloads().flatMap((operation) => {
121
+ const download = settingsDownload(operation);
122
+ return !download.endReason && download.localSave?.status !== "saving" && download.expiresAt && (!skipElapsed || Date.parse(download.expiresAt) > this.now().getTime()) ? [Date.parse(download.expiresAt)] : [];
123
+ });
124
+ if (!deadlines.length)
125
+ return;
126
+ this.downloadTimer = setTimeout(() => {
127
+ this.downloadTimer = undefined;
128
+ void this.maintenance(() => this.maintainDownloads()).catch(() => {
129
+ // Durable cleanup remains pending/failed; a same-operation read or
130
+ // startup can retry. Never spin on an already elapsed deadline.
131
+ });
132
+ }, Math.max(0, Math.min(...deadlines) - this.now().getTime()));
133
+ this.downloadTimer.unref?.();
134
+ }
135
+ async maintainDownloads() {
136
+ this.options.assertCurrent();
137
+ let failure;
138
+ try {
139
+ this.records.expireDownloads();
140
+ }
141
+ catch (error) {
142
+ this.options.assertCurrent();
143
+ failure = error;
144
+ }
145
+ try {
146
+ await this.cleanupTemporaryFiles();
147
+ }
148
+ catch (error) {
149
+ this.options.assertCurrent();
150
+ failure ??= error;
151
+ }
152
+ // A failed, already-due journal stays retryable through Check status/startup;
153
+ // it cannot spin at zero delay or cancel another snapshot's future deadline.
154
+ this.scheduleDownloadExpiry(failure !== undefined);
155
+ if (failure)
156
+ throw failure;
157
+ }
158
+ dataReplaced() {
159
+ // The replacement action owns and awaits its transferred cleanup journal.
160
+ // Starting another cleanup here would race its I/O reservation and return
161
+ // before the action's required cleanup result is known.
162
+ this.scheduleDownloadExpiry();
163
+ }
164
+ async close() {
165
+ this.closing = true;
166
+ if (this.downloadTimer !== undefined)
167
+ clearTimeout(this.downloadTimer);
168
+ this.downloadTimer = undefined;
169
+ await Promise.allSettled([...this.downloadTasks, ...[...this.downloadIO.values()].flatMap((tasks) => [...tasks])]);
170
+ await this.maintenance(async () => {
171
+ for (const operation of this.records.downloads())
172
+ this.observeLocalSave(operation);
173
+ await Promise.allSettled([...this.localSaveTasks.values()]);
174
+ this.records.transaction(() => { for (const operation of this.records.downloads())
175
+ this.records.endDownload(operation.operationId, "server_restarted"); });
176
+ await this.cleanupTemporaryFiles();
177
+ });
178
+ }
179
+ async verifyPriorExport(operation) {
180
+ const file = operation.fileId ? this.records.file(operation.fileId) : undefined;
181
+ let result = { outcome: "interrupted", recordedAt: this.now().toISOString(),
182
+ message: "Sui MCP restarted and the backup file could not be checked. Open a new Settings card to create another backup." };
183
+ if (file) {
184
+ try {
185
+ await this.options.files.verifyExport(file);
186
+ result = { outcome: "completed", recordedAt: this.now().toISOString(), message: "The previously created local backup was verified.",
187
+ details: { path: this.options.files.exportPath(file), filename: file.displayName, fileId: file.fileId, sha256: file.sha256, totalSize: file.totalSize } };
188
+ }
189
+ catch { /* A missing artifact is never regenerated on a read. */ }
190
+ }
191
+ this.options.assertCurrent();
192
+ this.retainResult(operation.operationId, result, false, file && result.outcome === "completed" ? { ...file, sealed: true, receivedSize: file.totalSize } : undefined);
193
+ }
194
+ async readFile(record, fileId, offset, limit, attemptId) {
195
+ const operation = this.records.forCard(record), file = this.records.file(fileId);
196
+ if (operation.draft?.kind === "download_backup") {
197
+ if (this.closing)
198
+ throw new SettingsConflict("This server is closing; the download cannot continue.");
199
+ this.records.expireDownloads(operation.operationId);
200
+ const checked = this.records.requireDownloadRead(record, fileId, attemptId), length = Math.min(limit, checked.file.totalSize - offset);
201
+ if (!Number.isSafeInteger(offset) || offset < 0 || length < 1)
202
+ throw new SettingsConflict("This part of the backup is unavailable.");
203
+ let data;
204
+ try {
205
+ data = await this.trackDownloadIO(fileId, () => this.options.files.readDownloadRange(checked.file, offset, length));
206
+ }
207
+ catch {
208
+ this.options.assertCurrent();
209
+ this.records.transaction(() => this.records.endDownload(operation.operationId, "source_unavailable"));
210
+ await this.cleanupTemporaryFiles(operation.operationId);
211
+ this.scheduleDownloadExpiry();
212
+ throw new SettingsConflict("The download snapshot could not be read or verified.");
213
+ }
214
+ this.options.assertCurrent();
215
+ try {
216
+ this.records.expireDownloads(operation.operationId);
217
+ this.records.requireDownloadRead(record, fileId, attemptId);
218
+ return { file: checked.file, offset, chunkBase64: data.toString("base64") };
219
+ }
220
+ finally {
221
+ // A read may have been the last physical I/O keeping an ended snapshot
222
+ // alive. Settle its own journal; a saved-result read need not await it.
223
+ await this.cleanupTemporaryFiles(operation.operationId);
224
+ }
225
+ }
226
+ if (operation.result?.outcome !== "completed" || operation.draft?.kind !== "export_local_data" || operation.fileId !== fileId ||
227
+ !file || file.operationId !== operation.operationId || !file.sealed || file.invalidated || attemptId !== undefined)
228
+ throw new SettingsConflict("This completed backup is not available for download.");
229
+ const length = Math.min(limit, file.totalSize - offset);
230
+ const data = await this.options.files.readExportRange(file, offset, length);
231
+ this.options.assertCurrent();
232
+ return { file, offset, chunkBase64: data.toString("base64") };
233
+ }
234
+ async startDownload(expected, input) {
235
+ let bytes;
236
+ const file = this.records.admitDownload(expected, input, (operation) => {
237
+ bytes = Buffer.from(JSON.stringify(this.options.localData.exportLocalDataSync(this.now())), "utf8");
238
+ const fileId = randomUUID();
239
+ return { fileId, operationId: operation.operationId, role: "export", relativePath: settingsDownloadPath(fileId),
240
+ displayName: `${operation.operationId}.json`, totalSize: bytes.length, receivedSize: 0, sha256: settingsFileDigest(bytes),
241
+ sealed: false, invalidated: false, cleanup: "pending" };
242
+ });
243
+ const task = this.writeDownload(file, bytes);
244
+ this.downloadTasks.add(task);
245
+ void task.catch(() => { }).finally(() => this.downloadTasks.delete(task));
246
+ }
247
+ async writeDownload(file, data) {
248
+ let result, readyAt;
249
+ try {
250
+ await this.trackDownloadIO(file.fileId, () => this.options.files.createDownload(file, data));
251
+ this.options.assertCurrent();
252
+ readyAt = this.now().toISOString();
253
+ result = { outcome: "completed", completionScope: "backup_snapshot", recordedAt: readyAt,
254
+ message: "The backup is ready for download. This does not confirm that a file was saved.",
255
+ details: { filename: file.displayName, fileId: file.fileId, sha256: file.sha256, totalSize: file.totalSize } };
256
+ }
257
+ catch (error) {
258
+ this.options.assertCurrent();
259
+ if (error instanceof SettingsDownloadPathExists)
260
+ this.records.transaction(() => {
261
+ this.records.endDownload(file.operationId, "source_unavailable");
262
+ const existing = this.records.file(file.fileId);
263
+ // An exclusive-create collision never made this request the physical
264
+ // file's owner. Do not delete that pre-existing item as cleanup.
265
+ this.records.writeFile({ ...existing, invalidated: true, cleanup: "complete" });
266
+ });
267
+ result = { outcome: "failed", completionScope: "backup_snapshot", recordedAt: this.now().toISOString(),
268
+ message: "The backup could not be prepared. Open a new Settings card to try again." };
269
+ }
270
+ this.finishedFileIO.add(file.fileId);
271
+ this.retainResult(file.operationId, result, false, result.outcome === "completed" ? { ...file, sealed: true, receivedSize: file.totalSize } : undefined, readyAt);
272
+ this.scheduleDownloadExpiry();
273
+ await this.cleanupTemporaryFiles(file.operationId);
274
+ }
275
+ async continueDownload(record, action) {
276
+ const file = this.records.continueDownload(record, action);
277
+ if (file)
278
+ this.runLocalSave(file, true);
279
+ this.scheduleDownloadExpiry();
280
+ await this.cleanupTemporaryFiles(action.operationId);
281
+ }
282
+ observeLocalSave(operation) {
283
+ if (operation.ownerId !== this.records.ownerId || operation.dataEpoch !== this.records.control().data_epoch ||
284
+ settingsDownload(operation)?.localSave?.status !== "saving" || this.localSaveTasks.has(operation.operationId))
285
+ return;
286
+ const file = operation.fileId && this.records.file(operation.fileId);
287
+ if (file)
288
+ this.runLocalSave(file, false);
289
+ }
290
+ runLocalSave(file, publish) {
291
+ if (this.localSaveTasks.has(file.operationId))
292
+ return;
293
+ const task = this.settleLocalSave(file, publish);
294
+ this.localSaveTasks.set(file.operationId, task);
295
+ this.downloadTasks.add(task);
296
+ void task.catch(() => { }).finally(() => {
297
+ this.localSaveTasks.delete(file.operationId);
298
+ this.downloadTasks.delete(task);
299
+ });
300
+ }
301
+ async settleLocalSave(file, publish) {
302
+ const operation = this.records.get(file.operationId), saved = settingsDownload(operation)?.localSave;
303
+ if (saved?.status !== "saving" || !saved.currentSaveAttemptId)
304
+ return;
305
+ const saveAttemptId = saved.currentSaveAttemptId;
306
+ this.records.localSaveContext(file.operationId, saveAttemptId);
307
+ let retained = this.localSaveResults.get(file.operationId);
308
+ if (!retained) {
309
+ const outcome = await this.trackDownloadIO(file.fileId, async () => {
310
+ if (publish) {
311
+ try {
312
+ return await this.options.files.publishDownload(file, (identity) => {
313
+ this.options.assertCurrent();
314
+ this.records.rememberLocalSaveSource(file.operationId, saveAttemptId, identity);
315
+ });
316
+ }
317
+ catch {
318
+ this.options.assertCurrent();
319
+ }
320
+ }
321
+ const current = this.records.localSaveContext(file.operationId, saveAttemptId);
322
+ return this.options.files.inspectDownloadPublication(file, current.save.sourceIdentity);
323
+ });
324
+ this.options.assertCurrent();
325
+ retained = { saveAttemptId, outcome };
326
+ this.localSaveResults.set(file.operationId, retained);
327
+ }
328
+ // Retain the ended physical outcome if the DB write fails. A read retries
329
+ // this same settlement, not link(), a new snapshot, or a Host request.
330
+ this.records.settleLocalSave(file.operationId, retained.saveAttemptId, retained.outcome);
331
+ this.localSaveResults.delete(file.operationId);
332
+ this.scheduleDownloadExpiry();
333
+ await this.cleanupTemporaryFiles(file.operationId);
334
+ }
335
+ now() { return this.records.now(); }
336
+ withoutPreparation(operation) {
337
+ const { preparationId: _id, fingerprint: _fingerprint, preparation: _preparation, preparedUserDataRevision: _revision, ...rest } = operation;
338
+ const rejection = retainedBackupRejection(operation, operation.fileId ? this.records.file(operation.fileId) : undefined);
339
+ return rejection ? { ...rest, preparation: { restoreCheck: rejection } } : rest;
340
+ }
341
+ async backupInput(file) {
342
+ const bytes = await this.options.files.readUpload(file);
343
+ return this.parseBackupBytes(bytes);
344
+ }
345
+ parseBackupBytes(bytes) {
346
+ let text;
347
+ try {
348
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
349
+ }
350
+ catch (error) {
351
+ if (!(error instanceof TypeError) || !("code" in error) || error.code !== "ERR_ENCODING_INVALID_ENCODED_DATA")
352
+ throw error;
353
+ throw new LocalDataError("input_invalid", "The backup is not valid UTF-8.", { reason: "invalid_backup_encoding" });
354
+ }
355
+ try {
356
+ return JSON.parse(text);
357
+ }
358
+ catch (error) {
359
+ if (!(error instanceof SyntaxError))
360
+ throw error;
361
+ throw new LocalDataError("input_invalid", "The backup is not valid JSON.", { reason: "invalid_backup_json" });
362
+ }
363
+ }
364
+ fingerprint(operation) {
365
+ const control = this.records.control(), draft = operation.draft;
366
+ if (!draft)
367
+ throw new SettingsConflict("Choose what you want to change in Settings first.");
368
+ const common = { draft, epoch: control.data_epoch };
369
+ if (draft.kind === "restart_wallet_service") {
370
+ const context = this.options.wallet.currentRecoveryFacts();
371
+ const active = control.active_operation_id && this.records.get(control.active_operation_id);
372
+ return settingsIdentity({ ...common, runId: context.availability.walletRunId, impact: context.impact,
373
+ activeId: active ? active.operationId : null, activePhase: active ? active.phase : null });
374
+ }
375
+ if (draft.kind === "set_endpoint" || draft.kind === "restore_endpoint")
376
+ return settingsIdentity({ ...common, target: this.options.resolveEndpoint(draft),
377
+ current: this.options.preferences.getLocalSettingSync(draft.endpoint === "grpc" ? "suiGrpcUrl" : "suiGraphqlUrl") });
378
+ if (draft.kind === "clear_read_address")
379
+ return settingsIdentity({ ...common,
380
+ current: this.records.db.prepare("SELECT * FROM active_account_context WHERE id=1").get() ?? null });
381
+ if (draft.kind === "import_local_data" || draft.kind === "reset_local_data")
382
+ return settingsIdentity({ ...common,
383
+ userDataRevision: control.user_data_revision, file: operation.fileId ? this.records.file(operation.fileId) : null });
384
+ return settingsIdentity(common);
385
+ }
386
+ requireRestartPreparation(context) {
387
+ const id = this.records.control().active_operation_id, active = id ? this.records.get(id) : undefined;
388
+ if (context.availability.status === "recovering" || active &&
389
+ (active.phase !== "starting" || active.nextRunId !== context.availability.walletRunId || context.availability.status !== "initializing")) {
390
+ throw new SettingsConflict("A Settings change or service restart has not finished. This restart cannot replace it.");
391
+ }
392
+ return active;
393
+ }
394
+ canPrepareRestart() {
395
+ try {
396
+ this.requireRestartPreparation(this.options.wallet.currentRecoveryFacts());
397
+ return true;
398
+ }
399
+ catch (error) {
400
+ if (error instanceof SettingsConflict)
401
+ return false;
402
+ throw error;
403
+ }
404
+ }
405
+ requireSupersedable(operationId, runId) {
406
+ const previous = this.records.get(operationId);
407
+ if (!previous || previous.phase !== "starting" || previous.nextRunId !== runId || this.pendingResults.has(operationId)) {
408
+ throw new SettingsConflict("The earlier service startup changed. Check its result before continuing.");
409
+ }
410
+ return previous;
411
+ }
412
+ requireConfirmation(record, operation) {
413
+ if (["export_local_data", "download_backup"].includes(operation.draft?.kind ?? ""))
414
+ throw new SettingsConflict("Open a new Settings card and use Download backup.");
415
+ const replacing = operation.draft?.kind === "restart_wallet_service" && typeof operation.preparation?.supersedesOperationId === "string"
416
+ ? operation.preparation.supersedesOperationId : undefined;
417
+ this.records.requireAdmission(record, { action: "settings_confirm", operationId: operation.operationId,
418
+ preparationId: operation.preparationId }, () => this.fingerprint(operation), replacing);
419
+ if (replacing)
420
+ this.requireSupersedable(replacing, operation.priorRunId);
421
+ if (operation.draft?.kind === "import_local_data" || operation.draft?.kind === "reset_local_data") {
422
+ this.options.localData.assertReplacementAllowed(this.now());
423
+ }
424
+ }
425
+ canConfirm(record, operation) {
426
+ try {
427
+ this.requireConfirmation(record, operation);
428
+ return true;
429
+ }
430
+ catch (error) {
431
+ if (error instanceof SettingsConflict || error instanceof LocalDataError && error.details.reason === "wallet_request_unsettled")
432
+ return false;
433
+ throw error;
434
+ }
435
+ }
436
+ restoreCheck(operation, record, file) {
437
+ if (!file || file.invalidated)
438
+ return { status: "no_file" };
439
+ const unavailable = (reason, message) => ({ status: "cannot_restore", reason, message });
440
+ const required = (message) => ({ status: "check_required", message });
441
+ if (record.state.state !== "ready" || record.ownerId !== this.records.ownerId ||
442
+ operation.dataEpoch !== this.records.control().data_epoch || Date.parse(record.state.expiresAt) <= this.now().getTime()) {
443
+ return unavailable("input_unavailable", "This Settings request can no longer restore data. Open a new Settings card.");
444
+ }
445
+ if (this.checkingFiles.has(file.fileId))
446
+ return { status: "checking" };
447
+ const rejection = retainedBackupRejection(operation, file);
448
+ if (rejection)
449
+ return rejection.check;
450
+ if (operation.phase === "prepared" && operation.preparationId) {
451
+ try {
452
+ this.requireConfirmation(record, operation);
453
+ return { status: "can_restore" };
454
+ }
455
+ catch (error) {
456
+ if (error instanceof LocalDataError && error.details.reason === "wallet_request_unsettled") {
457
+ return unavailable("wallet_request_unsettled", SETTINGS_REPLACEMENT_BLOCKED_MESSAGE);
458
+ }
459
+ if (!(error instanceof SettingsConflict))
460
+ throw error;
461
+ const control = this.records.control();
462
+ return control.file_io_id !== null || control.active_operation_id !== null
463
+ ? unavailable("operation_busy", "Another Settings change or file transfer is in progress. Wait for it to finish before restoring.")
464
+ : required("Local records changed or the earlier check is out of date. Check the same backup again.");
465
+ }
466
+ }
467
+ const saved = storedRestoreCheckSchema.safeParse(operation.preparation?.restoreCheck);
468
+ if (saved.success && saved.data.fileId === file.fileId && saved.data.sha256 === file.sha256 && saved.data.totalSize === file.totalSize &&
469
+ saved.data.dataEpoch === operation.dataEpoch && saved.data.userDataRevision === this.records.control().user_data_revision) {
470
+ if (saved.data.check.status === "cannot_restore") {
471
+ if (saved.data.check.reason === "wallet_request_unsettled") {
472
+ try {
473
+ this.options.localData.assertReplacementAllowed(this.now());
474
+ }
475
+ catch (error) {
476
+ if (error instanceof LocalDataError && error.details.reason === "wallet_request_unsettled")
477
+ return saved.data.check;
478
+ throw error;
479
+ }
480
+ return required("The earlier wallet or transaction wait has ended. Check this backup again.");
481
+ }
482
+ if (saved.data.check.reason === "operation_busy" && this.records.control().file_io_id === null &&
483
+ this.records.control().active_operation_id === null) {
484
+ return required("The earlier Settings change or file transfer has ended. Check this backup again.");
485
+ }
486
+ return saved.data.check;
487
+ }
488
+ if (saved.data.check.status === "checking")
489
+ return unavailable("check_failed", "The backup check did not finish. Check this backup again.");
490
+ if (saved.data.check.status === "check_required")
491
+ return saved.data.check;
492
+ }
493
+ return file.sealed ? required("Check this backup again before restoring.") : { status: "receiving" };
494
+ }
495
+ async describe(record) {
496
+ this.options.assertCurrent();
497
+ let operation = this.records.forCard(record);
498
+ this.observeLocalSave(operation);
499
+ let downloadExpiryPending = false;
500
+ if (operation.result) {
501
+ // The result is already committed. A failed release of known-ended I/O
502
+ // must remain retryable without making that result depend on the write.
503
+ try {
504
+ this.synchronize(operation.operationId);
505
+ }
506
+ catch {
507
+ this.options.assertCurrent();
508
+ }
509
+ }
510
+ if (operation.result === undefined) {
511
+ this.synchronize(operation.operationId);
512
+ operation = this.records.get(operation.operationId);
513
+ if (operation.checkpoint === "verify_export_after_owner_change" && !operation.result)
514
+ await this.verifyPriorExport(operation);
515
+ if (operation.draft?.kind === "restart_wallet_service" && operation.admittedAt) {
516
+ this.options.wallet.recoveryContext();
517
+ this.assertRecoveryRecorded();
518
+ }
519
+ this.records.transaction(() => {
520
+ record = this.records.cards.evaluate(record, () => this.now()).record;
521
+ if (record.state.state === "closed" && record.state.reason === "expired") {
522
+ if (operation.fileId)
523
+ this.invalidateFile(operation.fileId);
524
+ this.records.finish(operation.operationId, { outcome: "interrupted", recordedAt: this.now().toISOString(), message: "The time to confirm this Settings change has ended." });
525
+ }
526
+ });
527
+ operation = this.records.get(operation.operationId);
528
+ }
529
+ try {
530
+ this.records.expireDownloads(operation.operationId);
531
+ }
532
+ catch (error) {
533
+ this.options.assertCurrent();
534
+ if (!operation.result)
535
+ throw error;
536
+ downloadExpiryPending = true;
537
+ }
538
+ operation = this.records.get(operation.operationId);
539
+ if (operation.result) {
540
+ for (const file of this.records.filesNeedingCleanup(operation.operationId)) {
541
+ await this.cleanupFile(file, false).catch(() => { this.options.assertCurrent(); });
542
+ }
543
+ }
544
+ // Completed facts do not depend on current wallet or unrelated cleanup writes.
545
+ const facts = operation.result ? undefined : await this.options.readFacts();
546
+ this.options.assertCurrent();
547
+ record = this.records.cards.get(record.state.cardId);
548
+ operation = this.records.forCard(record);
549
+ const file = operation.fileId ? this.records.file(operation.fileId) : undefined;
550
+ const restoreCheck = operation.draft?.kind === "import_local_data" && !operation.result
551
+ ? this.restoreCheck(operation, record, file) : undefined;
552
+ const allowed = [];
553
+ if (record.state.state === "ready" && record.ownerId === this.records.ownerId && operation.dataEpoch === this.records.control().data_epoch && !operation.result) {
554
+ const fileIOAvailable = this.records.fileIOAvailable();
555
+ if (fileIOAvailable) {
556
+ allowed.push("settings_select", "settings_cancel");
557
+ if (this.records.control().active_operation_id === null)
558
+ allowed.push("settings_download_backup");
559
+ if (operation.draft)
560
+ allowed.push("settings_back");
561
+ }
562
+ if (operation.phase === "prepared" && (restoreCheck ? restoreCheck.status === "can_restore" : this.canConfirm(record, operation))) {
563
+ allowed.push("settings_confirm");
564
+ }
565
+ if (operation.draft?.kind === "import_local_data") {
566
+ if (fileIOAvailable && file && !file.invalidated && file.receivedSize === file.totalSize && !file.reservation &&
567
+ restoreCheck && permitsBackupRecheck(restoreCheck))
568
+ allowed.push("settings_prepare");
569
+ // A matching upload may resume without allocating another file. New
570
+ // writes still require the shared reservation check at their admission.
571
+ if (fileIOAvailable && ["draft", "uploading"].includes(operation.phase) &&
572
+ (file && !file.invalidated || this.records.canReserveFileIO(operation.operationId)))
573
+ allowed.push("settings_begin_upload");
574
+ }
575
+ else if (operation.phase !== "prepared" && operation.draft && !["export_local_data", "download_backup"].includes(operation.draft.kind) &&
576
+ (operation.draft.kind !== "restart_wallet_service" || this.canPrepareRestart()))
577
+ allowed.push("settings_prepare");
578
+ if (operation.phase === "uploading" && file && !file.invalidated && !file.sealed) {
579
+ if (file.receivedSize > 0 || file.receivedSize < file.totalSize && this.records.canReserveFileIO(operation.operationId)) {
580
+ allowed.push("settings_upload_chunk");
581
+ }
582
+ if (fileIOAvailable && file.receivedSize === file.totalSize && !file.reservation && !this.checkingFiles.has(file.fileId) &&
583
+ !retainedBackupRejection(operation, file))
584
+ allowed.push("settings_seal_upload");
585
+ }
586
+ }
587
+ const storedDownload = settingsDownload(operation);
588
+ const localSave = storedDownload?.localSave;
589
+ const downloadEnd = storedDownload?.endReason ?? (localSave?.status !== "saving" && storedDownload?.expiresAt && this.now().getTime() >= Date.parse(storedDownload.expiresAt) ? "expired" : undefined);
590
+ const currentAttempt = storedDownload?.attempts.find((attempt) => attempt.attemptId === storedDownload.currentAttemptId);
591
+ const currentDownloadOwner = operation.ownerId === this.records.ownerId && operation.dataEpoch === this.records.control().data_epoch && record.ownerId === this.records.ownerId;
592
+ const download = storedDownload ? {
593
+ availability: downloadEnd ? "ended" : storedDownload.readyAt ? "available" : "preparing",
594
+ currentAttemptId: storedDownload.currentAttemptId,
595
+ ...(currentAttempt?.hostReport ? { hostReport: currentAttempt.hostReport } : {}),
596
+ previousUnconfirmed: this.records.previousDeliveryPossible(storedDownload),
597
+ ...(localSave ? { localSave: { status: localSave.status, ...(localSave.currentSaveAttemptId ? { currentSaveAttemptId: localSave.currentSaveAttemptId } : {}),
598
+ ...(localSave.failure ? { failure: localSave.failure } : {}), ...(localSave.savedAt ? { savedAt: localSave.savedAt } : {}) } } : {}),
599
+ ...(downloadEnd ? { endReason: downloadEnd } : {}),
600
+ ...(storedDownload.expiresAt ? { expiresAt: storedDownload.expiresAt, remainingMs: Math.max(0, Date.parse(storedDownload.expiresAt) - this.now().getTime()) } : {}),
601
+ allowedFileActions: currentDownloadOwner && !this.closing ? [
602
+ ...(storedDownload.readyAt ? ["settings_report_download"] : []),
603
+ ...(!downloadEnd && file && !file.invalidated && localSave?.status !== "saving" ? ["settings_cancel_download",
604
+ ...(!localSave && storedDownload.readyAt ? ["settings_retry_download"] : []),
605
+ ...(localSave && (localSave.status === "needs_consent" || localSave.status === "failed" && localSave.failure === "not_published") &&
606
+ this.records.canReserveFileIO(operation.operationId) ? ["settings_store_backup"] : [])] : [])
607
+ ] : []
608
+ } : undefined;
609
+ if (storedDownload && !downloadExpiryPending)
610
+ this.scheduleDownloadExpiry();
611
+ const cleanup = operation.result ? downloadExpiryPending ? "pending" : this.records.temporaryFileCleanup(operation.operationId) : undefined;
612
+ const backupPath = localSave?.status === "saved" && file ? this.options.files.localBackupPath(file)
613
+ : operation.draft?.kind === "export_local_data" && operation.result?.outcome === "completed" ? operation.result.details?.path : undefined;
614
+ const data = { kind: "settings", operationId: operation.operationId, phase: operation.phase, allowedActions: allowed,
615
+ observe: !operation.result && (["executing", "stopping", "starting"].includes(operation.phase) || restoreCheck?.status === "checking"),
616
+ ...(restoreCheck ? { restoreCheck } : {}), ...(download ? { download } : {}),
617
+ ...(typeof backupPath === "string" ? { backupLocation: { directory: dirname(backupPath), filename: basename(backupPath) } } : {}),
618
+ ...(operation.draft ? { draft: operation.draft } : {}), ...(operation.preparationId ? { preparationId: operation.preparationId } : {}),
619
+ ...(operation.preparation && !storedDownload ? { preparation: operation.preparation } : {}), ...(operation.result ? { result: operation.result } : {}),
620
+ ...(cleanup ? { temporaryFileCleanup: cleanup } : {}),
621
+ ...(file ? { file: { fileId: file.fileId, displayName: file.displayName, totalSize: file.totalSize, receivedSize: file.receivedSize, sha256: file.sha256, sealed: file.sealed, cleanup: file.cleanup } } : {}),
622
+ ...(facts ? { facts } : {}), ...(operation.priorRunId ? { priorRunId: operation.priorRunId } : {}),
623
+ ...(operation.nextRunId ? { nextRunId: operation.nextRunId } : {}),
624
+ ...(operation.supersedesOperationId ? { supersedesOperationId: operation.supersedesOperationId } : {}),
625
+ ...(operation.supersededByOperationId ? { supersededByOperationId: operation.supersededByOperationId } : {}) };
626
+ assertNoForbiddenMcpFields(data);
627
+ return { record, data: settingsViewSchema.parse(data) };
628
+ }
629
+ async act(expected, action) {
630
+ this.options.assertCurrent();
631
+ if (this.closing)
632
+ throw new SettingsConflict("This server is closing; Settings cannot accept another action.");
633
+ const stored = this.records.forCard(expected);
634
+ if (action.action === "settings_download_backup") {
635
+ if (stored.acceptedDigest === settingsIdentity(action))
636
+ return;
637
+ await this.startDownload(expected, action);
638
+ return;
639
+ }
640
+ if (isSettingsDownloadFileAction(action)) {
641
+ await this.continueDownload(expected, action);
642
+ return;
643
+ }
644
+ if (action.action === "settings_select" && ["export_local_data", "download_backup"].includes(action.draft.kind)) {
645
+ throw new SettingsConflict("Use Download backup to request a backup in one step.");
646
+ }
647
+ if (action.action === "settings_confirm" && stored.acceptedDigest === settingsIdentity(action))
648
+ return;
649
+ if (action.action === "settings_confirm") {
650
+ await this.confirm(expected, action);
651
+ return;
652
+ }
653
+ if (action.action === "settings_prepare") {
654
+ await this.prepare(expected, action.operationId);
655
+ return;
656
+ }
657
+ if (action.action === "settings_begin_upload") {
658
+ await this.beginUpload(expected, action);
659
+ return;
660
+ }
661
+ if (action.action === "settings_upload_chunk") {
662
+ await this.upload(expected, action);
663
+ return;
664
+ }
665
+ if (action.action === "settings_seal_upload") {
666
+ await this.seal(expected, action);
667
+ return;
668
+ }
669
+ this.records.transaction(() => {
670
+ const { card, operation } = this.records.requireInput(expected, "operationId" in action ? action.operationId : undefined);
671
+ if (!this.records.fileIOAvailable())
672
+ throw new SettingsConflict("Wait for the file transfer to finish before changing your selection.");
673
+ if (action.action === "settings_cancel") {
674
+ if (operation.fileId)
675
+ this.invalidateFile(operation.fileId);
676
+ this.records.finish(operation.operationId, { outcome: "interrupted", recordedAt: this.now().toISOString(), message: "This Settings change was cancelled. Nothing was applied." });
677
+ return;
678
+ }
679
+ if (action.action === "settings_select" && action.draft.kind === "set_endpoint") {
680
+ // Drafts are public display state. Reject credentials and invalid URLs
681
+ // before storing or projecting the user's endpoint input.
682
+ action = { ...action, draft: { ...action.draft,
683
+ value: (action.draft.endpoint === "grpc" ? parseGrpcUrl : parseGraphqlUrl)(action.draft.value) } };
684
+ }
685
+ if (action.action === "settings_back") {
686
+ this.records.resetPreparation(operation, card);
687
+ return;
688
+ }
689
+ const next = this.withoutPreparation(operation);
690
+ if (action.action === "settings_select") {
691
+ if (operation.fileId)
692
+ this.invalidateFile(operation.fileId);
693
+ delete next.fileId;
694
+ delete next.preparation;
695
+ next.draft = action.draft;
696
+ next.phase = "draft";
697
+ }
698
+ this.records.write(operation, next, card);
699
+ });
700
+ await this.cleanupTemporaryFiles(stored.operationId);
701
+ }
702
+ invalidateFile(id) {
703
+ const file = this.records.file(id);
704
+ if (file?.role === "upload")
705
+ this.records.writeFile({ ...file, invalidated: true, cleanup: "pending" });
706
+ }
707
+ async prepare(expected, id) {
708
+ const initial = this.records.requireInput(expected, id);
709
+ const draft = initial.operation.draft;
710
+ if (!draft)
711
+ throw new SettingsConflict("Choose what you want to change in Settings first.");
712
+ if (["export_local_data", "download_backup"].includes(draft.kind))
713
+ throw new SettingsConflict("Open a new Settings card and use Download backup.");
714
+ let preparation;
715
+ if (draft.kind === "import_local_data") {
716
+ await this.checkImport(expected, id);
717
+ return;
718
+ }
719
+ else if (draft.kind === "restart_wallet_service") {
720
+ const context = this.options.wallet.recoveryContext();
721
+ this.assertRecoveryRecorded();
722
+ const active = this.requireRestartPreparation(context);
723
+ preparation = { walletRunId: context.availability.walletRunId, impact: context.impact,
724
+ ...(active ? { supersedesOperationId: active.operationId } : {}) };
725
+ }
726
+ else if (draft.kind === "set_endpoint" || draft.kind === "restore_endpoint") {
727
+ if (draft.kind === "set_endpoint")
728
+ (draft.endpoint === "grpc" ? parseGrpcUrl : parseGraphqlUrl)(draft.value);
729
+ const collected = this.fingerprint(initial.operation);
730
+ const localSettings = (await this.options.readFacts()).localSettings;
731
+ const current = draft.endpoint === "grpc" ? localSettings.suiGrpcUrl : localSettings.suiGraphqlUrl;
732
+ preparation = { localSettings, endpoint: { endpoint: draft.endpoint, ...this.options.resolveEndpoint(draft),
733
+ storedValue: current.storedValue, effectiveValue: current.effectiveValue, source: current.source } };
734
+ if (collected !== this.fingerprint(initial.operation))
735
+ throw new SettingsConflict("The network setting changed while it was being checked. Review the change again.");
736
+ }
737
+ else
738
+ preparation = { counts: this.options.localData.getDataCountsSync() };
739
+ this.options.assertCurrent();
740
+ let refused;
741
+ this.records.transaction(() => {
742
+ const decisionTime = this.now();
743
+ if (draft.kind === "reset_local_data")
744
+ preparation = { counts: this.options.localData.currentDataCountsSync(decisionTime) };
745
+ let current;
746
+ try {
747
+ current = this.records.requireInput(expected, id, decisionTime);
748
+ }
749
+ catch (error) {
750
+ if (!(error instanceof SettingsConflict))
751
+ throw error;
752
+ refused = error;
753
+ return;
754
+ }
755
+ const { card, operation } = current;
756
+ const fingerprint = this.fingerprint(operation);
757
+ this.records.write(operation, { ...operation, phase: "prepared", preparationId: randomUUID(), fingerprint,
758
+ preparedUserDataRevision: this.records.control().user_data_revision, preparation,
759
+ ...(draft.kind === "restart_wallet_service" ? { priorRunId: String(preparation.walletRunId) } : {}) }, card);
760
+ });
761
+ if (refused)
762
+ throw refused;
763
+ }
764
+ async beginUpload(expected, action) {
765
+ let file, createNeeded = false;
766
+ this.records.transaction(() => {
767
+ const { card, operation } = this.records.requireInput(expected, action.operationId);
768
+ if (operation.draft?.kind !== "import_local_data")
769
+ throw new SettingsConflict("This Settings selection does not accept a backup file.");
770
+ const existing = operation.fileId && this.records.file(operation.fileId);
771
+ if (!this.records.fileIOAvailable())
772
+ throw new SettingsConflict("Wait for the current file transfer.");
773
+ if (existing && !existing.invalidated && existing.totalSize === action.totalSize && existing.sha256 === action.sha256) {
774
+ file = existing;
775
+ if (!existing.sealed && operation.phase !== "uploading")
776
+ this.records.write(operation, { ...this.withoutPreparation(operation), phase: "uploading" }, card);
777
+ return;
778
+ }
779
+ if (operation.fileId)
780
+ this.invalidateFile(operation.fileId);
781
+ const fileId = randomUUID();
782
+ file = { fileId, operationId: operation.operationId, role: "upload", relativePath: `.settings-staging/${fileId}.upload`, displayName: action.name,
783
+ totalSize: action.totalSize, receivedSize: 0, sha256: action.sha256, sealed: false, invalidated: false, cleanup: "pending" };
784
+ this.records.createFile(file);
785
+ this.records.reserveFileIO(fileId, operation.operationId);
786
+ createNeeded = true;
787
+ const next = this.withoutPreparation(operation);
788
+ delete next.preparation;
789
+ this.records.write(operation, { ...next, fileId, phase: "uploading" }, card);
790
+ });
791
+ if (!createNeeded)
792
+ return;
793
+ try {
794
+ await this.options.files.createUpload(file);
795
+ }
796
+ catch (error) {
797
+ this.options.assertCurrent();
798
+ this.records.writeFile({ ...file, invalidated: true, cleanup: "failed" });
799
+ throw error;
800
+ }
801
+ finally {
802
+ this.finishFileIO(file.fileId);
803
+ }
804
+ await this.cleanupTemporaryFiles(action.operationId);
805
+ }
806
+ async upload(expected, action) {
807
+ const data = decodeSettingsChunk(action.chunkBase64);
808
+ if (!data.length)
809
+ throw new SettingsConflict("The file transfer contained no new file content.");
810
+ let file, alreadyReceived = false;
811
+ this.records.transaction(() => {
812
+ const { operation } = this.records.requireInput(expected, action.operationId);
813
+ const current = this.records.file(action.fileId);
814
+ if (operation.phase !== "uploading" || operation.fileId !== action.fileId || !current || current.invalidated || current.sealed)
815
+ throw new SettingsConflict("This file transfer is no longer available. Choose the backup file again.");
816
+ const reservation = { offset: action.offset, length: data.length, sha256: settingsFileDigest(data) };
817
+ if (action.offset + data.length <= current.receivedSize) {
818
+ alreadyReceived = true;
819
+ file = current;
820
+ return;
821
+ }
822
+ if (action.offset !== current.receivedSize || action.offset + data.length > current.totalSize ||
823
+ current.reservation && settingsIdentity(current.reservation) !== settingsIdentity(reservation))
824
+ throw new SettingsConflict("The next part of the file does not match the transfer already in progress. Choose the same backup file again.");
825
+ file = { ...current, reservation };
826
+ this.records.reserveFileIO(file.fileId, operation.operationId);
827
+ this.records.writeFile(file);
828
+ });
829
+ if (alreadyReceived) {
830
+ await this.options.files.verifyUploadPrefix(file, action.offset, data);
831
+ return;
832
+ }
833
+ try {
834
+ await this.options.files.writeReservedChunk(file, data);
835
+ this.options.assertCurrent();
836
+ this.records.transaction(() => {
837
+ const { card, operation } = this.records.requireInput(expected, action.operationId);
838
+ const current = this.records.file(file.fileId);
839
+ if (!current || current.invalidated || settingsIdentity(current.reservation) !== settingsIdentity(file.reservation))
840
+ throw new SettingsConflict("The file transfer changed while its contents were being saved. Choose the same backup file again.");
841
+ const { reservation: _reservation, ...complete } = current;
842
+ this.records.writeFile({ ...complete, receivedSize: action.offset + data.length });
843
+ this.records.write(operation, operation, card);
844
+ });
845
+ }
846
+ finally {
847
+ this.finishFileIO(file.fileId);
848
+ }
849
+ }
850
+ async seal(expected, action) {
851
+ const { operation } = this.records.requireInput(expected, action.operationId);
852
+ if (operation.fileId !== action.fileId)
853
+ throw new SettingsConflict("This file transfer is no longer available. Choose the backup file again.");
854
+ await this.checkImport(expected, action.operationId);
855
+ }
856
+ async checkImport(expected, id) {
857
+ const initial = this.records.requireInput(expected, id);
858
+ const file = initial.operation.fileId ? this.records.file(initial.operation.fileId) : undefined;
859
+ if (initial.operation.draft?.kind !== "import_local_data" || !file || file.role !== "upload" || file.invalidated ||
860
+ file.receivedSize !== file.totalSize || file.reservation)
861
+ throw new SettingsConflict("Finish receiving the selected backup before checking it.");
862
+ const rejection = retainedBackupRejection(initial.operation, file);
863
+ if (rejection)
864
+ throw new SettingsConflict(rejection.check.status === "cannot_restore" ? rejection.check.message : "Choose another backup file.");
865
+ if (this.checkingFiles.has(file.fileId))
866
+ throw new SettingsConflict("This backup is already being checked.");
867
+ const control = this.records.control();
868
+ const binding = { fileId: file.fileId, sha256: file.sha256, totalSize: file.totalSize, dataEpoch: control.data_epoch, userDataRevision: control.user_data_revision };
869
+ let checking;
870
+ this.records.transaction(() => {
871
+ const { card, operation } = this.records.requireInput(expected, id);
872
+ if (!this.records.fileIOAvailable())
873
+ throw new SettingsConflict("The last part of the file is still being saved. Wait for the transfer to finish.");
874
+ this.records.write(operation, { ...this.withoutPreparation(operation), phase: file.sealed ? "sealed" : "uploading",
875
+ preparation: { restoreCheck: { ...binding, check: { status: "checking" } } } }, card);
876
+ checking = this.records.cards.get(card.state.cardId);
877
+ });
878
+ this.checkingFiles.add(file.fileId);
879
+ let verified = false, input, failure;
880
+ let unexpected;
881
+ try {
882
+ try {
883
+ const bytes = await this.options.files.readUpload(file);
884
+ verified = true;
885
+ this.options.assertCurrent();
886
+ input = this.parseBackupBytes(bytes);
887
+ }
888
+ catch (error) {
889
+ this.options.assertCurrent();
890
+ if (error instanceof LocalDataError && error.kind === "input_invalid") {
891
+ const classified = settingsInputError(error, "import_local_data");
892
+ failure = { status: "cannot_restore", reason: classified.restoreReason, message: classified.message };
893
+ }
894
+ else if (error instanceof SettingsConflict || error !== null && typeof error === "object" && "code" in error &&
895
+ ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(error.code))) {
896
+ failure = { status: "cannot_restore", reason: "file_unavailable", message: "The received backup could not be verified. Choose the backup file again." };
897
+ }
898
+ else {
899
+ failure = { status: "cannot_restore", reason: "check_failed", message: "The backup could not be checked. Try checking it again." };
900
+ unexpected = error;
901
+ }
902
+ }
903
+ this.records.transaction(() => {
904
+ const decisionTime = this.now(), { card, operation } = this.records.requireInput(checking, id, decisionTime);
905
+ const currentFile = this.records.file(file.fileId);
906
+ if (operation.fileId !== file.fileId || !currentFile || currentFile.invalidated ||
907
+ currentFile.sha256 !== file.sha256 || currentFile.totalSize !== file.totalSize || currentFile.reservation) {
908
+ throw new SettingsConflict("The selected backup changed while it was being checked.");
909
+ }
910
+ if (verified)
911
+ this.records.writeFile({ ...currentFile, sealed: true });
912
+ let preparation;
913
+ if (!failure && this.records.control().user_data_revision !== binding.userDataRevision) {
914
+ failure = { status: "check_required", message: "Local data changed while the backup was being checked. Check this backup again." };
915
+ }
916
+ if (!failure) {
917
+ try {
918
+ const preview = this.options.localData.previewImportLocalDataSync(input, decisionTime);
919
+ preparation = { preview };
920
+ this.options.localData.assertReplacementAllowed(decisionTime);
921
+ this.records.requireFinalSlot();
922
+ }
923
+ catch (error) {
924
+ if (error instanceof LocalDataError && error.kind === "input_invalid") {
925
+ const classified = settingsInputError(error, "import_local_data");
926
+ failure = { status: "cannot_restore", reason: classified.restoreReason, message: classified.message };
927
+ }
928
+ else if (error instanceof SettingsConflict) {
929
+ failure = { status: "cannot_restore", reason: "operation_busy", message: "Another Settings change or file transfer is in progress. Wait for it to finish before restoring." };
930
+ }
931
+ else {
932
+ failure = { status: "cannot_restore", reason: "check_failed", message: "The backup could not be checked. Try checking it again." };
933
+ unexpected = error;
934
+ }
935
+ }
936
+ }
937
+ const current = this.records.get(id);
938
+ const check = failure ?? { status: "can_restore" };
939
+ const recorded = { ...binding, userDataRevision: this.records.control().user_data_revision, check };
940
+ this.records.write(current, failure
941
+ ? { ...this.withoutPreparation(current), phase: verified ? "sealed" : "uploading", preparation: { ...preparation, restoreCheck: recorded } }
942
+ : { ...current, phase: "prepared", preparationId: randomUUID(), fingerprint: this.fingerprint(current),
943
+ preparedUserDataRevision: this.records.control().user_data_revision, preparation: { ...preparation, restoreCheck: recorded } }, card);
944
+ });
945
+ if (unexpected)
946
+ throw unexpected;
947
+ }
948
+ finally {
949
+ this.checkingFiles.delete(file.fileId);
950
+ }
951
+ }
952
+ async confirm(expected, action) {
953
+ const initial = this.records.requireInput(expected, action.operationId), draft = initial.operation.draft;
954
+ if (!draft || initial.operation.preparationId !== action.preparationId)
955
+ throw new SettingsConflict("Review the current Settings change before confirming it.");
956
+ if (["export_local_data", "download_backup"].includes(draft.kind))
957
+ throw new SettingsConflict("Open a new Settings card and use Download backup.");
958
+ if (draft.kind === "restart_wallet_service") {
959
+ this.restart(expected, action);
960
+ return;
961
+ }
962
+ let endpoint;
963
+ let imported;
964
+ if (draft.kind === "set_endpoint" || draft.kind === "restore_endpoint") {
965
+ endpoint = await this.options.verifyEndpoint(draft);
966
+ const prepared = endpointPreparationSchema.parse(initial.operation.preparation?.endpoint);
967
+ if (!prepared || prepared.endpoint !== draft.endpoint || prepared.key !== endpoint.key || prepared.value !== endpoint.value) {
968
+ throw new SettingsConflict("The selected network address changed. Review it again before confirming.");
969
+ }
970
+ }
971
+ if (draft.kind === "import_local_data") {
972
+ const file = initial.operation.fileId && this.records.file(initial.operation.fileId);
973
+ if (!file || !this.records.hasReusableUpload(initial.operation.operationId))
974
+ throw new SettingsConflict("The selected backup is no longer available. Choose the backup file again.");
975
+ const rejection = retainedBackupRejection(initial.operation, file);
976
+ if (rejection)
977
+ throw new SettingsConflict(rejection.check.status === "cannot_restore" ? rejection.check.message : "Choose another backup file.");
978
+ try {
979
+ imported = await this.options.localData.parseAndVerifyImportEnvelope(await this.backupInput(file));
980
+ }
981
+ catch (error) {
982
+ this.options.assertCurrent();
983
+ // A rejected final verification does not apply data or end the card,
984
+ // but it must revoke the previous ready result for this exact file.
985
+ this.records.transaction(() => {
986
+ const { card, operation } = this.records.requireInput(expected, action.operationId);
987
+ if (operation.fileId !== file.fileId)
988
+ throw new SettingsConflict("The selected backup changed before its check result could be saved.");
989
+ const physical = error instanceof SettingsConflict || error !== null && typeof error === "object" && "code" in error &&
990
+ ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(error.code));
991
+ const control = this.records.control();
992
+ const classified = settingsInputError(error, "import_local_data");
993
+ const check = { status: "cannot_restore", reason: physical ? "file_unavailable" : classified?.restoreReason ?? "check_failed",
994
+ message: physical ? "The received backup could not be verified. Choose the backup file again."
995
+ : classified?.message ?? "The backup could not be verified for restoration. Check its availability and try again." };
996
+ this.records.write(operation, { ...this.withoutPreparation(operation), phase: "sealed",
997
+ preparation: { restoreCheck: { fileId: file.fileId, sha256: file.sha256, totalSize: file.totalSize, dataEpoch: control.data_epoch,
998
+ userDataRevision: control.user_data_revision, check } } }, card);
999
+ });
1000
+ throw error;
1001
+ }
1002
+ }
1003
+ this.options.assertCurrent();
1004
+ const replacement = draft.kind === "reset_local_data" || draft.kind === "import_local_data";
1005
+ let refused;
1006
+ this.records.transaction(() => {
1007
+ const decisionTime = this.now();
1008
+ const fingerprint = () => this.fingerprint(this.records.get(action.operationId));
1009
+ if (replacement) {
1010
+ this.options.localData.currentDataCountsSync(decisionTime);
1011
+ try {
1012
+ this.records.requireAdmission(expected, action, fingerprint, undefined, decisionTime);
1013
+ }
1014
+ catch (error) {
1015
+ if (!(error instanceof SettingsConflict))
1016
+ throw error;
1017
+ refused = error;
1018
+ return;
1019
+ }
1020
+ }
1021
+ const operation = this.records.admit(expected, action, fingerprint, undefined, decisionTime);
1022
+ let details = {};
1023
+ if (endpoint) {
1024
+ const previous = this.options.preferences.getLocalSettingSync(endpoint.key);
1025
+ this.options.preferences.setLocalSettingSync(endpoint.key, endpoint.value, this.now());
1026
+ const prepared = endpointPreparationSchema.parse(operation.preparation.endpoint);
1027
+ details = { status: endpoint.status, storedValue: endpoint.value, previousStoredValue: previous?.value,
1028
+ endpoint: draft.kind === "set_endpoint" || draft.kind === "restore_endpoint" ? draft.endpoint : undefined,
1029
+ effectiveValueAtSave: prepared.effectiveValue, effectiveSourceAtSave: prepared.source,
1030
+ appliesAfter: SETTINGS_APPLIES_AFTER_RESTART };
1031
+ }
1032
+ else if (draft.kind === "clear_read_address")
1033
+ this.options.activity.clearActiveAccountSync(this.now());
1034
+ else if (replacement)
1035
+ details = this.options.localData.applyValidatedReplacement(imported, decisionTime, expected.state.cardId);
1036
+ this.records.finish(operation.operationId, { outcome: "completed", recordedAt: this.now().toISOString(),
1037
+ message: endpoint ? "The network address was saved. This result describes the settings at save time."
1038
+ : draft.kind === "clear_read_address" ? "The default address for viewing assets was cleared. Your wallet connection and transaction requests are unchanged."
1039
+ : draft.kind === "import_local_data" ? "Local records were replaced. Restart the apps using Sui MCP before continuing."
1040
+ : "Local data was reset. This does not cancel a transaction on Sui or disconnect your wallet app.", details });
1041
+ });
1042
+ if (refused)
1043
+ throw refused;
1044
+ if (replacement)
1045
+ this.options.localData.notifyDataReplaced();
1046
+ await this.cleanupTemporaryFiles(action.operationId);
1047
+ }
1048
+ restart(expected, action) {
1049
+ this.options.wallet.recoveryContext();
1050
+ this.assertRecoveryRecorded();
1051
+ let admitted;
1052
+ try {
1053
+ this.records.transaction(() => {
1054
+ const preparation = this.records.get(action.operationId)?.preparation;
1055
+ const supersedes = typeof preparation?.supersedesOperationId === "string" ? preparation.supersedesOperationId : undefined;
1056
+ admitted = this.records.admit(expected, action, () => this.fingerprint(this.records.get(action.operationId)), supersedes);
1057
+ if (!admitted.priorRunId)
1058
+ throw new SettingsConflict("The wallet connection service changed. Check status before continuing.");
1059
+ if (supersedes) {
1060
+ const previous = this.requireSupersedable(supersedes, admitted.priorRunId);
1061
+ const previousCard = this.records.cards.get(previous.cardId);
1062
+ this.records.write(previous, { ...previous, phase: "terminal", supersededByOperationId: admitted.operationId,
1063
+ result: { outcome: "superseded", recordedAt: this.now().toISOString(), message: "A restart confirmed in another Settings card replaced this service startup." } }, previousCard, { ...previousCard, state: { ...previousCard.state, state: "closed", reason: "cancelled", revision: previousCard.state.revision + 1 } });
1064
+ const card = this.records.cards.get(admitted.cardId);
1065
+ this.records.write(admitted, { ...admitted, supersedesOperationId: supersedes }, card);
1066
+ admitted = this.records.get(admitted.operationId);
1067
+ }
1068
+ this.options.interruptRun(admitted.priorRunId);
1069
+ this.records.db.prepare("UPDATE settings_control SET wallet_input_blocked=1,wallet_block_operation_id=? WHERE id=1").run(admitted.operationId);
1070
+ });
1071
+ }
1072
+ catch (error) {
1073
+ if (!(error instanceof SettingsConflict))
1074
+ this.options.wallet.blockSettingsAdmissionFailure();
1075
+ throw error;
1076
+ }
1077
+ this.options.wallet.fenceForSettings(admitted.priorRunId);
1078
+ void this.options.wallet.replaceForSettings(admitted.priorRunId, (nextRunId) => {
1079
+ this.records.transaction(() => {
1080
+ const operation = this.records.get(admitted.operationId), control = this.records.control();
1081
+ if (!operation || operation.phase !== "stopping" || operation.dataEpoch !== control.data_epoch || control.active_operation_id !== operation.operationId)
1082
+ throw new SettingsConflict("This restart is no longer current.");
1083
+ const card = this.records.cards.get(operation.cardId);
1084
+ this.records.write(operation, { ...operation, phase: "starting", nextRunId }, card);
1085
+ });
1086
+ }).catch(() => {
1087
+ this.options.assertCurrent();
1088
+ this.retainResult(admitted.operationId, { outcome: "failed", recordedAt: this.now().toISOString(), message: "The wallet connection service restart could not be confirmed. Open a new Settings card if you want to try restarting again." }, false);
1089
+ }).catch(() => { });
1090
+ }
1091
+ runtimeEvent(event) {
1092
+ const id = this.records.control().active_operation_id, operation = id && this.records.get(id);
1093
+ if (!operation || operation.phase !== "starting" || operation.nextRunId !== event.runId)
1094
+ return;
1095
+ if (event.type !== "lost" && !(event.type === "snapshot" && event.ready))
1096
+ return;
1097
+ const ready = event.type === "snapshot";
1098
+ if (!ready && this.pendingResults.get(operation.operationId)?.ready)
1099
+ this.pendingResults.delete(operation.operationId);
1100
+ const existing = this.pendingResults.get(operation.operationId);
1101
+ const result = existing?.result ?? { outcome: ready ? "completed" : "failed", recordedAt: this.now().toISOString(),
1102
+ message: ready ? "The connection service restarted. Check the original request's result separately."
1103
+ : "The wallet connection service could not start. Open a new Settings card if you want to try restarting again.",
1104
+ details: { priorRunId: operation.priorRunId, nextRunId: operation.nextRunId } };
1105
+ if (existing && !existing.ready && ready)
1106
+ throw new SettingsConflict("The startup failure could not yet be saved. Check status before continuing.");
1107
+ this.retainResult(operation.operationId, result, ready);
1108
+ }
1109
+ retainResult(id, result, ready, file, downloadReadyAt) {
1110
+ if (!this.pendingResults.has(id))
1111
+ this.pendingResults.set(id, { result, ready, ...(file ? { file } : {}), ...(downloadReadyAt ? { downloadReadyAt } : {}) });
1112
+ const retained = this.pendingResults.get(id);
1113
+ let settledFileId;
1114
+ const applied = this.records.transaction(() => {
1115
+ if (retained.file) {
1116
+ const current = this.records.file(retained.file.fileId);
1117
+ this.records.writeFile(current && isSettingsDownloadFile(current)
1118
+ ? { ...retained.file, invalidated: current.invalidated, cleanup: current.cleanup } : retained.file);
1119
+ }
1120
+ if (retained.downloadReadyAt)
1121
+ this.records.recordDownloadReady(id, retained.downloadReadyAt, DOWNLOAD_RETRY_WINDOW_MS);
1122
+ else if (this.records.get(id)?.draft?.kind === "download_backup" && retained.result.outcome !== "completed")
1123
+ this.records.endDownload(id, "source_unavailable");
1124
+ const fileId = retained.file?.fileId ?? this.records.get(id)?.fileId;
1125
+ if (fileId && this.finishedFileIO.has(fileId)) {
1126
+ this.records.releaseFileIO(fileId);
1127
+ settledFileId = fileId;
1128
+ }
1129
+ return this.records.finish(id, retained.result);
1130
+ });
1131
+ // A later cleanup can reserve this same file. The ended publication must
1132
+ // not be reused as evidence that this new I/O has also ended.
1133
+ if (settledFileId)
1134
+ this.finishedFileIO.delete(settledFileId);
1135
+ if (applied && !ready)
1136
+ this.pendingResults.delete(id);
1137
+ }
1138
+ readyPublished(runId) {
1139
+ for (const [id, pending] of this.pendingResults) {
1140
+ if (pending.ready && this.records.get(id)?.nextRunId === runId &&
1141
+ this.records.get(id)?.result?.outcome === "completed")
1142
+ this.pendingResults.delete(id);
1143
+ }
1144
+ }
1145
+ assertRecoveryRecorded() {
1146
+ for (const id of this.pendingResults.keys()) {
1147
+ if (this.records.get(id)?.draft?.kind === "restart_wallet_service")
1148
+ throw new Error("The connection-service result could not be saved. Use Check status to save that result before trying another restart.");
1149
+ }
1150
+ }
1151
+ finishFileIO(fileId) {
1152
+ this.options.assertCurrent();
1153
+ this.finishedFileIO.add(fileId);
1154
+ this.records.releaseFileIO(fileId);
1155
+ this.finishedFileIO.delete(fileId);
1156
+ }
1157
+ synchronize(operationId, recoveryOnly = false) {
1158
+ this.options.assertCurrent();
1159
+ if (!recoveryOnly)
1160
+ for (const id of this.finishedFileIO) {
1161
+ const file = this.records.file(id);
1162
+ if (operationId !== undefined && (file?.cleanupOperationId ?? file?.operationId) !== operationId)
1163
+ continue;
1164
+ this.records.releaseFileIO(id);
1165
+ this.finishedFileIO.delete(id);
1166
+ }
1167
+ for (const [id, pending] of this.pendingResults) {
1168
+ if (operationId !== undefined && id !== operationId || recoveryOnly && this.records.get(id)?.draft?.kind !== "restart_wallet_service")
1169
+ continue;
1170
+ // Ready is replayed only inside the SDK snapshot transaction; a previous
1171
+ // failed transaction did not prove that its observation was committed.
1172
+ if (!pending.ready)
1173
+ this.retainResult(id, pending.result, false, pending.file);
1174
+ }
1175
+ }
1176
+ async cleanupFile(file, waitForIO = true) {
1177
+ if (isSettingsDownloadFile(file)) {
1178
+ const pending = this.downloadIO.get(file.fileId);
1179
+ if (pending) {
1180
+ if (!waitForIO)
1181
+ return;
1182
+ await Promise.allSettled([...pending]);
1183
+ }
1184
+ this.options.assertCurrent();
1185
+ const current = this.records.file(file.fileId);
1186
+ if (!current || !current.invalidated || current.cleanup === "complete")
1187
+ return;
1188
+ file = current;
1189
+ }
1190
+ this.records.transaction(() => this.records.reserveFileIO(file.fileId, file.cleanupOperationId ?? file.operationId));
1191
+ try {
1192
+ await this.options.files.cleanup(file);
1193
+ this.options.assertCurrent();
1194
+ this.records.writeFile({ ...this.records.file(file.fileId), cleanup: "complete" });
1195
+ }
1196
+ catch {
1197
+ this.options.assertCurrent();
1198
+ const current = this.records.file(file.fileId);
1199
+ if (current)
1200
+ this.records.writeFile({ ...current, cleanup: "failed" });
1201
+ throw new Error("The local data change was saved, but temporary files could not be removed.");
1202
+ }
1203
+ finally {
1204
+ this.finishFileIO(file.fileId);
1205
+ }
1206
+ }
1207
+ async cleanupTemporaryFiles(operationId) {
1208
+ const files = this.records.filesForCleanup(operationId);
1209
+ for (const file of files) {
1210
+ try {
1211
+ await this.cleanupFile(file);
1212
+ }
1213
+ catch (error) {
1214
+ if (!(error instanceof SettingsConflict))
1215
+ throw error;
1216
+ }
1217
+ }
1218
+ }
1219
+ }