@ixo/oracle-runtime-workers 0.22.0 → 0.23.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/README.md +15 -13
- package/package.json +10 -4
- package/src/action-proposals/policy.ts +54 -0
- package/src/action-proposals/reconcile.ts +87 -0
- package/src/action-proposals/store.ts +863 -0
- package/src/action-proposals/topic-budget.ts +84 -0
- package/src/action-proposals/topic-chain.ts +272 -0
- package/src/action-proposals/topic-effects.ts +44 -0
- package/src/action-proposals/topic-errors.ts +79 -0
- package/src/action-proposals/topic-history.ts +267 -0
- package/src/action-proposals/topic-matrix.ts +292 -0
- package/src/action-proposals/topic-prepare.ts +299 -0
- package/src/action-proposals/topic-store.ts +999 -0
- package/src/action-proposals/topic-verify.ts +279 -0
- package/src/action-proposals/verify.ts +223 -0
- package/src/channels/auth.ts +6 -1
- package/src/channels/contract.ts +4 -0
- package/src/channels/turns.ts +1 -0
- package/src/core/env.ts +89 -5
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +2 -0
- package/src/core/runtime-context.ts +12 -0
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +39 -0
- package/src/do/user-oracle-do.ts +1253 -7
- package/src/hub-actions/client.ts +163 -0
- package/src/hub-actions/request.ts +110 -0
- package/src/hub-actions/store.ts +485 -0
- package/src/matrix/gateway-do.ts +176 -0
- package/src/matrix/reply-parts.ts +8 -2
- package/src/plugin-api/types.ts +54 -0
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/shell/app.ts +195 -1
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
|
+
import { digestTopicProposalEffect } from '../action-proposals/topic-store';
|
|
2
|
+
import {
|
|
3
|
+
chainIdsFromConfig,
|
|
4
|
+
chainIdConfigSchema,
|
|
5
|
+
} from '../plugins/ixo-transaction/chain-ids';
|
|
6
|
+
import { TopicProposalStore } from '../action-proposals/topic-store';
|
|
7
|
+
import {
|
|
8
|
+
prepareTopicSubject,
|
|
9
|
+
type TopicCandidate,
|
|
10
|
+
} from '../action-proposals/topic-prepare';
|
|
11
|
+
import {
|
|
12
|
+
assertTopicDependencies,
|
|
13
|
+
reconcileTopicReferences,
|
|
14
|
+
type TopicVerificationContext,
|
|
15
|
+
} from '../action-proposals/topic-verify';
|
|
16
|
+
import { topicExpectedEffect } from '../action-proposals/topic-effects';
|
|
17
|
+
import {
|
|
18
|
+
topicActionOutcome,
|
|
19
|
+
type ActionProposalWire,
|
|
20
|
+
type TopicActionProposal,
|
|
21
|
+
type TopicProposalSubject,
|
|
22
|
+
type BeginTopicStep,
|
|
23
|
+
type TopicReceiptReferences,
|
|
24
|
+
} from '@ixo/ixo-transaction/topic-proposals';
|
|
25
|
+
import type { ChannelTurnResponse } from '../channels/contract';
|
|
26
|
+
import { refreshActionProposal } from '../action-proposals/reconcile';
|
|
27
|
+
import { assertActionProposalPolicy } from '../action-proposals/policy';
|
|
28
|
+
import { TopicReadUnavailable } from '../action-proposals/topic-errors';
|
|
29
|
+
import { actionOutcome } from '@ixo/ixo-transaction/action-proposals';
|
|
30
|
+
import {
|
|
31
|
+
ActionProposalConflict,
|
|
32
|
+
ActionProposalStore,
|
|
33
|
+
type ProposalCommand,
|
|
34
|
+
} from '../action-proposals/store';
|
|
35
|
+
import type {
|
|
36
|
+
ActionProposal,
|
|
37
|
+
ActionProposalSubject,
|
|
38
|
+
} from '@ixo/ixo-transaction/action-proposals';
|
|
1
39
|
import { ChannelTurns } from '../channels/turns';
|
|
2
|
-
import {
|
|
40
|
+
import { HubActionClient } from '../hub-actions/client';
|
|
41
|
+
import { prepareHubAction } from '../hub-actions/request';
|
|
42
|
+
import {
|
|
43
|
+
HUB_CHECK_AFTER_EXPIRY_MS,
|
|
44
|
+
HubActionRefused,
|
|
45
|
+
HubActionStore,
|
|
46
|
+
TERMINAL_HUB_STATUSES,
|
|
47
|
+
} from '../hub-actions/store';
|
|
48
|
+
import {
|
|
49
|
+
canonicalJson,
|
|
50
|
+
type ActionCapabilitiesV1,
|
|
51
|
+
type HubActionOptionsV2,
|
|
52
|
+
type ActionOperationV1,
|
|
53
|
+
type ActionOutcomeV1,
|
|
54
|
+
} from '@ixo/action-protocol';
|
|
55
|
+
import {
|
|
56
|
+
assertActiveChannelBinding,
|
|
57
|
+
assertChannelAttemptAllowed,
|
|
58
|
+
} from '../channels/auth';
|
|
3
59
|
import {
|
|
4
60
|
CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
5
61
|
ChannelError,
|
|
@@ -348,6 +404,59 @@ import {
|
|
|
348
404
|
} from './ucan-service';
|
|
349
405
|
import type { UcanDelegation, UserContextData } from '../plugin-api/types';
|
|
350
406
|
|
|
407
|
+
/**
|
|
408
|
+
* The gateway's topic read errors that describe what the homeserver
|
|
409
|
+
* returned rather than a read that failed (`MatrixGatewayDO.getTopicEvent`
|
|
410
|
+
* / `getTopicHistory`).
|
|
411
|
+
*/
|
|
412
|
+
const GATEWAY_TOPIC_FACTS = new Set([
|
|
413
|
+
'Topic event room binding mismatch',
|
|
414
|
+
'Topic history event room binding mismatch',
|
|
415
|
+
'Topic history event has no ID',
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The transfer card store failed to open for this owner: every transfer
|
|
420
|
+
* route is told to retry later, with the reason in the log.
|
|
421
|
+
*/
|
|
422
|
+
function storeUnavailable(id: string): {
|
|
423
|
+
ok: false;
|
|
424
|
+
kind: 'unavailable';
|
|
425
|
+
error: string;
|
|
426
|
+
} {
|
|
427
|
+
console.warn(`[action-proposals] ${id} refused: card store unavailable`);
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
kind: 'unavailable',
|
|
431
|
+
error: 'Action cards are unavailable',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A topic command's refusal, for the shell: a `conflict` is the card or an
|
|
437
|
+
* authority it was sealed on having changed (the Portal re-reads and
|
|
438
|
+
* retries), `unavailable` is a room, DID or chain read that failed and may
|
|
439
|
+
* succeed later. Anything else is a bug and surfaces as one.
|
|
440
|
+
*/
|
|
441
|
+
function topicRefusal(
|
|
442
|
+
error: unknown,
|
|
443
|
+
id: string,
|
|
444
|
+
): { ok: false; kind: 'conflict' | 'unavailable'; error: string } {
|
|
445
|
+
if (error instanceof ActionProposalConflict)
|
|
446
|
+
return { ok: false, kind: 'conflict', error: error.message };
|
|
447
|
+
if (error instanceof TopicReadUnavailable) {
|
|
448
|
+
console.warn(
|
|
449
|
+
`[topic-actions] ${id} authority unavailable: ${error.message}`,
|
|
450
|
+
);
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
kind: 'unavailable',
|
|
454
|
+
error: 'Topic authority is unavailable',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
|
|
351
460
|
/** What one attempt's agent build reads from the request body. */
|
|
352
461
|
type TurnBuildBody = Pick<
|
|
353
462
|
TurnBody,
|
|
@@ -476,6 +585,22 @@ const ACCESS_PERSIST_INTERVAL_MS = 60 * 60_000;
|
|
|
476
585
|
const TITLE_TIMEOUT_MS = 15_000;
|
|
477
586
|
/** A new sweep of expired ambient blobs starts at most this often per instance. */
|
|
478
587
|
const BLOB_SWEEP_INTERVAL_MS = 60 * 60_000;
|
|
588
|
+
/**
|
|
589
|
+
* Wall time the alarm's action-card tick may spend on chain lookups (two at
|
|
590
|
+
* most, run together); an abort counts as an RPC outage (the card stays
|
|
591
|
+
* `unknown`, its attempt spent).
|
|
592
|
+
*/
|
|
593
|
+
const ACTION_TICK_BUDGET_MS = 15_000;
|
|
594
|
+
/** The card retention sweep runs at most this often per instance. */
|
|
595
|
+
const ACTION_PRUNE_INTERVAL_MS = 60 * 60_000;
|
|
596
|
+
/** How long the Hub's capabilities answer is reused per object. */
|
|
597
|
+
const HUB_CAPABILITIES_CACHE_MS = 60_000;
|
|
598
|
+
/** How long a `hub_action_options` answer vouches for a Gmail connection. */
|
|
599
|
+
const HUB_OPTIONS_REUSE_MS = 10 * 60_000;
|
|
600
|
+
/** A channel poll waits this long for Auth Hub, once per poll at most. */
|
|
601
|
+
const HUB_POLL_TIMEOUT_MS = 3_000;
|
|
602
|
+
/** How long a passed topic authority read is reused for the same card. */
|
|
603
|
+
const TOPIC_AUTHORITY_MEMO_MS = 30_000;
|
|
479
604
|
/** The Blocksync lookup of the user's homeserver is abandoned after this long (the fallback applies). */
|
|
480
605
|
const HOMESERVER_LOOKUP_TIMEOUT_MS = 3_000;
|
|
481
606
|
/** Set once the legacy Matrix copy has been removed (or found absent): no more lookups. */
|
|
@@ -864,6 +989,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
864
989
|
private resultStore: ResultStore | null = null;
|
|
865
990
|
/** Chat delivery's artefacts; null when no bucket is bound (see `canShare` for new ones). */
|
|
866
991
|
private artifacts: ArtifactStore | null = null;
|
|
992
|
+
private actionProposals: ActionProposalStore | null = null;
|
|
993
|
+
private hubActionStore: HubActionStore | null = null;
|
|
994
|
+
private hubActionClient: HubActionClient | null = null;
|
|
995
|
+
/** The Hub's capabilities for this oracle account, reused for `HUB_CAPABILITIES_CACHE_MS`. */
|
|
996
|
+
private hubCapabilities: {
|
|
997
|
+
value: ActionCapabilitiesV1;
|
|
998
|
+
at: number;
|
|
999
|
+
} | null = null;
|
|
1000
|
+
/** The last `hub_action_options` answer for this owner (Gmail connection checks). */
|
|
1001
|
+
private lastHubOptions: { value: HubActionOptionsV2; at: number } | null =
|
|
1002
|
+
null;
|
|
1003
|
+
private topicProposals: TopicProposalStore | null = null;
|
|
1004
|
+
/** `assertTopicDependenciesMemo`: card id and digest -> when it passed. */
|
|
1005
|
+
private topicAuthorityMemo: Map<string, number> | undefined;
|
|
1006
|
+
/** When this instance last ran the card retention sweep (at most hourly). */
|
|
1007
|
+
private lastActionPruneAt: number | null = null;
|
|
867
1008
|
/** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
|
|
868
1009
|
private attachmentTextCache: AttachmentTextCacheStore | null = null;
|
|
869
1010
|
/**
|
|
@@ -1767,6 +1908,48 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1767
1908
|
oracleName: this.core.identity.name,
|
|
1768
1909
|
});
|
|
1769
1910
|
await this.saver.setup();
|
|
1911
|
+
if (this.env.HUB_ACTIONS_ENABLED === 'true') {
|
|
1912
|
+
try {
|
|
1913
|
+
if (!this.env.AUTH_HUB_URL || !this.env.AUTH_HUB_CHANNEL_SERVICE_KEY)
|
|
1914
|
+
throw new Error('Hub URL and oracle service key are required');
|
|
1915
|
+
const store = new HubActionStore(liveDb);
|
|
1916
|
+
await store.setup();
|
|
1917
|
+
this.hubActionStore = store;
|
|
1918
|
+
this.hubActionClient = new HubActionClient({
|
|
1919
|
+
origin: this.env.AUTH_HUB_URL,
|
|
1920
|
+
key: this.env.AUTH_HUB_CHANNEL_SERVICE_KEY,
|
|
1921
|
+
fetcher: this.env.AUTH_HUB,
|
|
1922
|
+
});
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
this.hubActionStore = null;
|
|
1925
|
+
this.hubActionClient = null;
|
|
1926
|
+
console.error(
|
|
1927
|
+
`[hub-actions] unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
// A card store this build cannot open must not take the user's whole
|
|
1932
|
+
// object down: the feature is unavailable for this user, the rest works.
|
|
1933
|
+
try {
|
|
1934
|
+
const actionProposals = new ActionProposalStore(liveDb);
|
|
1935
|
+
await actionProposals.setup();
|
|
1936
|
+
this.actionProposals = actionProposals;
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
this.actionProposals = null;
|
|
1939
|
+
console.error(
|
|
1940
|
+
`[action-proposals] store unavailable for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
try {
|
|
1944
|
+
const topicProposals = new TopicProposalStore(liveDb);
|
|
1945
|
+
await topicProposals.setup();
|
|
1946
|
+
this.topicProposals = topicProposals;
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
this.topicProposals = null;
|
|
1949
|
+
console.error(
|
|
1950
|
+
`[topic-actions] store unavailable for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1770
1953
|
this.sessions = new SessionsStore(liveDb);
|
|
1771
1954
|
await this.sessions.setup();
|
|
1772
1955
|
this.matrixLedger = new MatrixTurnLedger(liveDb);
|
|
@@ -1984,6 +2167,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1984
2167
|
logger: console,
|
|
1985
2168
|
});
|
|
1986
2169
|
this.ambient = createAmbientServices({
|
|
2170
|
+
...(this.hubActionStore && this.hubActionClient
|
|
2171
|
+
? {
|
|
2172
|
+
hubActions: {
|
|
2173
|
+
capabilities: () => this.cachedHubCapabilities(),
|
|
2174
|
+
options: (input) => this.hubActionOptions(input),
|
|
2175
|
+
request: (input: {
|
|
2176
|
+
ownerDid: string;
|
|
2177
|
+
sessionId: string;
|
|
2178
|
+
runId: string;
|
|
2179
|
+
requestId: string;
|
|
2180
|
+
provider: string;
|
|
2181
|
+
bindingId: string;
|
|
2182
|
+
bindingRevision: number;
|
|
2183
|
+
requestKey: string;
|
|
2184
|
+
operation: ActionOperationV1;
|
|
2185
|
+
}) => this.requestHubAction(input),
|
|
2186
|
+
status: (ownerDid: string, actionId: string) =>
|
|
2187
|
+
this.statusHubAction(ownerDid, actionId),
|
|
2188
|
+
},
|
|
2189
|
+
}
|
|
2190
|
+
: {}),
|
|
2191
|
+
actionProposals: {
|
|
2192
|
+
create: (subject) => this.prepareActionProposal(subject),
|
|
2193
|
+
prepareTopic: (input) => this.prepareTopicProposal(input),
|
|
2194
|
+
},
|
|
1987
2195
|
...(this.taskScheduler ? { tasks: this.taskScheduler.surface } : {}),
|
|
1988
2196
|
preferences: this.preferences,
|
|
1989
2197
|
kv: new SqliteUserKv(liveDb),
|
|
@@ -2061,6 +2269,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2061
2269
|
this.runs = null;
|
|
2062
2270
|
this.resultStore = null;
|
|
2063
2271
|
this.artifacts = null;
|
|
2272
|
+
this.actionProposals = null;
|
|
2273
|
+
this.hubActionStore = null;
|
|
2274
|
+
this.hubActionClient = null;
|
|
2275
|
+
this.hubCapabilities = null;
|
|
2276
|
+
this.lastHubOptions = null;
|
|
2277
|
+
this.topicProposals = null;
|
|
2064
2278
|
this.attachmentTextCache = null;
|
|
2065
2279
|
this.domainContextStore = null;
|
|
2066
2280
|
// Its store is bound to the database just dropped: a caller that sees
|
|
@@ -2468,6 +2682,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2468
2682
|
}
|
|
2469
2683
|
}
|
|
2470
2684
|
|
|
2685
|
+
// Action cards: look up recorded transactions the chain has not
|
|
2686
|
+
// confirmed yet, close cards claimed and never executed, post the
|
|
2687
|
+
// receipts that are due, drop old cards. It runs after the flush and
|
|
2688
|
+
// the task scheduler (a slow chain RPC must not delay a user's task)
|
|
2689
|
+
// under one wall budget, and before the eviction check (its deadline
|
|
2690
|
+
// keeps the copy). One bad row must not stop what follows, so the tick
|
|
2691
|
+
// is guarded like its neighbours.
|
|
2692
|
+
try {
|
|
2693
|
+
const actionAt = await this.tickActionProposals();
|
|
2694
|
+
if (actionAt !== undefined)
|
|
2695
|
+
deadlines.push(Math.max(actionAt, now + 1000));
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
console.error(
|
|
2698
|
+
`[user-do] action proposal tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2699
|
+
);
|
|
2700
|
+
deadlines.push(now + 60_000);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2471
2703
|
// Legacy-blob compaction: one bounded batch per tick until the whole
|
|
2472
2704
|
// file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
|
|
2473
2705
|
// flight holds a snapshot of the file, which the compactor's swap-in
|
|
@@ -2852,7 +3084,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2852
3084
|
}
|
|
2853
3085
|
return {
|
|
2854
3086
|
ok: true,
|
|
2855
|
-
result: await this.
|
|
3087
|
+
result: await this.withActionOutcomes(
|
|
3088
|
+
identity.userDid,
|
|
3089
|
+
await this.channelTurns!.submit(identity, input, requestHash),
|
|
3090
|
+
),
|
|
2856
3091
|
};
|
|
2857
3092
|
} catch (error) {
|
|
2858
3093
|
// A failure retrying cannot fix (the room refused the oracle, the
|
|
@@ -2894,7 +3129,363 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2894
3129
|
const result = await this.channelTurns.poll(identity, input, requestHash);
|
|
2895
3130
|
if (!result) return null;
|
|
2896
3131
|
await this.recordAccess();
|
|
2897
|
-
return {
|
|
3132
|
+
return {
|
|
3133
|
+
ok: true,
|
|
3134
|
+
result: await this.withActionOutcomes(identity.userDid, result),
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
private async withActionOutcomes(
|
|
3139
|
+
ownerDid: string,
|
|
3140
|
+
result: ChannelTurnResponse,
|
|
3141
|
+
): Promise<ChannelTurnResponse> {
|
|
3142
|
+
if (result.status !== 'finished') return result;
|
|
3143
|
+
const actionOutcomes = [];
|
|
3144
|
+
let attempted = false;
|
|
3145
|
+
// Each store on its own: the other one failing to open must not hide
|
|
3146
|
+
// this one's cards from the poll.
|
|
3147
|
+
const store = this.actionProposals;
|
|
3148
|
+
if (store)
|
|
3149
|
+
for (const proposal of await store.forRun(
|
|
3150
|
+
ownerDid,
|
|
3151
|
+
result.sessionId,
|
|
3152
|
+
result.runId,
|
|
3153
|
+
)) {
|
|
3154
|
+
const refreshed: Awaited<ReturnType<typeof refreshActionProposal>> =
|
|
3155
|
+
attempted
|
|
3156
|
+
? { proposal, attempted: false }
|
|
3157
|
+
: await refreshActionProposal(
|
|
3158
|
+
store,
|
|
3159
|
+
proposal,
|
|
3160
|
+
this.actionProposalRpc(proposal),
|
|
3161
|
+
);
|
|
3162
|
+
attempted ||= refreshed.attempted;
|
|
3163
|
+
actionOutcomes.push(actionOutcome(refreshed.proposal));
|
|
3164
|
+
}
|
|
3165
|
+
const topics = this.topicProposals;
|
|
3166
|
+
if (topics) {
|
|
3167
|
+
let cards: TopicActionProposal[] = [];
|
|
3168
|
+
try {
|
|
3169
|
+
cards = await topics.forRun(ownerDid, result.sessionId, result.runId);
|
|
3170
|
+
} catch (error) {
|
|
3171
|
+
console.error(
|
|
3172
|
+
`[topic-actions] cards of run ${result.runId} unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
3175
|
+
for (const proposal of cards) {
|
|
3176
|
+
let refreshed = proposal;
|
|
3177
|
+
// A card whose reference row this build cannot read is reported as
|
|
3178
|
+
// it stands and taken off the verification schedule; the turn
|
|
3179
|
+
// result is not failed over it.
|
|
3180
|
+
try {
|
|
3181
|
+
const refs = await topics.references(ownerDid, proposal.id);
|
|
3182
|
+
if (
|
|
3183
|
+
!attempted &&
|
|
3184
|
+
refs.length &&
|
|
3185
|
+
(await topics.claimVerification(ownerDid, proposal.id))
|
|
3186
|
+
) {
|
|
3187
|
+
attempted = true;
|
|
3188
|
+
refreshed = await reconcileTopicReferences(
|
|
3189
|
+
topics,
|
|
3190
|
+
proposal,
|
|
3191
|
+
{ v: 2, references: refs },
|
|
3192
|
+
this.topicVerificationContext(proposal),
|
|
3193
|
+
true,
|
|
3194
|
+
);
|
|
3195
|
+
}
|
|
3196
|
+
} catch (error) {
|
|
3197
|
+
await topics.quarantineVerification(ownerDid, proposal.id, error);
|
|
3198
|
+
}
|
|
3199
|
+
actionOutcomes.push(topicActionOutcome(refreshed));
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
if (attempted) {
|
|
3203
|
+
this.markDirty();
|
|
3204
|
+
await this.requestHousekeeping(Date.now() + 1000);
|
|
3205
|
+
}
|
|
3206
|
+
const hubActionOutcomes = await this.hubOutcomesForRun(
|
|
3207
|
+
ownerDid,
|
|
3208
|
+
result.sessionId,
|
|
3209
|
+
result.runId,
|
|
3210
|
+
);
|
|
3211
|
+
return {
|
|
3212
|
+
...result,
|
|
3213
|
+
...(actionOutcomes.length ? { actionOutcomes } : {}),
|
|
3214
|
+
...(hubActionOutcomes.length ? { hubActionOutcomes } : {}),
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
private async hubActionOptions(input: {
|
|
3219
|
+
ownerDid: string;
|
|
3220
|
+
provider: string;
|
|
3221
|
+
bindingId: string;
|
|
3222
|
+
bindingRevision: number;
|
|
3223
|
+
}) {
|
|
3224
|
+
if (!this.hubActionClient || input.ownerDid !== this.userDid)
|
|
3225
|
+
throw new Error('Hub action options are unavailable for this owner');
|
|
3226
|
+
await assertActiveChannelBinding(
|
|
3227
|
+
{
|
|
3228
|
+
userDid: input.ownerDid,
|
|
3229
|
+
channel: {
|
|
3230
|
+
provider: input.provider,
|
|
3231
|
+
bindingId: input.bindingId,
|
|
3232
|
+
bindingRevision: input.bindingRevision,
|
|
3233
|
+
},
|
|
3234
|
+
},
|
|
3235
|
+
this.env,
|
|
3236
|
+
);
|
|
3237
|
+
const options = await this.hubActionClient.options(input);
|
|
3238
|
+
if (
|
|
3239
|
+
options.ownerDid !== input.ownerDid ||
|
|
3240
|
+
options.bindingId !== input.bindingId ||
|
|
3241
|
+
options.bindingRevision !== input.bindingRevision
|
|
3242
|
+
)
|
|
3243
|
+
throw new Error(
|
|
3244
|
+
'Hub action options do not match this owner and binding',
|
|
3245
|
+
);
|
|
3246
|
+
this.lastHubOptions = { value: options, at: Date.now() };
|
|
3247
|
+
return options;
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* A Gmail operation that names a connection must name one the owner's
|
|
3252
|
+
* options list (service, id and label), from the last
|
|
3253
|
+
* `hub_action_options` read when it is recent, else a fresh one. One
|
|
3254
|
+
* Gmail send in flight per mailbox: while an earlier send to the same
|
|
3255
|
+
* connection or account label is authorized, submitted or unknown, a
|
|
3256
|
+
* new one is refused with that action's id.
|
|
3257
|
+
*/
|
|
3258
|
+
private async assertGmailOperationAllowed(input: {
|
|
3259
|
+
ownerDid: string;
|
|
3260
|
+
provider: string;
|
|
3261
|
+
bindingId: string;
|
|
3262
|
+
bindingRevision: number;
|
|
3263
|
+
operation: ActionOperationV1;
|
|
3264
|
+
}): Promise<void> {
|
|
3265
|
+
const operation = input.operation;
|
|
3266
|
+
if (
|
|
3267
|
+
operation.kind !== 'gmail.message.send.v1' &&
|
|
3268
|
+
operation.kind !== 'gmail.disconnect.v1'
|
|
3269
|
+
)
|
|
3270
|
+
return;
|
|
3271
|
+
const cached = this.lastHubOptions ?? null;
|
|
3272
|
+
const options =
|
|
3273
|
+
cached &&
|
|
3274
|
+
Date.now() - cached.at < HUB_OPTIONS_REUSE_MS &&
|
|
3275
|
+
cached.value.ownerDid === input.ownerDid &&
|
|
3276
|
+
cached.value.bindingId === input.bindingId &&
|
|
3277
|
+
cached.value.bindingRevision === input.bindingRevision
|
|
3278
|
+
? cached.value
|
|
3279
|
+
: await this.hubActionOptions(input);
|
|
3280
|
+
if (
|
|
3281
|
+
!options.connections.some(
|
|
3282
|
+
(c) =>
|
|
3283
|
+
c.service === 'gmail' &&
|
|
3284
|
+
c.id === operation.connectionId &&
|
|
3285
|
+
c.label === operation.accountLabel,
|
|
3286
|
+
)
|
|
3287
|
+
)
|
|
3288
|
+
throw new HubActionRefused(
|
|
3289
|
+
'refused',
|
|
3290
|
+
"That Gmail connection is not one of this owner's; read hub_action_options and use a listed connection.",
|
|
3291
|
+
);
|
|
3292
|
+
if (operation.kind !== 'gmail.message.send.v1') return;
|
|
3293
|
+
const prior = await this.hubActionStore?.gmailSendInFlight(
|
|
3294
|
+
input.ownerDid,
|
|
3295
|
+
operation.connectionId,
|
|
3296
|
+
operation.accountLabel,
|
|
3297
|
+
);
|
|
3298
|
+
if (prior)
|
|
3299
|
+
throw new HubActionRefused(
|
|
3300
|
+
'refused',
|
|
3301
|
+
`An earlier email from this mailbox (action ${prior}) is still in flight or unconfirmed; wait for its outcome, or ask the owner to check Gmail's Sent folder, before preparing another.`,
|
|
3302
|
+
prior,
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/** The Hub's capabilities, cached per object for `HUB_CAPABILITIES_CACHE_MS`. */
|
|
3307
|
+
private async cachedHubCapabilities(): Promise<ActionCapabilitiesV1> {
|
|
3308
|
+
const client = this.hubActionClient;
|
|
3309
|
+
if (!client) throw new Error('Hub actions are unavailable');
|
|
3310
|
+
const now = Date.now();
|
|
3311
|
+
const cached = this.hubCapabilities ?? null;
|
|
3312
|
+
if (cached && now - cached.at < HUB_CAPABILITIES_CACHE_MS)
|
|
3313
|
+
return cached.value;
|
|
3314
|
+
const value = await client.capabilities();
|
|
3315
|
+
this.hubCapabilities = { value, at: now };
|
|
3316
|
+
return value;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
private async requestHubAction(input: {
|
|
3320
|
+
ownerDid: string;
|
|
3321
|
+
sessionId: string;
|
|
3322
|
+
runId: string;
|
|
3323
|
+
requestId: string;
|
|
3324
|
+
provider: string;
|
|
3325
|
+
bindingId: string;
|
|
3326
|
+
bindingRevision: number;
|
|
3327
|
+
requestKey: string;
|
|
3328
|
+
operation: ActionOperationV1;
|
|
3329
|
+
}) {
|
|
3330
|
+
const store = this.hubActionStore;
|
|
3331
|
+
const client = this.hubActionClient;
|
|
3332
|
+
if (!store || !client || input.ownerDid !== this.userDid)
|
|
3333
|
+
throw new Error('Hub actions are unavailable for this channel');
|
|
3334
|
+
// Only a run an inbound channel message started may prepare a review:
|
|
3335
|
+
// a scheduled or background run has no user asking for it.
|
|
3336
|
+
const record = await this.runStore?.get(input.runId);
|
|
3337
|
+
const turn = record
|
|
3338
|
+
? (JSON.parse(record.request) as StoredRunRequest).turn
|
|
3339
|
+
: null;
|
|
3340
|
+
if (
|
|
3341
|
+
!turn ||
|
|
3342
|
+
turn.client !== 'channel' ||
|
|
3343
|
+
!turn.channel?.remoteMessageRef ||
|
|
3344
|
+
turn.identity.userDid !== input.ownerDid ||
|
|
3345
|
+
turn.sessionId !== input.sessionId ||
|
|
3346
|
+
turn.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)
|
|
3347
|
+
)
|
|
3348
|
+
throw new HubActionRefused(
|
|
3349
|
+
'limit',
|
|
3350
|
+
'Reviews are prepared only in reply to a message from the user.',
|
|
3351
|
+
);
|
|
3352
|
+
await assertActiveChannelBinding(
|
|
3353
|
+
{
|
|
3354
|
+
userDid: input.ownerDid,
|
|
3355
|
+
channel: {
|
|
3356
|
+
provider: input.provider,
|
|
3357
|
+
bindingId: input.bindingId,
|
|
3358
|
+
bindingRevision: input.bindingRevision,
|
|
3359
|
+
},
|
|
3360
|
+
},
|
|
3361
|
+
this.env,
|
|
3362
|
+
);
|
|
3363
|
+
await this.assertGmailOperationAllowed(input);
|
|
3364
|
+
const reference = await prepareHubAction(
|
|
3365
|
+
{
|
|
3366
|
+
capabilities: () => this.cachedHubCapabilities(),
|
|
3367
|
+
request: (body) => client.request(body),
|
|
3368
|
+
},
|
|
3369
|
+
{
|
|
3370
|
+
reserve: async (origin, operation, expiresAt) => {
|
|
3371
|
+
const reserved = await store.reserve(origin, operation, expiresAt);
|
|
3372
|
+
this.markDirty();
|
|
3373
|
+
return reserved;
|
|
3374
|
+
},
|
|
3375
|
+
record: (origin, reference) => store.record(origin, reference),
|
|
3376
|
+
byRequestKey: (key) => store.byRequestKey(key),
|
|
3377
|
+
},
|
|
3378
|
+
input,
|
|
3379
|
+
);
|
|
3380
|
+
this.markDirty();
|
|
3381
|
+
return reference;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
private async statusHubAction(
|
|
3385
|
+
ownerDid: string,
|
|
3386
|
+
actionId: string,
|
|
3387
|
+
timeoutMs?: number,
|
|
3388
|
+
): Promise<ActionOutcomeV1 | null> {
|
|
3389
|
+
const held = await this.hubActionStore?.get(ownerDid, actionId);
|
|
3390
|
+
if (!held || !this.hubActionClient) return null;
|
|
3391
|
+
const status = await this.hubActionClient.status(actionId, timeoutMs);
|
|
3392
|
+
if (
|
|
3393
|
+
status.ownerDid !== ownerDid ||
|
|
3394
|
+
canonicalJson(status.reference) !== canonicalJson(held.reference)
|
|
3395
|
+
)
|
|
3396
|
+
throw new Error('Hub status does not match the prepared action');
|
|
3397
|
+
if (status.outcome) {
|
|
3398
|
+
if (
|
|
3399
|
+
status.revision !== status.outcome.authorizedRevision ||
|
|
3400
|
+
status.digest !== status.outcome.authorizedDigest
|
|
3401
|
+
)
|
|
3402
|
+
return null;
|
|
3403
|
+
if (await this.hubActionStore!.recordOutcome(status.outcome))
|
|
3404
|
+
this.markDirty();
|
|
3405
|
+
}
|
|
3406
|
+
return status.outcome;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/**
|
|
3410
|
+
* The run's Hub outcomes for a channel poll: stored outcomes first
|
|
3411
|
+
* (callback-delivered ones included, so a Hub outage hides nothing
|
|
3412
|
+
* already known), then at most ONE status refresh per poll, only for an
|
|
3413
|
+
* action with no terminal outcome whose backoff (15 s doubling to
|
|
3414
|
+
* 10 min) has passed, with a 3 s timeout. The newer outcome by
|
|
3415
|
+
* sequence wins.
|
|
3416
|
+
*/
|
|
3417
|
+
private async hubOutcomesForRun(
|
|
3418
|
+
ownerDid: string,
|
|
3419
|
+
sessionId: string,
|
|
3420
|
+
runId: string,
|
|
3421
|
+
): Promise<ActionOutcomeV1[]> {
|
|
3422
|
+
const store = this.hubActionStore;
|
|
3423
|
+
if (!store) return [];
|
|
3424
|
+
const now = Date.now();
|
|
3425
|
+
let refreshed = false;
|
|
3426
|
+
const current: ActionOutcomeV1[] = [];
|
|
3427
|
+
for (const row of await store.runState(ownerDid, sessionId, runId)) {
|
|
3428
|
+
let latest = row.latest;
|
|
3429
|
+
if (
|
|
3430
|
+
!refreshed &&
|
|
3431
|
+
this.hubActionClient &&
|
|
3432
|
+
!(latest && TERMINAL_HUB_STATUSES.has(latest.status)) &&
|
|
3433
|
+
row.nextCheckAt <= now &&
|
|
3434
|
+
row.reference.expiresAt * 1000 + HUB_CHECK_AFTER_EXPIRY_MS > now
|
|
3435
|
+
) {
|
|
3436
|
+
refreshed = true;
|
|
3437
|
+
// The attempt is spent before the read: a Hub that hangs is
|
|
3438
|
+
// asked again only after the backoff.
|
|
3439
|
+
await store.scheduleCheck(row.reference.id, row.checkAttempts);
|
|
3440
|
+
// The schedule is SQLite state the owner copy must carry (else a
|
|
3441
|
+
// restored copy asks the Hub again at once). Only a poll that
|
|
3442
|
+
// refreshed writes it, at most one per poll.
|
|
3443
|
+
this.markDirty();
|
|
3444
|
+
try {
|
|
3445
|
+
const fresh = await this.statusHubAction(
|
|
3446
|
+
ownerDid,
|
|
3447
|
+
row.reference.id,
|
|
3448
|
+
HUB_POLL_TIMEOUT_MS,
|
|
3449
|
+
);
|
|
3450
|
+
if (fresh && (!latest || fresh.sequence > latest.sequence))
|
|
3451
|
+
latest = fresh;
|
|
3452
|
+
} catch (error) {
|
|
3453
|
+
console.warn(
|
|
3454
|
+
`[hub-actions] status recovery unavailable for ${row.reference.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3455
|
+
);
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
if (latest) current.push(latest);
|
|
3459
|
+
}
|
|
3460
|
+
return current;
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
async recordHubActionOutcome(input: ActionOutcomeV1): Promise<void> {
|
|
3464
|
+
if (this.env.HUB_ACTIONS_ENABLED !== 'true')
|
|
3465
|
+
throw new Error('Hub actions disabled');
|
|
3466
|
+
// A Hub callback is not the user: it must not count as access (that
|
|
3467
|
+
// would delay idle eviction).
|
|
3468
|
+
await this.ready({ userDid: input.ownerDid }, { recordAccess: false });
|
|
3469
|
+
const held = await this.hubActionStore?.get(
|
|
3470
|
+
input.ownerDid,
|
|
3471
|
+
input.reference.id,
|
|
3472
|
+
);
|
|
3473
|
+
if (!held || !this.hubActionClient) throw new Error('Unknown Hub action');
|
|
3474
|
+
const remote = await this.hubActionClient.status(input.reference.id);
|
|
3475
|
+
if (
|
|
3476
|
+
!remote.outcome ||
|
|
3477
|
+
remote.ownerDid !== input.ownerDid ||
|
|
3478
|
+
canonicalJson(remote.reference) !== canonicalJson(held.reference) ||
|
|
3479
|
+
canonicalJson(input.reference) !== canonicalJson(held.reference) ||
|
|
3480
|
+
remote.outcome.sequence < input.sequence ||
|
|
3481
|
+
(remote.outcome.sequence === input.sequence &&
|
|
3482
|
+
canonicalJson(remote.outcome) !== canonicalJson(input))
|
|
3483
|
+
)
|
|
3484
|
+
throw new Error(
|
|
3485
|
+
'Hub outcome was not confirmed by authenticated status',
|
|
3486
|
+
);
|
|
3487
|
+
if (await this.hubActionStore!.recordOutcome(remote.outcome))
|
|
3488
|
+
this.markDirty();
|
|
2898
3489
|
}
|
|
2899
3490
|
|
|
2900
3491
|
async listSessions(
|
|
@@ -3035,9 +3626,652 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3035
3626
|
}
|
|
3036
3627
|
|
|
3037
3628
|
/**
|
|
3038
|
-
* `
|
|
3039
|
-
*
|
|
3629
|
+
* `assertTopicDependencies`, remembered per card (id and digest) for
|
|
3630
|
+
* `TOPIC_AUTHORITY_MEMO_MS` after it passed (or after preparation read
|
|
3631
|
+
* the same facts): prepare, claim and begin within seconds of each other
|
|
3632
|
+
* read the room, history and chain once, not three times. Only a pass
|
|
3633
|
+
* is remembered; a refusal re-reads.
|
|
3040
3634
|
*/
|
|
3635
|
+
private async assertTopicDependenciesMemo(
|
|
3636
|
+
proposal: TopicActionProposal,
|
|
3637
|
+
): Promise<void> {
|
|
3638
|
+
const key = `${proposal.id}:${proposal.digest}`;
|
|
3639
|
+
const now = Date.now();
|
|
3640
|
+
const passed = this.topicAuthorityMemo?.get(key);
|
|
3641
|
+
if (passed !== undefined && now - passed < TOPIC_AUTHORITY_MEMO_MS)
|
|
3642
|
+
return;
|
|
3643
|
+
await assertTopicDependencies(
|
|
3644
|
+
proposal,
|
|
3645
|
+
this.topicVerificationContext(proposal),
|
|
3646
|
+
);
|
|
3647
|
+
this.rememberTopicAuthority(key, Date.now());
|
|
3648
|
+
}
|
|
3649
|
+
private rememberTopicAuthority(key: string, at: number): void {
|
|
3650
|
+
const memo = (this.topicAuthorityMemo ??= new Map());
|
|
3651
|
+
for (const [k, t] of memo)
|
|
3652
|
+
if (at - t >= TOPIC_AUTHORITY_MEMO_MS) memo.delete(k);
|
|
3653
|
+
memo.set(key, at);
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
private topicVerificationContext(
|
|
3657
|
+
proposal: TopicActionProposal,
|
|
3658
|
+
): TopicVerificationContext {
|
|
3659
|
+
// The gateway's errors arrive as plain errors over the stub. Most are
|
|
3660
|
+
// reads that failed (oversized state, timed-out history, a room it
|
|
3661
|
+
// cannot read, an event it cannot decrypt yet); the few that describe
|
|
3662
|
+
// what the homeserver returned (an event bound to another room, an
|
|
3663
|
+
// event without an id) are facts about the reference.
|
|
3664
|
+
const read = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
3665
|
+
try {
|
|
3666
|
+
return await run();
|
|
3667
|
+
} catch (error) {
|
|
3668
|
+
const message =
|
|
3669
|
+
error instanceof Error ? error.message : String(error);
|
|
3670
|
+
throw GATEWAY_TOPIC_FACTS.has(message)
|
|
3671
|
+
? new ActionProposalConflict(message)
|
|
3672
|
+
: new TopicReadUnavailable(message);
|
|
3673
|
+
}
|
|
3674
|
+
};
|
|
3675
|
+
// Room state and history are lists of events; anything else from the
|
|
3676
|
+
// gateway is a read that failed, not content for the contract to judge.
|
|
3677
|
+
const readList = (raw: string): unknown[] => {
|
|
3678
|
+
const value: unknown = JSON.parse(raw);
|
|
3679
|
+
if (!Array.isArray(value))
|
|
3680
|
+
throw new TopicReadUnavailable('Topic read is not a list of events');
|
|
3681
|
+
return value;
|
|
3682
|
+
};
|
|
3683
|
+
return {
|
|
3684
|
+
reader: {
|
|
3685
|
+
state: async (room) =>
|
|
3686
|
+
readList(await read(() => this.gateway.getTopicRoomState(room))),
|
|
3687
|
+
event: async (room, id, opts) => {
|
|
3688
|
+
const raw = await read(() =>
|
|
3689
|
+
this.gateway.getTopicEvent(room, id, opts),
|
|
3690
|
+
);
|
|
3691
|
+
return raw ? JSON.parse(raw) : null;
|
|
3692
|
+
},
|
|
3693
|
+
history: async (room, root, opts) =>
|
|
3694
|
+
readList(
|
|
3695
|
+
await read(() => this.gateway.getTopicHistory(room, root, opts)),
|
|
3696
|
+
),
|
|
3697
|
+
},
|
|
3698
|
+
blocksync: this.env.BLOCKSYNC_GRAPHQL_URL,
|
|
3699
|
+
rpc: this.actionProposalRpc(proposal),
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
private async prepareTopicProposal(input: {
|
|
3703
|
+
candidate: TopicCandidate;
|
|
3704
|
+
origin: TopicProposalSubject['origin'];
|
|
3705
|
+
evidenceRefs: string[];
|
|
3706
|
+
}): Promise<TopicActionProposal> {
|
|
3707
|
+
const topics = this.topicProposals;
|
|
3708
|
+
if (
|
|
3709
|
+
this.env.ACTION_PROPOSALS_ENABLED !== 'true' ||
|
|
3710
|
+
this.env.ACTION_TOPIC_PROPOSALS_ENABLED !== 'true' ||
|
|
3711
|
+
!this.userDid ||
|
|
3712
|
+
!topics
|
|
3713
|
+
)
|
|
3714
|
+
throw new Error('Topic preparation unavailable');
|
|
3715
|
+
const record = await this.runStore!.get(input.origin.runId);
|
|
3716
|
+
if (!record) throw new Error('Originating run unavailable');
|
|
3717
|
+
const request = (JSON.parse(record.request) as StoredRunRequest).turn;
|
|
3718
|
+
if (
|
|
3719
|
+
request.identity.userDid !== this.userDid ||
|
|
3720
|
+
request.sessionId !== input.origin.sessionId
|
|
3721
|
+
)
|
|
3722
|
+
throw new Error('Topic origin mismatch');
|
|
3723
|
+
const candidateDigest = await digestTopicProposalEffect(input.candidate);
|
|
3724
|
+
const prior = await topics.replayPreparation(
|
|
3725
|
+
this.userDid,
|
|
3726
|
+
input.origin,
|
|
3727
|
+
input.candidate,
|
|
3728
|
+
input.evidenceRefs,
|
|
3729
|
+
);
|
|
3730
|
+
if (prior) return prior;
|
|
3731
|
+
// The caps before the authority read it would waste (create checks
|
|
3732
|
+
// them again, atomically).
|
|
3733
|
+
await topics.assertCapacity(
|
|
3734
|
+
this.userDid,
|
|
3735
|
+
input.origin.sessionId,
|
|
3736
|
+
input.origin.runId,
|
|
3737
|
+
);
|
|
3738
|
+
const rpc = {
|
|
3739
|
+
devnet: this.env.ACTION_PROPOSALS_RPC_DEVNET,
|
|
3740
|
+
testnet: this.env.ACTION_PROPOSALS_RPC_TESTNET,
|
|
3741
|
+
mainnet: this.env.ACTION_PROPOSALS_RPC_MAINNET,
|
|
3742
|
+
}[input.candidate.network];
|
|
3743
|
+
const chainId = chainIdsFromConfig(chainIdConfigSchema.parse(this.env))[
|
|
3744
|
+
input.candidate.network
|
|
3745
|
+
];
|
|
3746
|
+
const subject = await prepareTopicSubject(
|
|
3747
|
+
input.candidate,
|
|
3748
|
+
[...input.evidenceRefs, `candidate:${candidateDigest}`],
|
|
3749
|
+
{
|
|
3750
|
+
ownerDid: this.userDid,
|
|
3751
|
+
...input.origin,
|
|
3752
|
+
blocksync: this.env.BLOCKSYNC_GRAPHQL_URL,
|
|
3753
|
+
rpc,
|
|
3754
|
+
chainId,
|
|
3755
|
+
reader: {
|
|
3756
|
+
state: async (room) =>
|
|
3757
|
+
JSON.parse(await this.gateway.getTopicRoomState(room)),
|
|
3758
|
+
event: async (room, id, opts) => {
|
|
3759
|
+
const raw = await this.gateway.getTopicEvent(room, id, opts);
|
|
3760
|
+
return raw ? JSON.parse(raw) : null;
|
|
3761
|
+
},
|
|
3762
|
+
history: async (room, root, opts) =>
|
|
3763
|
+
JSON.parse(await this.gateway.getTopicHistory(room, root, opts)),
|
|
3764
|
+
},
|
|
3765
|
+
},
|
|
3766
|
+
);
|
|
3767
|
+
const roomId =
|
|
3768
|
+
request.roomId ??
|
|
3769
|
+
(await this.sessions!.getSession(request.sessionId))?.roomId;
|
|
3770
|
+
const origin =
|
|
3771
|
+
request.sessionId.startsWith('$') && roomId
|
|
3772
|
+
? {
|
|
3773
|
+
sessionId: request.sessionId,
|
|
3774
|
+
roomId,
|
|
3775
|
+
client: request.client,
|
|
3776
|
+
...(request.channel ? { channel: request.channel } : {}),
|
|
3777
|
+
}
|
|
3778
|
+
: null;
|
|
3779
|
+
const p = await topics.create(subject, origin);
|
|
3780
|
+
this.markDirty();
|
|
3781
|
+
// Preparation just read and sealed every fact the claim re-checks.
|
|
3782
|
+
this.rememberTopicAuthority(`${p.id}:${p.digest}`, Date.now());
|
|
3783
|
+
return p;
|
|
3784
|
+
}
|
|
3785
|
+
/** Both switches: topic cards may be claimed and their steps permitted. */
|
|
3786
|
+
private topicExecutionEnabled(): boolean {
|
|
3787
|
+
return (
|
|
3788
|
+
this.env.ACTION_PROPOSALS_ENABLED === 'true' &&
|
|
3789
|
+
this.env.ACTION_TOPIC_PROPOSALS_ENABLED === 'true'
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
async beginTopicStep(
|
|
3793
|
+
identity: TurnIdentity,
|
|
3794
|
+
id: string,
|
|
3795
|
+
input: BeginTopicStep,
|
|
3796
|
+
): Promise<string | null> {
|
|
3797
|
+
await this.ready(identity);
|
|
3798
|
+
const store = this.topicProposals;
|
|
3799
|
+
if (!store)
|
|
3800
|
+
return JSON.stringify(
|
|
3801
|
+
topicRefusal(new TopicReadUnavailable('topic store unavailable'), id),
|
|
3802
|
+
);
|
|
3803
|
+
try {
|
|
3804
|
+
const proposal = await store.get(identity.userDid, id);
|
|
3805
|
+
if (!proposal) return null;
|
|
3806
|
+
// Switching the feature off stops new permits; reads, reconciliation
|
|
3807
|
+
// and receipts of cards already in flight keep working.
|
|
3808
|
+
if (!this.topicExecutionEnabled())
|
|
3809
|
+
throw new ActionProposalConflict(
|
|
3810
|
+
'Topic action execution is disabled',
|
|
3811
|
+
);
|
|
3812
|
+
// The local refusals first (not claimed, expired, out of order,
|
|
3813
|
+
// another executor); the ten second authority read only for a step
|
|
3814
|
+
// that could actually start.
|
|
3815
|
+
await store.assertBeginnable(identity.userDid, id, input);
|
|
3816
|
+
// A step whose reference is recorded is spoken for: no new permit,
|
|
3817
|
+
// and no authority read to find that out.
|
|
3818
|
+
if (await store.hasReference(identity.userDid, id, input.step))
|
|
3819
|
+
return JSON.stringify({
|
|
3820
|
+
ok: true,
|
|
3821
|
+
result: { status: 'already_started', proposal },
|
|
3822
|
+
});
|
|
3823
|
+
await this.assertTopicDependenciesMemo(proposal);
|
|
3824
|
+
const effect = await topicExpectedEffect(proposal, input.step);
|
|
3825
|
+
const result = await store.begin(identity.userDid, id, input, effect);
|
|
3826
|
+
this.markDirty();
|
|
3827
|
+
return result ? JSON.stringify({ ok: true, result }) : null;
|
|
3828
|
+
} catch (error) {
|
|
3829
|
+
return JSON.stringify(topicRefusal(error, id));
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
private async prepareActionProposal(
|
|
3834
|
+
subject: ActionProposalSubject,
|
|
3835
|
+
): Promise<ActionProposal> {
|
|
3836
|
+
const userDid = this.userDid;
|
|
3837
|
+
if (this.env.ACTION_PROPOSALS_ENABLED !== 'true' || !userDid)
|
|
3838
|
+
throw new Error('Action proposal preparation unavailable');
|
|
3839
|
+
const store = this.requireActionProposals();
|
|
3840
|
+
// The owner object's own policy, whichever plugin prepared the card:
|
|
3841
|
+
// this owner signs from their own account, off mainnet, on a chain the
|
|
3842
|
+
// Worker is configured for and can verify against, stamped now, within
|
|
3843
|
+
// the expiry window.
|
|
3844
|
+
assertActionProposalPolicy(subject, {
|
|
3845
|
+
ownerDid: userDid,
|
|
3846
|
+
chainIds: chainIdsFromConfig(chainIdConfigSchema.parse(this.env)),
|
|
3847
|
+
rpc: this.actionProposalRpcs(),
|
|
3848
|
+
now: store.now(),
|
|
3849
|
+
});
|
|
3850
|
+
const record = await this.runStore!.get(subject.origin.runId);
|
|
3851
|
+
if (!record) throw new Error('Action proposal origin unavailable');
|
|
3852
|
+
const request = (JSON.parse(record.request) as StoredRunRequest).turn;
|
|
3853
|
+
if (
|
|
3854
|
+
request.identity.userDid !== subject.ownerDid ||
|
|
3855
|
+
request.sessionId !== subject.origin.sessionId
|
|
3856
|
+
)
|
|
3857
|
+
throw new Error('Action proposal origin mismatch');
|
|
3858
|
+
// A card names one person's wallet; a group room would show its link
|
|
3859
|
+
// and its receipt to everyone in the room. The gateway marks every
|
|
3860
|
+
// Matrix turn, so an unmarked one is refused rather than trusted.
|
|
3861
|
+
if (request.client === 'matrix' && request.roomKind !== 'direct')
|
|
3862
|
+
throw new ActionProposalConflict(
|
|
3863
|
+
'Action cards are prepared in direct chats only',
|
|
3864
|
+
);
|
|
3865
|
+
let origin = null;
|
|
3866
|
+
if (request.sessionId.startsWith('$')) {
|
|
3867
|
+
const roomId =
|
|
3868
|
+
request.roomId ??
|
|
3869
|
+
this.sessionRooms.get(request.sessionId) ??
|
|
3870
|
+
(await this.sessions!.getSession(request.sessionId))?.roomId ??
|
|
3871
|
+
(
|
|
3872
|
+
await retryGateway(() =>
|
|
3873
|
+
this.gateway.resolveUserRoom(subject.ownerDid),
|
|
3874
|
+
)
|
|
3875
|
+
)?.roomId;
|
|
3876
|
+
if (!roomId) throw new Error('Action proposal room unavailable');
|
|
3877
|
+
origin = {
|
|
3878
|
+
sessionId: request.sessionId,
|
|
3879
|
+
roomId,
|
|
3880
|
+
client: request.client,
|
|
3881
|
+
...(request.channel ? { channel: request.channel } : {}),
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
const proposal = await store.create(subject, origin);
|
|
3885
|
+
this.markDirty();
|
|
3886
|
+
return proposal;
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
/** The card store, or the one error every card route answers when boot could not open it. */
|
|
3890
|
+
private requireActionProposals(): ActionProposalStore {
|
|
3891
|
+
if (!this.actionProposals)
|
|
3892
|
+
throw new Error('Action proposals are unavailable for this user');
|
|
3893
|
+
return this.actionProposals;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
/** The chain RPC per network, as configured. */
|
|
3897
|
+
private actionProposalRpcs(): Record<
|
|
3898
|
+
'devnet' | 'testnet' | 'mainnet',
|
|
3899
|
+
string | undefined
|
|
3900
|
+
> {
|
|
3901
|
+
return {
|
|
3902
|
+
devnet: this.env.ACTION_PROPOSALS_RPC_DEVNET,
|
|
3903
|
+
testnet: this.env.ACTION_PROPOSALS_RPC_TESTNET,
|
|
3904
|
+
mainnet: this.env.ACTION_PROPOSALS_RPC_MAINNET,
|
|
3905
|
+
};
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
/**
|
|
3909
|
+
* The alarm's share of action cards: at most two chain lookups (run
|
|
3910
|
+
* together) for cards whose transaction is recorded but unconfirmed (so
|
|
3911
|
+
* a card the user signed and walked away from still gets its receipt
|
|
3912
|
+
* once the chain has indexed it), closing claimed cards whose execution
|
|
3913
|
+
* window passed with no transaction, the receipts due now (posted in the
|
|
3914
|
+
* background: their schedule already moved forward, so a crash cannot
|
|
3915
|
+
* post one sooner), and the retention sweep, at most hourly. All of it
|
|
3916
|
+
* shares `ACTION_TICK_BUDGET_MS` of wall time. Returns the earliest
|
|
3917
|
+
* moment any of it is due again.
|
|
3918
|
+
*/
|
|
3919
|
+
private async tickActionProposals(): Promise<number | undefined> {
|
|
3920
|
+
if (!this.userDid) return undefined;
|
|
3921
|
+
const userDid = this.userDid;
|
|
3922
|
+
// Each store on its own: the other one failing to open must not stop
|
|
3923
|
+
// this one's verifications, receipts or retention.
|
|
3924
|
+
const store = this.actionProposals;
|
|
3925
|
+
const topics = this.topicProposals;
|
|
3926
|
+
const budget = new AbortController();
|
|
3927
|
+
const timer = setTimeout(() => budget.abort(), ACTION_TICK_BUDGET_MS);
|
|
3928
|
+
const settle = (
|
|
3929
|
+
label: string,
|
|
3930
|
+
results: PromiseSettledResult<unknown>[],
|
|
3931
|
+
) => {
|
|
3932
|
+
for (const result of results)
|
|
3933
|
+
if (result.status === 'rejected')
|
|
3934
|
+
console.warn(
|
|
3935
|
+
`[${label}] verification failed for ${userDid}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
|
|
3936
|
+
);
|
|
3937
|
+
};
|
|
3938
|
+
try {
|
|
3939
|
+
await Promise.all([
|
|
3940
|
+
(async () => {
|
|
3941
|
+
if (!store) return;
|
|
3942
|
+
const due = await store.dueVerifications(userDid);
|
|
3943
|
+
const settled = await Promise.allSettled(
|
|
3944
|
+
due.map(async (proposal) => {
|
|
3945
|
+
const refreshed = await refreshActionProposal(
|
|
3946
|
+
store,
|
|
3947
|
+
proposal,
|
|
3948
|
+
this.actionProposalRpc(proposal),
|
|
3949
|
+
fetch,
|
|
3950
|
+
budget.signal,
|
|
3951
|
+
);
|
|
3952
|
+
if (refreshed.attempted) this.markDirty();
|
|
3953
|
+
}),
|
|
3954
|
+
);
|
|
3955
|
+
settle('action-proposals', settled);
|
|
3956
|
+
})(),
|
|
3957
|
+
// Topic cards with started steps the room or chain has not
|
|
3958
|
+
// confirmed yet: reconcile their recorded references on the same
|
|
3959
|
+
// cooldown, under the same budget. Guarded on its own: a topic
|
|
3960
|
+
// card's trouble must not hold up the transfer receipts below.
|
|
3961
|
+
(async () => {
|
|
3962
|
+
if (!topics) return;
|
|
3963
|
+
try {
|
|
3964
|
+
const due = await topics.dueVerifications(userDid);
|
|
3965
|
+
const settled = await Promise.allSettled(
|
|
3966
|
+
due.map(async (proposal) => {
|
|
3967
|
+
// The attempt is spent before the references are read: a
|
|
3968
|
+
// reference row this build cannot parse is quarantined,
|
|
3969
|
+
// not retried every second.
|
|
3970
|
+
if (!(await topics.claimVerification(userDid, proposal.id)))
|
|
3971
|
+
return;
|
|
3972
|
+
this.markDirty();
|
|
3973
|
+
try {
|
|
3974
|
+
const refs = await topics.references(userDid, proposal.id);
|
|
3975
|
+
if (refs.length)
|
|
3976
|
+
await reconcileTopicReferences(
|
|
3977
|
+
topics,
|
|
3978
|
+
proposal,
|
|
3979
|
+
{ v: 2, references: refs },
|
|
3980
|
+
{
|
|
3981
|
+
...this.topicVerificationContext(proposal),
|
|
3982
|
+
signal: budget.signal,
|
|
3983
|
+
},
|
|
3984
|
+
true,
|
|
3985
|
+
);
|
|
3986
|
+
} catch (error) {
|
|
3987
|
+
await topics.quarantineVerification(
|
|
3988
|
+
userDid,
|
|
3989
|
+
proposal.id,
|
|
3990
|
+
error,
|
|
3991
|
+
);
|
|
3992
|
+
}
|
|
3993
|
+
}),
|
|
3994
|
+
);
|
|
3995
|
+
settle('topic-actions', settled);
|
|
3996
|
+
} catch (err) {
|
|
3997
|
+
console.error(
|
|
3998
|
+
`[topic-actions] tick failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
3999
|
+
);
|
|
4000
|
+
}
|
|
4001
|
+
})(),
|
|
4002
|
+
]);
|
|
4003
|
+
} finally {
|
|
4004
|
+
clearTimeout(timer);
|
|
4005
|
+
}
|
|
4006
|
+
if (store && (await store.closeUnexecuted(userDid)) > 0) this.markDirty();
|
|
4007
|
+
const now = store?.now() ?? Date.now();
|
|
4008
|
+
const lastPruneAt = this.lastActionPruneAt ?? null;
|
|
4009
|
+
if (
|
|
4010
|
+
lastPruneAt === null ||
|
|
4011
|
+
now - lastPruneAt >= ACTION_PRUNE_INTERVAL_MS
|
|
4012
|
+
) {
|
|
4013
|
+
this.lastActionPruneAt = now;
|
|
4014
|
+
if (store && (await store.prune(userDid)) > 0) this.markDirty();
|
|
4015
|
+
try {
|
|
4016
|
+
if (topics && (await topics.prune(userDid)) > 0) this.markDirty();
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
console.error(
|
|
4019
|
+
`[topic-actions] prune failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
4020
|
+
);
|
|
4021
|
+
}
|
|
4022
|
+
// Hub action rows (requests, references, outcomes) 30 days after
|
|
4023
|
+
// their review expired.
|
|
4024
|
+
try {
|
|
4025
|
+
if (this.hubActionStore && (await this.hubActionStore.prune()) > 0)
|
|
4026
|
+
this.markDirty();
|
|
4027
|
+
} catch (err) {
|
|
4028
|
+
console.error(
|
|
4029
|
+
`[hub-actions] prune failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
4030
|
+
);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
const receiptAt = await this.mirrorActionReceipts(true);
|
|
4034
|
+
const next = [
|
|
4035
|
+
receiptAt,
|
|
4036
|
+
store ? await store.nextVerificationAt(userDid) : undefined,
|
|
4037
|
+
store ? await store.nextUnexecutedCloseAt(userDid) : undefined,
|
|
4038
|
+
topics ? await topics.nextVerificationAt(userDid) : undefined,
|
|
4039
|
+
].filter((at): at is number => at !== undefined);
|
|
4040
|
+
return next.length ? Math.min(...next) : undefined;
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
/**
|
|
4044
|
+
* Take the receipts due now (both card stores) and post them. `detach`
|
|
4045
|
+
* hands the posts to `ctx.waitUntil` (the alarm does not wait on a slow
|
|
4046
|
+
* room); otherwise they are awaited.
|
|
4047
|
+
*/
|
|
4048
|
+
private async mirrorActionReceipts(
|
|
4049
|
+
detach = false,
|
|
4050
|
+
): Promise<number | undefined> {
|
|
4051
|
+
if (!this.userDid) return undefined;
|
|
4052
|
+
const store = this.actionProposals;
|
|
4053
|
+
const topics = this.topicProposals;
|
|
4054
|
+
const receipts = store ? await store.takeReceipts(this.userDid) : [];
|
|
4055
|
+
const topicReceipts = topics
|
|
4056
|
+
? await topics.takeReceipts(this.userDid)
|
|
4057
|
+
: [];
|
|
4058
|
+
if (receipts.length || topicReceipts.length) this.markDirty();
|
|
4059
|
+
const posting = Promise.all([
|
|
4060
|
+
store ? this.postActionReceipts(store, receipts) : undefined,
|
|
4061
|
+
topics ? this.postTopicReceipts(topics, topicReceipts) : undefined,
|
|
4062
|
+
]).then(() => undefined);
|
|
4063
|
+
if (detach) this.ctx.waitUntil(posting);
|
|
4064
|
+
else await posting;
|
|
4065
|
+
const due = store
|
|
4066
|
+
? await store.nextReceiptAttempt(this.userDid)
|
|
4067
|
+
: undefined;
|
|
4068
|
+
const topicDue = topics
|
|
4069
|
+
? await topics.nextReceiptAttempt(this.userDid)
|
|
4070
|
+
: undefined;
|
|
4071
|
+
return due === undefined
|
|
4072
|
+
? topicDue
|
|
4073
|
+
: topicDue === undefined
|
|
4074
|
+
? due
|
|
4075
|
+
: Math.min(due, topicDue);
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
private async postActionReceipts(
|
|
4079
|
+
store: ActionProposalStore,
|
|
4080
|
+
receipts: Awaited<ReturnType<ActionProposalStore['takeReceipts']>>,
|
|
4081
|
+
): Promise<void> {
|
|
4082
|
+
for (const { proposal, origin } of receipts) {
|
|
4083
|
+
try {
|
|
4084
|
+
if (
|
|
4085
|
+
proposal.lifecycle.status !== 'succeeded' &&
|
|
4086
|
+
proposal.lifecycle.status !== 'failed'
|
|
4087
|
+
)
|
|
4088
|
+
continue;
|
|
4089
|
+
const status =
|
|
4090
|
+
proposal.lifecycle.status === 'succeeded'
|
|
4091
|
+
? 'confirmed on chain'
|
|
4092
|
+
: 'failed on chain';
|
|
4093
|
+
const text = `${proposal.title} ${status}. Transaction ${proposal.lifecycle.transactionHash}. Block ${proposal.lifecycle.height}. Code ${proposal.lifecycle.code}.`;
|
|
4094
|
+
await this.replayToRoom(
|
|
4095
|
+
{
|
|
4096
|
+
...origin,
|
|
4097
|
+
identity: { userDid: proposal.ownerDid },
|
|
4098
|
+
message: '',
|
|
4099
|
+
requestId: `action-receipt:${proposal.id}:${proposal.digest}`,
|
|
4100
|
+
},
|
|
4101
|
+
text,
|
|
4102
|
+
'oracle',
|
|
4103
|
+
true,
|
|
4104
|
+
);
|
|
4105
|
+
await store.receiptMirrored(proposal.ownerDid, proposal.id);
|
|
4106
|
+
this.markDirty();
|
|
4107
|
+
} catch (error) {
|
|
4108
|
+
console.warn(
|
|
4109
|
+
'[action-proposals] receipt mirror pending',
|
|
4110
|
+
error instanceof Error ? error.message : 'mirror failed',
|
|
4111
|
+
);
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
private async postTopicReceipts(
|
|
4117
|
+
topics: TopicProposalStore,
|
|
4118
|
+
receipts: Awaited<ReturnType<TopicProposalStore['takeReceipts']>>,
|
|
4119
|
+
): Promise<void> {
|
|
4120
|
+
for (const { proposal, origin } of receipts) {
|
|
4121
|
+
try {
|
|
4122
|
+
const receipt = proposal.lifecycle.receipt;
|
|
4123
|
+
if (!receipt) continue;
|
|
4124
|
+
const text =
|
|
4125
|
+
receipt.kind === 'topic-created'
|
|
4126
|
+
? `${proposal.title} created. Room ${receipt.roomId}. Topic ${receipt.topicId}. Root ${receipt.rootEventId}.`
|
|
4127
|
+
: `${proposal.title} ${receipt.kind === 'topic-access-granted' ? 'confirmed' : 'failed'} on chain. Transaction ${receipt.transactionHash}. Block ${receipt.height}. Code ${receipt.code}.`;
|
|
4128
|
+
await this.replayToRoom(
|
|
4129
|
+
{
|
|
4130
|
+
...origin,
|
|
4131
|
+
identity: { userDid: proposal.ownerDid },
|
|
4132
|
+
message: '',
|
|
4133
|
+
requestId: `action-receipt:${proposal.id}:${proposal.digest}`,
|
|
4134
|
+
},
|
|
4135
|
+
text,
|
|
4136
|
+
'oracle',
|
|
4137
|
+
true,
|
|
4138
|
+
);
|
|
4139
|
+
await topics.receiptMirrored(proposal.ownerDid, proposal.id);
|
|
4140
|
+
this.markDirty();
|
|
4141
|
+
} catch (error) {
|
|
4142
|
+
console.warn(
|
|
4143
|
+
'[topic-actions] receipt mirror pending',
|
|
4144
|
+
error instanceof Error ? error.message : 'mirror failed',
|
|
4145
|
+
);
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
private actionProposalRpc(
|
|
4151
|
+
proposal: ActionProposalWire,
|
|
4152
|
+
): string | undefined {
|
|
4153
|
+
return this.actionProposalRpcs()[proposal.action.network];
|
|
4154
|
+
}
|
|
4155
|
+
async actionProposal(
|
|
4156
|
+
identity: TurnIdentity,
|
|
4157
|
+
id: string,
|
|
4158
|
+
command?:
|
|
4159
|
+
| ProposalCommand
|
|
4160
|
+
| { kind: 'topic-reconcile'; input: TopicReceiptReferences },
|
|
4161
|
+
): Promise<string | null> {
|
|
4162
|
+
await this.ready(identity);
|
|
4163
|
+
const topics = this.topicProposals;
|
|
4164
|
+
const topic = topics ? await topics.get(identity.userDid, id) : null;
|
|
4165
|
+
if (topics && topic) {
|
|
4166
|
+
try {
|
|
4167
|
+
let result = topic;
|
|
4168
|
+
let topicAttempted = false;
|
|
4169
|
+
const verification = {
|
|
4170
|
+
...this.topicVerificationContext(topic),
|
|
4171
|
+
onAttempt: () => {
|
|
4172
|
+
topicAttempted = true;
|
|
4173
|
+
},
|
|
4174
|
+
};
|
|
4175
|
+
if (command?.kind === 'topic-reconcile')
|
|
4176
|
+
result = await reconcileTopicReferences(
|
|
4177
|
+
topics,
|
|
4178
|
+
topic,
|
|
4179
|
+
command.input,
|
|
4180
|
+
verification,
|
|
4181
|
+
);
|
|
4182
|
+
else if (command) {
|
|
4183
|
+
if (command.kind === 'reconcile')
|
|
4184
|
+
throw new ActionProposalConflict(
|
|
4185
|
+
'Topic reconciliation requires references',
|
|
4186
|
+
);
|
|
4187
|
+
if (command.kind === 'claim') {
|
|
4188
|
+
if (!this.topicExecutionEnabled())
|
|
4189
|
+
throw new ActionProposalConflict(
|
|
4190
|
+
'Topic action execution is disabled',
|
|
4191
|
+
);
|
|
4192
|
+
// The local refusals first (already consumed, changed,
|
|
4193
|
+
// expired): the remote authority read only for a claim that
|
|
4194
|
+
// could succeed.
|
|
4195
|
+
await topics.assertClaimable(
|
|
4196
|
+
identity.userDid,
|
|
4197
|
+
id,
|
|
4198
|
+
command.digest,
|
|
4199
|
+
);
|
|
4200
|
+
await this.assertTopicDependenciesMemo(topic);
|
|
4201
|
+
}
|
|
4202
|
+
const next = await topics.transition(
|
|
4203
|
+
identity.userDid,
|
|
4204
|
+
id,
|
|
4205
|
+
command.kind,
|
|
4206
|
+
command.digest,
|
|
4207
|
+
);
|
|
4208
|
+
if (!next) return null;
|
|
4209
|
+
result = next;
|
|
4210
|
+
} else {
|
|
4211
|
+
const refs = await topics.references(identity.userDid, id);
|
|
4212
|
+
if (refs.length)
|
|
4213
|
+
result = await reconcileTopicReferences(
|
|
4214
|
+
topics,
|
|
4215
|
+
topic,
|
|
4216
|
+
{ v: 2, references: refs },
|
|
4217
|
+
verification,
|
|
4218
|
+
);
|
|
4219
|
+
}
|
|
4220
|
+
if (command || topicAttempted) {
|
|
4221
|
+
this.markDirty();
|
|
4222
|
+
await this.requestHousekeeping(Date.now() + 1000);
|
|
4223
|
+
}
|
|
4224
|
+
return JSON.stringify({ ok: true, proposal: result });
|
|
4225
|
+
} catch (error) {
|
|
4226
|
+
return JSON.stringify(topicRefusal(error, id));
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
// Without the topic store an id the transfer store does not know may
|
|
4230
|
+
// be a topic card: the Portal is told to retry, not that the card is
|
|
4231
|
+
// gone. Without the transfer store, the same for the other side.
|
|
4232
|
+
const unknownCard = () =>
|
|
4233
|
+
topics
|
|
4234
|
+
? null
|
|
4235
|
+
: JSON.stringify(
|
|
4236
|
+
topicRefusal(
|
|
4237
|
+
new TopicReadUnavailable('topic store unavailable'),
|
|
4238
|
+
id,
|
|
4239
|
+
),
|
|
4240
|
+
);
|
|
4241
|
+
if (command?.kind === 'topic-reconcile') return unknownCard();
|
|
4242
|
+
const store = this.actionProposals;
|
|
4243
|
+
if (!store) return JSON.stringify(storeUnavailable(id));
|
|
4244
|
+
try {
|
|
4245
|
+
let result = command
|
|
4246
|
+
? await store.transition(identity.userDid, id, command)
|
|
4247
|
+
: await store.get(identity.userDid, id);
|
|
4248
|
+
if (command && result) this.markDirty();
|
|
4249
|
+
if (command?.kind === 'reconcile' && result) {
|
|
4250
|
+
const refreshed = await refreshActionProposal(
|
|
4251
|
+
store,
|
|
4252
|
+
result,
|
|
4253
|
+
this.actionProposalRpc(result),
|
|
4254
|
+
);
|
|
4255
|
+
result = refreshed.proposal;
|
|
4256
|
+
if (refreshed.attempted) {
|
|
4257
|
+
this.markDirty();
|
|
4258
|
+
await this.requestHousekeeping(Date.now() + 1000);
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
return result
|
|
4262
|
+
? JSON.stringify({ ok: true, proposal: result })
|
|
4263
|
+
: unknownCard();
|
|
4264
|
+
} catch (error) {
|
|
4265
|
+
if (error instanceof ActionProposalConflict)
|
|
4266
|
+
return JSON.stringify({
|
|
4267
|
+
ok: false,
|
|
4268
|
+
kind: 'conflict',
|
|
4269
|
+
error: error.message,
|
|
4270
|
+
});
|
|
4271
|
+
throw error;
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
3041
4275
|
async artifact(
|
|
3042
4276
|
identity: TurnIdentity,
|
|
3043
4277
|
artifactId: string,
|
|
@@ -6185,6 +7419,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6185
7419
|
id: req.sessionId,
|
|
6186
7420
|
client: req.client,
|
|
6187
7421
|
requestId: req.requestId,
|
|
7422
|
+
runId: run.runId,
|
|
7423
|
+
...(req.channel?.bindingRevision
|
|
7424
|
+
? {
|
|
7425
|
+
channel: {
|
|
7426
|
+
provider: req.channel.provider,
|
|
7427
|
+
bindingId: req.channel.bindingId,
|
|
7428
|
+
bindingRevision: req.channel.bindingRevision,
|
|
7429
|
+
},
|
|
7430
|
+
}
|
|
7431
|
+
: {}),
|
|
6188
7432
|
roomId: sessionRoomId,
|
|
6189
7433
|
},
|
|
6190
7434
|
model: effectiveModel,
|
|
@@ -6342,8 +7586,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6342
7586
|
req: TurnRequest,
|
|
6343
7587
|
text: string,
|
|
6344
7588
|
who: 'user' | 'oracle',
|
|
7589
|
+
receipt = false,
|
|
6345
7590
|
): Promise<void> {
|
|
6346
|
-
if (req.client === 'matrix') return Promise.resolve();
|
|
7591
|
+
if (req.client === 'matrix' && !receipt) return Promise.resolve();
|
|
6347
7592
|
if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
|
|
6348
7593
|
return Promise.resolve();
|
|
6349
7594
|
// A local session id has no marker event to thread on: the user had
|
|
@@ -6358,6 +7603,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6358
7603
|
req.sessionId,
|
|
6359
7604
|
async () => {
|
|
6360
7605
|
const roomId =
|
|
7606
|
+
(receipt ? req.roomId : undefined) ??
|
|
6361
7607
|
this.sessionRooms.get(req.sessionId) ??
|
|
6362
7608
|
req.roomId ??
|
|
6363
7609
|
(await this.sessions?.getSession(req.sessionId))?.roomId ??
|
|
@@ -6385,7 +7631,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6385
7631
|
};
|
|
6386
7632
|
},
|
|
6387
7633
|
label,
|
|
6388
|
-
req.client === 'channel',
|
|
7634
|
+
req.client === 'channel' || receipt,
|
|
6389
7635
|
);
|
|
6390
7636
|
}
|
|
6391
7637
|
|