@ixo/oracle-runtime-workers 0.21.2 → 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 +16 -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 +107 -5
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +16 -0
- package/src/core/main-agent.ts +26 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +27 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/active-runs.ts +171 -0
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +86 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +1420 -17
- 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/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +117 -7
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +22 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +218 -2
- package/src/sqlite/sessions-store.ts +22 -0
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,
|
|
@@ -118,6 +174,7 @@ import {
|
|
|
118
174
|
priorMetadataState,
|
|
119
175
|
} from './turn-metadata';
|
|
120
176
|
import {
|
|
177
|
+
frontendParkTimeoutMs,
|
|
121
178
|
RealtimeEndpoint,
|
|
122
179
|
type RealtimeStatus,
|
|
123
180
|
} from '../realtime/realtime-endpoint';
|
|
@@ -261,6 +318,13 @@ import {
|
|
|
261
318
|
storedRunRequest,
|
|
262
319
|
type StoredRunRequest,
|
|
263
320
|
} from './run-request';
|
|
321
|
+
import {
|
|
322
|
+
activeChatRuns,
|
|
323
|
+
activeRunSummaryOf,
|
|
324
|
+
listedChatRuns,
|
|
325
|
+
withActiveRuns,
|
|
326
|
+
withWaitingFor,
|
|
327
|
+
} from './active-runs';
|
|
264
328
|
import {
|
|
265
329
|
createToolMarksMiddleware,
|
|
266
330
|
toolEffectOf,
|
|
@@ -340,6 +404,59 @@ import {
|
|
|
340
404
|
} from './ucan-service';
|
|
341
405
|
import type { UcanDelegation, UserContextData } from '../plugin-api/types';
|
|
342
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
|
+
|
|
343
460
|
/** What one attempt's agent build reads from the request body. */
|
|
344
461
|
type TurnBuildBody = Pick<
|
|
345
462
|
TurnBody,
|
|
@@ -468,6 +585,22 @@ const ACCESS_PERSIST_INTERVAL_MS = 60 * 60_000;
|
|
|
468
585
|
const TITLE_TIMEOUT_MS = 15_000;
|
|
469
586
|
/** A new sweep of expired ambient blobs starts at most this often per instance. */
|
|
470
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;
|
|
471
604
|
/** The Blocksync lookup of the user's homeserver is abandoned after this long (the fallback applies). */
|
|
472
605
|
const HOMESERVER_LOOKUP_TIMEOUT_MS = 3_000;
|
|
473
606
|
/** Set once the legacy Matrix copy has been removed (or found absent): no more lookups. */
|
|
@@ -558,6 +691,14 @@ const META_LAST_ACCESS = 'meta:lastAccessAt';
|
|
|
558
691
|
* `POST /delegation` — the same place the Node runtime reads it from.
|
|
559
692
|
*/
|
|
560
693
|
const META_DELEGATION = 'meta:delegation';
|
|
694
|
+
/**
|
|
695
|
+
* `false` only when no run can be active: written at a boot that found
|
|
696
|
+
* none, turned `true` before the first run row an instance creates (once
|
|
697
|
+
* per instance, not per turn). A cold object whose flag is `false` answers
|
|
698
|
+
* `GET /runs/active` without booting; absent or `true`, it boots and asks
|
|
699
|
+
* the coordinator.
|
|
700
|
+
*/
|
|
701
|
+
const META_RUNS_MAYBE_ACTIVE = 'meta:runsMaybeActive';
|
|
561
702
|
/** How long a "no deposited delegation" verdict is trusted before re-checking the room. */
|
|
562
703
|
const DELEGATION_MISS_TTL_MS = 60_000;
|
|
563
704
|
/** Storage key prefix: a room this object found end-to-end encrypted. */
|
|
@@ -818,8 +959,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
818
959
|
private runStore: RunStore | null = null;
|
|
819
960
|
/**
|
|
820
961
|
* Tool concurrency of this user object (tool-scheduler.ts): writes one
|
|
821
|
-
* at a time across every session and turn, reads
|
|
822
|
-
* bounded lanes. Lives as
|
|
962
|
+
* at a time across every session and turn, reads in a bounded lane,
|
|
963
|
+
* browser tools and sub-agents in bounded lanes per session. Lives as
|
|
964
|
+
* long as the object; `GET /debug/runs` reports its occupancy.
|
|
823
965
|
*/
|
|
824
966
|
private readonly toolScheduler = new ToolScheduler();
|
|
825
967
|
/**
|
|
@@ -832,6 +974,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
832
974
|
/** Per-turn domain-context provenance (`domain_context_runs`). */
|
|
833
975
|
private domainContextStore: DomainContextStore | null = null;
|
|
834
976
|
private runs: RunCoordinator | null = null;
|
|
977
|
+
/**
|
|
978
|
+
* The boot's scan for runs a previous incarnation left failed: rows may
|
|
979
|
+
* be active that the coordinator does not hold, so the user-wide run
|
|
980
|
+
* listings read the store as well.
|
|
981
|
+
*/
|
|
982
|
+
private runRecoveryFailed = false;
|
|
983
|
+
/** Whether this instance has written `META_RUNS_MAYBE_ACTIVE` true. */
|
|
984
|
+
private runsMarkedActive = false;
|
|
835
985
|
private readonly runConfig: RunDurabilityConfig = runDurabilityConfig(
|
|
836
986
|
this.env,
|
|
837
987
|
);
|
|
@@ -839,6 +989,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
839
989
|
private resultStore: ResultStore | null = null;
|
|
840
990
|
/** Chat delivery's artefacts; null when no bucket is bound (see `canShare` for new ones). */
|
|
841
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;
|
|
842
1008
|
/** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
|
|
843
1009
|
private attachmentTextCache: AttachmentTextCacheStore | null = null;
|
|
844
1010
|
/**
|
|
@@ -1742,6 +1908,48 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1742
1908
|
oracleName: this.core.identity.name,
|
|
1743
1909
|
});
|
|
1744
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
|
+
}
|
|
1745
1953
|
this.sessions = new SessionsStore(liveDb);
|
|
1746
1954
|
await this.sessions.setup();
|
|
1747
1955
|
this.matrixLedger = new MatrixTurnLedger(liveDb);
|
|
@@ -1788,6 +1996,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1788
1996
|
runAttempt: (live, resumed) => this.runAttempt(live, resumed),
|
|
1789
1997
|
checkpointIdOf: (sessionId) => this.checkpointIdOf(sessionId),
|
|
1790
1998
|
onRunEnded: (record, outcome) => this.onRunEnded(record, outcome),
|
|
1999
|
+
beforeCreate: () => this.markRunsMaybeActive(),
|
|
1791
2000
|
});
|
|
1792
2001
|
|
|
1793
2002
|
this.channelTurns = new ChannelTurns(liveDb, {
|
|
@@ -1909,11 +2118,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1909
2118
|
// (attempts run from the alarm, never inside this boot).
|
|
1910
2119
|
try {
|
|
1911
2120
|
await this.runs.recoverOrphans();
|
|
2121
|
+
this.runRecoveryFailed = false;
|
|
1912
2122
|
} catch (err) {
|
|
2123
|
+
this.runRecoveryFailed = true;
|
|
1913
2124
|
console.error(
|
|
1914
2125
|
`[user-do] run recovery scan failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1915
2126
|
);
|
|
1916
2127
|
}
|
|
2128
|
+
// Nothing left in flight: a cold poll of `GET /runs/active` may skip
|
|
2129
|
+
// the boot until this or a later instance creates a run.
|
|
2130
|
+
const runsMarked = await this.ctx.storage.get<boolean>(
|
|
2131
|
+
META_RUNS_MAYBE_ACTIVE,
|
|
2132
|
+
);
|
|
2133
|
+
if (!this.runRecoveryFailed && this.runs.size === 0) {
|
|
2134
|
+
if (runsMarked !== false)
|
|
2135
|
+
await this.ctx.storage.put(META_RUNS_MAYBE_ACTIVE, false);
|
|
2136
|
+
this.runsMarkedActive = false;
|
|
2137
|
+
} else {
|
|
2138
|
+
this.runsMarkedActive = runsMarked === true;
|
|
2139
|
+
}
|
|
1917
2140
|
|
|
1918
2141
|
// Resume (or start) legacy-blob compaction in the background: files
|
|
1919
2142
|
// imported from the Node runtime arrive with uncompressed blobs.
|
|
@@ -1944,6 +2167,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1944
2167
|
logger: console,
|
|
1945
2168
|
});
|
|
1946
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
|
+
},
|
|
1947
2195
|
...(this.taskScheduler ? { tasks: this.taskScheduler.surface } : {}),
|
|
1948
2196
|
preferences: this.preferences,
|
|
1949
2197
|
kv: new SqliteUserKv(liveDb),
|
|
@@ -2021,6 +2269,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2021
2269
|
this.runs = null;
|
|
2022
2270
|
this.resultStore = null;
|
|
2023
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;
|
|
2024
2278
|
this.attachmentTextCache = null;
|
|
2025
2279
|
this.domainContextStore = null;
|
|
2026
2280
|
// Its store is bound to the database just dropped: a caller that sees
|
|
@@ -2428,6 +2682,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2428
2682
|
}
|
|
2429
2683
|
}
|
|
2430
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
|
+
|
|
2431
2703
|
// Legacy-blob compaction: one bounded batch per tick until the whole
|
|
2432
2704
|
// file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
|
|
2433
2705
|
// flight holds a snapshot of the file, which the compactor's swap-in
|
|
@@ -2812,7 +3084,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2812
3084
|
}
|
|
2813
3085
|
return {
|
|
2814
3086
|
ok: true,
|
|
2815
|
-
result: await this.
|
|
3087
|
+
result: await this.withActionOutcomes(
|
|
3088
|
+
identity.userDid,
|
|
3089
|
+
await this.channelTurns!.submit(identity, input, requestHash),
|
|
3090
|
+
),
|
|
2816
3091
|
};
|
|
2817
3092
|
} catch (error) {
|
|
2818
3093
|
// A failure retrying cannot fix (the room refused the oracle, the
|
|
@@ -2854,7 +3129,363 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2854
3129
|
const result = await this.channelTurns.poll(identity, input, requestHash);
|
|
2855
3130
|
if (!result) return null;
|
|
2856
3131
|
await this.recordAccess();
|
|
2857
|
-
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();
|
|
2858
3489
|
}
|
|
2859
3490
|
|
|
2860
3491
|
async listSessions(
|
|
@@ -2875,7 +3506,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2875
3506
|
o.offset ?? 0,
|
|
2876
3507
|
TASK_SESSION_PREFIX,
|
|
2877
3508
|
);
|
|
2878
|
-
|
|
3509
|
+
// One read of the active runs for the whole page, never one per row.
|
|
3510
|
+
return {
|
|
3511
|
+
sessions: withActiveRuns(
|
|
3512
|
+
sessions.map(toSummary),
|
|
3513
|
+
await this.activeChatRunRecords(),
|
|
3514
|
+
this.parkedFrontendCall,
|
|
3515
|
+
),
|
|
3516
|
+
total,
|
|
3517
|
+
};
|
|
2879
3518
|
}
|
|
2880
3519
|
|
|
2881
3520
|
/**
|
|
@@ -2987,9 +3626,652 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2987
3626
|
}
|
|
2988
3627
|
|
|
2989
3628
|
/**
|
|
2990
|
-
* `
|
|
2991
|
-
*
|
|
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.
|
|
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.
|
|
2992
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
|
+
|
|
2993
4275
|
async artifact(
|
|
2994
4276
|
identity: TurnIdentity,
|
|
2995
4277
|
artifactId: string,
|
|
@@ -3562,8 +4844,23 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3562
4844
|
* a queued run is dropped. (A client that merely disconnects does NOT
|
|
3563
4845
|
* reach here — runs outlive their connections.)
|
|
3564
4846
|
*/
|
|
3565
|
-
|
|
3566
|
-
|
|
4847
|
+
/**
|
|
4848
|
+
* Stop the session's current message. From `POST /messages/abort`
|
|
4849
|
+
* (`identity` given) the object is booted first — the boot recovers the
|
|
4850
|
+
* runs a previous incarnation left, so a run only the store still knows
|
|
4851
|
+
* (`recovering` after a reset) is live and stopped like any other — and
|
|
4852
|
+
* the answer waits until the runs it stopped have ended (at most the
|
|
4853
|
+
* supersede grace), so the client's next look sees the chat idle. The
|
|
4854
|
+
* scheduler calls it on a booted object without `identity`.
|
|
4855
|
+
*/
|
|
4856
|
+
async abortTurn(
|
|
4857
|
+
sessionId: string,
|
|
4858
|
+
identity?: TurnIdentity,
|
|
4859
|
+
): Promise<boolean> {
|
|
4860
|
+
if (identity) {
|
|
4861
|
+
await this.ready(identity);
|
|
4862
|
+
if (await this.runs?.abortSessionSettled(sessionId)) return true;
|
|
4863
|
+
} else if (this.runs?.abortSession(sessionId)) return true;
|
|
3567
4864
|
const controller = this.aborts.get(sessionId);
|
|
3568
4865
|
if (!controller) return false;
|
|
3569
4866
|
controller.abort();
|
|
@@ -4311,10 +5608,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4311
5608
|
// disconnects (`WsService.removeClientConnection`); same trigger here.
|
|
4312
5609
|
onSessionDrained: (sessionId) =>
|
|
4313
5610
|
this.scheduleHistoryIndexing(sessionId),
|
|
5611
|
+
parkTimeoutMs: frontendParkTimeoutMs(this.env),
|
|
4314
5612
|
});
|
|
4315
5613
|
return this.realtimeEndpoint;
|
|
4316
5614
|
}
|
|
4317
5615
|
|
|
5616
|
+
/**
|
|
5617
|
+
* The session's parked frontend call, for `waitingFor` on run
|
|
5618
|
+
* summaries. Never builds the endpoint: without one nothing is parked.
|
|
5619
|
+
*/
|
|
5620
|
+
private readonly parkedFrontendCall = (sessionId: string) =>
|
|
5621
|
+
this.realtimeEndpoint?.frontend.parkedFor(sessionId) ?? null;
|
|
5622
|
+
|
|
4318
5623
|
async realtimeStatus(): Promise<RealtimeStatus> {
|
|
4319
5624
|
return {
|
|
4320
5625
|
...this.realtime.status(),
|
|
@@ -5079,7 +6384,60 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
5079
6384
|
const live = this.runs?.activeForSession(sessionId);
|
|
5080
6385
|
const record =
|
|
5081
6386
|
live?.record ?? (await this.runStore?.activeForSession(sessionId));
|
|
5082
|
-
return record
|
|
6387
|
+
return record
|
|
6388
|
+
? withWaitingFor(runSummaryOf(record), this.parkedFrontendCall)
|
|
6389
|
+
: null;
|
|
6390
|
+
}
|
|
6391
|
+
|
|
6392
|
+
/**
|
|
6393
|
+
* `GET /runs/active` — the active runs of the chats `GET /sessions`
|
|
6394
|
+
* lists (same room rule, task runs out), oldest first. A cold object
|
|
6395
|
+
* that has run nothing since a boot found none answers without booting
|
|
6396
|
+
* (`META_RUNS_MAYBE_ACTIVE`): an idle user's polls never pay a boot,
|
|
6397
|
+
* and do not count as access for the idle eviction.
|
|
6398
|
+
*/
|
|
6399
|
+
async activeRuns(identity: TurnIdentity): Promise<RunSummary[]> {
|
|
6400
|
+
if (
|
|
6401
|
+
!this.initPromise &&
|
|
6402
|
+
(await this.ctx.storage.get<boolean>(META_RUNS_MAYBE_ACTIVE)) === false
|
|
6403
|
+
)
|
|
6404
|
+
return [];
|
|
6405
|
+
await this.ready(identity);
|
|
6406
|
+
const runs = await this.activeChatRunRecords();
|
|
6407
|
+
if (runs.length === 0) return [];
|
|
6408
|
+
const mainRoomId = await this.resolveMainRoomId(identity.userDid);
|
|
6409
|
+
const sessionRooms =
|
|
6410
|
+
mainRoomId === null
|
|
6411
|
+
? new Map<string, string | null>()
|
|
6412
|
+
: await this.sessions!.roomsOf([
|
|
6413
|
+
...new Set(runs.map((run) => run.sessionId)),
|
|
6414
|
+
]);
|
|
6415
|
+
return listedChatRuns(runs, { mainRoomId, sessionRooms }).map((run) =>
|
|
6416
|
+
activeRunSummaryOf(run, this.parkedFrontendCall),
|
|
6417
|
+
);
|
|
6418
|
+
}
|
|
6419
|
+
|
|
6420
|
+
/**
|
|
6421
|
+
* The active chat runs (task runs excluded). After a boot whose
|
|
6422
|
+
* recovery scan succeeded every active row is live, and the live record
|
|
6423
|
+
* is the truth (a run leaves the map before its row is closed): no SQL.
|
|
6424
|
+
* Without a coordinator, or after a failed scan, the stored rows count
|
|
6425
|
+
* too, live records winning.
|
|
6426
|
+
*/
|
|
6427
|
+
private async activeChatRunRecords(): Promise<RunRecord[]> {
|
|
6428
|
+
if (this.runs && !this.runRecoveryFailed)
|
|
6429
|
+
return activeChatRuns(this.runs.records(), []);
|
|
6430
|
+
return activeChatRuns(
|
|
6431
|
+
this.runs?.records() ?? [],
|
|
6432
|
+
(await this.runStore?.listActive()) ?? [],
|
|
6433
|
+
);
|
|
6434
|
+
}
|
|
6435
|
+
|
|
6436
|
+
/** Before a run row is created: from now on a cold poll must boot. */
|
|
6437
|
+
private async markRunsMaybeActive(): Promise<void> {
|
|
6438
|
+
if (this.runsMarkedActive) return;
|
|
6439
|
+
await this.ctx.storage.put(META_RUNS_MAYBE_ACTIVE, true);
|
|
6440
|
+
this.runsMarkedActive = true;
|
|
5083
6441
|
}
|
|
5084
6442
|
|
|
5085
6443
|
/** `GET /debug/context?model=` — the context budget a model gets here. */
|
|
@@ -5246,6 +6604,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
5246
6604
|
live: this.runs?.snapshot() ?? [],
|
|
5247
6605
|
runs,
|
|
5248
6606
|
claims: store ? await store.listClaims() : [],
|
|
6607
|
+
tools: this.toolScheduler.snapshot(),
|
|
5249
6608
|
};
|
|
5250
6609
|
}
|
|
5251
6610
|
|
|
@@ -5825,19 +7184,40 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
5825
7184
|
: clientSurfaceFor(body, priorState);
|
|
5826
7185
|
|
|
5827
7186
|
// Write-ahead tool marks + the resume policy (tool-marks.ts). The
|
|
5828
|
-
// effect
|
|
5829
|
-
// call time, so the middleware can be created
|
|
7187
|
+
// effect and surface maps are filled from the build below; the
|
|
7188
|
+
// closures read them at call time, so the middleware can be created
|
|
7189
|
+
// before the agent.
|
|
5830
7190
|
const toolEffects = new Map<string, ToolEffect>();
|
|
7191
|
+
const toolSurfaces = new Map<string, 'frontend'>();
|
|
5831
7192
|
const subAgentToolNames = new Set<string>();
|
|
5832
7193
|
const effectOf = (name: string): ToolEffect =>
|
|
5833
7194
|
toolEffects.get(name) ?? toolEffectOf({ name });
|
|
7195
|
+
const runStore = this.runStore!;
|
|
5834
7196
|
const marksMiddleware = createToolMarksMiddleware({
|
|
5835
7197
|
runId: run.runId,
|
|
5836
|
-
store:
|
|
7198
|
+
store: runStore,
|
|
5837
7199
|
effectOf,
|
|
7200
|
+
surfaceOf: (name) => toolSurfaces.get(name),
|
|
5838
7201
|
continuation: run.continuation,
|
|
5839
7202
|
logger: console,
|
|
5840
7203
|
});
|
|
7204
|
+
// `ctx.markDispatched`: a frontend call reached a browser socket, so a
|
|
7205
|
+
// restart from here on cannot tell whether it ran. A tool's context
|
|
7206
|
+
// carries its own LangChain call id, which is its mark's id on the
|
|
7207
|
+
// main agent. A sub-agent's inner marks are scoped to the dispatch
|
|
7208
|
+
// (`<dispatch>/<id>`), so an inner call's report names no mark and
|
|
7209
|
+
// they stay unsent; that never decides a resume, since a dispatch is a
|
|
7210
|
+
// write and is not run again. (An inner id equal to a main-agent call
|
|
7211
|
+
// id would mark that call sent early: the conservative side.)
|
|
7212
|
+
const markDispatched = (toolCallId: string): void => {
|
|
7213
|
+
void runStore
|
|
7214
|
+
.markSent(run.runId, toolCallId)
|
|
7215
|
+
.catch((error: unknown) =>
|
|
7216
|
+
console.warn(
|
|
7217
|
+
`[runs] ${run.runId}: could not record that ${toolCallId} was sent: ${error instanceof Error ? error.message : String(error)}`,
|
|
7218
|
+
),
|
|
7219
|
+
);
|
|
7220
|
+
};
|
|
5841
7221
|
// The turn's budget (turn-budget.ts): model tokens are accounted (for
|
|
5842
7222
|
// the usage line below, never limited) by the metered adapter every
|
|
5843
7223
|
// model of this turn comes from, tool calls are charged by
|
|
@@ -5874,12 +7254,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
5874
7254
|
`[harness] turn ${req.requestId} usage: ~${usage.tokens} tokens (${usage.reportedTokens} reported over ${usage.modelCalls} model calls), ${usage.toolAttempts} tool attempts, ${usage.elapsedMs} ms`,
|
|
5875
7255
|
);
|
|
5876
7256
|
});
|
|
7257
|
+
// A dispatch waits in its session's sub-agent lane, a browser tool
|
|
7258
|
+
// (`surface: 'frontend'`) in its session's frontend lane whatever its
|
|
7259
|
+
// effect, every other tool in the user-wide lane of its effect.
|
|
5877
7260
|
const laneOf = (name: string): ToolLane =>
|
|
5878
|
-
subAgentToolNames.has(name)
|
|
7261
|
+
subAgentToolNames.has(name)
|
|
7262
|
+
? 'subagent'
|
|
7263
|
+
: toolSurfaces.has(name)
|
|
7264
|
+
? 'frontend'
|
|
7265
|
+
: effectOf(name);
|
|
5879
7266
|
const executionMiddleware = createToolExecutionMiddleware({
|
|
5880
7267
|
budget,
|
|
5881
7268
|
scheduler: this.toolScheduler,
|
|
5882
7269
|
laneOf,
|
|
7270
|
+
effectOf,
|
|
5883
7271
|
runId: run.runId,
|
|
5884
7272
|
sessionId: req.sessionId,
|
|
5885
7273
|
claims: this.runStore!,
|
|
@@ -5976,6 +7364,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
5976
7364
|
llm: meteredLlm,
|
|
5977
7365
|
attachments: attachmentAccess,
|
|
5978
7366
|
onTurnEnd: (dispose) => turnDisposables.add(dispose),
|
|
7367
|
+
markDispatched,
|
|
5979
7368
|
},
|
|
5980
7369
|
hooks: {
|
|
5981
7370
|
// Drives the Matrix `work_status` card (one step per model/tool
|
|
@@ -6030,6 +7419,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6030
7419
|
id: req.sessionId,
|
|
6031
7420
|
client: req.client,
|
|
6032
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
|
+
: {}),
|
|
6033
7432
|
roomId: sessionRoomId,
|
|
6034
7433
|
},
|
|
6035
7434
|
model: effectiveModel,
|
|
@@ -6051,6 +7450,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6051
7450
|
const { agent, context } = built;
|
|
6052
7451
|
for (const [name, effect] of built.toolEffects)
|
|
6053
7452
|
toolEffects.set(name, effect);
|
|
7453
|
+
for (const [name, surface] of built.toolSurfaces)
|
|
7454
|
+
toolSurfaces.set(name, surface);
|
|
6054
7455
|
for (const name of built.subAgentToolNames) subAgentToolNames.add(name);
|
|
6055
7456
|
|
|
6056
7457
|
const attachmentKwargs =
|
|
@@ -6185,8 +7586,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6185
7586
|
req: TurnRequest,
|
|
6186
7587
|
text: string,
|
|
6187
7588
|
who: 'user' | 'oracle',
|
|
7589
|
+
receipt = false,
|
|
6188
7590
|
): Promise<void> {
|
|
6189
|
-
if (req.client === 'matrix') return Promise.resolve();
|
|
7591
|
+
if (req.client === 'matrix' && !receipt) return Promise.resolve();
|
|
6190
7592
|
if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
|
|
6191
7593
|
return Promise.resolve();
|
|
6192
7594
|
// A local session id has no marker event to thread on: the user had
|
|
@@ -6201,6 +7603,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6201
7603
|
req.sessionId,
|
|
6202
7604
|
async () => {
|
|
6203
7605
|
const roomId =
|
|
7606
|
+
(receipt ? req.roomId : undefined) ??
|
|
6204
7607
|
this.sessionRooms.get(req.sessionId) ??
|
|
6205
7608
|
req.roomId ??
|
|
6206
7609
|
(await this.sessions?.getSession(req.sessionId))?.roomId ??
|
|
@@ -6228,7 +7631,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6228
7631
|
};
|
|
6229
7632
|
},
|
|
6230
7633
|
label,
|
|
6231
|
-
req.client === 'channel',
|
|
7634
|
+
req.client === 'channel' || receipt,
|
|
6232
7635
|
);
|
|
6233
7636
|
}
|
|
6234
7637
|
|