@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
@@ -126,46 +126,6 @@ export class SqlitePrivateReviewArtifactStore {
126
126
  this.db.prepare(`DELETE FROM live_private_review_artifacts`).run();
127
127
  }
128
128
  }
129
- /**
130
- * SQLite-backed id-keyed store for the short-lived settings
131
- * session manager. Each record is a JSON blob keyed by id; the table name is a
132
- * trusted constant supplied by the factories below (never user input).
133
- */
134
- export class SqliteKeyedRecordStore {
135
- db;
136
- table;
137
- revive;
138
- constructor(db, table, revive) {
139
- this.db = db;
140
- this.table = table;
141
- this.revive = revive;
142
- }
143
- get(id) {
144
- const row = this.db
145
- .prepare(`SELECT session_json FROM ${this.table} WHERE id = ?`)
146
- .get(id);
147
- return row ? this.revive(JSON.parse(row.session_json)) : undefined;
148
- }
149
- set(id, value) {
150
- this.db
151
- .prepare(`INSERT INTO ${this.table} (id, session_json) VALUES (?, ?)
152
- ON CONFLICT(id) DO UPDATE SET session_json = excluded.session_json`)
153
- .run(id, JSON.stringify(value));
154
- }
155
- delete(id) {
156
- this.db.prepare(`DELETE FROM ${this.table} WHERE id = ?`).run(id);
157
- }
158
- ids() {
159
- const rows = this.db.prepare(`SELECT id FROM ${this.table}`).all();
160
- return rows.map((row) => row.id);
161
- }
162
- clear() {
163
- this.db.prepare(`DELETE FROM ${this.table}`).run();
164
- }
165
- }
166
- export function createSqliteSettingsRecordStore(db) {
167
- return new SqliteKeyedRecordStore(db, "live_settings_sessions", (raw) => raw);
168
- }
169
129
  // Reconstruct the ReviewSession from columns + JSON blobs. Optional fields are
170
130
  // omitted (not set to undefined) so the shape matches the in-memory store exactly.
