@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
|
@@ -29,6 +29,8 @@ import { briefBody, record } from './document';
|
|
|
29
29
|
import { boundFindings } from './findings';
|
|
30
30
|
import { decode, INDEX_MAX_BYTES, verifyBytes } from './integrity';
|
|
31
31
|
import { LruMap } from './lru';
|
|
32
|
+
import type { Logger } from '../../plugin-api/types';
|
|
33
|
+
import { NOOP_LOGGER } from '../utils';
|
|
32
34
|
import type {
|
|
33
35
|
DocumentRequest,
|
|
34
36
|
DomainAnchor,
|
|
@@ -189,8 +191,11 @@ export class DomainContextResolver {
|
|
|
189
191
|
weigh: parsedWeight,
|
|
190
192
|
});
|
|
191
193
|
|
|
192
|
-
|
|
194
|
+
private readonly logger: Logger;
|
|
195
|
+
|
|
196
|
+
constructor(opts: { now?: () => number; logger?: Logger } = {}) {
|
|
193
197
|
this.now = opts.now ?? Date.now;
|
|
198
|
+
this.logger = opts.logger ?? NOOP_LOGGER;
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
/**
|
|
@@ -374,6 +379,13 @@ export class DomainContextResolver {
|
|
|
374
379
|
|
|
375
380
|
private failed(snapshot: DomainSnapshot, error: unknown): DomainSnapshot {
|
|
376
381
|
const code = errorCode(error, 'resolution-failed');
|
|
382
|
+
// A failure outside the finding vocabulary is a runtime fault (a thrown
|
|
383
|
+
// TypeError, a provider's message), not a property of the domain: the
|
|
384
|
+
// provenance only carries the fallback code, so the cause goes to the log.
|
|
385
|
+
if (errorCode(error, '') === '')
|
|
386
|
+
this.logger.warn(
|
|
387
|
+
`[domain-context] resolution of ${snapshot.did} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
388
|
+
);
|
|
377
389
|
const failed: DomainSnapshot = {
|
|
378
390
|
...snapshot,
|
|
379
391
|
status: INVALID_PATTERN.test(code) ? 'invalid' : 'unavailable',
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* The network edge of domain context: the IID anchor lookup on Blocksync and
|
|
3
3
|
* document reads. Reads go only to https origins on an allowlist (configured
|
|
4
4
|
* origins, the VFS, the Matrix homeserver, the IPFS gateway), never follow
|
|
5
|
-
* redirects
|
|
6
|
-
* request
|
|
5
|
+
* redirects (a 3xx answer is refused: workerd has no `redirect: 'error'`, so
|
|
6
|
+
* every request is made with `redirect: 'manual'` and the status checked),
|
|
7
|
+
* never carry URL credentials and never buffer more than the request's byte
|
|
8
|
+
* bound. Private documents are read through the user's VFS
|
|
7
9
|
* UCAN bearer or the host's `readPrivateDocument` adapter.
|
|
8
10
|
*/
|
|
9
11
|
import { CID } from 'multiformats/cid';
|
|
@@ -21,6 +23,32 @@ import type {
|
|
|
21
23
|
DomainTransport,
|
|
22
24
|
} from './types';
|
|
23
25
|
|
|
26
|
+
/** What `createDomainTransport` needs beyond the runtime context; tests inject a fetch. */
|
|
27
|
+
export interface DomainTransportDeps {
|
|
28
|
+
/** Default: the global `fetch`, looked up per call. */
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A request that never follows a redirect. `redirect: 'error'` does not
|
|
34
|
+
* exist on workerd (it throws a TypeError before any network call), so the
|
|
35
|
+
* request is made with `redirect: 'manual'`, which hands back the 3xx
|
|
36
|
+
* answer itself, and any 3xx is refused with `code`, its body cancelled.
|
|
37
|
+
*/
|
|
38
|
+
export async function fetchWithoutRedirects(
|
|
39
|
+
fetcher: typeof fetch,
|
|
40
|
+
input: string | URL,
|
|
41
|
+
init: RequestInit,
|
|
42
|
+
code: string,
|
|
43
|
+
): Promise<Response> {
|
|
44
|
+
const response = await fetcher(input, { ...init, redirect: 'manual' });
|
|
45
|
+
if (response.status >= 300 && response.status < 400) {
|
|
46
|
+
await response.body?.cancel().catch(() => undefined);
|
|
47
|
+
throw new Error(code);
|
|
48
|
+
}
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
/** Bound of one document read when the caller's signal does not end it sooner. */
|
|
25
53
|
export const DOCUMENT_READ_TIMEOUT_MS = 10_000;
|
|
26
54
|
|
|
@@ -136,7 +164,12 @@ function positiveNumber(value: unknown, fallback: number): number {
|
|
|
136
164
|
export function createDomainTransport(
|
|
137
165
|
options: DomainContextOptions,
|
|
138
166
|
ctx: RuntimeContext,
|
|
167
|
+
deps: DomainTransportDeps = {},
|
|
139
168
|
): DomainTransport {
|
|
169
|
+
// Never keep the bare global: workerd rejects `fetch` invoked with a
|
|
170
|
+
// foreign `this`, and a test may stub the global after this is built.
|
|
171
|
+
const fetcher: typeof fetch =
|
|
172
|
+
deps.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
140
173
|
const blocksync =
|
|
141
174
|
typeof ctx.config.BLOCKSYNC_GRAPHQL_URL === 'string'
|
|
142
175
|
? ctx.config.BLOCKSYNC_GRAPHQL_URL
|
|
@@ -222,13 +255,17 @@ export function createDomainTransport(
|
|
|
222
255
|
|
|
223
256
|
async resolve(did, signal) {
|
|
224
257
|
if (!blocksync) throw new Error('iid-unavailable');
|
|
225
|
-
const response = await
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
258
|
+
const response = await fetchWithoutRedirects(
|
|
259
|
+
fetcher,
|
|
260
|
+
blocksync,
|
|
261
|
+
{
|
|
262
|
+
method: 'POST',
|
|
263
|
+
signal,
|
|
264
|
+
headers: { 'content-type': 'application/json' },
|
|
265
|
+
body: JSON.stringify({ query: IID_QUERY, variables: { id: did } }),
|
|
266
|
+
},
|
|
267
|
+
'iid-unavailable',
|
|
268
|
+
);
|
|
232
269
|
const body = iidResponse.safeParse(
|
|
233
270
|
JSON.parse(
|
|
234
271
|
new TextDecoder().decode(
|
|
@@ -332,7 +369,12 @@ export function createDomainTransport(
|
|
|
332
369
|
headers.Authorization = `Bearer ${credentials.accessToken}`;
|
|
333
370
|
}
|
|
334
371
|
return boundedBytes(
|
|
335
|
-
await
|
|
372
|
+
await fetchWithoutRedirects(
|
|
373
|
+
fetcher,
|
|
374
|
+
url,
|
|
375
|
+
{ headers, signal },
|
|
376
|
+
'document-redirect-refused',
|
|
377
|
+
),
|
|
336
378
|
request.maxBytes,
|
|
337
379
|
);
|
|
338
380
|
},
|
package/src/core/index.ts
CHANGED
|
@@ -488,11 +488,27 @@ export function createRuntimeCore(opts: RuntimeCoreOptions): RuntimeCore {
|
|
|
488
488
|
registries.manifests.register(plugin, manifestOverrides[plugin.name]);
|
|
489
489
|
}
|
|
490
490
|
const manifestErrors: string[] = [];
|
|
491
|
+
const lintVerbose = opts.env['BOOT_LINT_VERBOSE'] === 'true';
|
|
492
|
+
const lintCounts = new Map<string, number>();
|
|
491
493
|
for (const { pluginName, manifest } of registries.manifests.collect()) {
|
|
492
494
|
const result = validateManifest(manifest, pluginName);
|
|
493
|
-
|
|
495
|
+
if (lintVerbose)
|
|
496
|
+
for (const warning of result.warnings) logger.warn(`[boot] ${warning}`);
|
|
497
|
+
else if (result.warnings.length > 0)
|
|
498
|
+
lintCounts.set(pluginName, result.warnings.length);
|
|
494
499
|
if (!result.valid) manifestErrors.push(...result.errors);
|
|
495
500
|
}
|
|
501
|
+
// The soft-rule warnings are the same on every boot of an isolate (one
|
|
502
|
+
// core per isolate): one summary line, the full list with
|
|
503
|
+
// BOOT_LINT_VERBOSE=true.
|
|
504
|
+
if (lintCounts.size > 0)
|
|
505
|
+
logger.warn(
|
|
506
|
+
`[boot] manifest lint: ${[...lintCounts.values()].reduce((a, b) => a + b, 0)} soft warning(s) (${[
|
|
507
|
+
...lintCounts,
|
|
508
|
+
]
|
|
509
|
+
.map(([plugin, count]) => `${plugin}=${count}`)
|
|
510
|
+
.join(', ')}); set BOOT_LINT_VERBOSE=true to list them`,
|
|
511
|
+
);
|
|
496
512
|
const guides = registries.manifests.operatingGuides();
|
|
497
513
|
if (guides.length > 0) {
|
|
498
514
|
logger.log(
|
|
@@ -707,9 +723,26 @@ export function createRuntimeCore(opts: RuntimeCoreOptions): RuntimeCore {
|
|
|
707
723
|
registries.tools,
|
|
708
724
|
registries.subAgents,
|
|
709
725
|
);
|
|
710
|
-
|
|
726
|
+
if (lintVerbose) {
|
|
727
|
+
for (const issue of cross.errors) {
|
|
728
|
+
logger.warn(
|
|
729
|
+
`[boot] ${issue} (boot-time tools only — ignore if the tool comes from getRequestTools)`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
} else if (cross.errors.length > 0) {
|
|
733
|
+
const perPlugin = new Map<string, number>();
|
|
734
|
+
for (const issue of cross.errors) {
|
|
735
|
+
const plugin = /^\[([^\]]+)\]/.exec(issue)?.[1] ?? 'unknown';
|
|
736
|
+
perPlugin.set(plugin, (perPlugin.get(plugin) ?? 0) + 1);
|
|
737
|
+
}
|
|
711
738
|
logger.warn(
|
|
712
|
-
`[boot] ${
|
|
739
|
+
`[boot] manifest examples: ${cross.errors.length} reference(s) to tools not registered at boot (${[
|
|
740
|
+
...perPlugin,
|
|
741
|
+
]
|
|
742
|
+
.map(([plugin, count]) => `${plugin}=${count}`)
|
|
743
|
+
.join(
|
|
744
|
+
', ',
|
|
745
|
+
)}; ignore those from getRequestTools); set BOOT_LINT_VERBOSE=true to list them`,
|
|
713
746
|
);
|
|
714
747
|
}
|
|
715
748
|
} catch (err) {
|
package/src/do/contracts.ts
CHANGED
|
@@ -660,6 +660,16 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
660
660
|
input: ChannelTurnInput,
|
|
661
661
|
requestHash: string,
|
|
662
662
|
): Promise<ChannelTurnOutcome>;
|
|
663
|
+
/**
|
|
664
|
+
* A poll of a channel request whose run exists, answered by a warm
|
|
665
|
+
* object; `null` = admit it through `channelTurn` (after the binding
|
|
666
|
+
* check).
|
|
667
|
+
*/
|
|
668
|
+
channelPoll(
|
|
669
|
+
identity: TurnIdentity,
|
|
670
|
+
input: ChannelTurnInput,
|
|
671
|
+
requestHash: string,
|
|
672
|
+
): Promise<ChannelTurnOutcome | null>;
|
|
663
673
|
/** Run a turn and return the final text (used by the Matrix gateway). */
|
|
664
674
|
runTurn(req: TurnRequest): Promise<TurnResult>;
|
|
665
675
|
/**
|
package/src/do/room-mirror.ts
CHANGED
|
@@ -97,8 +97,10 @@ export class RoomMirror {
|
|
|
97
97
|
/**
|
|
98
98
|
* Queue one mirror behind the session's previous ones. `prepare` runs when
|
|
99
99
|
* the session's earlier mirrors have settled and resolves what to send
|
|
100
|
-
* (`null` = nothing to mirror, e.g. no room).
|
|
101
|
-
*
|
|
100
|
+
* (`null` = nothing to mirror, e.g. no room). A mirror that cannot be
|
|
101
|
+
* posted is logged and the chain moves on; a `required` one (a channel
|
|
102
|
+
* request's, whose receipt records the delivery) rejects its caller
|
|
103
|
+
* instead, and the chain still moves on.
|
|
102
104
|
*/
|
|
103
105
|
enqueue(
|
|
104
106
|
sessionId: string,
|
|
@@ -107,7 +109,10 @@ export class RoomMirror {
|
|
|
107
109
|
required = false,
|
|
108
110
|
): Promise<void> {
|
|
109
111
|
const previous = this.chains.get(sessionId) ?? Promise.resolve();
|
|
112
|
+
// Starts once the previous mirror settled either way: a required mirror
|
|
113
|
+
// that failed (and rejected its own caller) must not skip this one.
|
|
110
114
|
const tracked: Promise<void> = previous
|
|
115
|
+
.catch(() => undefined)
|
|
111
116
|
.then(() => prepare())
|
|
112
117
|
.then((send) => (send ? this.send(send, label) : undefined))
|
|
113
118
|
.catch((err: unknown) => {
|
|
@@ -412,8 +412,8 @@ async function legacyPrune(db: DoSqliteDatabase, now: number): Promise<void> {
|
|
|
412
412
|
await db.transaction(async () => {
|
|
413
413
|
if (row.client === 'channel')
|
|
414
414
|
await db.run(
|
|
415
|
-
'INSERT OR IGNORE INTO channel_run_tombstones (run_id, status) VALUES (?, ?)',
|
|
416
|
-
[row.run_id, row.status],
|
|
415
|
+
'INSERT OR IGNORE INTO channel_run_tombstones (run_id, status, pruned_at) VALUES (?, ?, ?)',
|
|
416
|
+
[row.run_id, row.status, now],
|
|
417
417
|
);
|
|
418
418
|
for (const table of [
|
|
419
419
|
'turn_tool_marks',
|
package/src/do/run-store.ts
CHANGED
|
@@ -486,8 +486,26 @@ export class RunStore {
|
|
|
486
486
|
state TEXT NOT NULL DEFAULT 'pending'
|
|
487
487
|
) WITHOUT ROWID`);
|
|
488
488
|
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_run_tombstones (
|
|
489
|
-
run_id TEXT PRIMARY KEY, status TEXT NOT NULL
|
|
489
|
+
run_id TEXT PRIMARY KEY, status TEXT NOT NULL, pruned_at INTEGER
|
|
490
490
|
) WITHOUT ROWID`);
|
|
491
|
+
// When the run was pruned (epoch ms): tombstones are removed a while
|
|
492
|
+
// later (`pruneChannelTombstones`). Rows from before the column count
|
|
493
|
+
// from its migration.
|
|
494
|
+
const tombstoneColumns = await this.db.exec<{ name: string }>(
|
|
495
|
+
'PRAGMA table_info(channel_run_tombstones)',
|
|
496
|
+
);
|
|
497
|
+
if (!tombstoneColumns.some((c) => c.name === 'pruned_at')) {
|
|
498
|
+
await this.db.run(
|
|
499
|
+
'ALTER TABLE channel_run_tombstones ADD COLUMN pruned_at INTEGER',
|
|
500
|
+
);
|
|
501
|
+
await this.db.run(
|
|
502
|
+
'UPDATE channel_run_tombstones SET pruned_at = ? WHERE pruned_at IS NULL',
|
|
503
|
+
[this.now()],
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
await this.db.run(
|
|
507
|
+
'CREATE INDEX IF NOT EXISTS idx_channel_run_tombstones_pruned ON channel_run_tombstones(pruned_at)',
|
|
508
|
+
);
|
|
491
509
|
// The Reply Plan a finished chat-surface run delivers (chat delivery).
|
|
492
510
|
await this.db.run(`CREATE TABLE IF NOT EXISTS turn_run_plans (
|
|
493
511
|
run_id TEXT PRIMARY KEY, plan TEXT NOT NULL
|
|
@@ -519,9 +537,9 @@ export class RunStore {
|
|
|
519
537
|
if (await this.db.get(`${stale} LIMIT 1`, [cutoff]))
|
|
520
538
|
await this.db.transaction(async () => {
|
|
521
539
|
await this.db.run(
|
|
522
|
-
`INSERT OR IGNORE INTO channel_run_tombstones (run_id, status)
|
|
523
|
-
SELECT run_id, status FROM turn_runs WHERE client = 'channel' AND ${STALE_RUN_FILTER}`,
|
|
524
|
-
[cutoff],
|
|
540
|
+
`INSERT OR IGNORE INTO channel_run_tombstones (run_id, status, pruned_at)
|
|
541
|
+
SELECT run_id, status, ? FROM turn_runs WHERE client = 'channel' AND ${STALE_RUN_FILTER}`,
|
|
542
|
+
[this.now(), cutoff],
|
|
525
543
|
);
|
|
526
544
|
for (const table of [
|
|
527
545
|
'turn_tool_marks',
|
|
@@ -537,8 +555,9 @@ export class RunStore {
|
|
|
537
555
|
});
|
|
538
556
|
// Every receipt whose run is tombstoned, this boot or an earlier one
|
|
539
557
|
// that stopped before getting here. Driven by the receipts (at most the
|
|
540
|
-
// retention window of them), one key lookup each into the tombstones
|
|
541
|
-
//
|
|
558
|
+
// retention window of them), one key lookup each into the tombstones
|
|
559
|
+
// (pruned separately, by age, from the housekeeping alarm through
|
|
560
|
+
// `pruneChannelTombstones`, never scanned here). The table exists once the
|
|
542
561
|
// object has served a channel turn.
|
|
543
562
|
const receipts = await this.db.get<{ name: string }>(
|
|
544
563
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'channel_requests'`,
|
|
@@ -569,6 +588,22 @@ export class RunStore {
|
|
|
569
588
|
return row?.plan ?? null;
|
|
570
589
|
}
|
|
571
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Remove up to `limit` tombstones of channel runs pruned before `before`
|
|
593
|
+
* (epoch ms); returns how many went. A request id reused after its
|
|
594
|
+
* tombstone is gone is a new request and runs again.
|
|
595
|
+
*/
|
|
596
|
+
async pruneChannelTombstones(before: number, limit: number): Promise<number> {
|
|
597
|
+
await this.setup();
|
|
598
|
+
const pruned = await this.db.run(
|
|
599
|
+
`DELETE FROM channel_run_tombstones WHERE run_id IN (
|
|
600
|
+
SELECT run_id FROM channel_run_tombstones WHERE pruned_at < ? LIMIT ?
|
|
601
|
+
)`,
|
|
602
|
+
[before, limit],
|
|
603
|
+
);
|
|
604
|
+
return pruned.changes;
|
|
605
|
+
}
|
|
606
|
+
|
|
572
607
|
async wasChannelRunPruned(runId: string): Promise<boolean> {
|
|
573
608
|
await this.setup();
|
|
574
609
|
return !!(await this.db.get<{ run_id: string }>(
|