@zktx.io/sui-mcp 0.7.0 → 0.8.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 (69) hide show
  1. package/README.md +83 -42
  2. package/dist/core/activity/localDataService.js +8 -21
  3. package/dist/core/activity/localDataValidation.js +22 -46
  4. package/dist/core/activity/sqliteAccountStore.js +46 -0
  5. package/dist/core/activity/sqliteActivityStore.js +65 -147
  6. package/dist/core/activity/sqliteActivityStoreSchema.js +42 -26
  7. package/dist/core/activity/transactionActivityService.js +2 -2
  8. package/dist/core/preferences/preferencesStore.js +2 -2
  9. package/dist/core/preferences/sqlitePreferencesRepository.js +10 -0
  10. package/dist/core/session/cardResult.js +41 -0
  11. package/dist/core/session/cardSession.js +1 -1
  12. package/dist/core/session/cardSessionStore.js +51 -19
  13. package/dist/core/session/chainAccounts.js +120 -0
  14. package/dist/core/session/sessionStore.js +4 -8
  15. package/dist/core/session/settingsFileStore.js +3 -30
  16. package/dist/core/session/settingsOperation.js +27 -19
  17. package/dist/core/session/settingsWorkflow.js +161 -87
  18. package/dist/core/session/sqliteCardStore.js +34 -14
  19. package/dist/core/session/sqliteSessionStore.js +5 -5
  20. package/dist/core/session/sqliteSettingsWorkflowStore.js +135 -41
  21. package/dist/core/session/sqliteTransactionRequest.js +18 -0
  22. package/dist/core/session/sqliteWalletConnectionOperation.js +13 -0
  23. package/dist/core/session/sqliteWalletWorkflowStore.js +569 -333
  24. package/dist/core/session/status.js +2 -2
  25. package/dist/core/session/wait.js +6 -11
  26. package/dist/core/session/walletConnection.js +71 -46
  27. package/dist/core/session/walletConnectionOperations.js +24 -0
  28. package/dist/core/session/walletRuntime.js +7 -13
  29. package/dist/core/session/walletWorkflow.js +248 -99
  30. package/dist/core/session/workflowState.js +104 -38
  31. package/dist/core/session/workflowView.js +92 -101
  32. package/dist/mcp/activeAccountResponse.js +3 -0
  33. package/dist/mcp/responseGuidance.js +7 -9
  34. package/dist/mcp/serverInfo.js +11 -10
  35. package/dist/mcp/toolErrors.js +5 -3
  36. package/dist/mcp/toolNames.js +1 -0
  37. package/dist/mcp/tools/action/prepareSuiActionReview.js +9 -16
  38. package/dist/mcp/tools/read/readToolHelpers.js +9 -7
  39. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  40. package/dist/mcp/tools/session/shared.js +2 -2
  41. package/dist/mcp/tools/session/statusTools.js +12 -10
  42. package/dist/mcp/tools/session/walletConnectionTools.js +26 -14
  43. package/dist/mcp/tools/settings/index.js +7 -4
  44. package/dist/mcp-app/account.html +7 -7
  45. package/dist/mcp-app/chart.html +3 -3
  46. package/dist/mcp-app/connect.html +13 -13
  47. package/dist/mcp-app/disconnect.html +3637 -0
  48. package/dist/mcp-app/disconnect.notices.txt +1480 -0
  49. package/dist/mcp-app/receipt.html +393 -393
  50. package/dist/mcp-app/review.html +459 -459
  51. package/dist/mcp-app/settings.html +7 -7
  52. package/dist/mcp-ui/contracts.js +3 -1
  53. package/dist/mcp-ui/tools.js +4 -4
  54. package/dist/runtime/application.js +1 -1
  55. package/dist/runtime/localSettingsService.js +7 -4
  56. package/dist/runtime/smokeMainnetRead.js +5 -4
  57. package/dist/runtime/walletConnectTransport.js +16 -16
  58. package/dist/runtime/walletSdkChildRuntime.js +29 -12
  59. package/dist/runtime/walletSdkIpc.js +5 -4
  60. package/dist/runtime/walletSdkProcess.js +24 -8
  61. package/docs/AGENT_BEHAVIOR.md +82 -54
  62. package/docs/LOCAL_DB_ARCHITECTURE.md +100 -70
  63. package/docs/MCP_SETUP.md +319 -339
  64. package/docs/MCP_TOOLS.md +292 -177
  65. package/docs/SDK_API.md +15 -6
  66. package/docs/TRANSACTION_ACTIVITY_LOG.md +4 -0
  67. package/docs/UTILITY_INDEX.md +9 -7
  68. package/docs/WALLET_CONNECTION.md +162 -167
  69. package/package.json +1 -1
@@ -17,11 +17,29 @@ export class SqliteSettingsWorkflowStore {
17
17
  }
