@zktx.io/sui-mcp 0.5.0 → 0.6.1

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 (138) hide show
  1. package/README.md +21 -21
  2. package/dist/adapters/adapterPromptSurfaces.js +0 -20
  3. package/dist/adapters/deepbook/deepbookQuotePolicy.js +0 -4
  4. package/dist/adapters/deepbook/deepbookReviewEvidence.js +0 -9
  5. package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +0 -9
  6. package/dist/adapters/intentPlanFactories.js +0 -6
  7. package/dist/core/action/contractNameRegistry.js +0 -53
  8. package/dist/core/action/forbiddenFields.js +0 -4
  9. package/dist/core/action/ptbVisualizationProducer.js +0 -3
  10. package/dist/core/action/receiptPtbGraph.js +0 -17
  11. package/dist/core/action/reviewTimeSimulationEvidence.js +0 -4
  12. package/dist/core/action/signableAdapterContract.js +0 -5
  13. package/dist/core/action/suiChainReceiptMapping.js +0 -18
  14. package/dist/core/action/suiChainReceiptReader.js +1 -21
  15. package/dist/core/action/suiChainReceiptVerifier.js +0 -10
  16. package/dist/core/action/transactionFunding.js +0 -7
  17. package/dist/core/action/types.js +0 -1
  18. package/dist/core/action/walletReviewContractAssembler.js +0 -2
  19. package/dist/core/activity/localDataService.js +65 -52
  20. package/dist/core/activity/localDataValidation.js +1 -3
  21. package/dist/core/activity/sqliteActivityStore.js +24 -24
  22. package/dist/core/activity/sqliteActivityStoreRows.js +0 -2
  23. package/dist/core/activity/sqliteActivityStoreSchema.js +81 -14
  24. package/dist/core/preferences/sqlitePreferencesRepository.js +5 -3
  25. package/dist/core/read/deepbookReadHelpers.js +0 -2
  26. package/dist/core/read/deepbookRegistry.js +0 -19
  27. package/dist/core/read/deepbookUsdcChartService.js +0 -2
  28. package/dist/core/read/readCardResult.js +0 -2
  29. package/dist/core/read/readService.js +0 -19
  30. package/dist/core/read/readServiceTypes.js +0 -2
  31. package/dist/core/session/cardSession.js +1 -5
  32. package/dist/core/session/cardSessionStore.js +54 -7
  33. package/dist/core/session/reviewValidity.js +0 -3
  34. package/dist/core/session/sessionRecordStore.js +0 -1
  35. package/dist/core/session/sessionStore.js +0 -32
  36. package/dist/core/session/settingsFileIdentity.js +9 -0
  37. package/dist/core/session/settingsFileProtocol.js +20 -0
  38. package/dist/core/session/settingsFileStore.js +337 -0
  39. package/dist/core/session/settingsInputError.js +32 -0
  40. package/dist/core/session/settingsOperation.js +220 -0
  41. package/dist/core/session/settingsPreparationSql.js +13 -0
  42. package/dist/core/session/settingsWorkflow.js +1183 -0
  43. package/dist/core/session/sqliteCardStore.js +0 -2
  44. package/dist/core/session/sqliteSessionStore.js +0 -51
  45. package/dist/core/session/sqliteSettingsWorkflowStore.js +453 -0
  46. package/dist/core/session/sqliteTransactionMaterialStore.js +0 -11
  47. package/dist/core/session/sqliteWalletWorkflowStore.js +160 -66
  48. package/dist/core/session/status.js +2 -4
  49. package/dist/core/session/transactionMaterialStore.js +0 -4
  50. package/dist/core/session/transactionRequest.js +0 -4
  51. package/dist/core/session/walletConnection.js +26 -10
  52. package/dist/core/session/walletWorkflow.js +70 -30
  53. package/dist/core/session/workflowState.js +14 -14
  54. package/dist/core/session/workflowView.js +18 -8
  55. package/dist/core/suiAddress.js +0 -5
  56. package/dist/core/suiEndpoint.js +0 -3
  57. package/dist/mcp/prompts.js +0 -12
  58. package/dist/mcp/responseGuidance.js +10 -5
  59. package/dist/mcp/schemas.js +0 -3
  60. package/dist/mcp/serverInfo.js +3 -4
  61. package/dist/mcp/toolNames.js +2 -2
  62. package/dist/mcp/tools/account/index.js +1 -19
  63. package/dist/mcp/tools/action/prepareSuiActionReview.js +0 -6
  64. package/dist/mcp/tools/read/readToolHelpers.js +5 -4
  65. package/dist/mcp/tools/read/serverStatusTools.js +0 -1
  66. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  67. package/dist/mcp/tools/session/shared.js +1 -2
  68. package/dist/mcp/tools/session/statusTools.js +4 -5
  69. package/dist/mcp/tools/settings/index.js +8 -28
  70. package/dist/mcp-app/account.html +14 -14
  71. package/dist/mcp-app/account.notices.txt +1 -1
  72. package/dist/mcp-app/chart.html +9 -9
  73. package/dist/mcp-app/chart.notices.txt +1 -1
  74. package/dist/mcp-app/connect.html +14 -14
  75. package/dist/mcp-app/connect.notices.txt +1 -1
  76. package/dist/mcp-app/receipt.html +1298 -1463
  77. package/dist/mcp-app/receipt.notices.txt +226 -1204
  78. package/dist/mcp-app/review.html +1298 -1463
  79. package/dist/mcp-app/review.notices.txt +226 -1204
  80. package/dist/mcp-app/settings.html +3133 -0
  81. package/dist/mcp-app/settings.notices.txt +973 -0
  82. package/dist/mcp-ui/contracts.js +8 -2
  83. package/dist/mcp-ui/readCards.js +1 -1
  84. package/dist/mcp-ui/tools.js +34 -4
  85. package/dist/review-server/server.js +3 -51
  86. package/dist/runtime/application.js +41 -14
  87. package/dist/runtime/config.js +0 -2
  88. package/dist/runtime/localSettingsService.js +23 -2
  89. package/dist/runtime/logger.js +0 -2
  90. package/dist/runtime/reviewServerAcquire.js +1 -18
  91. package/dist/runtime/runtimeTermination.js +1 -4
  92. package/dist/runtime/shared/authenticatedFetch.js +2 -4
  93. package/dist/runtime/shared/control.js +4 -7
  94. package/dist/runtime/shared/dataAccess.js +0 -2
  95. package/dist/runtime/shared/http.js +14 -0
  96. package/dist/runtime/shared/mcpHttp.js +6 -6
  97. package/dist/runtime/shared/ownerLease.js +0 -4
  98. package/dist/runtime/shared/server.js +1 -2
  99. package/dist/runtime/shared/stdio.js +2 -5
  100. package/dist/runtime/smokeMainnetRead.js +0 -3
  101. package/dist/runtime/smokeReview.js +0 -4
  102. package/dist/runtime/start.js +0 -2
  103. package/dist/runtime/suiTransactionGraphqlMapping.js +0 -2
  104. package/dist/runtime/suiTransactionGraphqlQueries.js +0 -3
  105. package/dist/runtime/walletConnectConfig.js +0 -2
  106. package/dist/runtime/walletConnectStorage.js +2 -10
  107. package/dist/runtime/walletConnectTransport.js +0 -11
  108. package/dist/runtime/walletSdkChildRuntime.js +2 -6
  109. package/dist/runtime/walletSdkIpc.js +2 -1
  110. package/dist/runtime/walletSdkProcess.js +3 -8
  111. package/docs/AGENT_BEHAVIOR.md +45 -15
  112. package/docs/LOCAL_DB_ARCHITECTURE.md +145 -22
  113. package/docs/MCP_SETUP.md +99 -39
  114. package/docs/MCP_TOOLS.md +288 -24
  115. package/docs/SDK_API.md +63 -10
  116. package/docs/UTILITY_INDEX.md +9 -6
  117. package/docs/WALLET_CONNECTION.md +32 -10
  118. package/package.json +22 -8
  119. package/dist/core/activity/schemaVersion.js +0 -2
  120. package/dist/core/session/keyedRecordStore.js +0 -18
  121. package/dist/core/session/settingsSession.js +0 -1
  122. package/dist/core/session/settingsSessions.js +0 -45
  123. package/dist/review-app/brand-dark.svg +0 -1
  124. package/dist/review-app/brand-light.svg +0 -1
  125. package/dist/review-app/favicon.svg +0 -1
  126. package/dist/review-app/settings.css +0 -1
  127. package/dist/review-app/settings.js +0 -1
  128. package/dist/review-app/ui.css +0 -809
  129. package/dist/review-server/assets.js +0 -48
  130. package/dist/review-server/html.js +0 -45
  131. package/dist/review-server/http.js +0 -47
  132. package/dist/review-server/middleware/reviewToken.js +0 -7
  133. package/dist/review-server/settingsApi.js +0 -158
  134. package/docs/AGENT_DEVELOPMENT_POLICY.md +0 -815
  135. package/docs/FRONTEND_POLICY.md +0 -534
  136. package/docs/golden-scenarios/BEHAVIOR_MATRIX.md +0 -166
  137. package/docs/golden-scenarios/INTENT_EVIDENCE_GOLDEN_ANSWERS.md +0 -161
  138. package/docs/golden-scenarios/INTENT_EVIDENCE_MATRIX.md +0 -259
