@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/contracts.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BeginTopicStep,
|
|
3
|
+
TopicReceiptReferences,
|
|
4
|
+
} from '@ixo/ixo-transaction/topic-proposals';
|
|
5
|
+
import type { ProposalCommand } from '../action-proposals/store';
|
|
6
|
+
import type { ActionOutcomeV1 } from '@ixo/action-protocol';
|
|
1
7
|
import type {
|
|
2
8
|
TopicDeliverableCommand,
|
|
3
9
|
TopicDeliverableResult,
|
|
@@ -14,9 +20,11 @@ import type {
|
|
|
14
20
|
} from '@ixo/common/ai/decisions';
|
|
15
21
|
import type { TranscriptPageOptions } from './transcript';
|
|
16
22
|
import type { TurnUsage } from '../core/turn-budget';
|
|
23
|
+
import type { ToolSchedulerSnapshot } from '../core/tool-scheduler';
|
|
17
24
|
import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
|
|
18
25
|
import type { AttachmentInput } from '../attachments/types';
|
|
19
26
|
import type { RealtimeStatus } from '../realtime/realtime-endpoint';
|
|
27
|
+
import type { FrontendWaitingFor } from '../plugin-api/types';
|
|
20
28
|
import type { TierFlushResult, TierStatus } from '../sqlite/do-vfs';
|
|
21
29
|
import type { OwnedArtifact } from '../artifacts/store';
|
|
22
30
|
import type {
|
|
@@ -48,6 +56,12 @@ import type { DomainContextProvenance } from '../core/domain-context';
|
|
|
48
56
|
/** Bindings every oracle Worker must declare (see the example `wrangler.jsonc`). */
|
|
49
57
|
export interface OracleWorkerEnv {
|
|
50
58
|
TOPIC_DELIVERABLES_ENABLED?: string;
|
|
59
|
+
ACTION_PROPOSALS_RPC_TESTNET?: string;
|
|
60
|
+
ACTION_PROPOSALS_RPC_DEVNET?: string;
|
|
61
|
+
ACTION_PROPOSALS_RPC_MAINNET?: string;
|
|
62
|
+
ACTION_PROPOSALS_ENABLED?: string;
|
|
63
|
+
ACTION_TOPIC_PROPOSALS_ENABLED?: string;
|
|
64
|
+
ACTION_PROPOSALS_PORTAL_ORIGIN?: string;
|
|
51
65
|
USER_ORACLE: DurableObjectNamespace<UserOracleObject>;
|
|
52
66
|
MATRIX_GATEWAY: DurableObjectNamespace<MatrixGatewayObject>;
|
|
53
67
|
|
|
@@ -70,6 +84,8 @@ export interface OracleWorkerEnv {
|
|
|
70
84
|
AUTH_HUB?: Fetcher;
|
|
71
85
|
AUTH_HUB_URL?: string;
|
|
72
86
|
AUTH_HUB_CHANNEL_SERVICE_KEY?: string;
|
|
87
|
+
HUB_ACTIONS_ENABLED?: string;
|
|
88
|
+
AUTH_HUB_ACTION_OUTCOME_KEY?: string;
|
|
73
89
|
|
|
74
90
|
// --- matrix -------------------------------------------------------------
|
|
75
91
|
MATRIX_BASE_URL: string;
|
|
@@ -251,6 +267,8 @@ export interface OracleWorkerEnv {
|
|
|
251
267
|
TURN_MAX_IDENTICAL_READS?: string;
|
|
252
268
|
/** Time limit per request-admission handler in ms (default 2000). */
|
|
253
269
|
REQUEST_ADMISSION_TIMEOUT_MS?: string;
|
|
270
|
+
/** How long a frontend call waits unsent for a browser, in ms (default 300000). */
|
|
271
|
+
FRONTEND_PARK_TIMEOUT_MS?: string;
|
|
254
272
|
/** Durable-run knobs (docs/plans/durable-runs.md); see `runDurabilityConfig`. */
|
|
255
273
|
RUN_KEEPALIVE_MS?: string;
|
|
256
274
|
RUN_SEGMENT_FLUSH_MS?: string;
|
|
@@ -367,6 +385,7 @@ export interface TurnRequest {
|
|
|
367
385
|
/** A provider id (`/^[a-z][a-z0-9_-]{0,31}$/`), e.g. `whatsapp`. */
|
|
368
386
|
provider: string;
|
|
369
387
|
bindingId: string;
|
|
388
|
+
bindingRevision?: number;
|
|
370
389
|
remoteMessageRef: string;
|
|
371
390
|
};
|
|
372
391
|
executionProfile?: TaskExecutionProfile;
|
|
@@ -405,7 +424,6 @@ export interface TurnRequest {
|
|
|
405
424
|
taskRunId?: string;
|
|
406
425
|
}
|
|
407
426
|
|
|
408
|
-
/** A run as the shell reports it (`GET /sessions/:id/run`, `GET /debug/runs`). */
|
|
409
427
|
/** `GET /debug/context` — the context budget a model gets on this object. */
|
|
410
428
|
/**
|
|
411
429
|
* Per-session context counters (`GET /debug/context?session=`): what the
|
|
@@ -500,6 +518,10 @@ export interface DomainContextRouterUpdate {
|
|
|
500
518
|
domainContext: DomainContextProvenance[];
|
|
501
519
|
}
|
|
502
520
|
|
|
521
|
+
/**
|
|
522
|
+
* A run as the shell reports it (`GET /sessions/:id/run`, `GET /runs/active`,
|
|
523
|
+
* `activeRun` on `GET /sessions` rows, `GET /debug/runs`).
|
|
524
|
+
*/
|
|
503
525
|
export interface RunSummary {
|
|
504
526
|
runId: string;
|
|
505
527
|
sessionId: string;
|
|
@@ -513,13 +535,27 @@ export interface RunSummary {
|
|
|
513
535
|
| 'aborted'
|
|
514
536
|
| 'interrupted'
|
|
515
537
|
| 'failed';
|
|
538
|
+
/**
|
|
539
|
+
* When the run's row was created (`RunStore.create`), unchanged when a
|
|
540
|
+
* queued run starts: a dequeued run's elapsed time includes its wait.
|
|
541
|
+
*/
|
|
516
542
|
startedAt: string;
|
|
517
543
|
updatedAt: string;
|
|
518
|
-
/**
|
|
544
|
+
/**
|
|
545
|
+
* Highest frame sequence packed when the row was last written; written
|
|
546
|
+
* when the run ends, so not a live cursor for an active run.
|
|
547
|
+
*/
|
|
519
548
|
lastSeq: number;
|
|
520
549
|
messageId?: string;
|
|
521
550
|
/** The reply so far, for a run that ended without a committed reply. */
|
|
522
551
|
partialText?: string;
|
|
552
|
+
/**
|
|
553
|
+
* The browser tool / AG-UI action the run is waiting on, parked because no
|
|
554
|
+
* browser of the session that can run it is connected: opening the chat
|
|
555
|
+
* sends it. Live state of this object only, never persisted; absent when
|
|
556
|
+
* the run waits on nothing.
|
|
557
|
+
*/
|
|
558
|
+
waitingFor?: FrontendWaitingFor;
|
|
523
559
|
}
|
|
524
560
|
|
|
525
561
|
/** `GET /debug/runs`: the durable-run configuration, live runs and recent rows. */
|
|
@@ -573,6 +609,12 @@ export interface RunsStatus {
|
|
|
573
609
|
startedAt: string;
|
|
574
610
|
state: 'pending' | 'warned';
|
|
575
611
|
}>;
|
|
612
|
+
/**
|
|
613
|
+
* Tool lanes of the object right now (tool-scheduler.ts): the user-wide
|
|
614
|
+
* `read` / `write` lanes, the per-session `frontend` and `subagent` lanes
|
|
615
|
+
* summed, and each busy per-session lane by key (`sessionLanes`).
|
|
616
|
+
*/
|
|
617
|
+
tools: ToolSchedulerSnapshot;
|
|
576
618
|
}
|
|
577
619
|
|
|
578
620
|
/** Non-streaming turn result. */
|
|
@@ -611,6 +653,12 @@ export interface SessionSummary {
|
|
|
611
653
|
createdAt: string;
|
|
612
654
|
lastUpdatedAt: string;
|
|
613
655
|
roomId?: string;
|
|
656
|
+
/**
|
|
657
|
+
* The run the session is busy with (queued / running / recovering), as
|
|
658
|
+
* `GET /runs/active` reports it: without `partialText`. Absent, never
|
|
659
|
+
* null, when the session has none.
|
|
660
|
+
*/
|
|
661
|
+
activeRun?: RunSummary;
|
|
614
662
|
}
|
|
615
663
|
|
|
616
664
|
/** `search_memory_engine`'s raw upstream schema + whether it converts to Zod. */
|
|
@@ -688,6 +736,7 @@ export interface StorageStatus {
|
|
|
688
736
|
* piped straight to the client.
|
|
689
737
|
*/
|
|
690
738
|
export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
739
|
+
recordHubActionOutcome(outcome: ActionOutcomeV1): Promise<void>;
|
|
691
740
|
topicDeliverable(
|
|
692
741
|
identity: TurnIdentity,
|
|
693
742
|
operationId: string,
|
|
@@ -726,6 +775,18 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
726
775
|
opts?: { limit?: number; offset?: number },
|
|
727
776
|
): Promise<{ sessions: SessionSummary[]; total: number }>;
|
|
728
777
|
deleteSession(identity: TurnIdentity, sessionId: string): Promise<boolean>;
|
|
778
|
+
actionProposal(
|
|
779
|
+
identity: TurnIdentity,
|
|
780
|
+
id: string,
|
|
781
|
+
command?:
|
|
782
|
+
| ProposalCommand
|
|
783
|
+
| { kind: 'topic-reconcile'; input: TopicReceiptReferences },
|
|
784
|
+
): Promise<JsonString | null>;
|
|
785
|
+
beginTopicStep(
|
|
786
|
+
identity: TurnIdentity,
|
|
787
|
+
id: string,
|
|
788
|
+
input: BeginTopicStep,
|
|
789
|
+
): Promise<JsonString | null>;
|
|
729
790
|
/** `GET /artifacts/:id` — the owner's canonical copy, or null. */
|
|
730
791
|
artifact(
|
|
731
792
|
identity: TurnIdentity,
|
|
@@ -761,12 +822,23 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
761
822
|
target: FeedbackTarget,
|
|
762
823
|
outcome: FeedbackSettlement,
|
|
763
824
|
): Promise<void>;
|
|
764
|
-
|
|
825
|
+
/**
|
|
826
|
+
* Stop the session's current message. With `identity` (the HTTP route)
|
|
827
|
+
* the object boots first and the answer waits until the stopped runs
|
|
828
|
+
* have ended (at most the supersede grace).
|
|
829
|
+
*/
|
|
830
|
+
abortTurn(sessionId: string, identity?: TurnIdentity): Promise<boolean>;
|
|
765
831
|
/** The session's active (queued/running/recovering) run, if any. */
|
|
766
832
|
sessionRun(
|
|
767
833
|
identity: TurnIdentity,
|
|
768
834
|
sessionId: string,
|
|
769
835
|
): Promise<RunSummary | null>;
|
|
836
|
+
/**
|
|
837
|
+
* `GET /runs/active` — every active run of the chats `GET /sessions`
|
|
838
|
+
* lists (same main-room rule, task runs excluded), oldest first, without
|
|
839
|
+
* `partialText`.
|
|
840
|
+
*/
|
|
841
|
+
activeRuns(identity: TurnIdentity): Promise<RunSummary[]>;
|
|
770
842
|
/** Diagnostics: recent runs with their tool marks and live state. */
|
|
771
843
|
runsStatus(): Promise<RunsStatus>;
|
|
772
844
|
/** `GET /debug/context` — the context budget a model gets on this object. */
|
|
@@ -1022,6 +1094,17 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1022
1094
|
): Promise<string>;
|
|
1023
1095
|
/** Full room state as a JSON-encoded array of `{ type, state_key, content, sender, event_id }`. */
|
|
1024
1096
|
getRoomState(roomId: string): Promise<JsonString>;
|
|
1097
|
+
getTopicRoomState(roomId: string): Promise<JsonString>;
|
|
1098
|
+
getTopicEvent(
|
|
1099
|
+
roomId: string,
|
|
1100
|
+
eventId: string,
|
|
1101
|
+
opts?: { deadlineAt?: number },
|
|
1102
|
+
): Promise<JsonString | null>;
|
|
1103
|
+
getTopicHistory(
|
|
1104
|
+
roomId: string,
|
|
1105
|
+
root: string,
|
|
1106
|
+
opts?: { deadlineAt?: number; sender?: string },
|
|
1107
|
+
): Promise<JsonString>;
|
|
1025
1108
|
/** One timeline event (decrypted when E2EE) as JSON `{ event_id, type, content, sender, origin_server_ts }`, or null. */
|
|
1026
1109
|
getEvent(roomId: string, eventId: string): Promise<JsonString | null>;
|
|
1027
1110
|
/**
|
|
@@ -108,6 +108,8 @@ export interface RunCoordinatorHost {
|
|
|
108
108
|
checkpointIdOf: (sessionId: string) => Promise<string | null>;
|
|
109
109
|
/** After a run reached a terminal state and its row was closed. */
|
|
110
110
|
onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
|
|
111
|
+
/** Before a run's row is created (the host marks that runs may be active). */
|
|
112
|
+
beforeCreate?: () => Promise<void>;
|
|
111
113
|
/** How long a superseded or aborted attempt gets to wind down (default 5 s). */
|
|
112
114
|
supersedeGraceMs?: number;
|
|
113
115
|
}
|
|
@@ -215,6 +217,11 @@ export class RunCoordinator {
|
|
|
215
217
|
return candidate;
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
/** The record of every run this coordinator holds (not ended yet). */
|
|
221
|
+
records(): RunRecord[] {
|
|
222
|
+
return [...this.live.values()].map((run) => run.record);
|
|
223
|
+
}
|
|
224
|
+
|
|
218
225
|
byRequestId(requestId: string): LiveRun | undefined {
|
|
219
226
|
for (const run of this.live.values())
|
|
220
227
|
if (run.requestId === requestId) return run;
|
|
@@ -367,6 +374,7 @@ export class RunCoordinator {
|
|
|
367
374
|
}
|
|
368
375
|
}
|
|
369
376
|
const checkpointId = await this.host.checkpointIdOf(sessionId);
|
|
377
|
+
await this.host.beforeCreate?.();
|
|
370
378
|
const record = await this.host.store.create({
|
|
371
379
|
runId: input.runId,
|
|
372
380
|
sessionId,
|
|
@@ -423,6 +431,28 @@ export class RunCoordinator {
|
|
|
423
431
|
return true;
|
|
424
432
|
}
|
|
425
433
|
|
|
434
|
+
/**
|
|
435
|
+
* `abortSession`, resolving once every run it stopped has ended — out of
|
|
436
|
+
* the live map, its row closed — or after the supersede grace, whichever
|
|
437
|
+
* comes first. Runs enqueued behind the active one are not stopped and
|
|
438
|
+
* not waited for.
|
|
439
|
+
*/
|
|
440
|
+
async abortSessionSettled(sessionId: string): Promise<boolean> {
|
|
441
|
+
const before = [...this.live.values()].filter(
|
|
442
|
+
(run) => run.sessionId === sessionId,
|
|
443
|
+
);
|
|
444
|
+
const stopped = this.abortSession(sessionId);
|
|
445
|
+
const ending = before.filter(
|
|
446
|
+
(run) => this.live.get(run.runId) !== run || run.abort.signal.aborted,
|
|
447
|
+
);
|
|
448
|
+
if (ending.length > 0)
|
|
449
|
+
await withinGrace(
|
|
450
|
+
this.graceMs,
|
|
451
|
+
Promise.all(ending.map((run) => run.done)),
|
|
452
|
+
);
|
|
453
|
+
return stopped;
|
|
454
|
+
}
|
|
455
|
+
|
|
426
456
|
/**
|
|
427
457
|
* End every run of the session: the executing attempt is aborted, queued
|
|
428
458
|
* runs and runs waiting for a recovery attempt are closed, and messages
|
package/src/do/run-request.ts
CHANGED
|
@@ -78,7 +78,20 @@ export function clientSurfaceFor(
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export
|
|
81
|
+
export interface RunSummaryOptions {
|
|
82
|
+
/**
|
|
83
|
+
* Carry the run's kept reply text (default true). The user-wide listings
|
|
84
|
+
* (`GET /runs/active`, `activeRun` on session rows) pass false: the text
|
|
85
|
+
* can be large and a client polling every chat does not need it; the
|
|
86
|
+
* session's own `GET /sessions/:id/run` still has it.
|
|
87
|
+
*/
|
|
88
|
+
withPartialText?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function runSummaryOf(
|
|
92
|
+
record: RunRecord,
|
|
93
|
+
{ withPartialText = true }: RunSummaryOptions = {},
|
|
94
|
+
): RunSummary {
|
|
82
95
|
return {
|
|
83
96
|
runId: record.runId,
|
|
84
97
|
sessionId: record.sessionId,
|
|
@@ -89,7 +102,7 @@ export function runSummaryOf(record: RunRecord): RunSummary {
|
|
|
89
102
|
updatedAt: record.updatedAt,
|
|
90
103
|
lastSeq: record.lastSeq,
|
|
91
104
|
...(record.messageId ? { messageId: record.messageId } : {}),
|
|
92
|
-
...(record.partialText && record.status !== 'finished'
|
|
105
|
+
...(withPartialText && record.partialText && record.status !== 'finished'
|
|
93
106
|
? { partialText: record.partialText }
|
|
94
107
|
: {}),
|
|
95
108
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DurableObject } from 'cloudflare:workers';
|
|
7
7
|
import { DoSqliteDatabase } from '../sqlite/database';
|
|
8
|
+
import { activeChatRuns } from './active-runs';
|
|
8
9
|
import type { RunSummary } from './contracts';
|
|
9
10
|
import type { PackedSegment } from './run-buffer';
|
|
10
11
|
import { RunCoordinator } from './run-coordinator';
|
|
@@ -172,6 +173,11 @@ export class RunStoreTestDO extends DurableObject {
|
|
|
172
173
|
return (await this.runStore()).listActive();
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
/** `activeChatRuns` over the given live records and this store's `listActive()`. */
|
|
177
|
+
async activeChatRuns(live: RunRecord[]): Promise<RunRecord[]> {
|
|
178
|
+
return activeChatRuns(live, await (await this.runStore()).listActive());
|
|
179
|
+
}
|
|
180
|
+
|
|
175
181
|
async queuedForSession(sessionId: string): Promise<RunRecord[]> {
|
|
176
182
|
return (await this.runStore()).queuedForSession(sessionId);
|
|
177
183
|
}
|
|
@@ -202,6 +208,10 @@ export class RunStoreTestDO extends DurableObject {
|
|
|
202
208
|
await (await this.runStore()).bumpMark(runId, toolCallId);
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
async markSent(runId: string, toolCallId: string): Promise<void> {
|
|
212
|
+
await (await this.runStore()).markSent(runId, toolCallId);
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
async finishMark(
|
|
206
216
|
runId: string,
|
|
207
217
|
toolCallId: string,
|
package/src/do/run-store.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* - `turn_tool_marks`: one row per tool call, written BEFORE the tool runs
|
|
15
15
|
* (`started`) and updated when it returns (`done`). This is what makes a
|
|
16
16
|
* resume safe: a started-but-unfinished write call is never re-executed.
|
|
17
|
+
* A frontend call also records when it reached a browser (`sent_at`):
|
|
18
|
+
* one still parked at the restart never left the object and runs again.
|
|
17
19
|
* - `turn_write_claims`: one row per write whose outcome is not known yet
|
|
18
20
|
* (tool-execution.ts), keyed by the fingerprint of the tool name and
|
|
19
21
|
* arguments. Released by a returned outcome; kept by an abort, a deadline
|
|
@@ -22,7 +24,8 @@
|
|
|
22
24
|
*
|
|
23
25
|
* Cost shape (SQLite-backed DO storage bills rows written or deleted): a run
|
|
24
26
|
* costs 1 insert + ~2 updates, a segment 1 insert + 1 delete, a tool call
|
|
25
|
-
* 1 insert + 1 update
|
|
27
|
+
* 1 insert + 1 update (a frontend call 1 update more, when it is sent), a
|
|
28
|
+
* write 1 insert + 1 delete more.
|
|
26
29
|
*/
|
|
27
30
|
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
28
31
|
import type { PackedSegment } from './run-buffer';
|
|
@@ -169,6 +172,14 @@ export interface ToolMark {
|
|
|
169
172
|
outcome: string | null;
|
|
170
173
|
/** Executions started for this call id (a read re-run bumps it). */
|
|
171
174
|
attempts: number;
|
|
175
|
+
/**
|
|
176
|
+
* When the call was handed to its executor, for tools that run outside
|
|
177
|
+
* the object (frontend tools: the moment a browser socket received it).
|
|
178
|
+
* `null` while a frontend call is parked waiting for a socket, so a
|
|
179
|
+
* restart knows the call never left the object. Always `null` for tools
|
|
180
|
+
* that run in the object.
|
|
181
|
+
*/
|
|
182
|
+
sentAt: string | null;
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
/** Rows of ended runs are kept a week (a re-join that late gets 404). */
|
|
@@ -331,6 +342,7 @@ type MarkRow = {
|
|
|
331
342
|
done_at: string | null;
|
|
332
343
|
outcome: string | null;
|
|
333
344
|
attempts: number;
|
|
345
|
+
sent_at: string | null;
|
|
334
346
|
} & Record<string, string | number | null>;
|
|
335
347
|
|
|
336
348
|
type SegmentRow = {
|
|
@@ -393,6 +405,7 @@ function toMark(row: MarkRow): ToolMark {
|
|
|
393
405
|
doneAt: row.done_at,
|
|
394
406
|
outcome: row.outcome,
|
|
395
407
|
attempts: Number(row.attempts),
|
|
408
|
+
sentAt: row.sent_at,
|
|
396
409
|
};
|
|
397
410
|
}
|
|
398
411
|
|
|
@@ -474,8 +487,21 @@ export class RunStore {
|
|
|
474
487
|
done_at TEXT,
|
|
475
488
|
outcome TEXT,
|
|
476
489
|
attempts INTEGER NOT NULL DEFAULT 1,
|
|
490
|
+
sent_at TEXT,
|
|
477
491
|
PRIMARY KEY (run_id, tool_call_id)
|
|
478
492
|
) WITHOUT ROWID`);
|
|
493
|
+
// When a call left the object (frontend calls; see `markSent`). Marks
|
|
494
|
+
// from before the column cannot tell, so they count as sent: a resume
|
|
495
|
+
// keeps treating them as it did when they were written.
|
|
496
|
+
const markColumns = await this.db.exec<{ name: string }>(
|
|
497
|
+
'PRAGMA table_info(turn_tool_marks)',
|
|
498
|
+
);
|
|
499
|
+
if (!markColumns.some((c) => c.name === 'sent_at')) {
|
|
500
|
+
await this.db.run('ALTER TABLE turn_tool_marks ADD COLUMN sent_at TEXT');
|
|
501
|
+
await this.db.run(
|
|
502
|
+
'UPDATE turn_tool_marks SET sent_at = started_at WHERE sent_at IS NULL',
|
|
503
|
+
);
|
|
504
|
+
}
|
|
479
505
|
await this.db.run(`
|
|
480
506
|
CREATE TABLE IF NOT EXISTS turn_write_claims (
|
|
481
507
|
fingerprint TEXT PRIMARY KEY,
|
|
@@ -705,12 +731,12 @@ export class RunStore {
|
|
|
705
731
|
return row ? toRecord(row) : undefined;
|
|
706
732
|
}
|
|
707
733
|
|
|
708
|
-
/** Every run that is not over, oldest first. */
|
|
734
|
+
/** Every run that is not over, oldest first (run id breaks a tie). */
|
|
709
735
|
async listActive(): Promise<RunRecord[]> {
|
|
710
736
|
await this.setup();
|
|
711
737
|
const rows = await this.db.exec<RunRow>(
|
|
712
738
|
`SELECT ${RUN_COLUMNS} FROM turn_runs
|
|
713
|
-
WHERE status IN ('running','recovering','queued') ORDER BY started_at ASC`,
|
|
739
|
+
WHERE status IN ('running','recovering','queued') ORDER BY started_at ASC, run_id ASC`,
|
|
714
740
|
);
|
|
715
741
|
return rows.map(toRecord);
|
|
716
742
|
}
|
|
@@ -853,16 +879,32 @@ export class RunStore {
|
|
|
853
879
|
return undefined;
|
|
854
880
|
}
|
|
855
881
|
|
|
856
|
-
/**
|
|
882
|
+
/**
|
|
883
|
+
* A call re-executed on resume: count the execution, keep the row open.
|
|
884
|
+
* The new execution has not been handed to an executor yet, so `sent_at`
|
|
885
|
+
* is cleared with it.
|
|
886
|
+
*/
|
|
857
887
|
async bumpMark(runId: string, toolCallId: string): Promise<void> {
|
|
858
888
|
await this.setup();
|
|
859
889
|
await this.db.run(
|
|
860
|
-
`UPDATE turn_tool_marks SET attempts = attempts + 1, started_at = ?, done_at = NULL, outcome = NULL
|
|
890
|
+
`UPDATE turn_tool_marks SET attempts = attempts + 1, started_at = ?, done_at = NULL, outcome = NULL, sent_at = NULL
|
|
861
891
|
WHERE run_id = ? AND tool_call_id = ?`,
|
|
862
892
|
[this.iso(), runId, toolCallId],
|
|
863
893
|
);
|
|
864
894
|
}
|
|
865
895
|
|
|
896
|
+
/**
|
|
897
|
+
* The call left the object: its executor (a browser socket for a frontend
|
|
898
|
+
* tool) received it. From here on a restart cannot tell whether it ran.
|
|
899
|
+
*/
|
|
900
|
+
async markSent(runId: string, toolCallId: string): Promise<void> {
|
|
901
|
+
await this.setup();
|
|
902
|
+
await this.db.run(
|
|
903
|
+
`UPDATE turn_tool_marks SET sent_at = ? WHERE run_id = ? AND tool_call_id = ?`,
|
|
904
|
+
[this.iso(), runId, toolCallId],
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
866
908
|
async finishMark(
|
|
867
909
|
runId: string,
|
|
868
910
|
toolCallId: string,
|
|
@@ -881,7 +923,7 @@ export class RunStore {
|
|
|
881
923
|
): Promise<ToolMark | undefined> {
|
|
882
924
|
await this.setup();
|
|
883
925
|
const row = await this.db.get<MarkRow>(
|
|
884
|
-
`SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts
|
|
926
|
+
`SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts, sent_at
|
|
885
927
|
FROM turn_tool_marks WHERE run_id = ? AND tool_call_id = ?`,
|
|
886
928
|
[runId, toolCallId],
|
|
887
929
|
);
|
|
@@ -891,7 +933,7 @@ export class RunStore {
|
|
|
891
933
|
async listMarks(runId: string): Promise<ToolMark[]> {
|
|
892
934
|
await this.setup();
|
|
893
935
|
const rows = await this.db.exec<MarkRow>(
|
|
894
|
-
`SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts
|
|
936
|
+
`SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts, sent_at
|
|
895
937
|
FROM turn_tool_marks WHERE run_id = ? ORDER BY started_at ASC`,
|
|
896
938
|
[runId],
|
|
897
939
|
);
|
|
@@ -902,10 +944,14 @@ export class RunStore {
|
|
|
902
944
|
|
|
903
945
|
/**
|
|
904
946
|
* Claim a write before it runs. `claimed` when no identical write is
|
|
905
|
-
* outstanding. `blocked` when one is: a `pending` claim
|
|
906
|
-
* (owned by this run, so the same
|
|
907
|
-
*
|
|
908
|
-
*
|
|
947
|
+
* outstanding. `blocked` when one is: a `pending` claim of this run, or of
|
|
948
|
+
* a run that is over, becomes `warned` (owned by this run, so the same
|
|
949
|
+
* turn stays blocked); a `pending` claim of ANOTHER run that is still
|
|
950
|
+
* active (queued / running / recovering) is left exactly as it is — its
|
|
951
|
+
* owner may still release it when its write returns, and a takeover would
|
|
952
|
+
* make that release delete nothing; a claim already `warned` by an
|
|
953
|
+
* earlier run is released to this run — the user was told and asked
|
|
954
|
+
* again.
|
|
909
955
|
*/
|
|
910
956
|
async claimWrite(input: {
|
|
911
957
|
fingerprint: string;
|
|
@@ -939,9 +985,16 @@ export class RunStore {
|
|
|
939
985
|
return { status: 'claimed' };
|
|
940
986
|
}
|
|
941
987
|
if (existing.state === 'pending')
|
|
988
|
+
// One statement: the owner's liveness is read where the row is
|
|
989
|
+
// written, so no release or run end can slip in between.
|
|
942
990
|
await this.db.run(
|
|
943
|
-
`UPDATE turn_write_claims SET state = 'warned', run_id = ?
|
|
944
|
-
|
|
991
|
+
`UPDATE turn_write_claims SET state = 'warned', run_id = ?
|
|
992
|
+
WHERE fingerprint = ? AND state = 'pending'
|
|
993
|
+
AND (run_id = ? OR NOT EXISTS (
|
|
994
|
+
SELECT 1 FROM turn_runs r WHERE r.run_id = turn_write_claims.run_id
|
|
995
|
+
AND r.status IN ('running','recovering','queued')
|
|
996
|
+
))`,
|
|
997
|
+
[input.runId, input.fingerprint, input.runId],
|
|
945
998
|
);
|
|
946
999
|
return {
|
|
947
1000
|
status: 'blocked',
|
package/src/do/turn-body.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* missing `message` are all 400s, never a failed turn or a 500.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { declaredToolEffect } from '../plugins/portal/declared-tools';
|
|
9
|
+
|
|
8
10
|
/** Top-level fields of Node's `SendMessageDto`; anything else is rejected. */
|
|
9
11
|
export const TURN_BODY_KEYS: ReadonlySet<string> = new Set([
|
|
10
12
|
'message',
|
|
@@ -45,6 +47,8 @@ export interface TurnBody {
|
|
|
45
47
|
name: string;
|
|
46
48
|
description: string;
|
|
47
49
|
schema: Record<string, unknown>;
|
|
50
|
+
/** Declared effect; any value but `'read'` / `'write'` is dropped. */
|
|
51
|
+
effect?: 'read' | 'write';
|
|
48
52
|
}>;
|
|
49
53
|
agActions?: Array<{
|
|
50
54
|
name: string;
|
|
@@ -87,6 +91,14 @@ function toolDeclaration(value: unknown): ToolDeclaration | null {
|
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/** A browser tool declaration: a tool declaration plus its optional `effect`. */
|
|
95
|
+
function browserToolDeclaration(value: unknown): ToolDeclaration | null {
|
|
96
|
+
const declaration = toolDeclaration(value);
|
|
97
|
+
if (!declaration || !isRecord(value)) return declaration;
|
|
98
|
+
const effect = declaredToolEffect(value.effect);
|
|
99
|
+
return effect ? { ...declaration, effect } : declaration;
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
/**
|
|
91
103
|
* Parse and validate a raw request body. Never throws. Every field the
|
|
92
104
|
* runtime reads is checked for its type here, so a wrong one is a 400 and
|
|
@@ -147,7 +159,7 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
|
|
|
147
159
|
if (!Array.isArray(record.tools)) return reject('tools must be an array');
|
|
148
160
|
tools = [];
|
|
149
161
|
for (const item of record.tools) {
|
|
150
|
-
const tool =
|
|
162
|
+
const tool = browserToolDeclaration(item);
|
|
151
163
|
if (!tool)
|
|
152
164
|
return reject(
|
|
153
165
|
'each tool needs a string name, description and an object schema',
|