@zktx.io/sui-mcp 0.5.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.
- package/LICENSE +21 -0
- package/LICENSES/@mysten-bcs-2.0.5-Apache-2.0.txt +205 -0
- package/LICENSES/@mysten-sui-2.17.0-Apache-2.0.txt +201 -0
- package/LICENSES/@mysten-utils-0.3.3-Apache-2.0.txt +205 -0
- package/LICENSES/fancy-canvas-2.1.0-MIT.txt +7 -0
- package/LICENSES/lightweight-charts-5.2.0-NOTICE.txt +2 -0
- package/README.md +288 -0
- package/dist/adapters/adapterLifecycleValidators.js +5 -0
- package/dist/adapters/adapterPromptSurfaces.js +60 -0
- package/dist/adapters/deepbook/deepbookHumanReviewProducer.js +178 -0
- package/dist/adapters/deepbook/deepbookQuotePolicy.js +112 -0
- package/dist/adapters/deepbook/deepbookReviewEvidence.js +507 -0
- package/dist/adapters/deepbook/deepbookReviewLifecycle.js +85 -0
- package/dist/adapters/deepbook/deepbookSwapIntent.js +79 -0
- package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +254 -0
- package/dist/adapters/intentPlanFactories.js +51 -0
- package/dist/adapters/reviewAdapters.js +47 -0
- package/dist/core/action/adapterLifecycleValidation.js +12 -0
- package/dist/core/action/contractNameRegistry.js +151 -0
- package/dist/core/action/forbiddenFields.js +43 -0
- package/dist/core/action/humanReadableReviewEvidence.js +193 -0
- package/dist/core/action/humanReadableReviewProjectionVerifier.js +29 -0
- package/dist/core/action/ptbVisualizationProducer.js +71 -0
- package/dist/core/action/receiptPtbGraph.js +29 -0
- package/dist/core/action/reviewCheckResults.js +6 -0
- package/dist/core/action/reviewStateValidation.js +11 -0
- package/dist/core/action/reviewTimeSimulationEvidence.js +470 -0
- package/dist/core/action/schemas.js +491 -0
- package/dist/core/action/signableAdapterContract.js +998 -0
- package/dist/core/action/suiChainReceiptEvidence.js +106 -0
- package/dist/core/action/suiChainReceiptMapping.js +107 -0
- package/dist/core/action/suiChainReceiptReader.js +211 -0
- package/dist/core/action/suiChainReceiptVerifier.js +178 -0
- package/dist/core/action/swapHumanReadableReviewProjection.js +130 -0
- package/dist/core/action/swapQuotePolicyEvidence.js +271 -0
- package/dist/core/action/transactionFunding.js +244 -0
- package/dist/core/action/transactionObjectOwnershipEvidence.js +256 -0
- package/dist/core/action/transactionObjectOwnershipProducer.js +323 -0
- package/dist/core/action/types.js +33 -0
- package/dist/core/action/walletReviewContractAssembler.js +286 -0
- package/dist/core/activity/accountAssetTimeline.js +157 -0
- package/dist/core/activity/accountAssetTimelineUsdcReferences.js +120 -0
- package/dist/core/activity/activityStore.js +16 -0
- package/dist/core/activity/externalActivityCoverage.js +76 -0
- package/dist/core/activity/externalActivityTransactionStream.js +82 -0
- package/dist/core/activity/localDataService.js +296 -0
- package/dist/core/activity/localDataTypes.js +11 -0
- package/dist/core/activity/localDataValidation.js +439 -0
- package/dist/core/activity/schemaVersion.js +2 -0
- package/dist/core/activity/sqliteActivityStore.js +1068 -0
- package/dist/core/activity/sqliteActivityStoreRows.js +389 -0
- package/dist/core/activity/sqliteActivityStoreSchema.js +367 -0
- package/dist/core/activity/sqliteActivityStoreTypes.js +5 -0
- package/dist/core/activity/suiFunctionTarget.js +43 -0
- package/dist/core/activity/transactionActivityAccountEffects.js +186 -0
- package/dist/core/activity/transactionActivityAnalysis.js +295 -0
- package/dist/core/activity/transactionActivityClassifier.js +306 -0
- package/dist/core/activity/transactionActivityDetails.js +229 -0
- package/dist/core/activity/transactionActivityProtocolRules.js +218 -0
- package/dist/core/activity/transactionActivityScanPolicy.js +170 -0
- package/dist/core/activity/transactionActivityService.js +379 -0
- package/dist/core/activity/transactionActivityTypes.js +18 -0
- package/dist/core/eventlog/sink.js +35 -0
- package/dist/core/evidence/settlementFamilies.js +85 -0
- package/dist/core/evidence/userAnswerUse.js +1 -0
- package/dist/core/numeric/rawU64.js +63 -0
- package/dist/core/preferences/preferencesStore.js +26 -0
- package/dist/core/preferences/sqlitePreferencesRepository.js +136 -0
- package/dist/core/proposal/externalProposalReview.js +347 -0
- package/dist/core/proposal/schemas.js +208 -0
- package/dist/core/proposal/types.js +35 -0
- package/dist/core/read/amounts.js +14 -0
- package/dist/core/read/coinMetadata.js +60 -0
- package/dist/core/read/deepbookOfficialIndexerSource.js +373 -0
- package/dist/core/read/deepbookRawQuoteClient.js +86 -0
- package/dist/core/read/deepbookReadHelpers.js +321 -0
- package/dist/core/read/deepbookRegistry.js +157 -0
- package/dist/core/read/deepbookSourceOwners.js +159 -0
- package/dist/core/read/deepbookUsdcChartService.js +342 -0
- package/dist/core/read/intentEvidenceResponseFormatting.js +228 -0
- package/dist/core/read/readCardInputs.js +19 -0
- package/dist/core/read/readCardResult.js +40 -0
- package/dist/core/read/readResponseGuidance.js +402 -0
- package/dist/core/read/readService.js +1639 -0
- package/dist/core/read/readServiceTypes.js +88 -0
- package/dist/core/read/settlementParityFormatting.js +82 -0
- package/dist/core/read/walletReadHelpers.js +99 -0
- package/dist/core/review/reviewChecks.js +45 -0
- package/dist/core/review/reviewComputation.js +38 -0
- package/dist/core/review/reviewComputationResult.js +87 -0
- package/dist/core/session/cardSession.js +31 -0
- package/dist/core/session/cardSessionStore.js +204 -0
- package/dist/core/session/keyedRecordStore.js +18 -0
- package/dist/core/session/liveReviewSessionContract.js +3 -0
- package/dist/core/session/localSession.js +31 -0
- package/dist/core/session/privateReviewArtifacts.js +75 -0
- package/dist/core/session/reviewValidity.js +49 -0
- package/dist/core/session/sessionErrors.js +9 -0
- package/dist/core/session/sessionRecordStore.js +40 -0
- package/dist/core/session/sessionStore.js +655 -0
- package/dist/core/session/settingsSession.js +1 -0
- package/dist/core/session/settingsSessions.js +45 -0
- package/dist/core/session/sqliteCardStore.js +88 -0
- package/dist/core/session/sqliteSessionStore.js +213 -0
- package/dist/core/session/sqliteTransactionMaterialStore.js +64 -0
- package/dist/core/session/sqliteWalletWorkflowStore.js +821 -0
- package/dist/core/session/status.js +60 -0
- package/dist/core/session/transactionMaterialStore.js +221 -0
- package/dist/core/session/transactionRequest.js +72 -0
- package/dist/core/session/wait.js +79 -0
- package/dist/core/session/walletConnection.js +92 -0
- package/dist/core/session/walletRuntime.js +38 -0
- package/dist/core/session/walletWorkflow.js +608 -0
- package/dist/core/session/workflowState.js +80 -0
- package/dist/core/session/workflowView.js +150 -0
- package/dist/core/suiAddress.js +18 -0
- package/dist/core/suiEndpoint.js +72 -0
- package/dist/mcp/activeAccountResponse.js +24 -0
- package/dist/mcp/prompts.js +146 -0
- package/dist/mcp/registerTool.js +19 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/responseGuidance.js +326 -0
- package/dist/mcp/result.js +17 -0
- package/dist/mcp/schemas.js +11 -0
- package/dist/mcp/server.js +32 -0
- package/dist/mcp/serverInfo.js +131 -0
- package/dist/mcp/toolErrors.js +118 -0
- package/dist/mcp/toolNames.js +58 -0
- package/dist/mcp/tools/account/index.js +44 -0
- package/dist/mcp/tools/action/prepareSuiActionReview.js +126 -0
- package/dist/mcp/tools/read/commonSchemas.js +43 -0
- package/dist/mcp/tools/read/deepbookReadTools.js +686 -0
- package/dist/mcp/tools/read/index.js +14 -0
- package/dist/mcp/tools/read/readToolHelpers.js +97 -0
- package/dist/mcp/tools/read/reviewActivityTools.js +179 -0
- package/dist/mcp/tools/read/serverStatusTools.js +100 -0
- package/dist/mcp/tools/read/transactionActivityOutput.js +371 -0
- package/dist/mcp/tools/read/transactionActivityTools.js +858 -0
- package/dist/mcp/tools/read/walletReadTools.js +734 -0
- package/dist/mcp/tools/session/executionResultTools.js +44 -0
- package/dist/mcp/tools/session/index.js +8 -0
- package/dist/mcp/tools/session/shared.js +36 -0
- package/dist/mcp/tools/session/statusTools.js +69 -0
- package/dist/mcp/tools/session/walletConnectionTools.js +69 -0
- package/dist/mcp/tools/settings/index.js +66 -0
- package/dist/mcp-app/account.html +3133 -0
- package/dist/mcp-app/account.notices.txt +973 -0
- package/dist/mcp-app/chart.html +3354 -0
- package/dist/mcp-app/chart.notices.txt +1194 -0
- package/dist/mcp-app/connect.html +3179 -0
- package/dist/mcp-app/connect.notices.txt +1012 -0
- package/dist/mcp-app/receipt.html +8430 -0
- package/dist/mcp-app/receipt.notices.txt +3549 -0
- package/dist/mcp-app/review.html +8430 -0
- package/dist/mcp-app/review.notices.txt +3549 -0
- package/dist/mcp-ui/contracts.js +31 -0
- package/dist/mcp-ui/readCards.js +28 -0
- package/dist/mcp-ui/tools.js +134 -0
- package/dist/review-app/brand-dark.svg +1 -0
- package/dist/review-app/brand-light.svg +1 -0
- package/dist/review-app/favicon.svg +1 -0
- package/dist/review-app/settings.css +1 -0
- package/dist/review-app/settings.js +1 -0
- package/dist/review-app/ui.css +809 -0
- package/dist/review-server/assets.js +48 -0
- package/dist/review-server/html.js +45 -0
- package/dist/review-server/http.js +47 -0
- package/dist/review-server/middleware/hostOrigin.js +48 -0
- package/dist/review-server/middleware/reviewToken.js +7 -0
- package/dist/review-server/reviewServerPolicy.js +1 -0
- package/dist/review-server/server.js +72 -0
- package/dist/review-server/settingsApi.js +158 -0
- package/dist/runtime/application.js +203 -0
- package/dist/runtime/config.js +97 -0
- package/dist/runtime/localSettingsService.js +198 -0
- package/dist/runtime/logger.js +66 -0
- package/dist/runtime/reviewDependencies.js +58 -0
- package/dist/runtime/reviewServerAcquire.js +93 -0
- package/dist/runtime/runtimeTermination.js +60 -0
- package/dist/runtime/shared/authenticatedFetch.js +127 -0
- package/dist/runtime/shared/control.js +93 -0
- package/dist/runtime/shared/dataAccess.js +66 -0
- package/dist/runtime/shared/mcpHttp.js +84 -0
- package/dist/runtime/shared/ownerLease.js +50 -0
- package/dist/runtime/shared/server.js +98 -0
- package/dist/runtime/shared/stdio.js +113 -0
- package/dist/runtime/smokeMainnetRead.js +391 -0
- package/dist/runtime/smokeMainnetReadAssertions.js +306 -0
- package/dist/runtime/smokeReview.js +40 -0
- package/dist/runtime/start.js +90 -0
- package/dist/runtime/suiEndpoint.js +97 -0
- package/dist/runtime/suiTransactionGraphqlMapping.js +200 -0
- package/dist/runtime/suiTransactionGraphqlQueries.js +231 -0
- package/dist/runtime/suiTransactionGraphqlSource.js +148 -0
- package/dist/runtime/walletConnectConfig.js +3 -0
- package/dist/runtime/walletConnectStorage.js +99 -0
- package/dist/runtime/walletConnectTransport.js +128 -0
- package/dist/runtime/walletSdkChild.js +3 -0
- package/dist/runtime/walletSdkChildRuntime.js +160 -0
- package/dist/runtime/walletSdkIpc.js +26 -0
- package/dist/runtime/walletSdkProcess.js +358 -0
- package/docs/AGENT_BEHAVIOR.md +524 -0
- package/docs/AGENT_DEVELOPMENT_POLICY.md +815 -0
- package/docs/FRONTEND_POLICY.md +534 -0
- package/docs/LOCAL_DB_ARCHITECTURE.md +381 -0
- package/docs/MCP_SETUP.md +742 -0
- package/docs/MCP_TOOLS.md +1318 -0
- package/docs/SDK_API.md +206 -0
- package/docs/SIGNABLE_ADAPTER_CONTRACT.md +396 -0
- package/docs/TRANSACTION_ACTIVITY_LOG.md +194 -0
- package/docs/UTILITY_INDEX.md +275 -0
- package/docs/WALLET_CONNECTION.md +238 -0
- package/docs/golden-scenarios/BEHAVIOR_MATRIX.md +166 -0
- package/docs/golden-scenarios/INTENT_EVIDENCE_GOLDEN_ANSWERS.md +161 -0
- package/docs/golden-scenarios/INTENT_EVIDENCE_MATRIX.md +259 -0
- package/package.json +79 -0
- package/protocols/deepbook-margin.md +15 -0
- package/protocols/deepbook-v3.md +24 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
import { decideReviewEvaluation, needsReviewMaterial } from "../session/reviewValidity.js";
|
|
2
|
+
import { workflowEligibility } from "../session/workflowState.js";
|
|
3
|
+
import { SessionStoreError } from "../session/sessionErrors.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { TRANSACTION_REQUEST_STATUSES, transactionRequestStatusSchema } from "../session/transactionRequest.js";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import Database from "better-sqlite3";
|
|
11
|
+
import { SqliteWalletWorkflowStore, readStoredTransactionRequest } from "../session/sqliteWalletWorkflowStore.js";
|
|
12
|
+
import { assertNoForbiddenMcpFields } from "../action/forbiddenFields.js";
|
|
13
|
+
import { parseLifecycleValidatedReviewState } from "../action/reviewStateValidation.js";
|
|
14
|
+
import { actionPlanSchema } from "../action/schemas.js";
|
|
15
|
+
import { parseSuiAddress } from "../suiAddress.js";
|
|
16
|
+
import { SqlitePreferencesRepository } from "../preferences/sqlitePreferencesRepository.js";
|
|
17
|
+
import { SqliteTransactionMaterialStore } from "../session/sqliteTransactionMaterialStore.js";
|
|
18
|
+
import { SqliteCardRecordStore } from "../session/sqliteCardStore.js";
|
|
19
|
+
import { sameHandle } from "../session/transactionMaterialStore.js";
|
|
20
|
+
import { SqliteSessionRecordStore, SqlitePrivateReviewArtifactStore, createSqliteSettingsRecordStore, sessionFromLiveReviewSessionRow, insertLiveReviewSessionRow, updateLiveReviewSessionRow } from "../session/sqliteSessionStore.js";
|
|
21
|
+
import { SqliteLocalDataService } from "./localDataService.js";
|
|
22
|
+
import { buildExternalActivityCoverageResult } from "./externalActivityCoverage.js";
|
|
23
|
+
import { buildExternalActivityTransactionStreamResult, canonicalExternalActivityTransactions } from "./externalActivityTransactionStream.js";
|
|
24
|
+
import { ActivityStoreReadError, EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS, REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD } from "./activityStore.js";
|
|
25
|
+
import { assertCurrentDatabaseFormat, configureDatabase, initializeDatabase } from "./sqliteActivityStoreSchema.js";
|
|
26
|
+
import { ActivityStoreError } from "./sqliteActivityStoreTypes.js";
|
|
27
|
+
import { EXTERNAL_ACTIVITY_RELATIONSHIPS, EXTERNAL_ACTIVITY_STATUSES, INTERNAL_SESSION_STATUSES, REVIEW_STATE_STATUSES, asAccountSource, asInternalSessionStatus, asReviewTransitionEvent, asString, assertDateRange, coinMetadataCacheRecordFromRow, countMap, emptyExternalActivitySummaryStats, emptyReviewFunnelSummary, externalActivityScanFromRow, externalActivitySummaryResult, externalActivityTransactionFromRow, extractRequestedIntent, normalizeExternalActivityLimit, normalizeListLimit, nullableSeconds, parseActionPlanEvidence, parseEvidenceJson, parseIsoTimestamp, parseOptionalIsoTimestamp, reasonForReviewState, reviewActivityListResult, reviewActivityRowFromStorage, reviewFunnelResult, reviewSessionWhere, serializeExternalActivityTransactionDetail, serializeJson, serializeOptionalJson } from "./sqliteActivityStoreRows.js";
|
|
28
|
+
export { ActivityStoreError };
|
|
29
|
+
const ACTIVE_ACCOUNT_SINGLETON_ID = 1;
|
|
30
|
+
export const DATA_DIR_ENV = "SUI_MCP_DATA_DIR";
|
|
31
|
+
export const ACTIVITY_DATABASE_FILENAME = "sui-mcp.sqlite";
|
|
32
|
+
// Best-effort permission hardening. The owner-only data directory is the primary
|
|
33
|
+
// protection; failures (e.g. on Windows, which ignores POSIX modes) are non-fatal.
|
|
34
|
+
function restrictPathPermissions(path, mode) {
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(path, mode);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Non-fatal: the 0700 directory remains the primary protection.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class SqliteActivityStore {
|
|
43
|
+
db;
|
|
44
|
+
validateAdapterLifecycle;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.validateAdapterLifecycle = options.validateAdapterLifecycle;
|
|
47
|
+
const dataDirectory = dirname(options.databasePath);
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dataDirectory, { recursive: true, mode: 0o700 });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new ActivityStoreError(`Could not create the local activity data directory. Check directory permissions or set ${DATA_DIR_ENV}.`);
|
|
53
|
+
}
|
|
54
|
+
if (options.databasePath !== ":memory:" && existsSync(options.databasePath)) {
|
|
55
|
+
const existing = new Database(options.databasePath, { readonly: true, fileMustExist: true });
|
|
56
|
+
try {
|
|
57
|
+
assertCurrentDatabaseFormat(existing);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
existing.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const database = new Database(options.databasePath);
|
|
64
|
+
this.db = options.guardDatabase?.(database) ?? database;
|
|
65
|
+
try {
|
|
66
|
+
initializeDatabase(this.db);
|
|
67
|
+
configureDatabase(this.db);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.db.close();
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
// This database persists unsigned transaction material (Option B), so restrict it to
|
|
74
|
+
// the owner. The 0700 directory is the primary protection (it also covers the WAL/SHM
|
|
75
|
+
// sidecars and blocks other OS users); the 0600 file is belt-and-suspenders. Best-effort
|
|
76
|
+
// because some platforms (e.g. Windows) ignore POSIX modes.
|
|
77
|
+
restrictPathPermissions(dataDirectory, 0o700);
|
|
78
|
+
restrictPathPermissions(options.databasePath, 0o600);
|
|
79
|
+
}
|
|
80
|
+
async upsertAccount(address, source, now = new Date()) {
|
|
81
|
+
return this.upsertAccountSync(address, source, now.toISOString());
|
|
82
|
+
}
|
|
83
|
+
async getKnownAccount(address) {
|
|
84
|
+
const normalized = parseSuiAddress(address);
|
|
85
|
+
if (!normalized) {
|
|
86
|
+
throw new ActivityStoreReadError("input_invalid", "Invalid account address", { field: "account" });
|
|
87
|
+
}
|
|
88
|
+
return this.getAccountByAddressSync(normalized);
|
|
89
|
+
}
|
|
90
|
+
async setActiveAccount(address, source, now = new Date(), wallet) {
|
|
91
|
+
return this.setActiveAccountSync(address, source, now, wallet);
|
|
92
|
+
}
|
|
93
|
+
setActiveAccountSync(address, source, now, wallet) {
|
|
94
|
+
const timestamp = now.toISOString();
|
|
95
|
+
return this.db.transaction(() => {
|
|
96
|
+
const account = this.upsertAccountSync(address, source, timestamp);
|
|
97
|
+
this.db
|
|
98
|
+
.prepare(`INSERT INTO active_account_context (id, account_id, source, set_at, wallet_name, wallet_id)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
100
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
101
|
+
account_id = excluded.account_id,
|
|
102
|
+
source = excluded.source,
|
|
103
|
+
set_at = excluded.set_at,
|
|
104
|
+
wallet_name = excluded.wallet_name,
|
|
105
|
+
wallet_id = excluded.wallet_id`)
|
|
106
|
+
.run(ACTIVE_ACCOUNT_SINGLETON_ID, account.id, source, timestamp, wallet?.name ?? null, wallet?.id ?? null);
|
|
107
|
+
return {
|
|
108
|
+
accountId: account.id,
|
|
109
|
+
address: account.address,
|
|
110
|
+
source,
|
|
111
|
+
setAt: timestamp,
|
|
112
|
+
...(wallet?.name ? { walletName: wallet.name } : {}),
|
|
113
|
+
...(wallet?.id ? { walletId: wallet.id } : {})
|
|
114
|
+
};
|
|
115
|
+
})();
|
|
116
|
+
}
|
|
117
|
+
async getActiveAccount() {
|
|
118
|
+
return this.getActiveAccountSync();
|
|
119
|
+
}
|
|
120
|
+
async clearActiveAccount(now = new Date()) {
|
|
121
|
+
this.db
|
|
122
|
+
.prepare(`INSERT INTO active_account_context (id, account_id, source, set_at)
|
|
123
|
+
VALUES (?, NULL, 'cleared', ?)
|
|
124
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
125
|
+
account_id = NULL,
|
|
126
|
+
source = 'cleared',
|
|
127
|
+
set_at = excluded.set_at`)
|
|
128
|
+
.run(ACTIVE_ACCOUNT_SINGLETON_ID, now.toISOString());
|
|
129
|
+
}
|
|
130
|
+
async recordReviewSession(input) {
|
|
131
|
+
const plan = parseActionPlanEvidence(input.plan);
|
|
132
|
+
const planJson = serializeJson(plan);
|
|
133
|
+
const intentJson = serializeOptionalJson(extractRequestedIntent(plan));
|
|
134
|
+
this.db
|
|
135
|
+
.transaction(() => {
|
|
136
|
+
this.db
|
|
137
|
+
.prepare(`INSERT INTO review_sessions
|
|
138
|
+
(id, plan_id, action_kind, adapter_id, protocol, current_status,
|
|
139
|
+
plan_json, intent_json, created_at, updated_at)
|
|
140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
141
|
+
.run(input.reviewSessionId, plan.id, plan.actionKind, plan.adapterId, plan.protocol, input.currentStatus, planJson, intentJson, input.createdAt, input.createdAt);
|
|
142
|
+
this.insertReviewTransition({
|
|
143
|
+
reviewSessionId: input.reviewSessionId,
|
|
144
|
+
event: "created",
|
|
145
|
+
toStatus: input.currentStatus,
|
|
146
|
+
transitionedAt: input.createdAt
|
|
147
|
+
});
|
|
148
|
+
})();
|
|
149
|
+
}
|
|
150
|
+
async recordReviewSessionWithLiveSession(input, live) {
|
|
151
|
+
const plan = parseActionPlanEvidence(input.plan);
|
|
152
|
+
const planJson = serializeJson(plan);
|
|
153
|
+
const intentJson = serializeOptionalJson(extractRequestedIntent(plan));
|
|
154
|
+
return this.runLiveReviewSessionMutation(live, () => {
|
|
155
|
+
this.db
|
|
156
|
+
.prepare(`INSERT INTO review_sessions
|
|
157
|
+
(id, plan_id, action_kind, adapter_id, protocol, current_status,
|
|
158
|
+
plan_json, intent_json, created_at, updated_at)
|
|
159
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
160
|
+
.run(input.reviewSessionId, plan.id, plan.actionKind, plan.adapterId, plan.protocol, input.currentStatus, planJson, intentJson, input.createdAt, input.createdAt);
|
|
161
|
+
this.insertReviewTransition({
|
|
162
|
+
reviewSessionId: input.reviewSessionId,
|
|
163
|
+
event: "created",
|
|
164
|
+
toStatus: input.currentStatus,
|
|
165
|
+
transitionedAt: input.createdAt
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async recordReviewTransition(input) {
|
|
170
|
+
this.db
|
|
171
|
+
.transaction(() => {
|
|
172
|
+
const accountId = input.account
|
|
173
|
+
? this.upsertAccountSync(input.account, "review_execution", input.transitionedAt).id
|
|
174
|
+
: null;
|
|
175
|
+
if (accountId !== null) {
|
|
176
|
+
this.assertReviewSessionAccount(input.reviewSessionId, accountId);
|
|
177
|
+
}
|
|
178
|
+
this.insertReviewTransition({ ...input, accountId });
|
|
179
|
+
this.db
|
|
180
|
+
.prepare(`UPDATE review_sessions
|
|
181
|
+
SET current_status = ?, account_id = COALESCE(account_id, ?), updated_at = ?
|
|
182
|
+
WHERE id = ?`)
|
|
183
|
+
.run(input.toStatus, accountId, input.transitionedAt, input.reviewSessionId);
|
|
184
|
+
})();
|
|
185
|
+
}
|
|
186
|
+
async recordReviewTransitionWithLiveSession(input, live) {
|
|
187
|
+
return this.recordReviewTransitionWithLiveSessionSync(input, live);
|
|
188
|
+
}
|
|
189
|
+
recordReviewTransitionWithLiveSessionSync(input, live) {
|
|
190
|
+
return this.runLiveReviewSessionMutation(live, () => {
|
|
191
|
+
const accountId = input.account
|
|
192
|
+
? this.upsertAccountSync(input.account, "review_execution", input.transitionedAt).id
|
|
193
|
+
: null;
|
|
194
|
+
if (accountId !== null) {
|
|
195
|
+
this.assertReviewSessionAccount(input.reviewSessionId, accountId);
|
|
196
|
+
if (input.event === "wallet_connected") {
|
|
197
|
+
this.assertActiveAccountSync(accountId, input.reviewSessionId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this.insertReviewTransition({ ...input, accountId });
|
|
201
|
+
this.db
|
|
202
|
+
.prepare(`UPDATE review_sessions
|
|
203
|
+
SET current_status = ?, account_id = COALESCE(account_id, ?), updated_at = ?
|
|
204
|
+
WHERE id = ?`)
|
|
205
|
+
.run(input.toStatus, accountId, input.transitionedAt, input.reviewSessionId);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async recordReviewStateSnapshot(input) {
|
|
209
|
+
const parsedState = parseLifecycleValidatedReviewState(input.state, this.validateAdapterLifecycle);
|
|
210
|
+
const stateJson = serializeJson(parsedState);
|
|
211
|
+
this.db
|
|
212
|
+
.transaction(() => {
|
|
213
|
+
const account = this.upsertAccountSync(parsedState.account, "review_execution", input.recordedAt);
|
|
214
|
+
this.assertReviewSessionAccount(input.reviewSessionId, account.id);
|
|
215
|
+
this.db
|
|
216
|
+
.prepare(`INSERT INTO review_state_snapshots
|
|
217
|
+
(review_session_id, plan_id, account_id, status, blocked_reason, refresh_reason,
|
|
218
|
+
state_json, updated_at, recorded_at, review_revision)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
220
|
+
.run(input.reviewSessionId, parsedState.planId, account.id, parsedState.status, "blockedReason" in parsedState ? parsedState.blockedReason : null, "refreshReason" in parsedState ? parsedState.refreshReason : null, stateJson, parsedState.updatedAt, input.recordedAt, input.reviewRevision);
|
|
221
|
+
this.insertReviewTransition({
|
|
222
|
+
reviewSessionId: input.reviewSessionId,
|
|
223
|
+
event: "state_computed",
|
|
224
|
+
fromStatus: input.fromStatus,
|
|
225
|
+
toStatus: parsedState.status,
|
|
226
|
+
accountId: account.id,
|
|
227
|
+
reason: reasonForReviewState(parsedState),
|
|
228
|
+
transitionedAt: input.recordedAt
|
|
229
|
+
});
|
|
230
|
+
this.db
|
|
231
|
+
.prepare(`UPDATE review_sessions
|
|
232
|
+
SET current_status = ?, account_id = ?, updated_at = ?
|
|
233
|
+
WHERE id = ?`)
|
|
234
|
+
.run(parsedState.status, account.id, input.recordedAt, input.reviewSessionId);
|
|
235
|
+
})();
|
|
236
|
+
}
|
|
237
|
+
async recordReviewStateSnapshotWithLiveSession(input, live) {
|
|
238
|
+
return this.recordReviewStateSnapshotWithLiveSessionSync(input, live);
|
|
239
|
+
}
|
|
240
|
+
recordReviewStateSnapshotWithLiveSessionSync(input, live) {
|
|
241
|
+
const parsedState = parseLifecycleValidatedReviewState(input.state, this.validateAdapterLifecycle);
|
|
242
|
+
const stateJson = serializeJson(parsedState);
|
|
243
|
+
return this.runLiveReviewSessionMutation(live, () => {
|
|
244
|
+
if (live.publication) {
|
|
245
|
+
const { material: checked, clock } = live.publication;
|
|
246
|
+
const at = clock(), handle = checked.artifacts.transactionMaterial;
|
|
247
|
+
const material = handle && this.createTransactionMaterialStore().getTransactionMaterial(handle, at);
|
|
248
|
+
const connection = live.expected?.walletConnectionId ? this.db.prepare("SELECT revision,status,owner_id,connection_json FROM live_wallet_connections WHERE id=?")
|
|
249
|
+
.get(live.expected.walletConnectionId) : undefined;
|
|
250
|
+
if (Date.parse(live.next.expiresAt) <= at.getTime() || !handle || !material || !sameHandle(material, handle) ||
|
|
251
|
+
!Buffer.from(material.transactionBytes).equals(checked.transactionBytes) || !connection || connection.status !== "connected" ||
|
|
252
|
+
connection.owner_id !== live.expected?.ownerId || connection.revision !== live.expected?.walletConnectionRevision ||
|
|
253
|
+
Date.parse(JSON.parse(connection.connection_json).expiresAt) <= at.getTime()) {
|
|
254
|
+
throw new SessionStoreError("invalid_session_transition", "Review material or wallet selection changed before publication.");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const account = this.upsertAccountSync(parsedState.account, "review_execution", input.recordedAt);
|
|
258
|
+
this.assertReviewSessionAccount(input.reviewSessionId, account.id);
|
|
259
|
+
this.db
|
|
260
|
+
.prepare(`INSERT INTO review_state_snapshots
|
|
261
|
+
(review_session_id, plan_id, account_id, status, blocked_reason, refresh_reason,
|
|
262
|
+
state_json, updated_at, recorded_at, review_revision)
|
|
263
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
264
|
+
.run(input.reviewSessionId, parsedState.planId, account.id, parsedState.status, "blockedReason" in parsedState ? parsedState.blockedReason : null, "refreshReason" in parsedState ? parsedState.refreshReason : null, stateJson, parsedState.updatedAt, input.recordedAt, input.reviewRevision);
|
|
265
|
+
this.insertReviewTransition({
|
|
266
|
+
reviewSessionId: input.reviewSessionId,
|
|
267
|
+
event: "state_computed",
|
|
268
|
+
fromStatus: input.fromStatus,
|
|
269
|
+
toStatus: parsedState.status,
|
|
270
|
+
accountId: account.id,
|
|
271
|
+
reason: reasonForReviewState(parsedState),
|
|
272
|
+
transitionedAt: input.recordedAt
|
|
273
|
+
});
|
|
274
|
+
this.db
|
|
275
|
+
.prepare(`UPDATE review_sessions
|
|
276
|
+
SET current_status = ?, account_id = ?, updated_at = ?
|
|
277
|
+
WHERE id = ?`)
|
|
278
|
+
.run(parsedState.status, account.id, input.recordedAt, input.reviewSessionId);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async listReviewActivity(filter) {
|
|
282
|
+
const from = parseOptionalIsoTimestamp(filter.from, "from"), to = parseOptionalIsoTimestamp(filter.to, "to");
|
|
283
|
+
assertDateRange(from, to);
|
|
284
|
+
const limit = normalizeListLimit(filter.limit), scope = this.resolveReviewActivityScope(filter);
|
|
285
|
+
if (scope.accountId === undefined)
|
|
286
|
+
return reviewActivityListResult(scope, from, to, [], false, 0);
|
|
287
|
+
const base = reviewSessionWhere(scope.accountId, from, to, filter.reviewStatus);
|
|
288
|
+
let where = base.whereSql;
|
|
289
|
+
const params = [...base.params];
|
|
290
|
+
if (filter.requestStatus !== undefined) {
|
|
291
|
+
where += " AND r.request_status=?";
|
|
292
|
+
params.push(transactionRequestStatusSchema.parse(filter.requestStatus));
|
|
293
|
+
}
|
|
294
|
+
if (filter.executionStatus !== undefined) {
|
|
295
|
+
where += " AND e.status=?";
|
|
296
|
+
params.push(z.enum(["success", "failure"]).parse(filter.executionStatus));
|
|
297
|
+
}
|
|
298
|
+
const joins = `FROM review_sessions rs JOIN accounts a ON a.id=rs.account_id
|
|
299
|
+
LEFT JOIN review_requests r ON r.attempt_id=rs.current_attempt_id AND r.review_session_id=rs.id AND r.account_id=rs.account_id
|
|
300
|
+
LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id`;
|
|
301
|
+
const count = this.db.prepare(`SELECT COUNT(*) AS count ${joins} ${where}`).get(...params).count;
|
|
302
|
+
const rows = this.db.prepare(`SELECT rs.id AS review_session_id, rs.plan_id, rs.action_kind, rs.adapter_id, rs.protocol,
|
|
303
|
+
rs.current_status, rs.current_attempt_id, a.sui_address AS account, rs.created_at, rs.updated_at,
|
|
304
|
+
e.status AS execution_status, e.tx_digest,
|
|
305
|
+
(SELECT COUNT(*) FROM review_state_snapshots s WHERE s.review_session_id=rs.id) AS snapshot_count,
|
|
306
|
+
(SELECT COUNT(*) FROM review_status_transitions t WHERE t.review_session_id=rs.id) AS transition_count
|
|
307
|
+
${joins} ${where} ORDER BY rs.created_at DESC,rs.id DESC LIMIT ?`).all(...params, limit + 1);
|
|
308
|
+
const activities = rows.slice(0, limit).map((row) => {
|
|
309
|
+
const request = row.current_attempt_id ? this.readRequestEvidence(row.current_attempt_id, row.review_session_id, row.account) : undefined;
|
|
310
|
+
return reviewActivityRowFromStorage(row, request);
|
|
311
|
+
});
|
|
312
|
+
return reviewActivityListResult(scope, from, to, activities, rows.length > limit, count);
|
|
313
|
+
}
|
|
314
|
+
async summarizeReviewFunnel(filter) {
|
|
315
|
+
const from = parseOptionalIsoTimestamp(filter.from, "from"), to = parseOptionalIsoTimestamp(filter.to, "to");
|
|
316
|
+
assertDateRange(from, to);
|
|
317
|
+
const scope = this.resolveReviewActivityScope(filter), summary = emptyReviewFunnelSummary();
|
|
318
|
+
if (scope.accountId === undefined)
|
|
319
|
+
return reviewFunnelResult(scope, from, to, summary, 0);
|
|
320
|
+
const { whereSql: where, params } = reviewSessionWhere(scope.accountId, from, to);
|
|
321
|
+
const totals = this.db.prepare(`SELECT COUNT(*) AS total,
|
|
322
|
+
SUM(CASE WHEN rs.opened_at IS NOT NULL THEN 1 ELSE 0 END) AS opened,
|
|
323
|
+
SUM(CASE WHEN r.attempt_id IS NULL THEN 1 ELSE 0 END) AS without_request,
|
|
324
|
+
SUM(CASE WHEN e.attempt_id IS NULL THEN 1 ELSE 0 END) AS without_execution,
|
|
325
|
+
SUM(CASE WHEN rs.current_status='expired' AND NOT EXISTS (SELECT 1 FROM review_executions x WHERE x.review_session_id=rs.id) THEN 1 ELSE 0 END) AS expired_without_execution
|
|
326
|
+
FROM review_sessions rs LEFT JOIN review_requests r ON r.attempt_id=rs.current_attempt_id AND r.review_session_id=rs.id
|
|
327
|
+
LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id ${where}`).get(...params);
|
|
328
|
+
summary.total = totals.total;
|
|
329
|
+
summary.opened = totals.opened ?? 0;
|
|
330
|
+
summary.withoutRequest = totals.without_request ?? 0;
|
|
331
|
+
summary.withoutExecutionResult = totals.without_execution ?? 0;
|
|
332
|
+
summary.expiredWithoutExecutionResult = totals.expired_without_execution ?? 0;
|
|
333
|
+
const eventCounts = this.db.prepare(`SELECT t.event AS key,COUNT(DISTINCT t.review_session_id) AS count
|
|
334
|
+
FROM review_status_transitions t JOIN review_sessions rs ON rs.id=t.review_session_id ${where} AND t.domain='review' GROUP BY t.event`)
|
|
335
|
+
.all(...params);
|
|
336
|
+
summary.walletConnected = eventCounts.find((row) => row.key === "wallet_connected")?.count ?? 0;
|
|
337
|
+
summary.stateComputed = eventCounts.find((row) => row.key === "state_computed")?.count ?? 0;
|
|
338
|
+
summary.reviewStatusCounts = countMap(INTERNAL_SESSION_STATUSES, this.db.prepare(`SELECT rs.current_status AS key,COUNT(*) AS count FROM review_sessions rs ${where} GROUP BY rs.current_status`).all(...params));
|
|
339
|
+
const requestCounts = countMap(TRANSACTION_REQUEST_STATUSES, this.db.prepare(`SELECT r.request_status AS key,COUNT(*) AS count FROM review_sessions rs
|
|
340
|
+
JOIN review_requests r ON r.attempt_id=rs.current_attempt_id AND r.review_session_id=rs.id ${where} GROUP BY r.request_status`).all(...params));
|
|
341
|
+
summary.requestStatusCounts = TRANSACTION_REQUEST_STATUSES.map((requestStatus) => ({ requestStatus, count: requestCounts[requestStatus] }));
|
|
342
|
+
summary.executionStatusCounts = countMap(["success", "failure"], this.db.prepare(`SELECT e.status AS key,COUNT(*) AS count FROM review_sessions rs
|
|
343
|
+
JOIN review_executions e ON e.attempt_id=rs.current_attempt_id AND e.review_session_id=rs.id ${where} GROUP BY e.status`).all(...params));
|
|
344
|
+
summary.everReachedReviewStateCounts = countMap(REVIEW_STATE_STATUSES, this.db.prepare(`SELECT t.to_status AS key,COUNT(DISTINCT rs.id) AS count
|
|
345
|
+
FROM review_status_transitions t JOIN review_sessions rs ON rs.id=t.review_session_id ${where} AND t.domain='review'
|
|
346
|
+
AND t.to_status IN ('ready_for_wallet_review','blocked','refresh_required') GROUP BY t.to_status`).all(...params));
|
|
347
|
+
summary.everAwaitedChainResult = this.db.prepare(`SELECT COUNT(DISTINCT rs.id) AS count FROM review_status_transitions t
|
|
348
|
+
JOIN review_sessions rs ON rs.id=t.review_session_id ${where} AND t.domain='request' AND t.to_status='awaiting_chain_result'`).get(...params).count;
|
|
349
|
+
const timing = this.db.prepare(`SELECT AVG((julianday(first.verified_at)-julianday(rs.created_at))*86400.0) AS created,
|
|
350
|
+
AVG(CASE WHEN rs.opened_at IS NULL THEN NULL ELSE (julianday(first.verified_at)-julianday(rs.opened_at))*86400.0 END) AS opened
|
|
351
|
+
FROM review_sessions rs JOIN (SELECT review_session_id,MIN(signature_verified_at) AS verified_at FROM review_requests
|
|
352
|
+
WHERE signature_verified_at IS NOT NULL GROUP BY review_session_id) first ON first.review_session_id=rs.id ${where}`)
|
|
353
|
+
.get(...params);
|
|
354
|
+
summary.avgCreatedToSignatureVerifiedSeconds = nullableSeconds(timing.created);
|
|
355
|
+
summary.avgOpenedToSignatureVerifiedSeconds = nullableSeconds(timing.opened);
|
|
356
|
+
const inconsistent = this.db.prepare(`SELECT rs.id FROM review_sessions rs LEFT JOIN review_requests r ON r.attempt_id=rs.current_attempt_id
|
|
357
|
+
LEFT JOIN review_executions e ON e.attempt_id=r.attempt_id ${where} AND
|
|
358
|
+
((rs.current_attempt_id IS NOT NULL AND (r.attempt_id IS NULL OR r.review_session_id!=rs.id OR r.account_id!=rs.account_id)) OR
|
|
359
|
+
(r.request_status='completed' AND e.attempt_id IS NULL) OR (e.attempt_id IS NOT NULL AND
|
|
360
|
+
(r.request_status!='completed' OR e.tx_digest!=r.transaction_digest OR json_valid(e.result_json)=0))) LIMIT 1`).get(...params);
|
|
361
|
+
if (inconsistent)
|
|
362
|
+
throw new ActivityStoreReadError("internal_error", "Stored review request evidence is inconsistent.", { reason: "invalid_stored_evidence" });
|
|
363
|
+
// Aggregation must not turn a malformed current result into a valid count.
|
|
364
|
+
for (const row of this.db.prepare(`SELECT rs.id,rs.current_attempt_id,a.sui_address AS account
|
|
365
|
+
FROM review_sessions rs JOIN accounts a ON a.id=rs.account_id ${where} AND rs.current_attempt_id IS NOT NULL`)
|
|
366
|
+
.all(...params)) {
|
|
367
|
+
this.readRequestEvidence(row.current_attempt_id, row.id, row.account);
|
|
368
|
+
}
|
|
369
|
+
return reviewFunnelResult(scope, from, to, summary, summary.total);
|
|
370
|
+
}
|
|
371
|
+
readRequestEvidence(id, reviewSessionId, account) {
|
|
372
|
+
try {
|
|
373
|
+
const request = readStoredTransactionRequest(this.db, id);
|
|
374
|
+
if (!request || request.reviewSessionId !== reviewSessionId || request.account !== account)
|
|
375
|
+
throw new Error("Request identity mismatch");
|
|
376
|
+
return request;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
throw new ActivityStoreReadError("internal_error", "Stored review request evidence is invalid.", { reason: "invalid_stored_evidence", reviewSessionId });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async getReviewSessionDetail(input) {
|
|
383
|
+
const scope = this.resolveReviewActivityScope({ account: input.account });
|
|
384
|
+
const row = scope.accountId === undefined ? undefined : this.db.prepare(`SELECT rs.*, a.sui_address AS account
|
|
385
|
+
FROM review_sessions rs JOIN accounts a ON a.id=rs.account_id WHERE rs.id=? AND rs.account_id=?`)
|
|
386
|
+
.get(input.reviewSessionId, scope.accountId);
|
|
387
|
+
if (!row)
|
|
388
|
+
throw new ActivityStoreReadError("session_not_found", "Review session not found", { reviewSessionId: input.reviewSessionId });
|
|
389
|
+
const snapshots = this.db.prepare(`SELECT s.*,a.sui_address AS account FROM review_state_snapshots s JOIN accounts a ON a.id=s.account_id
|
|
390
|
+
WHERE s.review_session_id=? AND s.account_id=? ORDER BY s.recorded_at,s.id LIMIT ?`)
|
|
391
|
+
.all(input.reviewSessionId, scope.accountId, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS + 1);
|
|
392
|
+
const transitions = this.db.prepare(`SELECT t.*,a.sui_address AS account FROM review_status_transitions t LEFT JOIN accounts a ON a.id=t.account_id
|
|
393
|
+
WHERE t.review_session_id=? AND (t.account_id IS NULL OR t.account_id=?) ORDER BY t.transitioned_at,t.id LIMIT ?`)
|
|
394
|
+
.all(input.reviewSessionId, scope.accountId, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS + 1);
|
|
395
|
+
const requestIds = this.db.prepare(`SELECT attempt_id FROM review_requests WHERE review_session_id=? AND account_id=? ORDER BY created_at,attempt_id LIMIT ?`)
|
|
396
|
+
.all(input.reviewSessionId, scope.accountId, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS + 1);
|
|
397
|
+
const requestCount = this.db.prepare("SELECT COUNT(*) AS count FROM review_requests WHERE review_session_id=? AND account_id=?")
|
|
398
|
+
.get(input.reviewSessionId, scope.accountId).count;
|
|
399
|
+
const recordCount = this.db.prepare("SELECT COUNT(*) AS count FROM review_sessions WHERE account_id=?").get(scope.accountId).count;
|
|
400
|
+
const request = row.current_attempt_id ? this.readRequestEvidence(row.current_attempt_id, row.id, row.account) : undefined;
|
|
401
|
+
return {
|
|
402
|
+
dataScope: { account: scope.account, recordCount }, accountSource: scope.accountSource,
|
|
403
|
+
lowSampleWarning: recordCount < REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD, lowSampleThreshold: REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD,
|
|
404
|
+
session: { reviewSessionId: row.id, planId: row.plan_id, actionKind: row.action_kind, adapterId: row.adapter_id,
|
|
405
|
+
protocol: row.protocol, reviewStatus: asInternalSessionStatus(row.current_status), account: row.account,
|
|
406
|
+
createdAt: row.created_at, updatedAt: row.updated_at, ...(request ? { currentAttemptId: request.attemptId } : {}) },
|
|
407
|
+
planJson: parseEvidenceJson(row.plan_json, row.id, "plan_json", actionPlanSchema),
|
|
408
|
+
...(row.intent_json === null ? {} : { intentJson: parseEvidenceJson(row.intent_json, row.id, "intent_json") }),
|
|
409
|
+
stateSnapshots: snapshots.slice(0, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS).map((snapshot) => ({
|
|
410
|
+
id: snapshot.id, reviewRevision: snapshot.review_revision, planId: snapshot.plan_id, account: snapshot.account, status: snapshot.status,
|
|
411
|
+
blockedReason: snapshot.blocked_reason ?? undefined, refreshReason: snapshot.refresh_reason ?? undefined,
|
|
412
|
+
stateJson: this.parseReviewStateEvidenceJson(snapshot.state_json, row.id, "state_json"),
|
|
413
|
+
updatedAt: snapshot.updated_at, recordedAt: snapshot.recorded_at
|
|
414
|
+
})),
|
|
415
|
+
transitions: transitions.slice(0, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS).map((event) => ({
|
|
416
|
+
id: event.id, event: asReviewTransitionEvent(event.event), domain: event.domain,
|
|
417
|
+
...(event.attempt_id === null ? {} : { attemptId: event.attempt_id }),
|
|
418
|
+
fromStatus: event.from_status ?? undefined, toStatus: event.to_status, isNoOp: event.from_status === event.to_status,
|
|
419
|
+
account: event.account ?? undefined, reason: event.reason ?? undefined, transitionedAt: event.transitioned_at
|
|
420
|
+
})),
|
|
421
|
+
...(request ? { request } : {}), requestCount,
|
|
422
|
+
requests: requestIds.slice(0, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS).map(({ attempt_id }) => this.readRequestEvidence(attempt_id, row.id, row.account)),
|
|
423
|
+
truncated: { activities: false, snapshots: snapshots.length > REVIEW_ACTIVITY_DETAIL_MAX_ITEMS,
|
|
424
|
+
transitions: transitions.length > REVIEW_ACTIVITY_DETAIL_MAX_ITEMS, requests: requestIds.length > REVIEW_ACTIVITY_DETAIL_MAX_ITEMS }
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async recordExternalActivityScan(input) {
|
|
428
|
+
try {
|
|
429
|
+
assertNoForbiddenMcpFields(input);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
throw new ActivityStoreReadError("input_invalid", "External activity scan contains forbidden fields", {
|
|
433
|
+
reason: "forbidden_field"
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const account = await this.getKnownAccount(input.account);
|
|
437
|
+
if (!account) {
|
|
438
|
+
throw new ActivityStoreReadError("input_invalid", "External activity scan account is not a known wallet", {
|
|
439
|
+
reason: "account_not_known"
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const fetchedAt = parseIsoTimestamp(input.fetchedAt, "fetchedAt");
|
|
443
|
+
const fromTimestamp = parseOptionalIsoTimestamp(input.fromTimestamp, "from");
|
|
444
|
+
const toTimestamp = parseOptionalIsoTimestamp(input.toTimestamp, "to");
|
|
445
|
+
assertDateRange(fromTimestamp, toTimestamp);
|
|
446
|
+
const record = this.db.transaction(() => {
|
|
447
|
+
this.db
|
|
448
|
+
.prepare(`INSERT INTO external_activity_scans
|
|
449
|
+
(scan_id, kind, account_id, relationship, input_digest, from_checkpoint, to_checkpoint,
|
|
450
|
+
from_timestamp, to_timestamp, limit_count, request_cursor, response_cursor, endpoint_host,
|
|
451
|
+
chain_identifier, fetched_at, stored_count, skipped_count, has_more, window_complete,
|
|
452
|
+
incomplete_reason)
|
|
453
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
454
|
+
.run(input.scanId, input.kind, account.id, input.relationship, input.inputDigest ?? null, input.fromCheckpoint ?? null, input.toCheckpoint ?? null, fromTimestamp ?? null, toTimestamp ?? null, input.limit, input.requestCursor ?? null, input.responseCursor ?? null, input.endpointHost, input.chainIdentifier, fetchedAt, 0, input.transactions.length, input.hasMore ? 1 : 0, input.windowComplete === null ? null : input.windowComplete ? 1 : 0, input.incompleteReason ?? null);
|
|
455
|
+
let storedCount = 0;
|
|
456
|
+
const skippedCount = input.skippedCount ?? 0;
|
|
457
|
+
const upsert = this.db.prepare(`INSERT INTO external_activity_transactions
|
|
458
|
+
(account_id, digest, relationship, checkpoint, timestamp, status, known_sender_account_id,
|
|
459
|
+
first_scan_id, last_scan_id, first_fetched_at, last_fetched_at, detail_json)
|
|
460
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
461
|
+
ON CONFLICT(account_id, digest, relationship) DO UPDATE SET
|
|
462
|
+
checkpoint = COALESCE(excluded.checkpoint, external_activity_transactions.checkpoint),
|
|
463
|
+
timestamp = COALESCE(excluded.timestamp, external_activity_transactions.timestamp),
|
|
464
|
+
status = excluded.status,
|
|
465
|
+
known_sender_account_id = COALESCE(excluded.known_sender_account_id, external_activity_transactions.known_sender_account_id),
|
|
466
|
+
last_scan_id = excluded.last_scan_id,
|
|
467
|
+
last_fetched_at = excluded.last_fetched_at,
|
|
468
|
+
detail_json = COALESCE(excluded.detail_json, external_activity_transactions.detail_json)`);
|
|
469
|
+
for (const transaction of input.transactions) {
|
|
470
|
+
if (transaction.knownSenderAccountId !== undefined && !this.accountIdExists(transaction.knownSenderAccountId)) {
|
|
471
|
+
throw new ActivityStoreReadError("input_invalid", "External activity sender account is not known", {
|
|
472
|
+
reason: "sender_account_not_known"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
upsert.run(account.id, transaction.digest, transaction.relationship, transaction.checkpoint ?? null, transaction.timestamp ? parseIsoTimestamp(transaction.timestamp, "transaction.timestamp") : null, transaction.status, transaction.knownSenderAccountId ?? null, input.scanId, input.scanId, fetchedAt, fetchedAt, transaction.details === undefined
|
|
476
|
+
? null
|
|
477
|
+
: serializeExternalActivityTransactionDetail(transaction.details, account.address));
|
|
478
|
+
storedCount += 1;
|
|
479
|
+
}
|
|
480
|
+
this.db
|
|
481
|
+
.prepare(`UPDATE external_activity_scans
|
|
482
|
+
SET stored_count = ?, skipped_count = ?
|
|
483
|
+
WHERE scan_id = ?`)
|
|
484
|
+
.run(storedCount, skippedCount + input.transactions.length - storedCount, input.scanId);
|
|
485
|
+
return this.externalActivityScanById(input.scanId);
|
|
486
|
+
})();
|
|
487
|
+
if (!record) {
|
|
488
|
+
throw new ActivityStoreReadError("internal_error", "External activity scan was not recorded", {
|
|
489
|
+
scanId: input.scanId
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return record;
|
|
493
|
+
}
|
|
494
|
+
async getExternalActivityCoverage(filter) {
|
|
495
|
+
const from = parseIsoTimestamp(filter.from, "from");
|
|
496
|
+
const to = parseIsoTimestamp(filter.to, "to");
|
|
497
|
+
assertDateRange(from, to);
|
|
498
|
+
assertNonEmptyHalfOpenRange(from, to);
|
|
499
|
+
const scope = this.resolveReviewActivityScope(filter);
|
|
500
|
+
if (scope.accountId === undefined) {
|
|
501
|
+
return buildExternalActivityCoverageResult({
|
|
502
|
+
scope,
|
|
503
|
+
from,
|
|
504
|
+
to,
|
|
505
|
+
scans: [],
|
|
506
|
+
scanCount: 0,
|
|
507
|
+
storedTransactionCount: 0
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
const scanWhereSql = `
|
|
511
|
+
WHERE eas.account_id = ?
|
|
512
|
+
AND (eas.from_timestamp IS NULL OR eas.from_timestamp < ?)
|
|
513
|
+
AND (eas.to_timestamp IS NULL OR eas.to_timestamp > ?)`;
|
|
514
|
+
const scanParams = [scope.accountId, to, from];
|
|
515
|
+
const scanCount = this.db
|
|
516
|
+
.prepare(`SELECT COUNT(*) AS count FROM external_activity_scans eas ${scanWhereSql}`)
|
|
517
|
+
.get(...scanParams).count;
|
|
518
|
+
const scanRows = this.db
|
|
519
|
+
.prepare(`SELECT eas.scan_id, eas.kind, eas.account_id, a.sui_address AS account, eas.relationship,
|
|
520
|
+
eas.input_digest, eas.from_checkpoint, eas.to_checkpoint, eas.from_timestamp,
|
|
521
|
+
eas.to_timestamp, eas.limit_count, eas.request_cursor, eas.response_cursor,
|
|
522
|
+
eas.endpoint_host, eas.chain_identifier, eas.fetched_at, eas.stored_count,
|
|
523
|
+
eas.skipped_count, eas.has_more, eas.window_complete, eas.incomplete_reason
|
|
524
|
+
FROM external_activity_scans eas
|
|
525
|
+
JOIN accounts a ON a.id = eas.account_id
|
|
526
|
+
${scanWhereSql}
|
|
527
|
+
ORDER BY eas.fetched_at DESC, eas.scan_id DESC
|
|
528
|
+
LIMIT ?`)
|
|
529
|
+
.all(...scanParams, EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS + 1);
|
|
530
|
+
const scansTruncated = scanRows.length > EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS;
|
|
531
|
+
const scans = scanRows
|
|
532
|
+
.slice(0, EXTERNAL_ACTIVITY_COVERAGE_SCAN_MAX_RECORDS)
|
|
533
|
+
.map(externalActivityScanFromRow);
|
|
534
|
+
const transactionWhereSql = "WHERE eat.account_id = ? AND eat.timestamp >= ? AND eat.timestamp < ?";
|
|
535
|
+
const transactionParams = [scope.accountId, from, to];
|
|
536
|
+
const canonicalTransactions = canonicalExternalActivityTransactions(this.externalActivityTransactions(transactionWhereSql, [...transactionParams]));
|
|
537
|
+
const storedTransactionCount = canonicalTransactions.length;
|
|
538
|
+
const storedTransactionRange = canonicalTransactions.length === 0
|
|
539
|
+
? undefined
|
|
540
|
+
: externalActivityTransactionRangeFromRecords(canonicalTransactions);
|
|
541
|
+
return buildExternalActivityCoverageResult({
|
|
542
|
+
scope,
|
|
543
|
+
from,
|
|
544
|
+
to,
|
|
545
|
+
scans,
|
|
546
|
+
scanCount,
|
|
547
|
+
storedTransactionCount,
|
|
548
|
+
storedTransactionRange,
|
|
549
|
+
scansTruncated
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
async listExternalActivityEffectTransactions(filter) {
|
|
553
|
+
const from = parseIsoTimestamp(filter.from, "from");
|
|
554
|
+
const to = parseIsoTimestamp(filter.to, "to");
|
|
555
|
+
assertDateRange(from, to);
|
|
556
|
+
assertNonEmptyHalfOpenRange(from, to);
|
|
557
|
+
const limit = normalizeExternalActivityLimit(filter.limit);
|
|
558
|
+
const scope = this.resolveReviewActivityScope(filter);
|
|
559
|
+
if (scope.accountId === undefined) {
|
|
560
|
+
return buildExternalActivityTransactionStreamResult({
|
|
561
|
+
scope,
|
|
562
|
+
from,
|
|
563
|
+
to,
|
|
564
|
+
transactions: [],
|
|
565
|
+
truncated: false,
|
|
566
|
+
transactionCount: 0
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const whereSql = "WHERE eat.account_id = ? AND eat.timestamp >= ? AND eat.timestamp < ?";
|
|
570
|
+
const canonicalTransactions = canonicalExternalActivityTransactions(this.externalActivityTransactions(whereSql, [scope.accountId, from, to]));
|
|
571
|
+
return buildExternalActivityTransactionStreamResult({
|
|
572
|
+
scope,
|
|
573
|
+
from,
|
|
574
|
+
to,
|
|
575
|
+
transactions: canonicalTransactions.slice(0, limit),
|
|
576
|
+
truncated: canonicalTransactions.length > limit,
|
|
577
|
+
transactionCount: canonicalTransactions.length
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
async summarizeExternalActivity(filter) {
|
|
581
|
+
const from = parseOptionalIsoTimestamp(filter.from, "from");
|
|
582
|
+
const to = parseOptionalIsoTimestamp(filter.to, "to");
|
|
583
|
+
assertDateRange(from, to);
|
|
584
|
+
const limit = normalizeExternalActivityLimit(filter.limit);
|
|
585
|
+
const scope = this.resolveReviewActivityScope(filter);
|
|
586
|
+
if (scope.accountId === undefined) {
|
|
587
|
+
return externalActivitySummaryResult(scope, from, to, [], false, emptyExternalActivitySummaryStats());
|
|
588
|
+
}
|
|
589
|
+
const where = ["eat.account_id = ?"];
|
|
590
|
+
const params = [scope.accountId];
|
|
591
|
+
if (from !== undefined) {
|
|
592
|
+
where.push("eat.timestamp >= ?");
|
|
593
|
+
params.push(from);
|
|
594
|
+
}
|
|
595
|
+
if (to !== undefined) {
|
|
596
|
+
where.push("eat.timestamp <= ?");
|
|
597
|
+
params.push(to);
|
|
598
|
+
}
|
|
599
|
+
const whereSql = `WHERE ${where.join(" AND ")}`;
|
|
600
|
+
const total = this.db
|
|
601
|
+
.prepare(`SELECT COUNT(*) AS count FROM external_activity_transactions eat ${whereSql}`)
|
|
602
|
+
.get(...params).count;
|
|
603
|
+
const statusCounts = countMap(EXTERNAL_ACTIVITY_STATUSES, this.db
|
|
604
|
+
.prepare(`SELECT eat.status AS key, COUNT(*) AS count FROM external_activity_transactions eat ${whereSql} GROUP BY eat.status`)
|
|
605
|
+
.all(...params));
|
|
606
|
+
const relationshipCounts = countMap(EXTERNAL_ACTIVITY_RELATIONSHIPS, this.db
|
|
607
|
+
.prepare(`SELECT eat.relationship AS key, COUNT(*) AS count FROM external_activity_transactions eat ${whereSql} GROUP BY eat.relationship`)
|
|
608
|
+
.all(...params));
|
|
609
|
+
const timestampRow = this.db
|
|
610
|
+
.prepare(`SELECT MIN(eat.timestamp) AS earliest_timestamp, MAX(eat.timestamp) AS latest_timestamp
|
|
611
|
+
FROM external_activity_transactions eat
|
|
612
|
+
${whereSql}`)
|
|
613
|
+
.get(...params);
|
|
614
|
+
const rows = this.db
|
|
615
|
+
.prepare(`SELECT eat.account_id, a.sui_address AS account, eat.digest, eat.relationship,
|
|
616
|
+
eat.checkpoint, eat.timestamp, eat.status, eat.known_sender_account_id,
|
|
617
|
+
eat.first_scan_id, eat.last_scan_id, eat.first_fetched_at, eat.last_fetched_at,
|
|
618
|
+
last_scan.incomplete_reason AS last_scan_incomplete_reason,
|
|
619
|
+
eat.detail_json
|
|
620
|
+
FROM external_activity_transactions eat
|
|
621
|
+
JOIN accounts a ON a.id = eat.account_id
|
|
622
|
+
LEFT JOIN external_activity_scans last_scan ON last_scan.scan_id = eat.last_scan_id
|
|
623
|
+
${whereSql}
|
|
624
|
+
ORDER BY
|
|
625
|
+
CASE WHEN eat.checkpoint IS NULL THEN 0 ELSE 1 END DESC,
|
|
626
|
+
CAST(eat.checkpoint AS INTEGER) DESC,
|
|
627
|
+
COALESCE(eat.timestamp, '') DESC,
|
|
628
|
+
eat.digest DESC
|
|
629
|
+
LIMIT ?`)
|
|
630
|
+
.all(...params, limit + 1);
|
|
631
|
+
return externalActivitySummaryResult(scope, from, to, rows.slice(0, limit).map(externalActivityTransactionFromRow), rows.length > limit, {
|
|
632
|
+
transactionCount: total,
|
|
633
|
+
statusCounts,
|
|
634
|
+
relationshipCounts,
|
|
635
|
+
earliestTimestamp: timestampRow.earliest_timestamp ?? undefined,
|
|
636
|
+
latestTimestamp: timestampRow.latest_timestamp ?? undefined
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
close() {
|
|
640
|
+
this.db.close();
|
|
641
|
+
}
|
|
642
|
+
createPreferencesRepository() {
|
|
643
|
+
return new SqlitePreferencesRepository(this.db);
|
|
644
|
+
}
|
|
645
|
+
createLocalDataService(options) {
|
|
646
|
+
return new SqliteLocalDataService(this.db, options, this.validateAdapterLifecycle);
|
|
647
|
+
}
|
|
648
|
+
createCoinMetadataCache() {
|
|
649
|
+
return new SqliteCoinMetadataCache(this.db);
|
|
650
|
+
}
|
|
651
|
+
createTransactionMaterialStore() {
|
|
652
|
+
return new SqliteTransactionMaterialStore(this.db);
|
|
653
|
+
}
|
|
654
|
+
createSessionRecordStore() {
|
|
655
|
+
return new SqliteSessionRecordStore(this.db, { usesActivityStoreLiveSessionMutations: true });
|
|
656
|
+
}
|
|
657
|
+
createCardRecordStore() {
|
|
658
|
+
return new SqliteCardRecordStore(this.db);
|
|
659
|
+
}
|
|
660
|
+
createPrivateReviewArtifactStore() {
|
|
661
|
+
return new SqlitePrivateReviewArtifactStore(this.db);
|
|
662
|
+
}
|
|
663
|
+
createWalletWorkflowStore(ownerId, clock = () => new Date()) {
|
|
664
|
+
const records = new SqliteWalletWorkflowStore(this.db, ownerId, (address, id, name, now) => this.setActiveAccountSync(address, "wallet_connection", now, { id, name }), clock, this.createTransactionMaterialStore(), (input, at) => this.evaluateWorkflowState(input, records, ownerId, at === undefined ? clock : () => at), () => this.getActiveAccountSync());
|
|
665
|
+
return records;
|
|
666
|
+
}
|
|
667
|
+
finalizeReviewEvaluation(candidate, clock) {
|
|
668
|
+
return this.db.transaction(() => this.finalizeReviewEvaluationAt(candidate, clock())).immediate();
|
|
669
|
+
}
|
|
670
|
+
finalizeReviewEvaluationAt(candidate, now) {
|
|
671
|
+
const row = this.liveReviewSessionRow(candidate.session.id);
|
|
672
|
+
if (!row)
|
|
673
|
+
throw new SessionStoreError("session_not_found", "The saved review is unavailable.");
|
|
674
|
+
const current = sessionFromLiveReviewSessionRow(row);
|
|
675
|
+
const artifacts = this.createPrivateReviewArtifactStore().get(current.id);
|
|
676
|
+
const admitted = this.createSessionRecordStore().hasAdmittedRevision(current.id, current.reviewRevision);
|
|
677
|
+
const material = !admitted && !current.preparationId && artifacts?.transactionMaterial
|
|
678
|
+
? this.createTransactionMaterialStore().getTransactionMaterial(artifacts.transactionMaterial, now) : undefined;
|
|
679
|
+
if (!admitted && !current.preparationId && artifacts && !material && !needsReviewMaterial(current, admitted)) {
|
|
680
|
+
this.createTransactionMaterialStore().deleteReviewSessionTransactionMaterials(current.id);
|
|
681
|
+
this.createPrivateReviewArtifactStore().delete(current.id);
|
|
682
|
+
}
|
|
683
|
+
const next = decideReviewEvaluation(candidate, current, row.revision, artifacts, material, admitted, now);
|
|
684
|
+
if (next === current)
|
|
685
|
+
return { session: current, events: [] };
|
|
686
|
+
const live = { expected: current, next, deleteTransactionMaterials: true };
|
|
687
|
+
const committed = next.status === "expired"
|
|
688
|
+
? this.recordReviewTransitionWithLiveSessionSync({ reviewSessionId: current.id, event: "expired", fromStatus: current.status,
|
|
689
|
+
toStatus: next.status, transitionedAt: now.toISOString() }, live)
|
|
690
|
+
: this.recordReviewStateSnapshotWithLiveSessionSync({ reviewSessionId: current.id, fromStatus: current.status,
|
|
691
|
+
state: next.reviewState, reviewRevision: next.reviewRevision, recordedAt: now.toISOString() }, live);
|
|
692
|
+
if (!committed)
|
|
693
|
+
throw new SessionStoreError("invalid_session_transition", "Review state changed before evaluation committed.");
|
|
694
|
+
return { session: next, events: next.status === "expired" ? [] : [{ type: "state.computed", sessionId: next.id,
|
|
695
|
+
status: next.status, reason: "private_review_artifacts_refresh_required", at: now.toISOString() }] };
|
|
696
|
+
}
|
|
697
|
+
evaluateWorkflowState(input, records, ownerId, clock) {
|
|
698
|
+
return this.db.transaction(() => {
|
|
699
|
+
const now = clock();
|
|
700
|
+
const cards = this.createCardRecordStore();
|
|
701
|
+
let record = input.expectedCard && records.currentCard(input.expectedCard);
|
|
702
|
+
const reviewId = record?.state.kind === "review" ? String(record.state.input.reviewSessionId) : input.reviewSessionId;
|
|
703
|
+
const target = reviewId ? records.reviewReadTarget(reviewId, record) : undefined;
|
|
704
|
+
if (input.readTarget && (!target || input.readTarget.reviewSessionId !== target.reviewSessionId ||
|
|
705
|
+
input.readTarget.attemptId !== target.attemptId || !input.readTarget.walletDependent && target.walletDependent)) {
|
|
706
|
+
throw new SessionStoreError("session_mismatch", "The review target changed while its state was being read. Check this review again.");
|
|
707
|
+
}
|
|
708
|
+
let evaluated;
|
|
709
|
+
if (reviewId) {
|
|
710
|
+
if (!input.candidate || input.candidate.session.id !== reviewId)
|
|
711
|
+
throw new SessionStoreError("session_not_found", "The saved review is unavailable.");
|
|
712
|
+
evaluated = this.finalizeReviewEvaluationAt(input.candidate, now);
|
|
713
|
+
if (input.uiObservation && record?.scope === "review")
|
|
714
|
+
records.markReviewOpened(reviewId, now);
|
|
715
|
+
}
|
|
716
|
+
if (!target || target.walletDependent) {
|
|
717
|
+
records.advanceRequestDeadlines(now);
|
|
718
|
+
records.expireConnections(now);
|
|
719
|
+
}
|
|
720
|
+
else
|
|
721
|
+
records.advanceRequestDeadlines(now, target.attemptId);
|
|
722
|
+
if (record)
|
|
723
|
+
record = cards.evaluate(record, () => now).record;
|
|
724
|
+
const session = evaluated?.session;
|
|
725
|
+
const request = target?.attemptId ? records.request(target.attemptId) : undefined;
|
|
726
|
+
const authority = request && records.authority(request.attemptId);
|
|
727
|
+
const details = request?.execution && records.executionDetails(request.attemptId);
|
|
728
|
+
const connection = record?.state.kind === "connect" && record.operationId ? records.connection(record.operationId) : undefined;
|
|
729
|
+
const storedAccount = this.getActiveAccountSync(), activeAccount = storedAccount?.address, targetAccount = session?.account ?? activeAccount;
|
|
730
|
+
const facts = { evaluatedAt: now.toISOString(), ownerId, record, session, request, authority,
|
|
731
|
+
hasReviewInput: !!session && session.status !== "expired" && Date.parse(session.expiresAt) > now.getTime() &&
|
|
732
|
+
cards.hasReviewInput(session.id, ownerId, now),
|
|
733
|
+
walletAvailability: input.walletAvailability, walletObservation: input.walletObservation,
|
|
734
|
+
runtimeRecovery: record && records.walletRecovery(record), activeAccount, activeAccountConnectionId: storedAccount?.walletId,
|
|
735
|
+
walletDependent: !target || target.walletDependent,
|
|
736
|
+
connections: records.connectionViews(),
|
|
737
|
+
connection: connection ? records.connectionView(connection.connection) : undefined,
|
|
738
|
+
boundReview: request && (record?.operationId || record?.scope === "review_manage") ? records.requestReview(request.attemptId) : undefined,
|
|
739
|
+
busyForAccount: !!targetAccount && records.busyForAccount(targetAccount, now),
|
|
740
|
+
receipt: details ? details.data : undefined, receiptDisplay: details ? details.receiptDisplay : undefined };
|
|
741
|
+
return { ...facts, ...workflowEligibility(facts), events: evaluated?.events ?? [] };
|
|
742
|
+
}).immediate();
|
|
743
|
+
}
|
|
744
|
+
createSettingsRecordStore() {
|
|
745
|
+
return createSqliteSettingsRecordStore(this.db);
|
|
746
|
+
}
|
|
747
|
+
runLiveReviewSessionMutation(live, writeActivity) {
|
|
748
|
+
const stale = new Error("live review session changed before commit");
|
|
749
|
+
try {
|
|
750
|
+
const commit = this.db.transaction(() => {
|
|
751
|
+
const current = this.liveReviewSessionRow(live.next.id);
|
|
752
|
+
if (live.expected ? !current || !isDeepStrictEqual(sessionFromLiveReviewSessionRow(current), live.expected) : !!current)
|
|
753
|
+
throw stale;
|
|
754
|
+
writeActivity();
|
|
755
|
+
if (!this.applyLiveReviewSessionMutation(live)) {
|
|
756
|
+
throw stale;
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
commit.immediate();
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (error === stale) {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
applyLiveReviewSessionMutation(live) {
|
|
770
|
+
if (!live.expected) {
|
|
771
|
+
if (this.liveReviewSessionRow(live.next.id)) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
insertLiveReviewSessionRow(this.db, live.next);
|
|
775
|
+
this.applyLiveReviewSessionSideEffects(live);
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
const row = this.liveReviewSessionRow(live.next.id);
|
|
779
|
+
if (!row || !isDeepStrictEqual(sessionFromLiveReviewSessionRow(row), live.expected)) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
if (!updateLiveReviewSessionRow(this.db, row.revision, live.next)) {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
this.applyLiveReviewSessionSideEffects(live);
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
applyLiveReviewSessionSideEffects(live) {
|
|
789
|
+
this.db.prepare(`UPDATE live_read_cards SET revision=revision+1 WHERE kind='review' AND state='ready'
|
|
790
|
+
AND json_extract(input_json,'$.reviewSessionId')=?`).run(live.next.id);
|
|
791
|
+
if (live.deleteTransactionMaterials) {
|
|
792
|
+
this.db.prepare(`DELETE FROM live_transaction_materials WHERE review_session_id = ?`).run(live.next.id);
|
|
793
|
+
this.db.prepare(`DELETE FROM live_private_review_artifacts WHERE review_session_id = ?`).run(live.next.id);
|
|
794
|
+
}
|
|
795
|
+
if (live.privateArtifactsJson === null) {
|
|
796
|
+
this.db.prepare(`DELETE FROM live_private_review_artifacts WHERE review_session_id = ?`).run(live.next.id);
|
|
797
|
+
}
|
|
798
|
+
else if (live.privateArtifactsJson !== undefined) {
|
|
799
|
+
this.db
|
|
800
|
+
.prepare(`INSERT INTO live_private_review_artifacts (review_session_id, artifacts_json)
|
|
801
|
+
VALUES (?, ?)
|
|
802
|
+
ON CONFLICT(review_session_id) DO UPDATE SET artifacts_json = excluded.artifacts_json`)
|
|
803
|
+
.run(live.next.id, live.privateArtifactsJson);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
liveReviewSessionRow(id) {
|
|
807
|
+
return this.db
|
|
808
|
+
.prepare(`SELECT * FROM live_review_sessions WHERE id = ?`)
|
|
809
|
+
.get(id);
|
|
810
|
+
}
|
|
811
|
+
upsertAccountSync(address, source, timestamp) {
|
|
812
|
+
const normalized = parseSuiAddress(address);
|
|
813
|
+
if (!normalized) {
|
|
814
|
+
throw new ActivityStoreError("Invalid Sui account address");
|
|
815
|
+
}
|
|
816
|
+
this.db
|
|
817
|
+
.prepare(`INSERT INTO accounts (sui_address, first_seen_at, last_used_at, first_source, last_source)
|
|
818
|
+
VALUES (?, ?, ?, ?, ?)
|
|
819
|
+
ON CONFLICT(sui_address) DO UPDATE SET
|
|
820
|
+
last_used_at = excluded.last_used_at,
|
|
821
|
+
last_source = excluded.last_source`)
|
|
822
|
+
.run(normalized, timestamp, timestamp, source, source);
|
|
823
|
+
const row = this.db
|
|
824
|
+
.prepare(`SELECT id, sui_address, first_seen_at, last_used_at, first_source, last_source
|
|
825
|
+
FROM accounts
|
|
826
|
+
WHERE sui_address = ?`)
|
|
827
|
+
.get(normalized);
|
|
828
|
+
if (!row) {
|
|
829
|
+
throw new ActivityStoreError(`Account was not recorded: ${normalized}`);
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
id: row.id,
|
|
833
|
+
address: asString(row.sui_address),
|
|
834
|
+
firstSeenAt: asString(row.first_seen_at),
|
|
835
|
+
lastUsedAt: asString(row.last_used_at),
|
|
836
|
+
firstSource: asAccountSource(row.first_source),
|
|
837
|
+
lastSource: asAccountSource(row.last_source)
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
parseReviewStateEvidenceJson(value, reviewSessionId, evidenceField) {
|
|
841
|
+
const parsed = parseEvidenceJson(value, reviewSessionId, evidenceField);
|
|
842
|
+
try {
|
|
843
|
+
return parseLifecycleValidatedReviewState(parsed, this.validateAdapterLifecycle);
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
throw new ActivityStoreReadError("internal_error", "Malformed activity JSON evidence", {
|
|
847
|
+
reviewSessionId,
|
|
848
|
+
evidenceField
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
getAccountByAddressSync(address) {
|
|
853
|
+
const row = this.db
|
|
854
|
+
.prepare(`SELECT id, sui_address, first_seen_at, last_used_at, first_source, last_source
|
|
855
|
+
FROM accounts
|
|
856
|
+
WHERE sui_address = ?`)
|
|
857
|
+
.get(address);
|
|
858
|
+
return row
|
|
859
|
+
? {
|
|
860
|
+
id: row.id,
|
|
861
|
+
address: asString(row.sui_address),
|
|
862
|
+
firstSeenAt: asString(row.first_seen_at),
|
|
863
|
+
lastUsedAt: asString(row.last_used_at),
|
|
864
|
+
firstSource: asAccountSource(row.first_source),
|
|
865
|
+
lastSource: asAccountSource(row.last_source)
|
|
866
|
+
}
|
|
867
|
+
: undefined;
|
|
868
|
+
}
|
|
869
|
+
accountIdExists(accountId) {
|
|
870
|
+
const row = this.db.prepare("SELECT id FROM accounts WHERE id = ?").get(accountId);
|
|
871
|
+
return row !== undefined;
|
|
872
|
+
}
|
|
873
|
+
externalActivityScanById(scanId) {
|
|
874
|
+
const row = this.db
|
|
875
|
+
.prepare(`SELECT eas.scan_id, eas.kind, eas.account_id, a.sui_address AS account, eas.relationship,
|
|
876
|
+
eas.input_digest, eas.from_checkpoint, eas.to_checkpoint, eas.from_timestamp,
|
|
877
|
+
eas.to_timestamp, eas.limit_count, eas.request_cursor, eas.response_cursor,
|
|
878
|
+
eas.endpoint_host, eas.chain_identifier, eas.fetched_at, eas.stored_count,
|
|
879
|
+
eas.skipped_count, eas.has_more, eas.window_complete, eas.incomplete_reason
|
|
880
|
+
FROM external_activity_scans eas
|
|
881
|
+
JOIN accounts a ON a.id = eas.account_id
|
|
882
|
+
WHERE eas.scan_id = ?`)
|
|
883
|
+
.get(scanId);
|
|
884
|
+
return row ? externalActivityScanFromRow(row) : undefined;
|
|
885
|
+
}
|
|
886
|
+
externalActivityTransactions(whereSql, params) {
|
|
887
|
+
const rows = this.db
|
|
888
|
+
.prepare(`SELECT eat.account_id, a.sui_address AS account, eat.digest, eat.relationship,
|
|
889
|
+
eat.checkpoint, eat.timestamp, eat.status, eat.known_sender_account_id,
|
|
890
|
+
eat.first_scan_id, eat.last_scan_id, eat.first_fetched_at, eat.last_fetched_at,
|
|
891
|
+
last_scan.incomplete_reason AS last_scan_incomplete_reason,
|
|
892
|
+
eat.detail_json
|
|
893
|
+
FROM external_activity_transactions eat
|
|
894
|
+
JOIN accounts a ON a.id = eat.account_id
|
|
895
|
+
LEFT JOIN external_activity_scans last_scan ON last_scan.scan_id = eat.last_scan_id
|
|
896
|
+
${whereSql}
|
|
897
|
+
ORDER BY
|
|
898
|
+
CASE WHEN eat.checkpoint IS NULL THEN 0 ELSE 1 END DESC,
|
|
899
|
+
CAST(eat.checkpoint AS INTEGER) DESC,
|
|
900
|
+
COALESCE(eat.timestamp, '') DESC,
|
|
901
|
+
eat.digest DESC`)
|
|
902
|
+
.all(...params);
|
|
903
|
+
return rows.map(externalActivityTransactionFromRow);
|
|
904
|
+
}
|
|
905
|
+
insertReviewTransition(input) {
|
|
906
|
+
this.db
|
|
907
|
+
.prepare(`INSERT INTO review_status_transitions
|
|
908
|
+
(review_session_id, event, from_status, to_status, account_id, reason, transitioned_at)
|
|
909
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
910
|
+
.run(input.reviewSessionId, input.event, input.fromStatus ?? null, input.toStatus, input.accountId ?? null, input.reason ?? null, input.transitionedAt);
|
|
911
|
+
}
|
|
912
|
+
resolveReviewActivityScope(filter) {
|
|
913
|
+
if (filter.account) {
|
|
914
|
+
const normalized = parseSuiAddress(filter.account);
|
|
915
|
+
if (!normalized) {
|
|
916
|
+
throw new ActivityStoreReadError("input_invalid", "Invalid account filter", { field: "account" });
|
|
917
|
+
}
|
|
918
|
+
const row = this.db
|
|
919
|
+
.prepare("SELECT id FROM accounts WHERE sui_address = ?")
|
|
920
|
+
.get(normalized);
|
|
921
|
+
return {
|
|
922
|
+
account: normalized,
|
|
923
|
+
accountId: row?.id,
|
|
924
|
+
accountSource: "explicit_filter"
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const active = this.getActiveAccountSync();
|
|
928
|
+
if (!active) {
|
|
929
|
+
throw new ActivityStoreReadError("active_account_not_set", "Active account read context is not set", {
|
|
930
|
+
action: "connect_wallet_connection"
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
account: active.address,
|
|
935
|
+
accountId: active.accountId,
|
|
936
|
+
accountSource: "active_account_context"
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
getActiveAccountSync() {
|
|
940
|
+
const row = this.db
|
|
941
|
+
.prepare(`SELECT a.id AS account_id, a.sui_address AS address, c.source AS source, c.set_at AS set_at,
|
|
942
|
+
c.wallet_name AS wallet_name, c.wallet_id AS wallet_id
|
|
943
|
+
FROM active_account_context c
|
|
944
|
+
JOIN accounts a ON a.id = c.account_id
|
|
945
|
+
WHERE c.id = ? AND c.account_id IS NOT NULL`)
|
|
946
|
+
.get(ACTIVE_ACCOUNT_SINGLETON_ID);
|
|
947
|
+
return row
|
|
948
|
+
? {
|
|
949
|
+
accountId: row.account_id,
|
|
950
|
+
address: asString(row.address),
|
|
951
|
+
source: "wallet_connection",
|
|
952
|
+
setAt: asString(row.set_at),
|
|
953
|
+
...(row.wallet_name ? { walletName: row.wallet_name } : {}),
|
|
954
|
+
...(row.wallet_id ? { walletId: row.wallet_id } : {})
|
|
955
|
+
}
|
|
956
|
+
: undefined;
|
|
957
|
+
}
|
|
958
|
+
assertReviewSessionAccount(reviewSessionId, accountId) {
|
|
959
|
+
const row = this.db
|
|
960
|
+
.prepare("SELECT account_id FROM review_sessions WHERE id = ?")
|
|
961
|
+
.get(reviewSessionId);
|
|
962
|
+
if (!row) {
|
|
963
|
+
throw new ActivityStoreError(`Review session not found: ${reviewSessionId}`);
|
|
964
|
+
}
|
|
965
|
+
if (row.account_id !== null && row.account_id !== accountId) {
|
|
966
|
+
throw new ActivityStoreError(`Review session already belongs to a different account: ${reviewSessionId}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
assertActiveAccountSync(accountId, reviewSessionId) {
|
|
970
|
+
const row = this.db
|
|
971
|
+
.prepare("SELECT account_id FROM active_account_context WHERE id = ? AND source = 'wallet_connection'")
|
|
972
|
+
.get(ACTIVE_ACCOUNT_SINGLETON_ID);
|
|
973
|
+
if (!row || row.account_id !== accountId) {
|
|
974
|
+
throw new ActivityStoreError(`Review session active account changed before commit: ${reviewSessionId}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
function assertNonEmptyHalfOpenRange(from, to) {
|
|
979
|
+
if (from >= to) {
|
|
980
|
+
throw new ActivityStoreReadError("input_invalid", "from must be before to for a non-empty half-open range", {
|
|
981
|
+
from,
|
|
982
|
+
to
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function externalActivityTransactionRangeFromRecords(rows) {
|
|
987
|
+
const timestamps = rows.flatMap((row) => row.timestamp ?? []).sort();
|
|
988
|
+
const checkpoints = rows.flatMap((row) => row.checkpoint ?? []).sort(compareIntegerStrings);
|
|
989
|
+
return {
|
|
990
|
+
earliestTimestamp: timestamps[0],
|
|
991
|
+
latestTimestamp: timestamps.at(-1),
|
|
992
|
+
earliestCheckpoint: checkpoints[0],
|
|
993
|
+
latestCheckpoint: checkpoints.at(-1)
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function compareIntegerStrings(a, b) {
|
|
997
|
+
const delta = BigInt(a) - BigInt(b);
|
|
998
|
+
if (delta < 0n)
|
|
999
|
+
return -1;
|
|
1000
|
+
if (delta > 0n)
|
|
1001
|
+
return 1;
|
|
1002
|
+
return 0;
|
|
1003
|
+
}
|
|
1004
|
+
class SqliteCoinMetadataCache {
|
|
1005
|
+
db;
|
|
1006
|
+
constructor(db) {
|
|
1007
|
+
this.db = db;
|
|
1008
|
+
}
|
|
1009
|
+
async getCoinMetadata(input) {
|
|
1010
|
+
const row = this.db
|
|
1011
|
+
.prepare(`SELECT coin_type, chain_identifier, decimals, symbol, name, fetched_at, expires_at
|
|
1012
|
+
FROM coin_metadata_cache
|
|
1013
|
+
WHERE coin_type = ? AND chain_identifier = ?`)
|
|
1014
|
+
.get(input.coinType, input.chainIdentifier);
|
|
1015
|
+
if (!row) {
|
|
1016
|
+
return { status: "miss" };
|
|
1017
|
+
}
|
|
1018
|
+
const record = coinMetadataCacheRecordFromRow(row);
|
|
1019
|
+
return record.expiresAt > input.now.toISOString()
|
|
1020
|
+
? { status: "hit", record }
|
|
1021
|
+
: { status: "expired", record };
|
|
1022
|
+
}
|
|
1023
|
+
async setCoinMetadata(record) {
|
|
1024
|
+
this.db
|
|
1025
|
+
.prepare(`INSERT INTO coin_metadata_cache
|
|
1026
|
+
(coin_type, chain_identifier, decimals, symbol, name, fetched_at, expires_at)
|
|
1027
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1028
|
+
ON CONFLICT(coin_type, chain_identifier) DO UPDATE SET
|
|
1029
|
+
decimals = excluded.decimals,
|
|
1030
|
+
symbol = excluded.symbol,
|
|
1031
|
+
name = excluded.name,
|
|
1032
|
+
fetched_at = excluded.fetched_at,
|
|
1033
|
+
expires_at = excluded.expires_at`)
|
|
1034
|
+
.run(record.coinType, record.chainIdentifier, record.decimals, record.symbol, record.name, record.fetchedAt, record.expiresAt);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
export function resolveActivityDatabasePath(env = process.env, platform = process.platform) {
|
|
1038
|
+
const configured = env[DATA_DIR_ENV];
|
|
1039
|
+
const dataDir = configured && configured.trim() ? configured : defaultDataDir(env, platform);
|
|
1040
|
+
if (dataDir.includes("\0")) {
|
|
1041
|
+
throw new ActivityStoreError(`${DATA_DIR_ENV} must not contain null bytes`);
|
|
1042
|
+
}
|
|
1043
|
+
return resolve(dataDir, ACTIVITY_DATABASE_FILENAME);
|
|
1044
|
+
}
|
|
1045
|
+
export function assertSqliteEngineAvailable() {
|
|
1046
|
+
const db = new Database(":memory:");
|
|
1047
|
+
try {
|
|
1048
|
+
db.exec("CREATE TABLE engine_check (id INTEGER PRIMARY KEY)");
|
|
1049
|
+
db.prepare("INSERT INTO engine_check (id) VALUES (?)").run(1);
|
|
1050
|
+
const row = db.prepare("SELECT id FROM engine_check").get();
|
|
1051
|
+
if (row?.id !== 1) {
|
|
1052
|
+
throw new ActivityStoreError("better-sqlite3 smoke query failed");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
finally {
|
|
1056
|
+
db.close();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function defaultDataDir(env, platform) {
|
|
1060
|
+
const home = homedir();
|
|
1061
|
+
if (platform === "darwin") {
|
|
1062
|
+
return resolve(home, "Library", "Application Support", "sui-mcp");
|
|
1063
|
+
}
|
|
1064
|
+
if (platform === "win32") {
|
|
1065
|
+
return resolve(env.APPDATA ?? resolve(home, "AppData", "Roaming"), "sui-mcp");
|
|
1066
|
+
}
|
|
1067
|
+
return resolve(env.XDG_DATA_HOME ?? resolve(home, ".local", "share"), "sui-mcp");
|
|
1068
|
+
}
|