171
131
  export function sessionFromLiveReviewSessionRow(row) {
@@ -0,0 +1,463 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { SqliteCardRecordStore } from "./sqliteCardStore.js";
4
+ import { SettingsConflict, settingsDownload, storedSettingsDownloadSchema, settingsDraftSchema, settingsResultSchema, retainedBackupRejection } from "./settingsOperation.js";
5
+ import { isSettingsDownloadFile, settingsBackupPath, SETTINGS_FILE_CLEANUP_SQL } from "./settingsFileIdentity.js";
6
+ import { SETTINGS_REUSABLE_UPLOAD_SQL, SETTINGS_RESET_PREPARATION_SQL } from "./settingsPreparationSql.js";
7
+ /** Settings transitions share the activity owner's SQLite connection and card CAS.
8
+ * Network and filesystem work must remain outside these transactions. */
9
+ export class SqliteSettingsWorkflowStore {
10
+ db;
11
+ ownerId;
12
+ now;
13
+ cards;
14
+ constructor(db, ownerId, now = () => new Date()) {
15
+ this.db = db;
16
+ this.ownerId = ownerId;
17
+ this.now = now;
18
+ this.cards = new SqliteCardRecordStore(db);
19
+ }
20
+ transaction(work) { return this.db.transaction(work).immediate(); }
21
+ control() { return this.db.prepare("SELECT * FROM settings_control WHERE id=1").get(); }
22
+ create(record) {
23
+ this.transaction(() => {
24
+ if (record.scope !== "settings" || record.state.kind !== "settings" || !record.operationId || record.ownerId !== this.ownerId) {
25
+ throw new SettingsConflict("This Settings card could not be identified.");
26
+ }
27
+ this.cards.create(record);
28
+ this.db.prepare(`INSERT INTO settings_operations (operation_id,card_id,owner_id,data_epoch,phase)
29
+ VALUES (?,?,?,?,'draft')`).run(record.operationId, record.state.cardId, this.ownerId, this.control().data_epoch);
30
+ });
31
+ }
32
+ get(id) {
33
+ const row = this.db.prepare("SELECT * FROM settings_operations WHERE operation_id=?").get(id);
34
+ if (!row)
35
+ return undefined;
36
+ return { operationId: row.operation_id, cardId: row.card_id, ownerId: row.owner_id, dataEpoch: row.data_epoch, phase: row.phase,
37
+ ...(row.draft_json === null ? {} : { draft: settingsDraftSchema.parse(JSON.parse(row.draft_json)) }),
38
+ ...(row.preparation_id === null ? {} : { preparationId: row.preparation_id }),
39
+ ...(row.fingerprint === null ? {} : { fingerprint: row.fingerprint }),
40
+ ...(row.prepared_user_data_revision === null ? {} : { preparedUserDataRevision: row.prepared_user_data_revision }),
41
+ ...(row.preparation_json === null ? {} : { preparation: JSON.parse(row.preparation_json) }),
42
+ ...(row.file_id === null ? {} : { fileId: row.file_id }),
43
+ ...(row.accepted_digest === null ? {} : { acceptedDigest: row.accepted_digest }),
44
+ ...(row.admitted_at === null ? {} : { admittedAt: row.admitted_at }),
45
+ ...(row.result_json === null ? {} : { result: settingsResultSchema.parse(JSON.parse(row.result_json)) }),
46
+ ...(row.prior_run_id === null ? {} : { priorRunId: row.prior_run_id }),
47
+ ...(row.next_run_id === null ? {} : { nextRunId: row.next_run_id }),
48
+ ...(row.supersedes_operation_id === null ? {} : { supersedesOperationId: row.supersedes_operation_id }),
49
+ ...(row.superseded_by_operation_id === null ? {} : { supersededByOperationId: row.superseded_by_operation_id }),
50
+ ...(row.checkpoint === null ? {} : { checkpoint: row.checkpoint }) };
51
+ }
52
+ forCard(record) {
53
+ const operation = record.operationId && this.get(record.operationId);
54
+ if (!operation || operation.cardId !== record.state.cardId || record.scope !== "settings") {
55
+ throw new SettingsConflict("This Settings request does not match the open card.");
56
+ }
57
+ return operation;
58
+ }
59
+ requireInput(expected, operationId, now = this.now()) {
60
+ const card = this.cards.get(expected.state.cardId);
61
+ if (!card || card.ownerId !== this.ownerId || card.tokenHash !== expected.tokenHash || card.scope !== "settings" ||
62
+ card.state.kind !== "settings" || card.state.revision !== expected.state.revision ||
63
+ card.state.state !== "ready" || card.acceptedInput !== undefined || Date.parse(card.state.expiresAt) <= now.getTime()) {
64
+ throw new SettingsConflict("This Settings selection changed or its time limit ended.");
65
+ }
66
+ const operation = this.forCard(card);
67
+ if (operationId !== undefined && operation.operationId !== operationId || operation.ownerId !== this.ownerId ||
68
+ operation.dataEpoch !== this.control().data_epoch || operation.phase === "terminal") {
69
+ throw new SettingsConflict("This Settings selection is out of date.");
70
+ }
71
+ return { card, operation };
72
+ }
73
+ write(expected, next, card, nextCard) {
74
+ this.transaction(() => {
75
+ if (!isDeepStrictEqual(this.get(expected.operationId), expected) || next.operationId !== expected.operationId ||
76
+ next.cardId !== expected.cardId || next.ownerId !== expected.ownerId)
77
+ throw new SettingsConflict("Settings state changed before it could be saved.");
78
+ this.db.prepare(`UPDATE settings_operations SET data_epoch=@epoch,kind=@kind,phase=@phase,draft_json=@draft,
79
+ preparation_id=@preparationId,fingerprint=@fingerprint,prepared_user_data_revision=@userRevision,preparation_json=@preparation,
80
+ file_id=@fileId,accepted_digest=@accepted,admitted_at=@admitted,result_json=@result,
81
+ prior_run_id=@prior,next_run_id=@next,supersedes_operation_id=@supersedes,superseded_by_operation_id=@supersededBy,checkpoint=@checkpoint
82
+ WHERE operation_id=@id`).run({ id: next.operationId, epoch: next.dataEpoch, kind: next.draft?.kind ?? null,
83
+ phase: next.phase, draft: next.draft ? JSON.stringify(next.draft) : null, preparationId: next.preparationId ?? null,
84
+ fingerprint: next.fingerprint ?? null, userRevision: next.preparedUserDataRevision ?? null,
85
+ preparation: next.preparation ? JSON.stringify(next.preparation) : null, fileId: next.fileId ?? null,
86
+ accepted: next.acceptedDigest ?? null, admitted: next.admittedAt ?? null, result: next.result ? JSON.stringify(next.result) : null,
87
+ prior: next.priorRunId ?? null, next: next.nextRunId ?? null,
88
+ supersedes: next.supersedesOperationId ?? null, supersededBy: next.supersededByOperationId ?? null, checkpoint: next.checkpoint ?? null });
89
+ if (!this.cards.replace(card, nextCard ?? { ...card, state: { ...card.state, revision: card.state.revision + 1 } })) {
90
+ throw new SettingsConflict("The Settings card changed before it could be saved.");
91
+ }
92
+ });
93
+ }
94
+ hasReusableUpload(operationId) {
95
+ return !!this.db.prepare(`SELECT 1 FROM settings_operations WHERE operation_id=? AND (${SETTINGS_REUSABLE_UPLOAD_SQL})`).get(operationId);
96
+ }
97
+ resetPreparation(expected, card) {
98
+ this.transaction(() => {
99
+ const current = this.requireInput(card, expected.operationId);
100
+ if (!isDeepStrictEqual(current.operation, expected))
101
+ throw new SettingsConflict("The Settings selection changed before it could be cleared.");
102
+ const rejection = retainedBackupRejection(current.operation, current.operation.fileId ? this.file(current.operation.fileId) : undefined);
103
+ this.db.prepare(`${SETTINGS_RESET_PREPARATION_SQL} WHERE operation_id=?`).run(expected.operationId);
104
+ if (rejection)
105
+ this.db.prepare("UPDATE settings_operations SET preparation_json=? WHERE operation_id=?")
106
+ .run(JSON.stringify({ restoreCheck: rejection }), expected.operationId);
107
+ if (!this.cards.replace(current.card, { ...current.card, state: { ...current.card.state, revision: current.card.state.revision + 1 } })) {
108
+ throw new SettingsConflict("The Settings card changed before its selection could be cleared.");
109
+ }
110
+ });
111
+ }
112
+ claimFinal(id, replaceId) {
113
+ this.requireFinalSlot(replaceId);
114
+ this.db.prepare("UPDATE settings_control SET active_operation_id=? WHERE id=1").run(id);
115
+ }
116
+ fileIOAvailable(control = this.control()) { return control.file_io_id === null; }
117
+ canReserveFileIO(operationId) {
118
+ const control = this.control();
119
+ return this.fileIOAvailable(control) && (control.active_operation_id === null || control.active_operation_id === operationId);
120
+ }
121
+ // Read-only eligibility check shared by preparation and final admission.
122
+ requireFinalSlot(replaceId) {
123
+ const control = this.control();
124
+ if (!this.fileIOAvailable(control) || control.active_operation_id !== (replaceId ?? null)) {
125
+ throw new SettingsConflict("Another Settings change or file transfer is in progress. Check its result before confirming again.");
126
+ }
127
+ }
128
+ reserveFileIO(fileId, operationId) {
129
+ if (!this.canReserveFileIO(operationId)) {
130
+ throw new SettingsConflict("A file transfer or Settings change is in progress. Check its status before trying again.");
131
+ }
132
+ this.db.prepare("UPDATE settings_control SET file_io_id=? WHERE id=1").run(fileId);
133
+ }
134
+ releaseFileIO(fileId) {
135
+ this.db.prepare("UPDATE settings_control SET file_io_id=NULL WHERE id=1 AND file_io_id=?").run(fileId);
136
+ }
137
+ admit(expected, input, currentFingerprint, replaceOperationId, now = this.now()) {
138
+ return this.transaction(() => {
139
+ const { card, operation } = this.requireAdmission(expected, input, currentFingerprint, replaceOperationId, now);
140
+ this.claimFinal(operation.operationId, replaceOperationId);
141
+ const next = { ...operation, phase: operation.draft.kind === "restart_wallet_service" ? "stopping" : "executing",
142
+ acceptedDigest: settingsIdentity(input), admittedAt: now.toISOString() };
143
+ this.write(operation, next, card, { ...card, acceptedInput: input, state: { ...card.state,
144
+ state: "running", revision: card.state.revision + 1 } });
145
+ return next;
146
+ });
147
+ }
148
+ // Pure admission checks let deadline reconciliation commit an expected refusal
149
+ // without claiming a final slot or consuming input. Admission uses the same rule.
150
+ requireAdmission(expected, input, currentFingerprint, replaceOperationId, now = this.now()) {
151
+ const result = this.requireInput(expected, input.operationId, now), operation = result.operation;
152
+ if (operation.phase !== "prepared" || !operation.draft || operation.preparationId !== input.preparationId ||
153
+ operation.fingerprint !== currentFingerprint())
154
+ throw new SettingsConflict("This change is out of date. Review it again before confirming.");
155
+ if (["import_local_data", "reset_local_data"].includes(operation.draft.kind) &&
156
+ operation.preparedUserDataRevision !== this.control().user_data_revision)
157
+ throw new SettingsConflict("Local records changed after they were checked. Review the current records before confirming.");
158
+ this.requireFinalSlot(replaceOperationId);
159
+ return result;
160
+ }
161
+ admitDownload(expected, input, snapshot) {
162
+ return this.transaction(() => {
163
+ const { card, operation } = this.requireInput(expected, input.operationId);
164
+ this.requireFinalSlot();
165
+ const file = snapshot(operation);
166
+ if (!isSettingsDownloadFile(file) || file.operationId !== operation.operationId)
167
+ throw new SettingsConflict("The download snapshot does not match this request.");
168
+ if (operation.fileId) {
169
+ const previous = this.file(operation.fileId);
170
+ if (previous?.role === "upload")
171
+ this.writeFile({ ...previous, invalidated: true, cleanup: "pending" });
172
+ }
173
+ this.createFile(file);
174
+ this.claimFinal(operation.operationId);
175
+ this.reserveFileIO(file.fileId, operation.operationId);
176
+ const admittedAt = this.now().toISOString();
177
+ const download = { currentAttemptId: input.attemptId, attempts: [{ attemptId: input.attemptId, admittedAt }] };
178
+ const next = { operationId: operation.operationId, cardId: operation.cardId, ownerId: operation.ownerId,
179
+ dataEpoch: operation.dataEpoch, phase: "executing", draft: { kind: "download_backup" }, fileId: file.fileId,
180
+ preparation: { download }, acceptedDigest: settingsIdentity(input), admittedAt };
181
+ this.write(operation, next, card, { ...card, acceptedInput: input,
182
+ state: { ...card.state, state: "running", revision: card.state.revision + 1 } });
183
+ return file;
184
+ });
185
+ }
186
+ downloadContext(expected, operationId, fileId) {
187
+ const card = this.cards.get(expected.state.cardId);
188
+ if (!card || card.tokenHash !== expected.tokenHash || card.ownerId !== this.ownerId || card.scope !== "settings" || card.operationId !== operationId) {
189
+ throw new SettingsConflict("This download no longer belongs to the current Settings request.");
190
+ }
191
+ const operation = this.get(operationId), file = this.file(fileId);
192
+ if (!operation || operation.cardId !== card.state.cardId || operation.ownerId !== this.ownerId ||
193
+ operation.dataEpoch !== this.control().data_epoch || operation.fileId !== fileId || !file ||
194
+ file.operationId !== operationId || !isSettingsDownloadFile(file))
195
+ throw new SettingsConflict("This download no longer belongs to the current Settings request.");
196
+ const download = settingsDownload(operation);
197
+ if (!download)
198
+ throw new SettingsConflict("This Settings request does not provide a download.");
199
+ return { card, operation, file, download };
200
+ }
201
+ requireDownloadRead(expected, fileId, attemptId) {
202
+ const context = this.downloadContext(expected, expected.operationId, fileId), { operation, download, file } = context;
203
+ if (!attemptId || attemptId !== download.currentAttemptId || download.endReason || download.localSave || !download.expiresAt ||
204
+ this.now().getTime() >= Date.parse(download.expiresAt) || !file.sealed || file.invalidated ||
205
+ operation.result?.outcome !== "completed")
206
+ throw new SettingsConflict("This backup's download period has ended or its attempt changed.");
207
+ return context;
208
+ }
209
+ writeDownloadState(expected, download, card) {
210
+ storedSettingsDownloadSchema.parse(download);
211
+ this.transaction(() => {
212
+ if (!isDeepStrictEqual(this.get(expected.operationId), expected) || expected.draft?.kind !== "download_backup") {
213
+ throw new SettingsConflict("The download state changed before it could be saved.");
214
+ }
215
+ // A terminal result is immutable serialized evidence. Updating delivery
216
+ // state must not reserialize it, even with semantically identical keys.
217
+ this.db.prepare("UPDATE settings_operations SET preparation_json=? WHERE operation_id=?")
218
+ .run(JSON.stringify({ download }), expected.operationId);
219
+ if (!this.cards.replace(card, { ...card, state: { ...card.state, revision: card.state.revision + 1 } })) {
220
+ throw new SettingsConflict("The download card changed before its state could be saved.");
221
+ }
222
+ });
223
+ }
224
+ endDownload(id, reason) {
225
+ const operation = this.get(id);
226
+ if (!operation)
227
+ return;
228
+ const download = settingsDownload(operation), card = this.cards.get(operation.cardId);
229
+ if (!download || !card || download.endReason || download.localSave?.status === "saving")
230
+ return;
231
+ if (reason !== "server_restarted" && operation.ownerId !== this.ownerId)
232
+ throw new SettingsConflict("The download owner changed.");
233
+ const file = operation.fileId ? this.file(operation.fileId) : undefined;
234
+ if (file && isSettingsDownloadFile(file))
235
+ this.writeFile({ ...file, invalidated: true, cleanup: "pending" });
236
+ this.writeDownloadState(operation, { ...download, endReason: reason }, card);
237
+ }
238
+ expireDownloads(operationId) {
239
+ const now = this.now().getTime();
240
+ let failure;
241
+ for (const operation of this.downloads(operationId)) {
242
+ const download = settingsDownload(operation);
243
+ if (!download.endReason && download.localSave?.status !== "saving" && download.expiresAt && now >= Date.parse(download.expiresAt)) {
244
+ try {
245
+ this.transaction(() => this.endDownload(operation.operationId, "expired"));
246
+ }
247
+ catch (error) {
248
+ failure ??= error;
249
+ }
250
+ }
251
+ }
252
+ if (failure)
253
+ throw failure;
254
+ }
255
+ downloads(operationId) {
256
+ return this.db.prepare(`SELECT operation_id FROM settings_operations WHERE kind='download_backup' AND owner_id=?
257
+ ${operationId === undefined ? "" : "AND operation_id=?"}`)
258
+ .all(this.ownerId, ...(operationId === undefined ? [] : [operationId])).map((row) => this.get(row.operation_id));
259
+ }
260
+ continueDownload(expected, input) {
261
+ return this.transaction(() => {
262
+ // Validate the caller's scope before any target reads or expiry writes.
263
+ this.downloadContext(expected, input.operationId, input.fileId);
264
+ this.expireDownloads(input.operationId);
265
+ const { card, operation, file, download } = this.downloadContext(expected, input.operationId, input.fileId);
266
+ if (input.action === "settings_store_backup") {
267
+ const save = download.localSave;
268
+ const duplicate = save?.attempts.find((a) => a.saveAttemptId === input.saveAttemptId);
269
+ if (duplicate) {
270
+ if (save.attemptId !== input.attemptId || duplicate.previousSaveAttemptId !== input.previousSaveAttemptId ||
271
+ duplicate.allowPossibleCopy !== input.allowPossibleCopy)
272
+ throw new SettingsConflict("This save does not match the recorded request.");
273
+ return;
274
+ }
275
+ if (!save || save.attemptId !== input.attemptId || download.endReason || !download.expiresAt ||
276
+ Date.parse(download.expiresAt) <= this.now().getTime() || file.invalidated || !file.sealed ||
277
+ (save.status === "needs_consent" ? input.previousSaveAttemptId !== undefined :
278
+ save.status !== "failed" || save.failure !== "not_published" || input.previousSaveAttemptId !== save.currentSaveAttemptId) ||
279
+ input.allowPossibleCopy !== this.previousDeliveryPossible(download))
280
+ throw new SettingsConflict("This backup cannot be saved by that request. Check its current status.");
281
+ const next = this.admitLocalSave(operation, file, save, input.saveAttemptId, input.allowPossibleCopy, input.previousSaveAttemptId);
282
+ this.writeDownloadState(operation, { ...download, localSave: next }, card);
283
+ return file;
284
+ }
285
+ if (input.action === "settings_retry_download") {
286
+ const duplicate = download.attempts.find((attempt) => attempt.attemptId === input.attemptId);
287
+ if (duplicate) {
288
+ if (duplicate.previousAttemptId !== input.previousAttemptId)
289
+ throw new SettingsConflict("This retry does not match the recorded download attempt.");
290
+ return;
291
+ }
292
+ if (download.currentAttemptId !== input.previousAttemptId || download.endReason || download.localSave || !download.expiresAt || !file.sealed ||
293
+ file.invalidated || operation.result?.outcome !== "completed")
294
+ throw new SettingsConflict("This backup is no longer available for that retry.");
295
+ const next = { ...download, currentAttemptId: input.attemptId,
296
+ attempts: [...download.attempts, { attemptId: input.attemptId, previousAttemptId: input.previousAttemptId, admittedAt: this.now().toISOString() }] };
297
+ this.writeDownloadState(operation, next, card);
298
+ return;
299
+ }
300
+ const entry = download.attempts.find((attempt) => attempt.attemptId === input.attemptId);
301
+ if (!entry)
302
+ throw new SettingsConflict("This download attempt was not admitted.");
303
+ if (input.action === "settings_report_download") {
304
+ if (!download.readyAt)
305
+ throw new SettingsConflict("The download snapshot is not ready.");
306
+ if (entry.hostReport) {
307
+ if (entry.hostReport !== input.status || !isDeepStrictEqual(entry.failure, input.failure))
308
+ throw new SettingsConflict("A different result was already reported for this download attempt.");
309
+ return;
310
+ }
311
+ const next = { ...download, attempts: download.attempts.map((attempt) => attempt === entry
312
+ ? { ...attempt, hostReport: input.status, ...(input.failure ? { failure: input.failure } : {}) } : attempt) };
313
+ let admitted = false;
314
+ if (input.status === "unsupported" && input.attemptId === download.currentAttemptId && !download.endReason &&
315
+ download.expiresAt && this.now().getTime() < Date.parse(download.expiresAt) && file.sealed && !file.invalidated) {
316
+ const save = { status: "needs_consent", attemptId: input.attemptId, fileId: file.fileId,
317
+ sha256: file.sha256, totalSize: file.totalSize, destination: settingsBackupPath(operation.operationId), ownerId: this.ownerId, attempts: [] };
318
+ if (this.previousDeliveryPossible(download))
319
+ next.localSave = save;
320
+ else {
321
+ next.localSave = this.admitLocalSave(operation, file, save, randomUUID(), false);
322
+ admitted = true;
323
+ }
324
+ }
325
+ this.writeDownloadState(operation, next, card);
326
+ if (input.attemptId === download.currentAttemptId && input.status === "acknowledged" && !download.endReason)
327
+ this.endDownload(operation.operationId, "acknowledged");
328
+ return admitted ? file : undefined;
329
+ }
330
+ else {
331
+ if (input.attemptId !== download.currentAttemptId)
332
+ throw new SettingsConflict("A different download attempt is current.");
333
+ if (download.localSave?.status === "saving")
334
+ throw new SettingsConflict("Saving has already started. Check its result before continuing.");
335
+ this.endDownload(operation.operationId, "cancelled");
336
+ }
337
+ });
338
+ }
339
+ previousDeliveryPossible(download) {
340
+ return download.attempts.some((a) => a.attemptId !== download.currentAttemptId && a.hostReport !== "unsupported");
341
+ }
342
+ admitLocalSave(operation, file, saved, saveAttemptId, allowPossibleCopy, previousSaveAttemptId) {
343
+ this.claimFinal(operation.operationId);
344
+ this.reserveFileIO(file.fileId, operation.operationId);
345
+ const { failure: _failure, sourceIdentity: _identity, savedAt: _savedAt, ...base } = saved;
346
+ return { ...base, status: "saving", currentSaveAttemptId: saveAttemptId, attempts: [...saved.attempts,
347
+ { saveAttemptId, allowPossibleCopy, admittedAt: this.now().toISOString(), ...(previousSaveAttemptId ? { previousSaveAttemptId } : {}) }] };
348
+ }
349
+ localSaveContext(id, saveAttemptId) {
350
+ const operation = this.get(id), download = operation && settingsDownload(operation), save = download?.localSave;
351
+ const file = operation?.fileId ? this.file(operation.fileId) : undefined;
352
+ const card = operation && this.cards.get(operation.cardId);
353
+ if (!operation || operation.ownerId !== this.ownerId || operation.dataEpoch !== this.control().data_epoch ||
354
+ !download || !save || !file || !card || save.status !== "saving" || save.currentSaveAttemptId !== saveAttemptId ||
355
+ save.fileId !== file.fileId || save.sha256 !== file.sha256 || save.totalSize !== file.totalSize ||
356
+ save.destination !== settingsBackupPath(id) || this.control().active_operation_id !== id || this.control().file_io_id !== file.fileId) {
357
+ throw new SettingsConflict("The admitted backup save changed before it could be settled.");
358
+ }
359
+ return { operation, download, save, file, card };
360
+ }
361
+ rememberLocalSaveSource(id, saveAttemptId, sourceIdentity) {
362
+ this.transaction(() => {
363
+ const { operation, download, save, card } = this.localSaveContext(id, saveAttemptId);
364
+ if (save.sourceIdentity && !isDeepStrictEqual(save.sourceIdentity, sourceIdentity))
365
+ throw new SettingsConflict("The backup file changed before saving.");
366
+ this.writeDownloadState(operation, { ...download, localSave: { ...save, sourceIdentity } }, card);
367
+ });
368
+ }
369
+ settleLocalSave(id, saveAttemptId, outcome) {
370
+ this.transaction(() => {
371
+ const { operation, download, save, file, card } = this.localSaveContext(id, saveAttemptId);
372
+ const expired = !download.expiresAt || this.now().getTime() >= Date.parse(download.expiresAt);
373
+ const ended = outcome.status === "saved" ? "stored_locally" : save.ownerId !== this.ownerId ? "server_restarted"
374
+ : expired ? "expired" : outcome.reason !== "not_published" ? "local_save_failed" : undefined;
375
+ const localSave = outcome.status === "saved"
376
+ ? { ...save, status: "saved", savedAt: this.now().toISOString() }
377
+ : { ...save, status: "failed", failure: save.ownerId !== this.ownerId && outcome.reason === "not_published" ? "interrupted" : outcome.reason };
378
+ this.writeDownloadState(operation, { ...download, localSave, ...(ended ? { endReason: ended } : {}) }, card);
379
+ if (ended)
380
+ this.writeFile({ ...file, invalidated: true, cleanup: "pending" });
381
+ this.releaseFileIO(file.fileId);
382
+ this.db.prepare("UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?").run(id);
383
+ });
384
+ }
385
+ recordDownloadReady(id, readyAt, retryWindowMs) {
386
+ const operation = this.get(id);
387
+ if (!operation || operation.ownerId !== this.ownerId || operation.dataEpoch !== this.control().data_epoch)
388
+ throw new SettingsConflict("The snapshot owner changed.");
389
+ const download = settingsDownload(operation);
390
+ if (download.readyAt)
391
+ return;
392
+ const card = this.cards.get(operation.cardId);
393
+ this.writeDownloadState(operation, { ...download, readyAt,
394
+ expiresAt: new Date(Date.parse(readyAt) + retryWindowMs).toISOString() }, card);
395
+ }
396
+ filesForCleanup(operationId) {
397
+ const rows = this.db.prepare(`SELECT file_id FROM settings_files WHERE cleanup!='complete' AND ${SETTINGS_FILE_CLEANUP_SQL}
398
+ ${operationId === undefined ? "" : "AND cleanup_operation_id=?"}`).all(...(operationId === undefined ? [] : [operationId]));
399
+ return rows.map((row) => this.file(row.file_id));
400
+ }
401
+ finish(id, result) {
402
+ return this.transaction(() => {
403
+ const old = this.get(id);
404
+ if (!old || old.ownerId !== this.ownerId)
405
+ return false;
406
+ if (old.result)
407
+ return isDeepStrictEqual(old.result, result);
408
+ if (old.dataEpoch !== this.control().data_epoch)
409
+ return false;
410
+ if (result.outcome === "completed" && (!old.admittedAt || !old.acceptedDigest ||
411
+ !["executing", "starting"].includes(old.phase)))
412
+ throw new SettingsConflict("An unconfirmed Settings change cannot complete.");
413
+ const card = this.cards.get(old.cardId);
414
+ if (!card || card.ownerId !== this.ownerId || card.operationId !== id)
415
+ return false;
416
+ const next = { ...old, phase: "terminal", result };
417
+ this.write(old, next, card, { ...card, state: { ...card.state, revision: card.state.revision + 1,
418
+ state: "closed", reason: card.state.reason ?? (result.outcome === "completed" ? "completed" : result.outcome === "superseded" || !old.admittedAt ? "cancelled" : "failed") } });
419
+ this.db.prepare(`UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?`).run(id);
420
+ this.db.prepare(`UPDATE settings_control SET wallet_input_blocked=0,wallet_block_operation_id=NULL
421
+ WHERE id=1 AND wallet_block_operation_id=?`).run(id);
422
+ return true;
423
+ });
424
+ }
425
+ file(id) {
426
+ const row = this.db.prepare("SELECT * FROM settings_files WHERE file_id=?").get(id);
427
+ return row ? { fileId: row.file_id, operationId: row.operation_id, role: row.role, relativePath: row.relative_path,
428
+ displayName: row.display_name, totalSize: row.total_size, receivedSize: row.received_size, sha256: row.sha256,
429
+ sealed: row.sealed === 1, invalidated: row.invalidated === 1, cleanup: row.cleanup,
430
+ cleanupOperationId: row.cleanup_operation_id,
431
+ ...(row.reservation_json === null ? {} : { reservation: JSON.parse(row.reservation_json) }) } : undefined;
432
+ }
433
+ filesNeedingCleanup(operationId) {
434
+ const rows = this.db.prepare(`SELECT file_id FROM settings_files WHERE cleanup_operation_id=? AND cleanup!='complete'
435
+ AND ${SETTINGS_FILE_CLEANUP_SQL}`).all(operationId);
436
+ return rows.map((row) => this.file(row.file_id));
437
+ }
438
+ temporaryFileCleanup(operationId) {
439
+ const row = this.db.prepare(`SELECT CASE WHEN sum(cleanup='failed')>0 THEN 'failed'
440
+ WHEN sum(cleanup='pending' OR file_id=(SELECT file_io_id FROM settings_control WHERE id=1))>0 THEN 'pending'
441
+ WHEN count(*)>0 THEN 'complete' ELSE NULL END AS status
442
+ FROM settings_files WHERE cleanup_operation_id=? AND ${SETTINGS_FILE_CLEANUP_SQL}`).get(operationId);
443
+ return row.status ?? undefined;
444
+ }
445
+ createFile(file) {
446
+ this.db.prepare(`INSERT INTO settings_files
447
+ (file_id,operation_id,cleanup_operation_id,role,relative_path,display_name,total_size,received_size,sha256,sealed,invalidated,cleanup,reservation_json)
448
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(file.fileId, file.operationId, file.cleanupOperationId ?? file.operationId, file.role, file.relativePath, file.displayName, file.totalSize, file.receivedSize, file.sha256, Number(file.sealed), Number(file.invalidated), file.cleanup, file.reservation ? JSON.stringify(file.reservation) : null);
449
+ }
450
+ writeFile(file) {
451
+ if (this.db.prepare(`UPDATE settings_files SET received_size=?,sealed=?,invalidated=?,cleanup=?,reservation_json=? WHERE file_id=?`)
452
+ .run(file.receivedSize, Number(file.sealed), Number(file.invalidated), file.cleanup, file.reservation ? JSON.stringify(file.reservation) : null, file.fileId).changes !== 1) {
453
+ throw new SettingsConflict("The selected file is no longer available.");
454
+ }
455
+ }
456
+ }
457
+ // The input is already a strict typed action. Sort object keys recursively so
458
+ // JSON-RPC ids and object insertion order never change business idempotency.
459
+ export function settingsIdentity(value) {
460
+ const canonical = (input) => Array.isArray(input) ? input.map(canonical) : input !== null && typeof input === "object"
461
+ ? Object.fromEntries(Object.entries(input).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonical(item)])) : input;
462
+ return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
463
+ }