@ixo/oracle-runtime-workers 0.17.0 → 0.18.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/package.json +3 -3
- package/src/channels/auth.ts +65 -0
- package/src/channels/contract.ts +32 -2
- package/src/channels/failures.ts +64 -0
- package/src/channels/turns.ts +660 -105
- package/src/core/domain-context/resolver.ts +13 -1
- package/src/core/domain-context/transport.ts +52 -10
- package/src/core/index.ts +36 -3
- package/src/do/contracts.ts +10 -0
- package/src/do/room-mirror.ts +7 -2
- package/src/do/run-store-test-do.ts +2 -2
- package/src/do/run-store.ts +41 -6
- package/src/do/user-oracle-do.ts +191 -78
- package/src/shell/app.ts +16 -9
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { ChannelTurns } from '../channels/turns';
|
|
2
2
|
import { assertChannelAttemptAllowed } from '../channels/auth';
|
|
3
3
|
import {
|
|
4
|
+
CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
4
5
|
ChannelError,
|
|
5
6
|
channelRequestHash,
|
|
6
7
|
channelOrigin,
|
|
7
8
|
delegationRequiredError,
|
|
8
9
|
requireChannelDelegation,
|
|
10
|
+
StaleSessionError,
|
|
9
11
|
type ChannelTurnInput,
|
|
10
12
|
type ChannelTurnOutcome,
|
|
11
13
|
} from '../channels/contract';
|
|
14
|
+
import { classifyChannelFailure } from '../channels/failures';
|
|
12
15
|
/* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
|
|
13
16
|
/**
|
|
14
17
|
* `UserOracleDO` — one Durable Object per (user DID, oracle).
|
|
@@ -498,6 +501,8 @@ const META_LAST_ACCESS = 'meta:lastAccessAt';
|
|
|
498
501
|
const META_DELEGATION = 'meta:delegation';
|
|
499
502
|
/** How long a "no deposited delegation" verdict is trusted before re-checking the room. */
|
|
500
503
|
const DELEGATION_MISS_TTL_MS = 60_000;
|
|
504
|
+
/** Storage key prefix: a room this object found end-to-end encrypted. */
|
|
505
|
+
const ROOM_ENCRYPTED_PREFIX = 'room-encrypted:';
|
|
501
506
|
const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
|
|
502
507
|
/** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
|
|
503
508
|
const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
|
|
@@ -762,7 +767,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
762
767
|
* Domain-context anchors and documents, cached for every session of this
|
|
763
768
|
* object; `refresh_domain_context` invalidates entries here.
|
|
764
769
|
*/
|
|
765
|
-
private readonly domainResolver = new DomainContextResolver(
|
|
770
|
+
private readonly domainResolver = new DomainContextResolver({
|
|
771
|
+
logger: console,
|
|
772
|
+
});
|
|
766
773
|
/** Per-turn domain-context provenance (`domain_context_runs`). */
|
|
767
774
|
private domainContextStore: DomainContextStore | null = null;
|
|
768
775
|
private runs: RunCoordinator | null = null;
|
|
@@ -860,6 +867,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
860
867
|
private readonly sessionRooms = new Map<string, string>();
|
|
861
868
|
/** The user's main oracle room, once resolved (the session list is scoped to it). */
|
|
862
869
|
private mainRoomId: string | null = null;
|
|
870
|
+
/** Rooms known to be end-to-end encrypted (see `roomIsEncrypted`). */
|
|
871
|
+
private readonly encryptedRooms = new Set<string>();
|
|
863
872
|
/** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
|
|
864
873
|
private reauthPrompter: ReauthPrompter | null = null;
|
|
865
874
|
/** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
|
|
@@ -1163,9 +1172,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1163
1172
|
* this object's cache, then the `ucan_delegation` room state the client
|
|
1164
1173
|
* deposited. Nothing found → plugins degrade exactly as before (no
|
|
1165
1174
|
* downstream auth), re-checked after `DELEGATION_MISS_TTL_MS`.
|
|
1175
|
+
*
|
|
1176
|
+
* `minRemainingSeconds` (the channel path's margin): a stored copy with
|
|
1177
|
+
* less than that left is held, but the room state is read anyway, at
|
|
1178
|
+
* once and without the miss throttle — a newer deposit whose push to
|
|
1179
|
+
* this object failed is there — and adopted when it outlives the held
|
|
1180
|
+
* copy. The throttle stays for true misses (nothing stored).
|
|
1166
1181
|
*/
|
|
1167
|
-
private async hydrateDelegation(
|
|
1182
|
+
private async hydrateDelegation(
|
|
1183
|
+
userDid: string,
|
|
1184
|
+
options: { minRemainingSeconds?: number } = {},
|
|
1185
|
+
): Promise<void> {
|
|
1168
1186
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1187
|
+
const margin = options.minRemainingSeconds ?? 0;
|
|
1169
1188
|
const live = (
|
|
1170
1189
|
d: StoredDelegation | null | undefined,
|
|
1171
1190
|
): d is StoredDelegation =>
|
|
@@ -1173,14 +1192,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1173
1192
|
(typeof d?.expiration !== 'number' || d.expiration > nowSeconds);
|
|
1174
1193
|
const cached =
|
|
1175
1194
|
await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
|
|
1195
|
+
let held: CachedDelegation | null = null;
|
|
1176
1196
|
if (live(cached)) {
|
|
1177
|
-
this.
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1197
|
+
held = await this.delegationRecord(cached.raw, cached.expiration);
|
|
1198
|
+
this.delegations.set(userDid, held);
|
|
1199
|
+
if (
|
|
1200
|
+
margin === 0 ||
|
|
1201
|
+
(typeof held.expiration === 'number' &&
|
|
1202
|
+
held.expiration > nowSeconds + margin)
|
|
1203
|
+
)
|
|
1204
|
+
return;
|
|
1182
1205
|
}
|
|
1183
|
-
if (Date.now() < this.delegationMissUntil) return;
|
|
1206
|
+
if (!held && Date.now() < this.delegationMissUntil) return;
|
|
1184
1207
|
try {
|
|
1185
1208
|
const room = await this.gateway.resolveUserRoom(userDid);
|
|
1186
1209
|
const json = room
|
|
@@ -1207,15 +1230,23 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1207
1230
|
: {}),
|
|
1208
1231
|
}
|
|
1209
1232
|
: null;
|
|
1210
|
-
if (live(deposited)) {
|
|
1211
|
-
this.
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
);
|
|
1215
|
-
await this.ctx.storage.put(META_DELEGATION, deposited);
|
|
1216
|
-
console.log(
|
|
1217
|
-
`[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
|
|
1233
|
+
if (live(deposited) && deposited.raw !== held?.raw) {
|
|
1234
|
+
const record = await this.delegationRecord(
|
|
1235
|
+
deposited.raw,
|
|
1236
|
+
deposited.expiration,
|
|
1218
1237
|
);
|
|
1238
|
+
// A held copy is replaced only by a deposit that outlives it.
|
|
1239
|
+
if (
|
|
1240
|
+
!held ||
|
|
1241
|
+
(typeof record.expiration === 'number' &&
|
|
1242
|
+
record.expiration > (held.expiration ?? 0))
|
|
1243
|
+
) {
|
|
1244
|
+
this.delegations.set(userDid, record);
|
|
1245
|
+
await this.ctx.storage.put(META_DELEGATION, deposited);
|
|
1246
|
+
console.log(
|
|
1247
|
+
`[user-do] delegation for ${userDid} loaded from the deposited room state (${held ? 'the held copy was inside the margin' : 'header-less turn'})`,
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1219
1250
|
return;
|
|
1220
1251
|
}
|
|
1221
1252
|
} catch (err) {
|
|
@@ -1223,7 +1254,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1223
1254
|
`[user-do] could not load ${userDid}'s deposited delegation: ${err instanceof Error ? err.message : String(err)}`,
|
|
1224
1255
|
);
|
|
1225
1256
|
}
|
|
1226
|
-
this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
|
|
1257
|
+
if (!held) this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
|
|
1227
1258
|
}
|
|
1228
1259
|
|
|
1229
1260
|
/**
|
|
@@ -1699,57 +1730,58 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1699
1730
|
|
|
1700
1731
|
this.channelTurns = new ChannelTurns(liveDb, {
|
|
1701
1732
|
createSession: async (identity, markerTxnId) => {
|
|
1702
|
-
const
|
|
1703
|
-
if (!
|
|
1733
|
+
const roomId = await this.resolveMainRoomId(identity.userDid);
|
|
1734
|
+
if (!roomId)
|
|
1704
1735
|
throw new ChannelError(
|
|
1705
1736
|
428,
|
|
1706
1737
|
'Companion room is not ready',
|
|
1707
1738
|
'room_not_ready',
|
|
1708
1739
|
);
|
|
1709
|
-
return (
|
|
1710
|
-
|
|
1711
|
-
roomId: room.roomId,
|
|
1712
|
-
markerTxnId,
|
|
1713
|
-
})
|
|
1714
|
-
).sessionId;
|
|
1740
|
+
return (await this.createSession(identity, { roomId, markerTxnId }))
|
|
1741
|
+
.sessionId;
|
|
1715
1742
|
},
|
|
1716
1743
|
assertSession: async (identity, sessionId) => {
|
|
1717
1744
|
const session = await this.sessions!.getSession(sessionId);
|
|
1718
|
-
|
|
1745
|
+
// Deleted (or never created here): admission releases the
|
|
1746
|
+
// binding's hold on it instead of refusing for ever.
|
|
1747
|
+
if (!session) throw new StaleSessionError();
|
|
1748
|
+
const roomId = await this.resolveMainRoomId(identity.userDid);
|
|
1719
1749
|
if (
|
|
1720
|
-
!session ||
|
|
1721
1750
|
!sessionId.startsWith('$') ||
|
|
1722
|
-
!
|
|
1723
|
-
session.roomId !==
|
|
1751
|
+
!roomId ||
|
|
1752
|
+
session.roomId !== roomId
|
|
1724
1753
|
)
|
|
1725
1754
|
throw new ChannelError(
|
|
1726
1755
|
404,
|
|
1727
1756
|
'Companion session not found',
|
|
1728
1757
|
'session_not_found',
|
|
1729
1758
|
);
|
|
1730
|
-
|
|
1731
|
-
room.roomId,
|
|
1732
|
-
'm.room.encryption',
|
|
1733
|
-
);
|
|
1734
|
-
const parsed: unknown = encryption ? JSON.parse(encryption) : null;
|
|
1735
|
-
if (
|
|
1736
|
-
!parsed ||
|
|
1737
|
-
typeof parsed !== 'object' ||
|
|
1738
|
-
!('algorithm' in parsed) ||
|
|
1739
|
-
parsed.algorithm !== 'm.megolm.v1.aes-sha2'
|
|
1740
|
-
)
|
|
1759
|
+
if (!(await this.roomIsEncrypted(roomId)))
|
|
1741
1760
|
throw new ChannelError(
|
|
1742
1761
|
409,
|
|
1743
1762
|
'Companion room must be encrypted',
|
|
1744
1763
|
'room_not_encrypted',
|
|
1745
1764
|
);
|
|
1746
1765
|
},
|
|
1747
|
-
//
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1766
|
+
// A new request only (a poll of an existing run is never refused
|
|
1767
|
+
// for it); checked again as the run starts. A held delegation
|
|
1768
|
+
// inside the margin re-reads the deposited room state first.
|
|
1769
|
+
requireDelegation: async (identity) => {
|
|
1770
|
+
try {
|
|
1771
|
+
requireChannelDelegation(
|
|
1772
|
+
this.delegations.get(identity.userDid),
|
|
1773
|
+
Math.floor(Date.now() / 1000),
|
|
1774
|
+
);
|
|
1775
|
+
} catch {
|
|
1776
|
+
await this.hydrateDelegation(identity.userDid, {
|
|
1777
|
+
minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
1778
|
+
});
|
|
1779
|
+
requireChannelDelegation(
|
|
1780
|
+
this.delegations.get(identity.userDid),
|
|
1781
|
+
Math.floor(Date.now() / 1000),
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1753
1785
|
getRun: (runId) => this.runStore!.get(runId),
|
|
1754
1786
|
getPlan: (runId) => this.runStore!.getPlan(runId),
|
|
1755
1787
|
wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
|
|
@@ -1770,12 +1802,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1770
1802
|
this.ctx.waitUntil(live.done.catch(() => undefined));
|
|
1771
1803
|
return live.record;
|
|
1772
1804
|
},
|
|
1805
|
+
requestOf: (record) =>
|
|
1806
|
+
(JSON.parse(record.request) as StoredRunRequest).turn,
|
|
1773
1807
|
mirror: (request, text, author) =>
|
|
1774
1808
|
this.replayToRoom(request, text, author),
|
|
1775
1809
|
changed: async () => {
|
|
1776
1810
|
this.markDirty();
|
|
1777
1811
|
await this.ctx.storage.sync();
|
|
1778
1812
|
},
|
|
1813
|
+
scheduleRetry: (at) => this.ctx.waitUntil(this.requestHousekeeping(at)),
|
|
1814
|
+
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
1815
|
+
pruneTombstones: (before, limit) =>
|
|
1816
|
+
this.runStore!.pruneChannelTombstones(before, limit),
|
|
1779
1817
|
});
|
|
1780
1818
|
|
|
1781
1819
|
const core = this.core;
|
|
@@ -2314,6 +2352,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2314
2352
|
if (next !== null) deadlines.push(Math.max(next, now + 1000));
|
|
2315
2353
|
}
|
|
2316
2354
|
|
|
2355
|
+
// Channel requests: retry the Companion room mirrors that are due, and
|
|
2356
|
+
// prune (bounded) receipts that never started a run and old run
|
|
2357
|
+
// tombstones (channels/turns.ts).
|
|
2358
|
+
if (this.channelTurns) {
|
|
2359
|
+
try {
|
|
2360
|
+
const next = await this.channelTurns.housekeeping();
|
|
2361
|
+
if (next !== null) deadlines.push(Math.max(next, now + 1000));
|
|
2362
|
+
} catch (err) {
|
|
2363
|
+
console.error(
|
|
2364
|
+
`[user-do] channel housekeeping failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2317
2369
|
// Legacy-blob compaction: one bounded batch per tick until the whole
|
|
2318
2370
|
// file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
|
|
2319
2371
|
// flight holds a snapshot of the file, which the compactor's swap-in
|
|
@@ -2661,24 +2713,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2661
2713
|
'channel_grant_not_tool_authority',
|
|
2662
2714
|
);
|
|
2663
2715
|
// The shell (the only caller) checked the binding just before.
|
|
2664
|
-
// The
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
//
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
)
|
|
2716
|
+
// The 900 s margin is checked by admission, for a NEW request only
|
|
2717
|
+
// (and again as its run starts): a poll of a run that exists
|
|
2718
|
+
// answers its status whatever the delegation's state. Before a
|
|
2719
|
+
// COLD object boots, though, a delegation must be live (present,
|
|
2720
|
+
// unexpired): the boot may load the owner copy under it, and a user
|
|
2721
|
+
// without one must get `delegation_required`, not a boot failure.
|
|
2722
|
+
// Hydration reads the stored copy, then the deposited room state
|
|
2723
|
+
// (re-read when the stored copy is inside the margin).
|
|
2724
|
+
if (!this.db) {
|
|
2725
|
+
const live = (): boolean => {
|
|
2726
|
+
const held = this.delegations.get(identity.userDid);
|
|
2727
|
+
return (
|
|
2728
|
+
Boolean(held?.raw) &&
|
|
2729
|
+
(typeof held?.expiration !== 'number' ||
|
|
2730
|
+
held.expiration > Math.floor(Date.now() / 1000))
|
|
2731
|
+
);
|
|
2732
|
+
};
|
|
2733
|
+
if (!live())
|
|
2734
|
+
await this.hydrateDelegation(identity.userDid, {
|
|
2735
|
+
minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
2736
|
+
});
|
|
2737
|
+
if (!live()) throw delegationRequiredError();
|
|
2682
2738
|
}
|
|
2683
2739
|
try {
|
|
2684
2740
|
await this.ready(identity);
|
|
@@ -2697,17 +2753,48 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2697
2753
|
result: await this.channelTurns!.submit(identity, input, requestHash),
|
|
2698
2754
|
};
|
|
2699
2755
|
} catch (error) {
|
|
2700
|
-
|
|
2756
|
+
// A failure retrying cannot fix (the room refused the oracle, the
|
|
2757
|
+
// file storage rejected it) is a final refusal, not a 503.
|
|
2758
|
+
const refusal =
|
|
2759
|
+
error instanceof ChannelError ? error : classifyChannelFailure(error);
|
|
2760
|
+
if (refusal)
|
|
2701
2761
|
return {
|
|
2702
2762
|
ok: false,
|
|
2703
|
-
status:
|
|
2704
|
-
code:
|
|
2705
|
-
message:
|
|
2763
|
+
status: refusal.status,
|
|
2764
|
+
code: refusal.code,
|
|
2765
|
+
message: refusal.message,
|
|
2706
2766
|
};
|
|
2707
2767
|
throw error;
|
|
2708
2768
|
}
|
|
2709
2769
|
}
|
|
2710
2770
|
|
|
2771
|
+
/**
|
|
2772
|
+
* A poll of a channel request whose run exists, answered by a warm
|
|
2773
|
+
* object from SQLite without booting, admitting or waiting on the
|
|
2774
|
+
* admission lock. `null` when the object is cold or the request has no
|
|
2775
|
+
* run yet: the shell then checks the binding with the Auth Hub and
|
|
2776
|
+
* calls `channelTurn`. A binding revoked meanwhile is still enforced
|
|
2777
|
+
* where it matters: before a queued or recovered attempt executes.
|
|
2778
|
+
*/
|
|
2779
|
+
async channelPoll(
|
|
2780
|
+
identity: TurnIdentity,
|
|
2781
|
+
input: ChannelTurnInput,
|
|
2782
|
+
requestHash: string,
|
|
2783
|
+
): Promise<ChannelTurnOutcome | null> {
|
|
2784
|
+
if (identity.ucanDelegation || !this.initPromise || this.evicting)
|
|
2785
|
+
return null;
|
|
2786
|
+
try {
|
|
2787
|
+
await this.initPromise;
|
|
2788
|
+
} catch {
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
if (this.userDid !== identity.userDid || !this.channelTurns) return null;
|
|
2792
|
+
const result = await this.channelTurns.poll(identity, input, requestHash);
|
|
2793
|
+
if (!result) return null;
|
|
2794
|
+
await this.recordAccess();
|
|
2795
|
+
return { ok: true, result };
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2711
2798
|
async listSessions(
|
|
2712
2799
|
identity: TurnIdentity,
|
|
2713
2800
|
o: { limit?: number; offset?: number } = {},
|
|
@@ -2743,6 +2830,37 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2743
2830
|
return room?.roomId ?? null;
|
|
2744
2831
|
}
|
|
2745
2832
|
|
|
2833
|
+
/**
|
|
2834
|
+
* Whether the room is end-to-end encrypted (Megolm). Encryption cannot
|
|
2835
|
+
* be turned off in Matrix, so a positive answer is remembered for good,
|
|
2836
|
+
* in memory and in storage (`room-encrypted:<roomId>`): the homeserver
|
|
2837
|
+
* is asked once per room, not once per channel admission. A negative
|
|
2838
|
+
* answer is never remembered.
|
|
2839
|
+
*/
|
|
2840
|
+
private async roomIsEncrypted(roomId: string): Promise<boolean> {
|
|
2841
|
+
if (this.encryptedRooms.has(roomId)) return true;
|
|
2842
|
+
const key = `${ROOM_ENCRYPTED_PREFIX}${roomId}`;
|
|
2843
|
+
if ((await this.ctx.storage.get<boolean>(key)) === true) {
|
|
2844
|
+
this.encryptedRooms.add(roomId);
|
|
2845
|
+
return true;
|
|
2846
|
+
}
|
|
2847
|
+
const encryption = await this.gateway.getRoomStateEvent(
|
|
2848
|
+
roomId,
|
|
2849
|
+
'm.room.encryption',
|
|
2850
|
+
);
|
|
2851
|
+
const parsed: unknown = encryption ? JSON.parse(encryption) : null;
|
|
2852
|
+
const encrypted =
|
|
2853
|
+
typeof parsed === 'object' &&
|
|
2854
|
+
parsed !== null &&
|
|
2855
|
+
'algorithm' in parsed &&
|
|
2856
|
+
parsed.algorithm === 'm.megolm.v1.aes-sha2';
|
|
2857
|
+
if (encrypted) {
|
|
2858
|
+
this.encryptedRooms.add(roomId);
|
|
2859
|
+
await this.ctx.storage.put(key, true);
|
|
2860
|
+
}
|
|
2861
|
+
return encrypted;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2746
2864
|
async deleteSession(
|
|
2747
2865
|
identity: TurnIdentity,
|
|
2748
2866
|
sessionId: string,
|
|
@@ -4644,16 +4762,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4644
4762
|
record: RunRecord,
|
|
4645
4763
|
outcome: RunOutcome,
|
|
4646
4764
|
): Promise<void> {
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
stored.turn,
|
|
4653
|
-
record.runId,
|
|
4654
|
-
outcome.text,
|
|
4655
|
-
);
|
|
4656
|
-
}
|
|
4765
|
+
// A channel run's reply (or the end of a run without one) is mirrored
|
|
4766
|
+
// into the Companion room in the background: never awaited here, so
|
|
4767
|
+
// the session's next queued run starts at once. Without the handle (a
|
|
4768
|
+
// reset dropped it mid-run) the next poll or alarm mirrors it.
|
|
4769
|
+
if (record.client === 'channel') this.channelTurns?.deliverReply();
|
|
4657
4770
|
if (!record.taskRunId || !this.taskScheduler) return;
|
|
4658
4771
|
if (this.taskScheduler.isRunActive(record.taskRunId)) return;
|
|
4659
4772
|
if (outcome.status === 'finished')
|
package/src/shell/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
authenticateChannel,
|
|
3
|
-
|
|
3
|
+
assertActiveChannelBindingCached,
|
|
4
4
|
channelAuthConfig,
|
|
5
5
|
} from '../channels/auth';
|
|
6
6
|
import {
|
|
@@ -212,7 +212,7 @@ export function isDidShaped(value: string): boolean {
|
|
|
212
212
|
*/
|
|
213
213
|
export function rateLimitKey(
|
|
214
214
|
env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
|
|
215
|
-
scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
|
|
215
|
+
scope: 'user' | 'channel' | 'artifact' | 'socket' | 'status' | 'start',
|
|
216
216
|
subject: string,
|
|
217
217
|
): string {
|
|
218
218
|
return `${env.ORACLE_DID}|${scope}|${subject}`;
|
|
@@ -442,21 +442,28 @@ export function createShell(
|
|
|
442
442
|
);
|
|
443
443
|
// Limited as soon as the user is proven, before the Auth Hub round
|
|
444
444
|
// trip: a flood or a tight poll must not cost one call there each.
|
|
445
|
+
// Channel traffic has its own budget (`channel` scope), apart from
|
|
446
|
+
// the user's Portal requests: polls never push the Portal into 429,
|
|
447
|
+
// nor the other way round.
|
|
445
448
|
if (
|
|
446
449
|
c.env.RATE_LIMIT &&
|
|
447
450
|
!(
|
|
448
451
|
await c.env.RATE_LIMIT.limit({
|
|
449
|
-
key: rateLimitKey(c.env, '
|
|
452
|
+
key: rateLimitKey(c.env, 'channel', identity.userDid),
|
|
450
453
|
})
|
|
451
454
|
).success
|
|
452
455
|
)
|
|
453
456
|
throw new ChannelError(429, 'Too many requests', 'rate_limited');
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
);
|
|
457
|
+
const stub = userStub(c.env, identity.userDid);
|
|
458
|
+
const requestHash = await channelRequestHash(raw);
|
|
459
|
+
// A poll of a run that exists is answered by the (warm) object with no
|
|
460
|
+
// Auth Hub call; anything else is checked with the Auth Hub (an
|
|
461
|
+
// "active" verdict is cached per isolate for 60 s) and admitted.
|
|
462
|
+
let outcome = await stub.channelPoll(identity, parsed.data, requestHash);
|
|
463
|
+
if (!outcome) {
|
|
464
|
+
await assertActiveChannelBindingCached(identity, c.env);
|
|
465
|
+
outcome = await stub.channelTurn(identity, parsed.data, requestHash);
|
|
466
|
+
}
|
|
460
467
|
if (!outcome.ok)
|
|
461
468
|
return c.json(
|
|
462
469
|
{ code: outcome.code, message: outcome.message },
|