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