@@ -60,8 +60,6 @@ export class SqliteCardRecordStore {
60
60
  @input, @accepted, @result, @display, @scope, @operation)`).run(values(record));
61
61
  }
62
62
  replace(expected, next) {
63
- // This single statement commits admission or a result atomically. A removed
64
- // card is never recreated by a delayed network response.
65
63
  return this.db.prepare(`UPDATE live_read_cards SET state=@state, reason=@reason, error=@error,
66
64
  revision=@revision, input_json=@input, accepted_input_json=@accepted, result_json=@result,
67
65
  receipt_display_json=@display, scope=@scope, operation_id=@operation
@@ -38,13 +38,6 @@ export function updateLiveReviewSessionRow(db, expectedRevision, session) {
38
38
  .run({ ...serialized, expectedRevision });
39
39
  return result.changes > 0;
40
40
  }
41
- /**
42
- * SQLite-backed live review-session records. Runtime product transitions are
43
- * committed by SqliteActivityStore when the store is created through that owner,
44
- * so live session state and review activity stay in one SQLite transaction.
45
- * Direct instances keep a low-level transition commit path for tests and mixed
46
- * harnesses that do not couple live state with activity rows.
47
- */
48
41
  export class SqliteSessionRecordStore {
49
42
  db;
50
43
  usesActivityStoreLiveSessionMutations;
@@ -107,8 +100,6 @@ export class SqlitePrivateReviewArtifactStore {
107
100
  if (!row) {
108
101
  return undefined;
109
102
  }
110
- // clonePrivateReviewArtifacts re-parses/validates the evidence on read, matching
111
- // the in-memory store's clone-on-read behaviour.
112
103
  return clonePrivateReviewArtifacts(JSON.parse(row.artifacts_json));
113
104
  }
114
105
  set(reviewSessionId, artifacts) {
@@ -126,48 +117,6 @@ export class SqlitePrivateReviewArtifactStore {
126
117
  this.db.prepare(`DELETE FROM live_private_review_artifacts`).run();
127
118
  }
128
119
  }
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
- // Reconstruct the ReviewSession from columns + JSON blobs. Optional fields are
170
- // omitted (not set to undefined) so the shape matches the in-memory store exactly.
171
120
  export function sessionFromLiveReviewSessionRow(row) {
172
121
  return {
173
122
  id: row.id,
@@ -0,0 +1,453 @@
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
+ export class SqliteSettingsWorkflowStore {
8
+ db;
9
+ ownerId;
10
+ now;
11
+ cards;
12
+ constructor(db, ownerId, now = () => new Date()) {
13
+ this.db = db;
14
+ this.ownerId = ownerId;
15
+ this.now = now;
16
+ this.cards = new SqliteCardRecordStore(db);
17
+ }
18
+ transaction(work) { return this.db.transaction(work).immediate(); }
19
+ control() { return this.db.prepare("SELECT * FROM settings_control WHERE id=1").get(); }
20
+ create(record) {
21
+ this.transaction(() => {
22
+ if (record.scope !== "settings" || record.state.kind !== "settings" || !record.operationId || record.ownerId !== this.ownerId) {
23
+ throw new SettingsConflict("This Settings card could not be identified.");
24
+ }
25
+ this.cards.create(record);
26
+ this.db.prepare(`INSERT INTO settings_operations (operation_id,card_id,owner_id,data_epoch,phase)
27
+ VALUES (?,?,?,?,'draft')`).run(record.operationId, record.state.cardId, this.ownerId, this.control().data_epoch);
28
+ });
29
+ }
30
+ get(id) {
31
+ const row = this.db.prepare("SELECT * FROM settings_operations WHERE operation_id=?").get(id);
32
+ if (!row)
33
+ return undefined;
34
+ return { operationId: row.operation_id, cardId: row.card_id, ownerId: row.owner_id, dataEpoch: row.data_epoch, phase: row.phase,
35
+ ...(row.draft_json === null ? {} : { draft: settingsDraftSchema.parse(JSON.parse(row.draft_json)) }),
36
+ ...(row.preparation_id === null ? {} : { preparationId: row.preparation_id }),
37
+ ...(row.fingerprint === null ? {} : { fingerprint: row.fingerprint }),
38
+ ...(row.prepared_user_data_revision === null ? {} : { preparedUserDataRevision: row.prepared_user_data_revision }),
39
+ ...(row.preparation_json === null ? {} : { preparation: JSON.parse(row.preparation_json) }),
40
+ ...(row.file_id === null ? {} : { fileId: row.file_id }),
41
+ ...(row.accepted_digest === null ? {} : { acceptedDigest: row.accepted_digest }),
42
+ ...(row.admitted_at === null ? {} : { admittedAt: row.admitted_at }),
43
+ ...(row.result_json === null ? {} : { result: settingsResultSchema.parse(JSON.parse(row.result_json)) }),
44
+ ...(row.prior_run_id === null ? {} : { priorRunId: row.prior_run_id }),
45
+ ...(row.next_run_id === null ? {} : { nextRunId: row.next_run_id }),
46
+ ...(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 }) };
49
+ }
50
+ forCard(record) {
51
+ const operation = record.operationId && this.get(record.operationId);
52
+ if (!operation || operation.cardId !== record.state.cardId || record.scope !== "settings") {
53
+ throw new SettingsConflict("This Settings request does not match the open card.");
54
+ }
55
+ return operation;
56
+ }
57
+ requireInput(expected, operationId, now = this.now()) {
58
+ const card = this.cards.get(expected.state.cardId);
59
+ if (!card || card.ownerId !== this.ownerId || card.tokenHash !== expected.tokenHash || card.scope !== "settings" ||
60
+ card.state.kind !== "settings" || card.state.revision !== expected.state.revision ||
61
+ card.state.state !== "ready" || card.acceptedInput !== undefined || Date.parse(card.state.expiresAt) <= now.getTime()) {
62
+ throw new SettingsConflict("This Settings selection changed or its time limit ended.");
63
+ }
64
+ const operation = this.forCard(card);
65
+ if (operationId !== undefined && operation.operationId !== operationId || operation.ownerId !== this.ownerId ||
66
+ operation.dataEpoch !== this.control().data_epoch || operation.phase === "terminal") {
67
+ throw new SettingsConflict("This Settings selection is out of date.");
68
+ }
69
+ return { card, operation };
70
+ }
71
+ write(expected, next, card, nextCard) {
72
+ this.transaction(() => {
73
+ if (!isDeepStrictEqual(this.get(expected.operationId), expected) || next.operationId !== expected.operationId ||
74
+ next.cardId !== expected.cardId || next.ownerId !== expected.ownerId)
75
+ throw new SettingsConflict("Settings state changed before it could be saved.");
76
+ this.db.prepare(`UPDATE settings_operations SET data_epoch=@epoch,kind=@kind,phase=@phase,draft_json=@draft,
77
+ preparation_id=@preparationId,fingerprint=@fingerprint,prepared_user_data_revision=@userRevision,preparation_json=@preparation,
78
+ 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
80
+ WHERE operation_id=@id`).run({ id: next.operationId, epoch: next.dataEpoch, kind: next.draft?.kind ?? null,
81
+ phase: next.phase, draft: next.draft ? JSON.stringify(next.draft) : null, preparationId: next.preparationId ?? null,
82
+ fingerprint: next.fingerprint ?? null, userRevision: next.preparedUserDataRevision ?? null,
83
+ preparation: next.preparation ? JSON.stringify(next.preparation) : null, fileId: next.fileId ?? null,
84
+ accepted: next.acceptedDigest ?? null, admitted: next.admittedAt ?? null, result: next.result ? JSON.stringify(next.result) : null,
85
+ 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 } })) {
88
+ throw new SettingsConflict("The Settings card changed before it could be saved.");
89
+ }
90
+ });
91
+ }
92
+ hasReusableUpload(operationId) {
93
+ return !!this.db.prepare(`SELECT 1 FROM settings_operations WHERE operation_id=? AND (${SETTINGS_REUSABLE_UPLOAD_SQL})`).get(operationId);
94
+ }
95
+ resetPreparation(expected, card) {
96
+ this.transaction(() => {
97
+ const current = this.requireInput(card, expected.operationId);
98
+ if (!isDeepStrictEqual(current.operation, expected))
99
+ throw new SettingsConflict("The Settings selection changed before it could be cleared.");
100
+ const rejection = retainedBackupRejection(current.operation, current.operation.fileId ? this.file(current.operation.fileId) : undefined);
101
+ this.db.prepare(`${SETTINGS_RESET_PREPARATION_SQL} WHERE operation_id=?`).run(expected.operationId);
102
+ if (rejection)
103
+ this.db.prepare("UPDATE settings_operations SET preparation_json=? WHERE operation_id=?")
104
+ .run(JSON.stringify({ restoreCheck: rejection }), expected.operationId);
105
+ if (!this.cards.replace(current.card, { ...current.card, state: { ...current.card.state, revision: current.card.state.revision + 1 } })) {
106
+ throw new SettingsConflict("The Settings card changed before its selection could be cleared.");
107
+ }
108
+ });
109
+ }
110
+ claimFinal(id, replaceId) {
111
+ this.requireFinalSlot(replaceId);
112
+ this.db.prepare("UPDATE settings_control SET active_operation_id=? WHERE id=1").run(id);
113
+ }
114
+ fileIOAvailable(control = this.control()) { return control.file_io_id === null; }
115
+ canReserveFileIO(operationId) {
116
+ const control = this.control();
117
+ return this.fileIOAvailable(control) && (control.active_operation_id === null || control.active_operation_id === operationId);
118
+ }
119
+ requireFinalSlot(replaceId) {
120
+ const control = this.control();
121
+ if (!this.fileIOAvailable(control) || control.active_operation_id !== (replaceId ?? null)) {
122
+ throw new SettingsConflict("Another Settings change or file transfer is in progress. Check its result before confirming again.");
123
+ }
124
+ }
125
+ reserveFileIO(fileId, operationId) {
126
+ if (!this.canReserveFileIO(operationId)) {
127
+ throw new SettingsConflict("A file transfer or Settings change is in progress. Check its status before trying again.");
128
+ }
129
+ this.db.prepare("UPDATE settings_control SET file_io_id=? WHERE id=1").run(fileId);
130
+ }
131
+ releaseFileIO(fileId) {
132
+ this.db.prepare("UPDATE settings_control SET file_io_id=NULL WHERE id=1 AND file_io_id=?").run(fileId);
133
+ }
134
+ admit(expected, input, currentFingerprint, replaceOperationId, now = this.now()) {
135
+ return this.transaction(() => {
136
+ const { card, operation } = this.requireAdmission(expected, input, currentFingerprint, replaceOperationId, now);
137
+ this.claimFinal(operation.operationId, replaceOperationId);
138
+ const next = { ...operation, phase: operation.draft.kind === "restart_wallet_service" ? "stopping" : "executing",
139
+ acceptedDigest: settingsIdentity(input), admittedAt: now.toISOString() };
140
+ this.write(operation, next, card, { ...card, acceptedInput: input, state: { ...card.state,
141
+ state: "running", revision: card.state.revision + 1 } });
142
+ return next;
143
+ });
144
+ }
145
+ requireAdmission(expected, input, currentFingerprint, replaceOperationId, now = this.now()) {
146
+ const result = this.requireInput(expected, input.operationId, now), operation = result.operation;
147
+ if (operation.phase !== "prepared" || !operation.draft || operation.preparationId !== input.preparationId ||
148
+ operation.fingerprint !== currentFingerprint())
149
+ throw new SettingsConflict("This change is out of date. Review it again before confirming.");
150
+ if (["import_local_data", "reset_local_data"].includes(operation.draft.kind) &&
151
+ operation.preparedUserDataRevision !== this.control().user_data_revision)
152
+ throw new SettingsConflict("Local records changed after they were checked. Review the current records before confirming.");
153
+ this.requireFinalSlot(replaceOperationId);
154
+ return result;
155
+ }
156
+ admitDownload(expected, input, snapshot) {
157
+ return this.transaction(() => {
158
+ const { card, operation } = this.requireInput(expected, input.operationId);
159
+ this.requireFinalSlot();
160
+ const file = snapshot(operation);
161
+ if (!isSettingsDownloadFile(file) || file.operationId !== operation.operationId)
162
+ 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
+ this.createFile(file);
169
+ this.claimFinal(operation.operationId);
170
+ this.reserveFileIO(file.fileId, operation.operationId);
171
+ const admittedAt = this.now().toISOString();
172
+ const download = { currentAttemptId: input.attemptId, attempts: [{ attemptId: input.attemptId, admittedAt }] };
173
+ const next = { operationId: operation.operationId, cardId: operation.cardId, ownerId: operation.ownerId,
174
+ dataEpoch: operation.dataEpoch, phase: "executing", draft: { kind: "download_backup" }, fileId: file.fileId,
175
+ preparation: { download }, acceptedDigest: settingsIdentity(input), admittedAt };
176
+ this.write(operation, next, card, { ...card, acceptedInput: input,
177
+ state: { ...card.state, state: "running", revision: card.state.revision + 1 } });
178
+ return file;
179
+ });
180
+ }
181
+ downloadContext(expected, operationId, fileId) {
182
+ const card = this.cards.get(expected.state.cardId);
183
+ if (!card || card.tokenHash !== expected.tokenHash || card.ownerId !== this.ownerId || card.scope !== "settings" || card.operationId !== operationId) {
184
+ throw new SettingsConflict("This download no longer belongs to the current Settings request.");
185
+ }
186
+ const operation = this.get(operationId), file = this.file(fileId);
187
+ if (!operation || operation.cardId !== card.state.cardId || operation.ownerId !== this.ownerId ||
188
+ operation.dataEpoch !== this.control().data_epoch || operation.fileId !== fileId || !file ||
189
+ file.operationId !== operationId || !isSettingsDownloadFile(file))
190
+ throw new SettingsConflict("This download no longer belongs to the current Settings request.");
191
+ const download = settingsDownload(operation);
192
+ if (!download)
193
+ throw new SettingsConflict("This Settings request does not provide a download.");
194
+ return { card, operation, file, download };
195
+ }
196
+ 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 ||
199
+ this.now().getTime() >= Date.parse(download.expiresAt) || !file.sealed || file.invalidated ||
200
+ operation.result?.outcome !== "completed")
201
+ throw new SettingsConflict("This backup's download period has ended or its attempt changed.");
202
+ return context;
203
+ }
204
+ writeDownloadState(expected, download, card) {
205
+ storedSettingsDownloadSchema.parse(download);
206
+ this.transaction(() => {
207
+ if (!isDeepStrictEqual(this.get(expected.operationId), expected) || expected.draft?.kind !== "download_backup") {
208
+ throw new SettingsConflict("The download state changed before it could be saved.");
209
+ }
210
+ this.db.prepare("UPDATE settings_operations SET preparation_json=? WHERE operation_id=?")
211
+ .run(JSON.stringify({ download }), expected.operationId);
212
+ if (!this.cards.replace(card, { ...card, state: { ...card.state, revision: card.state.revision + 1 } })) {
213
+ throw new SettingsConflict("The download card changed before its state could be saved.");
214
+ }
215
+ });
216
+ }
217
+ endDownload(id, reason) {
218
+ const operation = this.get(id);
219
+ if (!operation)
220
+ return;
221
+ const download = settingsDownload(operation), card = this.cards.get(operation.cardId);
222
+ if (!download || !card || download.endReason || download.localSave?.status === "saving")
223
+ return;
224
+ if (reason !== "server_restarted" && operation.ownerId !== this.ownerId)
225
+ throw new SettingsConflict("The download owner changed.");
226
+ const file = operation.fileId ? this.file(operation.fileId) : undefined;
227
+ if (file && isSettingsDownloadFile(file))
228
+ this.writeFile({ ...file, invalidated: true, cleanup: "pending" });
229
+ this.writeDownloadState(operation, { ...download, endReason: reason }, card);
230
+ }
231
+ expireDownloads(operationId) {
232
+ const now = this.now().getTime();
233
+ let failure;
234
+ for (const operation of this.downloads(operationId)) {
235
+ const download = settingsDownload(operation);
236
+ if (!download.endReason && download.localSave?.status !== "saving" && download.expiresAt && now >= Date.parse(download.expiresAt)) {
237
+ try {
238
+ this.transaction(() => this.endDownload(operation.operationId, "expired"));
239
+ }
240
+ catch (error) {
241
+ failure ??= error;
242
+ }
243
+ }
244
+ }
245
+ if (failure)
246
+ throw failure;
247
+ }
248
+ downloads(operationId) {
249
+ return this.db.prepare(`SELECT operation_id FROM settings_operations WHERE kind='download_backup' AND owner_id=?
250
+ ${operationId === undefined ? "" : "AND operation_id=?"}`)
251
+ .all(this.ownerId, ...(operationId === undefined ? [] : [operationId])).map((row) => this.get(row.operation_id));
252
+ }
253
+ continueDownload(expected, input) {
254
+ return this.transaction(() => {
255
+ this.downloadContext(expected, input.operationId, input.fileId);
256
+ this.expireDownloads(input.operationId);
257
+ const { card, operation, file, download } = this.downloadContext(expected, input.operationId, input.fileId);
258
+ if (input.action === "settings_store_backup") {
259
+ const save = download.localSave;
260
+ const duplicate = save?.attempts.find((a) => a.saveAttemptId === input.saveAttemptId);
261
+ if (duplicate) {
262
+ if (save.attemptId !== input.attemptId || duplicate.previousSaveAttemptId !== input.previousSaveAttemptId ||
263
+ duplicate.allowPossibleCopy !== input.allowPossibleCopy)
264
+ throw new SettingsConflict("This save does not match the recorded request.");
265
+ return;
266
+ }
267
+ if (!save || save.attemptId !== input.attemptId || download.endReason || !download.expiresAt ||
268
+ Date.parse(download.expiresAt) <= this.now().getTime() || file.invalidated || !file.sealed ||
269
+ (save.status === "needs_consent" ? input.previousSaveAttemptId !== undefined :
270
+ save.status !== "failed" || save.failure !== "not_published" || input.previousSaveAttemptId !== save.currentSaveAttemptId) ||
271
+ input.allowPossibleCopy !== this.previousDeliveryPossible(download))
272
+ 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);
275
+ return file;
276
+ }
277
+ if (input.action === "settings_retry_download") {
278
+ const duplicate = download.attempts.find((attempt) => attempt.attemptId === input.attemptId);
279
+ if (duplicate) {
280
+ if (duplicate.previousAttemptId !== input.previousAttemptId)
281
+ throw new SettingsConflict("This retry does not match the recorded download attempt.");
282
+ return;
283
+ }
284
+ if (download.currentAttemptId !== input.previousAttemptId || download.endReason || download.localSave || !download.expiresAt || !file.sealed ||
285
+ file.invalidated || operation.result?.outcome !== "completed")
286
+ throw new SettingsConflict("This backup is no longer available for that retry.");
287
+ 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);
290
+ return;
291
+ }
292
+ const entry = download.attempts.find((attempt) => attempt.attemptId === input.attemptId);
293
+ if (!entry)
294
+ throw new SettingsConflict("This download attempt was not admitted.");
295
+ if (input.action === "settings_report_download") {
296
+ if (!download.readyAt)
297
+ throw new SettingsConflict("The download snapshot is not ready.");
298
+ if (entry.hostReport) {
299
+ if (entry.hostReport !== input.status || !isDeepStrictEqual(entry.failure, input.failure))
300
+ throw new SettingsConflict("A different result was already reported for this download attempt.");
301
+ return;
302
+ }
303
+ const next = { ...download, attempts: download.attempts.map((attempt) => attempt === entry
304
+ ? { ...attempt, hostReport: input.status, ...(input.failure ? { failure: input.failure } : {}) } : attempt) };
305
+ let admitted = false;
306
+ if (input.status === "unsupported" && input.attemptId === download.currentAttemptId && !download.endReason &&
307
+ download.expiresAt && this.now().getTime() < Date.parse(download.expiresAt) && file.sealed && !file.invalidated) {
308
+ const save = { status: "needs_consent", attemptId: input.attemptId, fileId: file.fileId,
309
+ sha256: file.sha256, totalSize: file.totalSize, destination: settingsBackupPath(operation.operationId), ownerId: this.ownerId, attempts: [] };
310
+ if (this.previousDeliveryPossible(download))
311
+ next.localSave = save;
312
+ else {
313
+ next.localSave = this.admitLocalSave(operation, file, save, randomUUID(), false);
314
+ admitted = true;
315
+ }
316
+ }
317
+ this.writeDownloadState(operation, next, card);
318
+ if (input.attemptId === download.currentAttemptId && input.status === "acknowledged" && !download.endReason)
319
+ this.endDownload(operation.operationId, "acknowledged");
320
+ return admitted ? file : undefined;
321
+ }
322
+ else {
323
+ if (input.attemptId !== download.currentAttemptId)
324
+ throw new SettingsConflict("A different download attempt is current.");
325
+ if (download.localSave?.status === "saving")
326
+ throw new SettingsConflict("Saving has already started. Check its result before continuing.");
327
+ this.endDownload(operation.operationId, "cancelled");
328
+ }
329
+ });
330
+ }
331
+ previousDeliveryPossible(download) {
332
+ return download.attempts.some((a) => a.attemptId !== download.currentAttemptId && a.hostReport !== "unsupported");
333
+ }
334
+ admitLocalSave(operation, file, saved, saveAttemptId, allowPossibleCopy, previousSaveAttemptId) {
335
+ this.claimFinal(operation.operationId);
336
+ this.reserveFileIO(file.fileId, operation.operationId);
337
+ const { failure: _failure, sourceIdentity: _identity, savedAt: _savedAt, ...base } = saved;
338
+ return { ...base, status: "saving", currentSaveAttemptId: saveAttemptId, attempts: [...saved.attempts,
339
+ { saveAttemptId, allowPossibleCopy, admittedAt: this.now().toISOString(), ...(previousSaveAttemptId ? { previousSaveAttemptId } : {}) }] };
340
+ }
341
+ localSaveContext(id, saveAttemptId) {
342
+ const operation = this.get(id), download = operation && settingsDownload(operation), save = download?.localSave;
343
+ const file = operation?.fileId ? this.file(operation.fileId) : undefined;
344
+ const card = operation && this.cards.get(operation.cardId);
345
+ if (!operation || operation.ownerId !== this.ownerId || operation.dataEpoch !== this.control().data_epoch ||
346
+ !download || !save || !file || !card || save.status !== "saving" || save.currentSaveAttemptId !== saveAttemptId ||
347
+ save.fileId !== file.fileId || save.sha256 !== file.sha256 || save.totalSize !== file.totalSize ||
348
+ save.destination !== settingsBackupPath(id) || this.control().active_operation_id !== id || this.control().file_io_id !== file.fileId) {
349
+ throw new SettingsConflict("The admitted backup save changed before it could be settled.");
350
+ }
351
+ return { operation, download, save, file, card };
352
+ }
353
+ rememberLocalSaveSource(id, saveAttemptId, sourceIdentity) {
354
+ this.transaction(() => {
355
+ const { operation, download, save, card } = this.localSaveContext(id, saveAttemptId);
356
+ if (save.sourceIdentity && !isDeepStrictEqual(save.sourceIdentity, sourceIdentity))
357
+ throw new SettingsConflict("The backup file changed before saving.");
358
+ this.writeDownloadState(operation, { ...download, localSave: { ...save, sourceIdentity } }, card);
359
+ });
360
+ }
361
+ settleLocalSave(id, saveAttemptId, outcome) {
362
+ this.transaction(() => {
363
+ const { operation, download, save, file, card } = this.localSaveContext(id, saveAttemptId);
364
+ const expired = !download.expiresAt || this.now().getTime() >= Date.parse(download.expiresAt);
365
+ const ended = outcome.status === "saved" ? "stored_locally" : save.ownerId !== this.ownerId ? "server_restarted"
366
+ : expired ? "expired" : outcome.reason !== "not_published" ? "local_save_failed" : undefined;
367
+ const localSave = outcome.status === "saved"
368
+ ? { ...save, status: "saved", savedAt: this.now().toISOString() }
369
+ : { ...save, status: "failed", failure: save.ownerId !== this.ownerId && outcome.reason === "not_published" ? "interrupted" : outcome.reason };
370
+ this.writeDownloadState(operation, { ...download, localSave, ...(ended ? { endReason: ended } : {}) }, card);
371
+ if (ended)
372
+ this.writeFile({ ...file, invalidated: true, cleanup: "pending" });
373
+ this.releaseFileIO(file.fileId);
374
+ this.db.prepare("UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?").run(id);
375
+ });
376
+ }
377
+ recordDownloadReady(id, readyAt, retryWindowMs) {
378
+ const operation = this.get(id);
379
+ if (!operation || operation.ownerId !== this.ownerId || operation.dataEpoch !== this.control().data_epoch)
380
+ throw new SettingsConflict("The snapshot owner changed.");
381
+ const download = settingsDownload(operation);
382
+ if (download.readyAt)
383
+ return;
384
+ const card = this.cards.get(operation.cardId);
385
+ this.writeDownloadState(operation, { ...download, readyAt,
386
+ expiresAt: new Date(Date.parse(readyAt) + retryWindowMs).toISOString() }, card);
387
+ }
388
+ filesForCleanup(operationId) {
389
+ const rows = this.db.prepare(`SELECT file_id FROM settings_files WHERE cleanup!='complete' AND ${SETTINGS_FILE_CLEANUP_SQL}
390
+ ${operationId === undefined ? "" : "AND cleanup_operation_id=?"}`).all(...(operationId === undefined ? [] : [operationId]));
391
+ return rows.map((row) => this.file(row.file_id));
392
+ }
393
+ finish(id, result) {
394
+ return this.transaction(() => {
395
+ const old = this.get(id);
396
+ if (!old || old.ownerId !== this.ownerId)
397
+ return false;
398
+ if (old.result)
399
+ return isDeepStrictEqual(old.result, result);
400
+ if (old.dataEpoch !== this.control().data_epoch)
401
+ return false;
402
+ if (result.outcome === "completed" && (!old.admittedAt || !old.acceptedDigest ||
403
+ !["executing", "starting"].includes(old.phase)))
404
+ throw new SettingsConflict("An unconfirmed Settings change cannot complete.");
405
+ const card = this.cards.get(old.cardId);
406
+ if (!card || card.ownerId !== this.ownerId || card.operationId !== id)
407
+ return false;
408
+ const next = { ...old, phase: "terminal", result };
409
+ 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") } });
411
+ this.db.prepare(`UPDATE settings_control SET active_operation_id=NULL WHERE id=1 AND active_operation_id=?`).run(id);
412
+ this.db.prepare(`UPDATE settings_control SET wallet_input_blocked=0,wallet_block_operation_id=NULL
413
+ WHERE id=1 AND wallet_block_operation_id=?`).run(id);
414
+ return true;
415
+ });
416
+ }
417
+ file(id) {
418
+ const row = this.db.prepare("SELECT * FROM settings_files WHERE file_id=?").get(id);
419
+ return row ? { fileId: row.file_id, operationId: row.operation_id, role: row.role, relativePath: row.relative_path,
420
+ displayName: row.display_name, totalSize: row.total_size, receivedSize: row.received_size, sha256: row.sha256,
421
+ sealed: row.sealed === 1, invalidated: row.invalidated === 1, cleanup: row.cleanup,
422
+ cleanupOperationId: row.cleanup_operation_id,
423
+ ...(row.reservation_json === null ? {} : { reservation: JSON.parse(row.reservation_json) }) } : undefined;
424
+ }
425
+ filesNeedingCleanup(operationId) {
426
+ const rows = this.db.prepare(`SELECT file_id FROM settings_files WHERE cleanup_operation_id=? AND cleanup!='complete'
427
+ AND ${SETTINGS_FILE_CLEANUP_SQL}`).all(operationId);
428
+ return rows.map((row) => this.file(row.file_id));
429
+ }
430
+ temporaryFileCleanup(operationId) {
431
+ const row = this.db.prepare(`SELECT CASE WHEN sum(cleanup='failed')>0 THEN 'failed'
432
+ WHEN sum(cleanup='pending' OR file_id=(SELECT file_io_id FROM settings_control WHERE id=1))>0 THEN 'pending'
433
+ WHEN count(*)>0 THEN 'complete' ELSE NULL END AS status
434
+ FROM settings_files WHERE cleanup_operation_id=? AND ${SETTINGS_FILE_CLEANUP_SQL}`).get(operationId);
435
+ return row.status ?? undefined;
436
+ }
437
+ createFile(file) {
438
+ this.db.prepare(`INSERT INTO settings_files
439
+ (file_id,operation_id,cleanup_operation_id,role,relative_path,display_name,total_size,received_size,sha256,sealed,invalidated,cleanup,reservation_json)
440
+ 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);
441
+ }
442
+ writeFile(file) {
443
+ if (this.db.prepare(`UPDATE settings_files SET received_size=?,sealed=?,invalidated=?,cleanup=?,reservation_json=? WHERE file_id=?`)
444
+ .run(file.receivedSize, Number(file.sealed), Number(file.invalidated), file.cleanup, file.reservation ? JSON.stringify(file.reservation) : null, file.fileId).changes !== 1) {
445
+ throw new SettingsConflict("The selected file is no longer available.");
446
+ }
447
+ }
448
+ }
449
+ export function settingsIdentity(value) {
450
+ const canonical = (input) => Array.isArray(input) ? input.map(canonical) : input !== null && typeof input === "object"
451
+ ? Object.fromEntries(Object.entries(input).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonical(item)])) : input;
452
+ return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
453
+ }
@@ -1,13 +1,4 @@
1
1
  import { buildTransactionMaterialRecord, sameHandle, toMaterialHandle } from "./transactionMaterialStore.js";
2
- /**
3
- * SQLite-backed transaction material store. Holds the same synchronous contract as
4
- * InMemoryLocalTransactionMaterialStore so producers and the wallet handoff path are
5
- * unchanged, but persists records (including the unsigned transaction BLOB) to the
6
- * shared local database so any review-server process can read them. Validation,
7
- * handle identity, and expiry semantics are shared via transactionMaterialStore.ts,
8
- * so the two backends never drift. The unsigned bytes are protected at rest by the
9
- * data directory (0700) and database file (0600) permissions set in SqliteActivityStore.
10
- */
11
2
  export class SqliteTransactionMaterialStore {
12
3
  db;
13
4
  constructor(db) {
@@ -44,8 +35,6 @@ export class SqliteTransactionMaterialStore {
44
35
  this.db.prepare(`DELETE FROM live_transaction_materials WHERE review_session_id = ?`).run(reviewSessionId);
45
36
  }
46
37
  }
47
- // Each read rebuilds a fresh record (new Uint8Array from the BLOB, parsed
48
- // diagnostics) so a caller mutating the result never touches the stored row.
49
38
  function recordFromRow(row) {
50
39
  return {
51
40
  materialId: row.material_id,