18
18
  transaction(work) { return this.db.transaction(work).immediate(); }
19
19
  control() { return this.db.prepare("SELECT * FROM settings_control WHERE id=1").get(); }
20
+ clearReadAddress(connectionId, account) {
21
+ const changed = this.db.prepare(`UPDATE live_wallet_connections SET read_address_cleared=1
22
+ WHERE id=? AND current_slot=1 AND read_address_cleared=0 AND data_epoch=(SELECT data_epoch FROM settings_control WHERE id=1)
23
+ AND EXISTS(SELECT 1 FROM json_each(connection_json,'$.chainResults') r WHERE json_extract(r.value,'$.chainId')='sui:mainnet'
24
+ AND json_extract(r.value,'$.status')='bound' AND json_extract(r.value,'$.address')=?)`).run(connectionId, account);
25
+ if (!changed.changes)
26
+ throw new SettingsConflict("The saved read address changed. Check the current address before clearing it.");
27
+ }
20
28
  create(record) {
21
29
  this.transaction(() => {
22
30
  if (record.scope !== "settings" || record.state.kind !== "settings" || !record.operationId || record.ownerId !== this.ownerId) {
23
31
  throw new SettingsConflict("This Settings card could not be identified.");
24
32
  }
33
+ if (record.state.input.backupDelivery) {
34
+ const ref = record.state.input.backupDelivery;
35
+ const op = this.get(ref.operationId), file = this.file(ref.fileId), download = op && settingsDownload(op);
36
+ if (!op || op.ownerId !== this.ownerId || op.dataEpoch !== this.control().data_epoch || !file || op.fileId !== file.fileId ||
37
+ !download?.readyAt || download.endReason || !download.expiresAt || Date.parse(download.expiresAt) <= this.now().getTime() || file.invalidated ||
38
+ download.localSave?.status === "saving")
39
+ throw new SettingsConflict("This backup is no longer available for another delivery. Request a new backup.");
40
+ this.cards.create(record);
41
+ return;
42
+ }
25
43
  this.cards.create(record);
26
44
  this.db.prepare(`INSERT INTO settings_operations (operation_id,card_id,owner_id,data_epoch,phase)
27
45
  VALUES (?,?,?,?,'draft')`).run(record.operationId, record.state.cardId, this.ownerId, this.control().data_epoch);
@@ -44,12 +62,13 @@ export class SqliteSettingsWorkflowStore {
44
62
  ...(row.prior_run_id === null ? {} : { priorRunId: row.prior_run_id }),
45
63
  ...(row.next_run_id === null ? {} : { nextRunId: row.next_run_id }),
46
64
  ...(row.supersedes_operation_id === null ? {} : { supersedesOperationId: row.supersedes_operation_id }),
47
- ...(row.superseded_by_operation_id === null ? {} : { supersededByOperationId: row.superseded_by_operation_id }),
48
- ...(row.checkpoint === null ? {} : { checkpoint: row.checkpoint }) };
65
+ ...(row.superseded_by_operation_id === null ? {} : { supersededByOperationId: row.superseded_by_operation_id }) };
49
66
  }
50
67
  forCard(record) {
51
68
  const operation = record.operationId && this.get(record.operationId);
52
- if (!operation || operation.cardId !== record.state.cardId || record.scope !== "settings") {
69
+ if (!operation || record.scope !== "settings" || operation.cardId !== record.state.cardId &&
70
+ !(record.state.input.backupDelivery && record.state.input.backupDelivery.operationId === operation.operationId &&
71
+ record.state.input.backupDelivery.fileId === operation.fileId)) {
53
72
  throw new SettingsConflict("This Settings request does not match the open card.");
54
73
  }
55
74
  return operation;
@@ -57,7 +76,7 @@ export class SqliteSettingsWorkflowStore {
57
76
  requireInput(expected, operationId, now = this.now()) {
58
77
  const card = this.cards.get(expected.state.cardId);
59
78
  if (!card || card.ownerId !== this.ownerId || card.tokenHash !== expected.tokenHash || card.scope !== "settings" ||
60
- card.state.kind !== "settings" || card.state.revision !== expected.state.revision ||
79
+ card.state.kind !== "settings" || card.state.input.backupDelivery !== undefined || card.state.input.operationStatus !== undefined || card.state.revision !== expected.state.revision ||
61
80
  card.state.state !== "ready" || card.acceptedInput !== undefined || Date.parse(card.state.expiresAt) <= now.getTime()) {
62
81
  throw new SettingsConflict("This Settings selection changed or its time limit ended.");
63
82
  }
@@ -68,27 +87,41 @@ export class SqliteSettingsWorkflowStore {
68
87
  }
69
88
  return { card, operation };
70
89
  }
71
- write(expected, next, card, nextCard) {
90
+ write(expected, next, card, nextCard, display) {
72
91
  this.transaction(() => {
73
92
  if (!isDeepStrictEqual(this.get(expected.operationId), expected) || next.operationId !== expected.operationId ||
74
93
  next.cardId !== expected.cardId || next.ownerId !== expected.ownerId)
75
94
  throw new SettingsConflict("Settings state changed before it could be saved.");
95
+ if (next.fileId !== expected.fileId || next.draft?.kind !== "import_local_data" || next.phase === "terminal") {
96
+ this.invalidateUpload(expected);
97
+ }
76
98
  this.db.prepare(`UPDATE settings_operations SET data_epoch=@epoch,kind=@kind,phase=@phase,draft_json=@draft,
77
99
  preparation_id=@preparationId,fingerprint=@fingerprint,prepared_user_data_revision=@userRevision,preparation_json=@preparation,
78
100
  file_id=@fileId,accepted_digest=@accepted,admitted_at=@admitted,result_json=@result,
79
- prior_run_id=@prior,next_run_id=@next,supersedes_operation_id=@supersedes,superseded_by_operation_id=@supersededBy,checkpoint=@checkpoint
101
+ prior_run_id=@prior,next_run_id=@next,supersedes_operation_id=@supersedes,superseded_by_operation_id=@supersededBy
80
102
  WHERE operation_id=@id`).run({ id: next.operationId, epoch: next.dataEpoch, kind: next.draft?.kind ?? null,
81
103
  phase: next.phase, draft: next.draft ? JSON.stringify(next.draft) : null, preparationId: next.preparationId ?? null,
82
104
  fingerprint: next.fingerprint ?? null, userRevision: next.preparedUserDataRevision ?? null,
83
105
  preparation: next.preparation ? JSON.stringify(next.preparation) : null, fileId: next.fileId ?? null,
84
106
  accepted: next.acceptedDigest ?? null, admitted: next.admittedAt ?? null, result: next.result ? JSON.stringify(next.result) : null,
85
107
  prior: next.priorRunId ?? null, next: next.nextRunId ?? null,
86
- supersedes: next.supersedesOperationId ?? null, supersededBy: next.supersededByOperationId ?? null, checkpoint: next.checkpoint ?? null });
87
- if (!this.cards.replace(card, nextCard ?? { ...card, state: { ...card.state, revision: card.state.revision + 1 } })) {
108
+ supersedes: next.supersedesOperationId ?? null, supersededBy: next.supersededByOperationId ?? null });
109
+ let projected = nextCard ?? { ...card, state: { ...card.state, revision: card.state.revision + 1 } };
110
+ if (projected.state.state === "closed" && projected.state.data === undefined && next.result) {
111
+ projected = { ...projected, state: { ...projected.state, data: this.resultView(next, next.result, display) } };
112
+ }
113
+ if (card.state.state !== "closed" && !this.cards.replace(card, projected, this.now())) {
88
114
  throw new SettingsConflict("The Settings card changed before it could be saved.");
89
115
  }
90
116
  });
91
117
  }
118
+ invalidateUpload(operation) {
119
+ if (!operation.fileId)
120
+ return;
121
+ this.db.prepare(`UPDATE settings_files SET invalidated=1,cleanup='pending'
122
+ WHERE file_id=? AND operation_id=? AND role='upload' AND invalidated=0`)
123
+ .run(operation.fileId, operation.operationId);
124
+ }
92
125
  hasReusableUpload(operationId) {
93
126
  return !!this.db.prepare(`SELECT 1 FROM settings_operations WHERE operation_id=? AND (${SETTINGS_REUSABLE_UPLOAD_SQL})`).get(operationId);
94
127
  }
@@ -160,16 +193,11 @@ export class SqliteSettingsWorkflowStore {
160
193
  const file = snapshot(operation);
161
194
  if (!isSettingsDownloadFile(file) || file.operationId !== operation.operationId)
162
195
  throw new SettingsConflict("The download snapshot does not match this request.");
163
- if (operation.fileId) {
164
- const previous = this.file(operation.fileId);
165
- if (previous?.role === "upload")
166
- this.writeFile({ ...previous, invalidated: true, cleanup: "pending" });
167
- }
168
196
  this.createFile(file);
169
197
  this.claimFinal(operation.operationId);
170
198
  this.reserveFileIO(file.fileId, operation.operationId);
171
199
  const admittedAt = this.now().toISOString();
172
- const download = { currentAttemptId: input.attemptId, attempts: [{ attemptId: input.attemptId, admittedAt }] };
200
+ const download = { currentAttemptId: input.attemptId, attempts: [{ attemptId: input.attemptId, admittedAt, cardId: card.state.cardId }] };
173
201
  const next = { operationId: operation.operationId, cardId: operation.cardId, ownerId: operation.ownerId,
174
202
  dataEpoch: operation.dataEpoch, phase: "executing", draft: { kind: "download_backup" }, fileId: file.fileId,
175
203
  preparation: { download }, acceptedDigest: settingsIdentity(input), admittedAt };
@@ -184,7 +212,9 @@ export class SqliteSettingsWorkflowStore {
184
212
  throw new SettingsConflict("This download no longer belongs to the current Settings request.");
185
213
  }
186
214
  const operation = this.get(operationId), file = this.file(fileId);
187
- if (!operation || operation.cardId !== card.state.cardId || operation.ownerId !== this.ownerId ||
215
+ if (!operation || operation.cardId !== card.state.cardId && !(card.state.input.backupDelivery &&
216
+ card.state.input.backupDelivery.operationId === operationId &&
217
+ card.state.input.backupDelivery.fileId === fileId) || operation.ownerId !== this.ownerId ||
188
218
  operation.dataEpoch !== this.control().data_epoch || operation.fileId !== fileId || !file ||
189
219
  file.operationId !== operationId || !isSettingsDownloadFile(file))
190
220
  throw new SettingsConflict("This download no longer belongs to the current Settings request.");
@@ -194,14 +224,14 @@ export class SqliteSettingsWorkflowStore {
194
224
  return { card, operation, file, download };
195
225
  }
196
226
  requireDownloadRead(expected, fileId, attemptId) {
197
- const context = this.downloadContext(expected, expected.operationId, fileId), { operation, download, file } = context;
198
- if (!attemptId || attemptId !== download.currentAttemptId || download.endReason || download.localSave || !download.expiresAt ||
227
+ const context = this.downloadContext(expected, expected.operationId, fileId), { download, file } = context;
228
+ if (context.card.state.state === "closed" || !attemptId || download.attempts.find((a) => a.attemptId === attemptId)?.cardId !== context.card.state.cardId || attemptId !== download.currentAttemptId || download.endReason || download.localSave || !download.expiresAt ||
199
229
  this.now().getTime() >= Date.parse(download.expiresAt) || !file.sealed || file.invalidated ||
200
- operation.result?.outcome !== "completed")
230
+ !download.readyAt)
201
231
  throw new SettingsConflict("This backup's download period has ended or its attempt changed.");
202
232
  return context;
203
233
  }
204
- writeDownloadState(expected, download, card) {
234
+ writeDownloadState(expected, download, card, nextCard) {
205
235
  storedSettingsDownloadSchema.parse(download);
206
236
  this.transaction(() => {
207
237
  if (!isDeepStrictEqual(this.get(expected.operationId), expected) || expected.draft?.kind !== "download_backup") {
@@ -209,12 +239,13 @@ export class SqliteSettingsWorkflowStore {
209
239
  }
210
240
  this.db.prepare("UPDATE settings_operations SET preparation_json=? WHERE operation_id=?")
211
241
  .run(JSON.stringify({ download }), expected.operationId);
212
- if (!this.cards.replace(card, { ...card, state: { ...card.state, revision: card.state.revision + 1 } })) {
242
+ const current = this.cards.get(card.state.cardId);
243
+ if (current && current.state.state !== "closed" && !this.cards.replace(current, nextCard ?? { ...current, state: { ...current.state, revision: current.state.revision + 1 } })) {
213
244
  throw new SettingsConflict("The download card changed before its state could be saved.");
214
245
  }
215
246
  });
216
247
  }
217
- endDownload(id, reason) {
248
+ endDownload(id, reason, finalResult) {
218
249
  const operation = this.get(id);
219
250
  if (!operation)
220
251
  return;
@@ -227,6 +258,7 @@ export class SqliteSettingsWorkflowStore {
227
258
  if (file && isSettingsDownloadFile(file))
228
259
  this.writeFile({ ...file, invalidated: true, cleanup: "pending" });
229
260
  this.writeDownloadState(operation, { ...download, endReason: reason }, card);
261
+ this.finishDownloadCard(id, undefined, finalResult);
230
262
  }
231
263
  expireDownloads(operationId) {
232
264
  const now = this.now().getTime();
@@ -259,38 +291,40 @@ export class SqliteSettingsWorkflowStore {
259
291
  const save = download.localSave;
260
292
  const duplicate = save?.attempts.find((a) => a.saveAttemptId === input.saveAttemptId);
261
293
  if (duplicate) {
262
- if (save.attemptId !== input.attemptId || duplicate.previousSaveAttemptId !== input.previousSaveAttemptId ||
294
+ if (duplicate.cardId !== card.state.cardId || save.attemptId !== input.attemptId || duplicate.previousSaveAttemptId !== input.previousSaveAttemptId ||
263
295
  duplicate.allowPossibleCopy !== input.allowPossibleCopy)
264
296
  throw new SettingsConflict("This save does not match the recorded request.");
265
297
  return;
266
298
  }
267
- if (!save || save.attemptId !== input.attemptId || download.endReason || !download.expiresAt ||
299
+ if (card.state.state !== "ready" || !card.state.input.backupDelivery || !save || save.attemptId !== input.attemptId || download.endReason || !download.expiresAt ||
268
300
  Date.parse(download.expiresAt) <= this.now().getTime() || file.invalidated || !file.sealed ||
269
301
  (save.status === "needs_consent" ? input.previousSaveAttemptId !== undefined :
270
302
  save.status !== "failed" || save.failure !== "not_published" || input.previousSaveAttemptId !== save.currentSaveAttemptId) ||
271
303
  input.allowPossibleCopy !== this.previousDeliveryPossible(download))
272
304
  throw new SettingsConflict("This backup cannot be saved by that request. Check its current status.");
273
- const next = this.admitLocalSave(operation, file, save, input.saveAttemptId, input.allowPossibleCopy, input.previousSaveAttemptId);
274
- this.writeDownloadState(operation, { ...download, localSave: next }, card);
305
+ const next = this.admitLocalSave(operation, file, save, input.saveAttemptId, input.allowPossibleCopy, card.state.cardId, input.previousSaveAttemptId);
306
+ this.writeDownloadState(operation, { ...download, localSave: next }, card, { ...card, acceptedInput: input, state: { ...card.state, state: "running", revision: card.state.revision + 1 } });
275
307
  return file;
276
308
  }
277
309
  if (input.action === "settings_retry_download") {
278
310
  const duplicate = download.attempts.find((attempt) => attempt.attemptId === input.attemptId);
279
311
  if (duplicate) {
280
- if (duplicate.previousAttemptId !== input.previousAttemptId)
312
+ if (duplicate.cardId !== card.state.cardId || duplicate.previousAttemptId !== input.previousAttemptId)
281
313
  throw new SettingsConflict("This retry does not match the recorded download attempt.");
282
314
  return;
283
315
  }
284
- if (download.currentAttemptId !== input.previousAttemptId || download.endReason || download.localSave || !download.expiresAt || !file.sealed ||
285
- file.invalidated || operation.result?.outcome !== "completed")
316
+ if (card.state.state !== "ready" || !card.state.input.backupDelivery || download.currentAttemptId !== input.previousAttemptId || download.endReason || download.localSave || !download.expiresAt || !file.sealed ||
317
+ file.invalidated || !download.readyAt)
286
318
  throw new SettingsConflict("This backup is no longer available for that retry.");
319
+ const previousCardId = download.attempts.find((a) => a.attemptId === download.currentAttemptId).cardId;
320
+ this.finishDownloadCard(operation.operationId, previousCardId);
287
321
  const next = { ...download, currentAttemptId: input.attemptId,
288
- attempts: [...download.attempts, { attemptId: input.attemptId, previousAttemptId: input.previousAttemptId, admittedAt: this.now().toISOString() }] };
289
- this.writeDownloadState(operation, next, card);
322
+ attempts: [...download.attempts, { attemptId: input.attemptId, cardId: card.state.cardId, previousAttemptId: input.previousAttemptId, admittedAt: this.now().toISOString() }] };
323
+ this.writeDownloadState(this.get(operation.operationId), next, card, { ...card, acceptedInput: input, state: { ...card.state, state: "running", revision: card.state.revision + 1 } });
290
324
  return;
291
325
  }
292
326
  const entry = download.attempts.find((attempt) => attempt.attemptId === input.attemptId);
293
- if (!entry)
327
+ if (!entry || entry.cardId !== card.state.cardId)
294
328
  throw new SettingsConflict("This download attempt was not admitted.");
295
329
  if (input.action === "settings_report_download") {
296
330
  if (!download.readyAt)
@@ -303,24 +337,26 @@ export class SqliteSettingsWorkflowStore {
303
337
  const next = { ...download, attempts: download.attempts.map((attempt) => attempt === entry
304
338
  ? { ...attempt, hostReport: input.status, ...(input.failure ? { failure: input.failure } : {}) } : attempt) };
305
339
  let admitted = false;
306
- if (input.status === "unsupported" && input.attemptId === download.currentAttemptId && !download.endReason &&
340
+ if (input.status === "unsupported" && input.attemptId === download.currentAttemptId && card.state.state === "running" && !download.endReason &&
307
341
  download.expiresAt && this.now().getTime() < Date.parse(download.expiresAt) && file.sealed && !file.invalidated) {
308
342
  const save = { status: "needs_consent", attemptId: input.attemptId, fileId: file.fileId,
309
343
  sha256: file.sha256, totalSize: file.totalSize, destination: settingsBackupPath(operation.operationId), ownerId: this.ownerId, attempts: [] };
310
344
  if (this.previousDeliveryPossible(download))
311
345
  next.localSave = save;
312
346
  else {
313
- next.localSave = this.admitLocalSave(operation, file, save, randomUUID(), false);
347
+ next.localSave = this.admitLocalSave(operation, file, save, randomUUID(), false, card.state.cardId);
314
348
  admitted = true;
315
349
  }
316
350
  }
317
351
  this.writeDownloadState(operation, next, card);
318
352
  if (input.attemptId === download.currentAttemptId && input.status === "acknowledged" && !download.endReason)
319
353
  this.endDownload(operation.operationId, "acknowledged");
354
+ else if (!admitted)
355
+ this.finishDownloadCard(operation.operationId, card.state.cardId);
320
356
  return admitted ? file : undefined;
321
357
  }
322
358
  else {
323
- if (input.attemptId !== download.currentAttemptId)
359
+ if (card.state.state === "closed" || input.attemptId !== download.currentAttemptId)
324
360
  throw new SettingsConflict("A different download attempt is current.");
325
361
  if (download.localSave?.status === "saving")
326
362
  throw new SettingsConflict("Saving has already started. Check its result before continuing.");
@@ -329,19 +365,20 @@ export class SqliteSettingsWorkflowStore {
329
365
  });
330
366
  }
331
367
  previousDeliveryPossible(download) {
332
- return download.attempts.some((a) => a.attemptId !== download.currentAttemptId && a.hostReport !== "unsupported");
368
+ return download.attempts.some((a) => a.attemptId !== download.currentAttemptId && a.hostReport !== "unsupported" && a.hostReport !== "not_sent");
333
369
  }
334
- admitLocalSave(operation, file, saved, saveAttemptId, allowPossibleCopy, previousSaveAttemptId) {
370
+ admitLocalSave(operation, file, saved, saveAttemptId, allowPossibleCopy, cardId, previousSaveAttemptId) {
335
371
  this.claimFinal(operation.operationId);
336
372
  this.reserveFileIO(file.fileId, operation.operationId);
337
373
  const { failure: _failure, sourceIdentity: _identity, savedAt: _savedAt, ...base } = saved;
338
374
  return { ...base, status: "saving", currentSaveAttemptId: saveAttemptId, attempts: [...saved.attempts,
339
- { saveAttemptId, allowPossibleCopy, admittedAt: this.now().toISOString(), ...(previousSaveAttemptId ? { previousSaveAttemptId } : {}) }] };
375
+ { saveAttemptId, cardId, allowPossibleCopy, admittedAt: this.now().toISOString(), ...(previousSaveAttemptId ? { previousSaveAttemptId } : {}) }] };
340
376
  }
341
377
  localSaveContext(id, saveAttemptId) {
342
378
  const operation = this.get(id), download = operation && settingsDownload(operation), save = download?.localSave;
343
379
  const file = operation?.fileId ? this.file(operation.fileId) : undefined;
344
- const card = operation && this.cards.get(operation.cardId);
380
+ const ownerCardId = save?.attempts.find((a) => a.saveAttemptId === save.currentSaveAttemptId)?.cardId;
381
+ const card = ownerCardId && this.cards.get(ownerCardId);
345
382
  if (!operation || operation.ownerId !== this.ownerId || operation.dataEpoch !== this.control().data_epoch ||
346
383
  !download || !save || !file || !card || save.status !== "saving" || save.currentSaveAttemptId !== saveAttemptId ||
347
384
  save.fileId !== file.fileId || save.sha256 !== file.sha256 || save.totalSize !== file.totalSize ||
@@ -358,7 +395,7 @@ export class SqliteSettingsWorkflowStore {
358
395
  this.writeDownloadState(operation, { ...download, localSave: { ...save, sourceIdentity } }, card);
359
396
  });
360
397
  }
361
- settleLocalSave(id, saveAttemptId, outcome) {
398
+ settleLocalSave(id, saveAttemptId, outcome, backupLocation) {
362
399
  this.transaction(() => {
363
400
  const { operation, download, save, file, card } = this.localSaveContext(id, saveAttemptId);
364
401
  const expired = !download.expiresAt || this.now().getTime() >= Date.parse(download.expiresAt);
@@ -370,6 +407,7 @@ export class SqliteSettingsWorkflowStore {
370
407
  this.writeDownloadState(operation, { ...download, localSave, ...(ended ? { endReason: ended } : {}) }, card);
371
408
  if (ended)
372
409
  this.writeFile({ ...file, invalidated: true, cleanup: "pending" });
410
+ this.finishDownloadCard(id, card.state.cardId, undefined, backupLocation);
373
411
  this.releaseFileIO(file.fileId);
374
412
  this.db.prepare("UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?").run(id);
375
413
  });
@@ -385,12 +423,68 @@ export class SqliteSettingsWorkflowStore {
385
423
  this.writeDownloadState(operation, { ...download, readyAt,
386
424
  expiresAt: new Date(Date.parse(readyAt) + retryWindowMs).toISOString() }, card);
387
425
  }
426
+ releaseFinal(id) {
427
+ this.db.prepare("UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?").run(id);
428
+ }
429
+ resultView(operation, result, display) {
430
+ const file = operation.fileId ? this.file(operation.fileId) : undefined;
431
+ const { file: _file, ...extra } = display ?? {};
432
+ return { kind: "settings", operationId: operation.operationId, phase: "terminal", result,
433
+ ...(operation.draft ? { draft: operation.draft } : {}), temporaryFileCleanup: this.temporaryFileCleanup(operation.operationId),
434
+ allowedActions: [], observe: false,
435
+ ...(operation.priorRunId ? { priorRunId: operation.priorRunId } : {}), ...(operation.nextRunId ? { nextRunId: operation.nextRunId } : {}),
436
+ ...(operation.supersedesOperationId ? { supersedesOperationId: operation.supersedesOperationId } : {}),
437
+ ...(operation.supersededByOperationId ? { supersededByOperationId: operation.supersededByOperationId } : {}),
438
+ ...(file ? { file: { fileId: file.fileId, displayName: file.displayName, totalSize: file.totalSize, receivedSize: file.receivedSize,
439
+ sha256: file.sha256, sealed: file.sealed, cleanup: file.cleanup } } : {}), ...extra };
440
+ }
441
+ downloadResult(operation) {
442
+ const download = settingsDownload(operation);
443
+ if (!download)
444
+ throw new SettingsConflict("This Settings request does not provide a download.");
445
+ const local = download.localSave, report = download.attempts.find((a) => a.attemptId === download.currentAttemptId)?.hostReport;
446
+ const succeeded = download.endReason === "stored_locally" || report === "acknowledged";
447
+ return { completionScope: "backup_delivery", outcome: succeeded ? "completed" : download.endReason === "cancelled" || download.endReason === "server_restarted" ? "interrupted" : "failed", recordedAt: this.now().toISOString(),
448
+ message: download.endReason === "stored_locally" ? "Backup saved." : report === "acknowledged" ? "The app accepted the download request. This does not confirm that the file was saved or where it was saved."
449
+ : download.endReason === "cancelled" ? "Backup delivery was cancelled."
450
+ : download.endReason ? "This backup is no longer available for delivery. Ask in chat for a new Settings card to create a backup."
451
+ : report === "not_sent" ? "The backup could not be read or checked. Nothing was sent to the app for download." : local?.status === "needs_consent" ? "A local copy needs a new explicit request because another copy may exist."
452
+ : local?.status === "failed" && local.failure === "not_published" ? "The backup was not saved. Ask in chat for a new delivery card to retry saving the same backup."
453
+ : "Backup delivery was not confirmed. Ask in chat for a new delivery card." };
454
+ }
455
+ finishDownloadCard(id, cardId, finalResult, backupLocation) {
456
+ const operation = this.get(id), download = operation && settingsDownload(operation);
457
+ if (!operation || !download)
458
+ return;
459
+ const local = download.localSave;
460
+ const target = cardId ?? local?.attempts.find((a) => a.saveAttemptId === local.currentSaveAttemptId)?.cardId ?? download.attempts.find((a) => a.attemptId === download.currentAttemptId)?.cardId;
461
+ const card = target && this.cards.get(target);
462
+ if (!card)
463
+ return;
464
+ const report = download.attempts.find((a) => a.attemptId === download.currentAttemptId)?.hostReport;
465
+ const succeeded = download.endReason === "stored_locally" || report === "acknowledged";
466
+ const result = finalResult ?? this.downloadResult(operation);
467
+ const file = operation.fileId ? this.file(operation.fileId) : undefined;
468
+ const display = { ...(file ? { file: { fileId: file.fileId, displayName: file.displayName, totalSize: file.totalSize, receivedSize: file.receivedSize,
469
+ sha256: file.sha256, sealed: file.sealed, cleanup: file.cleanup } } : {}),
470
+ download: { availability: download.endReason ? "ended" : download.readyAt ? "available" : "preparing", currentAttemptId: download.currentAttemptId,
471
+ ...(report ? { hostReport: report } : {}), previousUnconfirmed: this.previousDeliveryPossible(download),
472
+ ...(download.endReason ? { endReason: download.endReason } : {}), ...(download.expiresAt ? { expiresAt: download.expiresAt, remainingMs: Math.max(0, Date.parse(download.expiresAt) - this.now().getTime()) } : {}),
473
+ ...(local ? { localSave: { status: local.status, currentSaveAttemptId: local.currentSaveAttemptId, failure: local.failure, savedAt: local.savedAt } } : {}), allowedFileActions: [] },
474
+ ...(backupLocation ? { backupLocation } : {}) };
475
+ const reason = download.endReason === "cancelled" ? "cancelled" : download.endReason === "expired" ? "expired" : download.endReason === "server_restarted" ? "server_restarted" : succeeded ? "completed" : "failed";
476
+ if (!operation.result && operation.cardId === card.state.cardId)
477
+ this.finish(id, result, display, reason);
478
+ else if (card.state.state !== "closed" && !this.cards.replace(card, { ...card, state: { ...card.state, state: "closed", reason,
479
+ revision: card.state.revision + 1, data: this.resultView(operation, result, display) } }, this.now()))
480
+ throw new SettingsConflict("The delivery result could not be saved.");
481
+ }
388
482
  filesForCleanup(operationId) {
389
483
  const rows = this.db.prepare(`SELECT file_id FROM settings_files WHERE cleanup!='complete' AND ${SETTINGS_FILE_CLEANUP_SQL}
390
484
  ${operationId === undefined ? "" : "AND cleanup_operation_id=?"}`).all(...(operationId === undefined ? [] : [operationId]));
391
485
  return rows.map((row) => this.file(row.file_id));
392
486
  }
393
- finish(id, result) {
487
+ finish(id, result, display, reason) {
394
488
  return this.transaction(() => {
395
489
  const old = this.get(id);
396
490
  if (!old || old.ownerId !== this.ownerId)
@@ -407,7 +501,7 @@ export class SqliteSettingsWorkflowStore {
407
501
  return false;
408
502
  const next = { ...old, phase: "terminal", result };
409
503
  this.write(old, next, card, { ...card, state: { ...card.state, revision: card.state.revision + 1,
410
- state: "closed", reason: card.state.reason ?? (result.outcome === "completed" ? "completed" : result.outcome === "superseded" || !old.admittedAt ? "cancelled" : "failed") } });
504
+ state: "closed", data: undefined, reason: card.state.reason ?? reason ?? (result.outcome === "completed" ? "completed" : result.outcome === "superseded" || !old.admittedAt ? "cancelled" : "failed") } }, display);
411
505
  this.db.prepare(`UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?`).run(id);
412
506
  this.db.prepare(`UPDATE settings_control SET wallet_input_blocked=0,wallet_block_operation_id=NULL
413
507
  WHERE id=1 AND wallet_block_operation_id=?`).run(id);
@@ -0,0 +1,18 @@
1
+ import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
2
+ import { transactionRequestSchema } from "./transactionRequest.js";
3
+ export function readStoredTransactionRequest(db, id) {
4
+ const row = db.prepare(`SELECT r.*, a.address AS account, e.result_json FROM review_requests r
5
+ JOIN accounts a ON a.id=r.account_id AND a.chain_id='sui:mainnet' LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id
6
+ WHERE r.attempt_id=?`).get(id);
7
+ if (!row)
8
+ return undefined;
9
+ const result = transactionRequestSchema.parse({ attemptId: row.attempt_id, reviewSessionId: row.review_session_id,
10
+ planId: row.plan_id, reviewRevision: row.review_revision, account: row.account, transactionDigest: row.transaction_digest,
11
+ requestStatus: row.request_status, revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at,
12
+ ...(row.reason === null ? {} : { reason: row.reason }),
13
+ ...(row.signature_verified_at === null ? {} : { signatureVerifiedAt: row.signature_verified_at }),
14
+ ...(row.submitted_at === null ? {} : { submittedAt: row.submitted_at }),
15
+ ...(row.result_json === null ? {} : { execution: JSON.parse(row.result_json) }) });
16
+ assertNoForbiddenMcpFields(result);
17
+ return result;
18
+ }
@@ -0,0 +1,13 @@
1
+ import { walletConnectionActionSchema, walletConnectionOperationSchema } from "./walletConnectionOperations.js";
2
+ export function readStoredWalletConnectionOperation(db, id) {
3
+ const row = db.prepare("SELECT * FROM wallet_connection_operations WHERE operation_id=?").get(id);
4
+ if (!row)
5
+ return undefined;
6
+ const input = walletConnectionActionSchema.parse(JSON.parse(row.input_json));
7
+ const result = row.result_json ? JSON.parse(row.result_json) : {};
8
+ return { ...walletConnectionOperationSchema.parse({ operationId: row.operation_id, cardId: row.card_id, action: row.action,
9
+ status: row.status, settlementPending: row.sdk_pending === 1, createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
10
+ ...(row.connection_id ? { connectionId: row.connection_id } : {}), ...({}),
11
+ ...result }),
12
+ ownerId: row.owner_id, runId: row.run_id, dataEpoch: row.data_epoch, input };
13
+ }