@parcha/agentrun-kernel 0.0.0-stage → 0.1.0-beta.29
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/CHANGELOG.md +135 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +52 -2
- package/dist/delegate.d.ts +87 -0
- package/dist/delegate.js +143 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +37 -0
- package/dist/host/alive.d.ts +2 -0
- package/dist/host/alive.js +28 -0
- package/dist/host/box-slot.d.ts +26 -0
- package/dist/host/box-slot.js +112 -0
- package/dist/host/box.d.ts +104 -0
- package/dist/host/box.js +16 -0
- package/dist/host/egress-proxy.d.ts +45 -0
- package/dist/host/egress-proxy.js +416 -0
- package/dist/host/egress-table.d.ts +24 -0
- package/dist/host/egress-table.js +165 -0
- package/dist/host/index.d.ts +16 -0
- package/dist/host/index.js +12 -0
- package/dist/host/overhead.d.ts +53 -0
- package/dist/host/overhead.js +75 -0
- package/dist/host/parent-death.d.ts +15 -0
- package/dist/host/parent-death.js +42 -0
- package/dist/host/parent-watch.d.ts +1 -0
- package/dist/host/parent-watch.js +38 -0
- package/dist/host/pinned-binary.d.ts +6 -0
- package/dist/host/pinned-binary.js +48 -0
- package/dist/host/proxy.d.ts +179 -0
- package/dist/host/proxy.js +629 -0
- package/dist/host/serial.d.ts +3 -0
- package/dist/host/serial.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/kernel/blobs.d.ts +16 -0
- package/dist/kernel/blobs.js +59 -0
- package/dist/kernel/drive.d.ts +35 -0
- package/dist/kernel/drive.js +202 -0
- package/dist/kernel/errors.d.ts +27 -0
- package/dist/kernel/errors.js +42 -0
- package/dist/kernel/follow-up.d.ts +67 -0
- package/dist/kernel/follow-up.js +146 -0
- package/dist/kernel/halt.d.ts +11 -0
- package/dist/kernel/halt.js +17 -0
- package/dist/kernel/index.d.ts +8 -0
- package/dist/kernel/index.js +5 -0
- package/dist/kernel/types.d.ts +120 -0
- package/dist/kernel/types.js +1 -0
- package/dist/process/builtins.d.ts +142 -0
- package/dist/process/builtins.js +207 -0
- package/dist/process/inbox.d.ts +29 -0
- package/dist/process/inbox.js +52 -0
- package/dist/process/index.d.ts +9 -0
- package/dist/process/index.js +11 -0
- package/dist/process/journal.d.ts +111 -0
- package/dist/process/journal.js +232 -0
- package/dist/process/records.d.ts +143 -0
- package/dist/process/records.js +142 -0
- package/dist/process/recovery.d.ts +76 -0
- package/dist/process/recovery.js +139 -0
- package/dist/process/settle.d.ts +11 -0
- package/dist/process/settle.js +20 -0
- package/dist/process/tail.d.ts +20 -0
- package/dist/process/tail.js +53 -0
- package/dist/process/tools.d.ts +105 -0
- package/dist/process/tools.js +120 -0
- package/dist/process/zod.d.ts +32 -0
- package/dist/process/zod.js +109 -0
- package/dist/runner.d.ts +154 -0
- package/dist/runner.js +547 -0
- package/dist/schema-check-cases.d.ts +7 -0
- package/dist/schema-check-cases.js +73 -0
- package/dist/schema-check.d.ts +3 -0
- package/dist/schema-check.js +88 -0
- package/dist/skill-files.d.ts +23 -0
- package/dist/skill-files.js +136 -0
- package/dist/testing.d.ts +91 -0
- package/dist/testing.js +196 -0
- package/dist/types.d.ts +237 -0
- package/dist/types.js +10 -0
- package/package.json +74 -4
- package/vendor/durable-harness/UPSTREAM.json +67 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { blobStreamName, type DriveContext, readBlobs } from '../kernel/index.js';
|
|
2
|
+
import { type JournalRecord, type TranscriptEntry } from './records.js';
|
|
3
|
+
/**
|
|
4
|
+
* Records longer than this (as JSON text) go to the blob stream instead of
|
|
5
|
+
* the log. The log is folded whole on every cold drive and polled by every
|
|
6
|
+
* viewer, so it should stay lean. The World's own limit is far higher (see
|
|
7
|
+
* the README, "Payload limits").
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_INLINE_MAX_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* The session's blob stream (harness-world's `blobs.ts`): content-addressed,
|
|
12
|
+
* append-only, one line per blob (or per piece of one). Shared with the
|
|
13
|
+
* document-backed workspace, which keeps its large files there.
|
|
14
|
+
*/
|
|
15
|
+
export { blobStreamName, readBlobs };
|
|
16
|
+
export interface NativeJournalOptions {
|
|
17
|
+
/** Offload records whose JSON text is longer than this. Default `DEFAULT_INLINE_MAX_BYTES`. */
|
|
18
|
+
inlineMaxBytes?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Called once, with the error, when a batch write fails or is refused.
|
|
21
|
+
* Default: `ctx.halt(err)`, which stops the drive (and so kills the child
|
|
22
|
+
* a `ProcessHost` spawned with `ctx.signal`).
|
|
23
|
+
*/
|
|
24
|
+
onPoison?: (err: unknown) => void;
|
|
25
|
+
/** Characters per blob chunk. Default 2M. */
|
|
26
|
+
blobPieceChars?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The writer (and reader) of a process harness's native journal, for one drive.
|
|
30
|
+
*
|
|
31
|
+
* `append` queues a record; the journal keeps one batch in flight and
|
|
32
|
+
* commits everything queued behind it as the next batch (group commit), one
|
|
33
|
+
* record-only effect `journal:<n>` per batch. `flush()` is the barrier: it
|
|
34
|
+
* resolves once every record appended before it is committed. Call it before
|
|
35
|
+
* a `replay: 'never'` tool runs (the tool bridge's `beforeNever`) and at the
|
|
36
|
+
* end of a turn.
|
|
37
|
+
*
|
|
38
|
+
* Under run-ahead durability (`DriverOptions.runAhead`) a batch is issued,
|
|
39
|
+
* not yet durable, when its effect returns; the kernel waits for it at its
|
|
40
|
+
* own barriers (before a `never` effect, before the drive goes idle), so
|
|
41
|
+
* `flush()` before a `never` tool still means "in the log before the tool".
|
|
42
|
+
*
|
|
43
|
+
* The journal's writes are in-band writes of the drive: they go through
|
|
44
|
+
* `ctx.effect`, so the writer fence (once a newer drive opens the session,
|
|
45
|
+
* this drive's writes are refused: a `Restart`), first-writer-wins per key and the
|
|
46
|
+
* outcome rules apply. A failed or refused write poisons the journal
|
|
47
|
+
* (`append` becomes a no-op, `flush` rejects) and halts the drive. So does a
|
|
48
|
+
* batch number another writer took first (two drives of one session, the
|
|
49
|
+
* other's batch already in this drive's fold, where `ctx.effect` would hand
|
|
50
|
+
* back its result as if it were this batch's): that is a `Restart`, and the
|
|
51
|
+
* drive rebuilds from the log instead of losing the batch's records.
|
|
52
|
+
*/
|
|
53
|
+
export declare class NativeJournal {
|
|
54
|
+
private readonly ctx;
|
|
55
|
+
private readonly inlineMax;
|
|
56
|
+
private readonly onPoison;
|
|
57
|
+
private readonly pieceChars;
|
|
58
|
+
/** The next batch number. */
|
|
59
|
+
private n;
|
|
60
|
+
/** Next position per stream (log plus queue). */
|
|
61
|
+
private readonly next;
|
|
62
|
+
/** Record ids in the log or queued. */
|
|
63
|
+
private readonly ids;
|
|
64
|
+
/** Blobs known to be in the blob stream. */
|
|
65
|
+
private readonly blobs;
|
|
66
|
+
private queue;
|
|
67
|
+
private inflight;
|
|
68
|
+
private appended;
|
|
69
|
+
private written;
|
|
70
|
+
private waiters;
|
|
71
|
+
private failure;
|
|
72
|
+
/** This writer, in each batch's output: a batch is this journal's only if it says so. */
|
|
73
|
+
private readonly writer;
|
|
74
|
+
constructor(ctx: DriveContext, options?: NativeJournalOptions);
|
|
75
|
+
/** The error that poisoned this journal, if one did. */
|
|
76
|
+
get poisoned(): unknown;
|
|
77
|
+
/** How many records `stream` has (committed plus queued). */
|
|
78
|
+
length(stream: string): number;
|
|
79
|
+
/** Whether a record with this id is in the log or queued. */
|
|
80
|
+
has(id: string): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Queue a record (a JSON value) on `stream`. Returns `false` when it was
|
|
83
|
+
* not queued: a record with the same `id` is already in the log or queued
|
|
84
|
+
* (dedupe), or the journal is poisoned.
|
|
85
|
+
*/
|
|
86
|
+
append(stream: string, data: unknown, options?: {
|
|
87
|
+
id?: string;
|
|
88
|
+
}): boolean;
|
|
89
|
+
/** Append an entry to the normalized transcript. */
|
|
90
|
+
message(entry: TranscriptEntry): boolean;
|
|
91
|
+
/** Resolves once every record appended so far is committed; rejects if the journal is poisoned. */
|
|
92
|
+
flush(): Promise<void>;
|
|
93
|
+
/** The records of `stream` (or every stream) in the log, in order. Offloaded ones have `data: undefined`. */
|
|
94
|
+
records<T = unknown>(stream?: string): JournalRecord<T>[];
|
|
95
|
+
/** The same records with their blobs read back from the blob stream and checked against their hashes. */
|
|
96
|
+
read<T = unknown>(stream?: string): Promise<JournalRecord<T>[]>;
|
|
97
|
+
private pump;
|
|
98
|
+
private settleWaiters;
|
|
99
|
+
private poison;
|
|
100
|
+
private write;
|
|
101
|
+
/** A record as the batch stores it: inline, or offloaded to the blob stream (written before the batch). */
|
|
102
|
+
private store;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read a session's journal with offloaded records resolved: the blob stream
|
|
106
|
+
* is read once and every blob is checked against its hash. A blob the log
|
|
107
|
+
* names but the stream does not hold is `Fatal` (the batch commits only
|
|
108
|
+
* after its blobs are written, so the session cannot continue); a read that
|
|
109
|
+
* fails is thrown as it is (the delivery is retried).
|
|
110
|
+
*/
|
|
111
|
+
export declare function readJournal<T = unknown>(ctx: Pick<DriveContext, 'world' | 'runId' | 'state'>, stream?: string): Promise<JournalRecord<T>[]>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Copyright 2026 Vercel Inc. Licensed under the Apache License, Version 2.0; see LICENSE in this package.
|
|
2
|
+
// From github.com/vercel-labs/durable-harness, packages/harness-process/src/journal.ts at ea8aa4060c (UPSTREAM.json records its blob and sha256).
|
|
3
|
+
// Changed by Parcha, imports rewritten for AgentRun; patches: 002-message-prefix, 004-comments-name-the-session-writer
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { BLOB_PIECE_CHARS, blobStreamName, Fatal, readBlobs, Restart, sha256Hex, writeBlob } from '../kernel/index.js';
|
|
6
|
+
import { JOURNAL, journalBatches, journalKey, journalRecords, TRANSCRIPT, } from './records.js';
|
|
7
|
+
/**
|
|
8
|
+
* Records longer than this (as JSON text) go to the blob stream instead of
|
|
9
|
+
* the log. The log is folded whole on every cold drive and polled by every
|
|
10
|
+
* viewer, so it should stay lean. The World's own limit is far higher (see
|
|
11
|
+
* the README, "Payload limits").
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_INLINE_MAX_BYTES = 32 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* The session's blob stream (harness-world's `blobs.ts`): content-addressed,
|
|
16
|
+
* append-only, one line per blob (or per piece of one). Shared with the
|
|
17
|
+
* document-backed workspace, which keeps its large files there.
|
|
18
|
+
*/
|
|
19
|
+
export { blobStreamName, readBlobs };
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
/**
|
|
22
|
+
* The writer (and reader) of a process harness's native journal, for one drive.
|
|
23
|
+
*
|
|
24
|
+
* `append` queues a record; the journal keeps one batch in flight and
|
|
25
|
+
* commits everything queued behind it as the next batch (group commit), one
|
|
26
|
+
* record-only effect `journal:<n>` per batch. `flush()` is the barrier: it
|
|
27
|
+
* resolves once every record appended before it is committed. Call it before
|
|
28
|
+
* a `replay: 'never'` tool runs (the tool bridge's `beforeNever`) and at the
|
|
29
|
+
* end of a turn.
|
|
30
|
+
*
|
|
31
|
+
* Under run-ahead durability (`DriverOptions.runAhead`) a batch is issued,
|
|
32
|
+
* not yet durable, when its effect returns; the kernel waits for it at its
|
|
33
|
+
* own barriers (before a `never` effect, before the drive goes idle), so
|
|
34
|
+
* `flush()` before a `never` tool still means "in the log before the tool".
|
|
35
|
+
*
|
|
36
|
+
* The journal's writes are in-band writes of the drive: they go through
|
|
37
|
+
* `ctx.effect`, so the writer fence (once a newer drive opens the session,
|
|
38
|
+
* this drive's writes are refused: a `Restart`), first-writer-wins per key and the
|
|
39
|
+
* outcome rules apply. A failed or refused write poisons the journal
|
|
40
|
+
* (`append` becomes a no-op, `flush` rejects) and halts the drive. So does a
|
|
41
|
+
* batch number another writer took first (two drives of one session, the
|
|
42
|
+
* other's batch already in this drive's fold, where `ctx.effect` would hand
|
|
43
|
+
* back its result as if it were this batch's): that is a `Restart`, and the
|
|
44
|
+
* drive rebuilds from the log instead of losing the batch's records.
|
|
45
|
+
*/
|
|
46
|
+
export class NativeJournal {
|
|
47
|
+
ctx;
|
|
48
|
+
inlineMax;
|
|
49
|
+
onPoison;
|
|
50
|
+
pieceChars;
|
|
51
|
+
/** The next batch number. */
|
|
52
|
+
n;
|
|
53
|
+
/** Next position per stream (log plus queue). */
|
|
54
|
+
next = new Map();
|
|
55
|
+
/** Record ids in the log or queued. */
|
|
56
|
+
ids = new Set();
|
|
57
|
+
/** Blobs known to be in the blob stream. */
|
|
58
|
+
blobs = new Set();
|
|
59
|
+
queue = [];
|
|
60
|
+
inflight;
|
|
61
|
+
appended = 0;
|
|
62
|
+
written = 0;
|
|
63
|
+
waiters = [];
|
|
64
|
+
failure;
|
|
65
|
+
/** This writer, in each batch's output: a batch is this journal's only if it says so. */
|
|
66
|
+
writer = randomUUID();
|
|
67
|
+
constructor(ctx, options = {}) {
|
|
68
|
+
this.ctx = ctx;
|
|
69
|
+
this.inlineMax = options.inlineMaxBytes ?? DEFAULT_INLINE_MAX_BYTES;
|
|
70
|
+
this.onPoison = options.onPoison ?? ((err) => ctx.halt?.(err));
|
|
71
|
+
this.pieceChars = options.blobPieceChars ?? BLOB_PIECE_CHARS;
|
|
72
|
+
const state = ctx.state();
|
|
73
|
+
this.n = journalBatches(state).length;
|
|
74
|
+
for (const r of journalRecords(state)) {
|
|
75
|
+
this.next.set(r.stream, r.seq + 1);
|
|
76
|
+
if (r.id !== undefined)
|
|
77
|
+
this.ids.add(r.id);
|
|
78
|
+
if (r.blob)
|
|
79
|
+
this.blobs.add(r.blob);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** The error that poisoned this journal, if one did. */
|
|
83
|
+
get poisoned() {
|
|
84
|
+
return this.failure?.err;
|
|
85
|
+
}
|
|
86
|
+
/** How many records `stream` has (committed plus queued). */
|
|
87
|
+
length(stream) {
|
|
88
|
+
return this.next.get(stream) ?? 0;
|
|
89
|
+
}
|
|
90
|
+
/** Whether a record with this id is in the log or queued. */
|
|
91
|
+
has(id) {
|
|
92
|
+
return this.ids.has(id);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Queue a record (a JSON value) on `stream`. Returns `false` when it was
|
|
96
|
+
* not queued: a record with the same `id` is already in the log or queued
|
|
97
|
+
* (dedupe), or the journal is poisoned.
|
|
98
|
+
*/
|
|
99
|
+
append(stream, data, options = {}) {
|
|
100
|
+
if (this.failure)
|
|
101
|
+
return false;
|
|
102
|
+
if (options.id !== undefined) {
|
|
103
|
+
if (this.ids.has(options.id))
|
|
104
|
+
return false;
|
|
105
|
+
this.ids.add(options.id);
|
|
106
|
+
}
|
|
107
|
+
const seq = this.next.get(stream) ?? 0;
|
|
108
|
+
this.next.set(stream, seq + 1);
|
|
109
|
+
this.queue.push({ stream, seq, data, ...(options.id !== undefined ? { id: options.id } : {}) });
|
|
110
|
+
this.appended++;
|
|
111
|
+
this.pump();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
/** Append an entry to the normalized transcript. */
|
|
115
|
+
message(entry) {
|
|
116
|
+
return this.append(TRANSCRIPT, entry);
|
|
117
|
+
}
|
|
118
|
+
/** Resolves once every record appended so far is committed; rejects if the journal is poisoned. */
|
|
119
|
+
flush() {
|
|
120
|
+
if (this.failure)
|
|
121
|
+
return Promise.reject(this.failure.err);
|
|
122
|
+
if (this.written >= this.appended)
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
return new Promise((resolve, reject) => this.waiters.push({ target: this.appended, resolve, reject }));
|
|
125
|
+
}
|
|
126
|
+
/** The records of `stream` (or every stream) in the log, in order. Offloaded ones have `data: undefined`. */
|
|
127
|
+
records(stream) {
|
|
128
|
+
return journalRecords(this.ctx.state(), stream);
|
|
129
|
+
}
|
|
130
|
+
/** The same records with their blobs read back from the blob stream and checked against their hashes. */
|
|
131
|
+
read(stream) {
|
|
132
|
+
return readJournal(this.ctx, stream);
|
|
133
|
+
}
|
|
134
|
+
pump() {
|
|
135
|
+
if (this.inflight)
|
|
136
|
+
return;
|
|
137
|
+
this.inflight = (async () => {
|
|
138
|
+
// Let a synchronous burst of appends ride in one batch.
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
while (this.queue.length > 0 && !this.failure) {
|
|
141
|
+
const batch = this.queue;
|
|
142
|
+
this.queue = [];
|
|
143
|
+
try {
|
|
144
|
+
await this.write(batch);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.poison(err);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.written += batch.length;
|
|
151
|
+
this.settleWaiters();
|
|
152
|
+
}
|
|
153
|
+
})().finally(() => {
|
|
154
|
+
this.inflight = undefined;
|
|
155
|
+
if (this.queue.length > 0 && !this.failure)
|
|
156
|
+
this.pump();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
settleWaiters() {
|
|
160
|
+
const still = [];
|
|
161
|
+
for (const w of this.waiters) {
|
|
162
|
+
if (this.written >= w.target)
|
|
163
|
+
w.resolve();
|
|
164
|
+
else
|
|
165
|
+
still.push(w);
|
|
166
|
+
}
|
|
167
|
+
this.waiters = still;
|
|
168
|
+
}
|
|
169
|
+
poison(err) {
|
|
170
|
+
if (this.failure)
|
|
171
|
+
return;
|
|
172
|
+
this.failure = { err };
|
|
173
|
+
this.queue = [];
|
|
174
|
+
for (const w of this.waiters.splice(0))
|
|
175
|
+
w.reject(err);
|
|
176
|
+
this.onPoison(err);
|
|
177
|
+
}
|
|
178
|
+
async write(queued) {
|
|
179
|
+
const streams = {};
|
|
180
|
+
for (const q of queued) {
|
|
181
|
+
const part = (streams[q.stream] ??= { from: q.seq, entries: [] });
|
|
182
|
+
part.entries.push(await this.store(q));
|
|
183
|
+
}
|
|
184
|
+
const n = this.n;
|
|
185
|
+
const batch = { v: 1, n, streams };
|
|
186
|
+
const writer = this.writer;
|
|
187
|
+
const out = await this.ctx.effect(journalKey(n), { name: JOURNAL, replay: 'safe', input: batch }, async () => ({ n, records: queued.length, writer }));
|
|
188
|
+
if (out?.writer !== writer)
|
|
189
|
+
throw new Restart(`agentrun-kernel: journal batch ${n} was written by another drive`);
|
|
190
|
+
this.n = n + 1;
|
|
191
|
+
}
|
|
192
|
+
/** A record as the batch stores it: inline, or offloaded to the blob stream (written before the batch). */
|
|
193
|
+
async store(q) {
|
|
194
|
+
const id = q.id !== undefined ? { id: q.id } : {};
|
|
195
|
+
// The transcript is what UIs read from the fold: it always stays inline.
|
|
196
|
+
if (q.stream === TRANSCRIPT)
|
|
197
|
+
return { ...id, data: q.data };
|
|
198
|
+
const text = JSON.stringify(q.data);
|
|
199
|
+
if (text === undefined)
|
|
200
|
+
throw new TypeError(`agentrun-kernel: journal record on ${q.stream} is not JSON`);
|
|
201
|
+
const bytes = encoder.encode(text).length;
|
|
202
|
+
if (bytes <= this.inlineMax)
|
|
203
|
+
return { ...id, data: q.data };
|
|
204
|
+
const hash = await sha256Hex(text);
|
|
205
|
+
if (!this.blobs.has(hash)) {
|
|
206
|
+
await writeBlob(this.ctx.world, this.ctx.runId, text, { hash, pieceChars: this.pieceChars });
|
|
207
|
+
this.blobs.add(hash);
|
|
208
|
+
}
|
|
209
|
+
return { ...id, blob: hash, bytes };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Read a session's journal with offloaded records resolved: the blob stream
|
|
214
|
+
* is read once and every blob is checked against its hash. A blob the log
|
|
215
|
+
* names but the stream does not hold is `Fatal` (the batch commits only
|
|
216
|
+
* after its blobs are written, so the session cannot continue); a read that
|
|
217
|
+
* fails is thrown as it is (the delivery is retried).
|
|
218
|
+
*/
|
|
219
|
+
export async function readJournal(ctx, stream) {
|
|
220
|
+
const records = journalRecords(ctx.state(), stream);
|
|
221
|
+
if (!records.some((r) => r.blob))
|
|
222
|
+
return records;
|
|
223
|
+
const blobs = await readBlobs(ctx.world, ctx.runId);
|
|
224
|
+
return records.map((r) => {
|
|
225
|
+
if (!r.blob)
|
|
226
|
+
return r;
|
|
227
|
+
const text = blobs.get(r.blob);
|
|
228
|
+
if (text === undefined)
|
|
229
|
+
throw new Fatal(`agentrun-kernel: journal blob ${r.blob} (${r.stream} #${r.seq}) is missing from ${blobStreamName(ctx.runId)}`);
|
|
230
|
+
return { ...r, data: JSON.parse(text) };
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { EffectRecord, SessionState } from '../kernel/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The shape of a process harness's records in the run's log, and the pure
|
|
4
|
+
* readers for it. No Node imports: UIs (the demo's transcript, in a browser
|
|
5
|
+
* tab) read sessions with this module.
|
|
6
|
+
*
|
|
7
|
+
* A process harness mirrors its native session state (Codex's rollout) into
|
|
8
|
+
* the log as **journal batches**: record-only effects named `journal`, keyed
|
|
9
|
+
* `journal:<n>`, each carrying the records appended since the last batch,
|
|
10
|
+
* grouped by stream. The kernel's rules make the batches linear: the first
|
|
11
|
+
* committed step per key wins, a stale driver's step is fenced (its writer is
|
|
12
|
+
* refused once a newer drive opens the session), and a batch
|
|
13
|
+
* counts only once its outcome is committed. Every reader, the writer
|
|
14
|
+
* included, sees the same records in the same order.
|
|
15
|
+
*
|
|
16
|
+
* Next to its native streams, every process harness writes one normalized
|
|
17
|
+
* stream, `transcript` (`TranscriptEntry`), so one view renders any of them.
|
|
18
|
+
*/
|
|
19
|
+
/** The effect name of every journal batch (`step//harness-world//journal` in dashboards). */
|
|
20
|
+
export declare const JOURNAL = "journal";
|
|
21
|
+
/** The normalized transcript's stream name. */
|
|
22
|
+
export declare const TRANSCRIPT = "transcript";
|
|
23
|
+
/** The memo key of batch `n`. */
|
|
24
|
+
export declare const journalKey: (n: number) => string;
|
|
25
|
+
/**
|
|
26
|
+
* One record as a batch stores it: its JSON value inline (`data`), or, when
|
|
27
|
+
* it is large, the SHA-256 of its JSON text (`blob`), whose bytes are in the
|
|
28
|
+
* session's blob stream (see `NativeJournal`).
|
|
29
|
+
*/
|
|
30
|
+
export interface StoredEntry {
|
|
31
|
+
/** The harness's own id for the record, if it has one (dedupe). */
|
|
32
|
+
id?: string;
|
|
33
|
+
data?: unknown;
|
|
34
|
+
blob?: string;
|
|
35
|
+
/** Length of the offloaded JSON text. */
|
|
36
|
+
bytes?: number;
|
|
37
|
+
}
|
|
38
|
+
/** A `journal` effect's input. */
|
|
39
|
+
export interface JournalBatch {
|
|
40
|
+
v: 1;
|
|
41
|
+
/** This batch's number: it is the `n`-th accepted batch of the session. */
|
|
42
|
+
n: number;
|
|
43
|
+
/** Records per stream, with the stream position of the first. */
|
|
44
|
+
streams: Record<string, {
|
|
45
|
+
from: number;
|
|
46
|
+
entries: StoredEntry[];
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
/** A record read back from the log. */
|
|
50
|
+
export interface JournalRecord<T = unknown> {
|
|
51
|
+
stream: string;
|
|
52
|
+
/** 0-based position in its stream. */
|
|
53
|
+
seq: number;
|
|
54
|
+
id?: string;
|
|
55
|
+
/** The record. `undefined` for an offloaded one read here; `NativeJournal.read` resolves blobs. */
|
|
56
|
+
data: T;
|
|
57
|
+
blob?: string;
|
|
58
|
+
/** The batch it arrived in. */
|
|
59
|
+
batch: number;
|
|
60
|
+
stepId: string;
|
|
61
|
+
/** The batch's index in `SessionState.effectOrder` (to relate records to other effects). */
|
|
62
|
+
at: number;
|
|
63
|
+
}
|
|
64
|
+
/** A tool call an assistant message made. Its effect is `tool:<id>` (see `toolBridge`). */
|
|
65
|
+
export interface TranscriptToolCall {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The normalized transcript. User prompts are not here: they appear through
|
|
71
|
+
* the submissions the harness placed (`placeSubmissions`). What is here:
|
|
72
|
+
*
|
|
73
|
+
* - `message` / `assistant`: the model's text, and the tool calls it made
|
|
74
|
+
* (their inputs and results are the tool effects themselves);
|
|
75
|
+
* - `message` / `user` with `synthetic`: a message the harness sent itself,
|
|
76
|
+
* such as a recovery note;
|
|
77
|
+
* - `turn`: a turn boundary. A turn is open from the first placement after
|
|
78
|
+
* the last `turn` entry until the next one.
|
|
79
|
+
*/
|
|
80
|
+
export type TranscriptEntry = {
|
|
81
|
+
type: 'message';
|
|
82
|
+
role: 'assistant';
|
|
83
|
+
text: string;
|
|
84
|
+
toolCalls?: TranscriptToolCall[];
|
|
85
|
+
} | {
|
|
86
|
+
type: 'message';
|
|
87
|
+
role: 'user';
|
|
88
|
+
text: string;
|
|
89
|
+
synthetic: string;
|
|
90
|
+
} | {
|
|
91
|
+
type: 'turn';
|
|
92
|
+
status: 'completed' | 'failed' | 'interrupted';
|
|
93
|
+
error?: string;
|
|
94
|
+
};
|
|
95
|
+
export interface BatchRef {
|
|
96
|
+
batch: JournalBatch;
|
|
97
|
+
record: EffectRecord;
|
|
98
|
+
/** Index in `effectOrder`. */
|
|
99
|
+
at: number;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The accepted batches, in order: `journal:0`, `journal:1`, ... up to the
|
|
103
|
+
* first that has no committed outcome. A batch whose intent landed but whose
|
|
104
|
+
* outcome did not (its writer died between the two writes) does not count;
|
|
105
|
+
* the next writer reuses its key.
|
|
106
|
+
*/
|
|
107
|
+
export declare function journalBatches(state: SessionState): BatchRef[];
|
|
108
|
+
/** Every record of `stream` (or of every stream), in log order. Offloaded records have `data: undefined`. */
|
|
109
|
+
export declare function journalRecords<T = unknown>(state: SessionState, stream?: string): JournalRecord<T>[];
|
|
110
|
+
/** The normalized transcript, in log order. */
|
|
111
|
+
export declare function transcriptOf(state: SessionState): JournalRecord<TranscriptEntry>[];
|
|
112
|
+
/**
|
|
113
|
+
* Where the last turn ended: the `effectOrder` index of the batch holding
|
|
114
|
+
* the last `turn` entry, or of the last `unavailable` fact (a turn the
|
|
115
|
+
* harness could not run: it is over, with an error, and nothing in it is
|
|
116
|
+
* recovered), or -1 if no turn has ended yet. Everything after it belongs to
|
|
117
|
+
* the current (or an interrupted) turn.
|
|
118
|
+
*/
|
|
119
|
+
export declare function lastTurnEnd(state: SessionState): number;
|
|
120
|
+
/**
|
|
121
|
+
* A batch input for UIs: the transcript entries as they are, every other
|
|
122
|
+
* stream as a count (native records can be large, and a page polling a
|
|
123
|
+
* session should not carry them).
|
|
124
|
+
*/
|
|
125
|
+
export declare function summarizeBatch(input: unknown): unknown;
|
|
126
|
+
/** The transcript entries of one batch input (inline only; transcript entries are never offloaded). */
|
|
127
|
+
export declare function batchTranscript(input: unknown): TranscriptEntry[];
|
|
128
|
+
export interface OpenTurn {
|
|
129
|
+
/** `effectOrder` index of the batch that ended the previous turn (-1: none ended yet). */
|
|
130
|
+
since: number;
|
|
131
|
+
/** Submissions placed in this turn. */
|
|
132
|
+
placed: string[];
|
|
133
|
+
/** Tool calls made in it. */
|
|
134
|
+
calls: EffectRecord[];
|
|
135
|
+
/** Transcript entries written in it (assistant text, earlier recovery notes). */
|
|
136
|
+
entries: JournalRecord<TranscriptEntry>[];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The turn that has not ended: everything after the last `turn` entry of
|
|
140
|
+
* the transcript. `undefined` when the last turn ended and nothing happened
|
|
141
|
+
* since (nothing to recover; new submissions start a new turn as usual).
|
|
142
|
+
*/
|
|
143
|
+
export declare function openTurn(state: SessionState): OpenTurn | undefined;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a process harness's records in the run's log, and the pure
|
|
3
|
+
* readers for it. No Node imports: UIs (the demo's transcript, in a browser
|
|
4
|
+
* tab) read sessions with this module.
|
|
5
|
+
*
|
|
6
|
+
* A process harness mirrors its native session state (Codex's rollout) into
|
|
7
|
+
* the log as **journal batches**: record-only effects named `journal`, keyed
|
|
8
|
+
* `journal:<n>`, each carrying the records appended since the last batch,
|
|
9
|
+
* grouped by stream. The kernel's rules make the batches linear: the first
|
|
10
|
+
* committed step per key wins, a stale driver's step is fenced (its writer is
|
|
11
|
+
* refused once a newer drive opens the session), and a batch
|
|
12
|
+
* counts only once its outcome is committed. Every reader, the writer
|
|
13
|
+
* included, sees the same records in the same order.
|
|
14
|
+
*
|
|
15
|
+
* Next to its native streams, every process harness writes one normalized
|
|
16
|
+
* stream, `transcript` (`TranscriptEntry`), so one view renders any of them.
|
|
17
|
+
*/
|
|
18
|
+
/** The effect name of every journal batch (`step//harness-world//journal` in dashboards). */
|
|
19
|
+
export const JOURNAL = 'journal';
|
|
20
|
+
/** The normalized transcript's stream name. */
|
|
21
|
+
export const TRANSCRIPT = 'transcript';
|
|
22
|
+
/** The memo key of batch `n`. */
|
|
23
|
+
export const journalKey = (n) => `${JOURNAL}:${n}`;
|
|
24
|
+
/**
|
|
25
|
+
* The accepted batches, in order: `journal:0`, `journal:1`, ... up to the
|
|
26
|
+
* first that has no committed outcome. A batch whose intent landed but whose
|
|
27
|
+
* outcome did not (its writer died between the two writes) does not count;
|
|
28
|
+
* the next writer reuses its key.
|
|
29
|
+
*/
|
|
30
|
+
export function journalBatches(state) {
|
|
31
|
+
const index = new Map();
|
|
32
|
+
state.effectOrder.forEach((r, i) => index.set(r, i));
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let n = 0;; n++) {
|
|
35
|
+
const record = state.effects.get(journalKey(n));
|
|
36
|
+
if (!record || record.status !== 'completed' || record.name !== JOURNAL)
|
|
37
|
+
break;
|
|
38
|
+
out.push({ batch: record.input, record, at: index.get(record) ?? -1 });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** Every record of `stream` (or of every stream), in log order. Offloaded records have `data: undefined`. */
|
|
43
|
+
export function journalRecords(state, stream) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const seqs = new Map();
|
|
46
|
+
for (const { batch, record, at } of journalBatches(state)) {
|
|
47
|
+
for (const [name, part] of Object.entries(batch.streams ?? {})) {
|
|
48
|
+
if (stream !== undefined && name !== stream)
|
|
49
|
+
continue;
|
|
50
|
+
for (const entry of part.entries) {
|
|
51
|
+
const seq = seqs.get(name) ?? 0;
|
|
52
|
+
seqs.set(name, seq + 1);
|
|
53
|
+
out.push({
|
|
54
|
+
stream: name,
|
|
55
|
+
seq,
|
|
56
|
+
...(entry.id !== undefined ? { id: entry.id } : {}),
|
|
57
|
+
data: entry.data,
|
|
58
|
+
...(entry.blob ? { blob: entry.blob } : {}),
|
|
59
|
+
batch: batch.n,
|
|
60
|
+
stepId: record.stepId,
|
|
61
|
+
at,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** The normalized transcript, in log order. */
|
|
69
|
+
export function transcriptOf(state) {
|
|
70
|
+
return journalRecords(state, TRANSCRIPT);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The `effectOrder` index of the last `unavailable` fact (harness-world's
|
|
74
|
+
* `lastUnavailable`, spelled here so this module stays type-only on the
|
|
75
|
+
* kernel), or -1.
|
|
76
|
+
*/
|
|
77
|
+
function lastUnavailable(state) {
|
|
78
|
+
for (let i = state.effectOrder.length - 1; i >= 0; i--)
|
|
79
|
+
if (state.effectOrder[i].name === 'unavailable')
|
|
80
|
+
return i;
|
|
81
|
+
return -1;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Where the last turn ended: the `effectOrder` index of the batch holding
|
|
85
|
+
* the last `turn` entry, or of the last `unavailable` fact (a turn the
|
|
86
|
+
* harness could not run: it is over, with an error, and nothing in it is
|
|
87
|
+
* recovered), or -1 if no turn has ended yet. Everything after it belongs to
|
|
88
|
+
* the current (or an interrupted) turn.
|
|
89
|
+
*/
|
|
90
|
+
export function lastTurnEnd(state) {
|
|
91
|
+
let at = -1;
|
|
92
|
+
for (const r of transcriptOf(state))
|
|
93
|
+
if (r.data?.type === 'turn')
|
|
94
|
+
at = r.at;
|
|
95
|
+
return Math.max(at, lastUnavailable(state));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A batch input for UIs: the transcript entries as they are, every other
|
|
99
|
+
* stream as a count (native records can be large, and a page polling a
|
|
100
|
+
* session should not carry them).
|
|
101
|
+
*/
|
|
102
|
+
export function summarizeBatch(input) {
|
|
103
|
+
const batch = input;
|
|
104
|
+
if (!batch || typeof batch !== 'object' || !batch.streams)
|
|
105
|
+
return input;
|
|
106
|
+
const streams = {};
|
|
107
|
+
for (const [name, part] of Object.entries(batch.streams)) {
|
|
108
|
+
streams[name] =
|
|
109
|
+
name === TRANSCRIPT
|
|
110
|
+
? part
|
|
111
|
+
: {
|
|
112
|
+
from: part.from,
|
|
113
|
+
count: part.entries.length,
|
|
114
|
+
blobs: part.entries.filter((e) => e.blob).length,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { v: batch.v, n: batch.n, streams };
|
|
118
|
+
}
|
|
119
|
+
/** The transcript entries of one batch input (inline only; transcript entries are never offloaded). */
|
|
120
|
+
export function batchTranscript(input) {
|
|
121
|
+
const part = input?.streams?.[TRANSCRIPT];
|
|
122
|
+
return (part?.entries ?? []).map((e) => e.data).filter(Boolean);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The turn that has not ended: everything after the last `turn` entry of
|
|
126
|
+
* the transcript. `undefined` when the last turn ended and nothing happened
|
|
127
|
+
* since (nothing to recover; new submissions start a new turn as usual).
|
|
128
|
+
*/
|
|
129
|
+
export function openTurn(state) {
|
|
130
|
+
const transcript = transcriptOf(state);
|
|
131
|
+
const end = transcript.findLast((r) => r.data?.type === 'turn');
|
|
132
|
+
const failed = lastUnavailable(state);
|
|
133
|
+
const since = Math.max(end?.at ?? -1, failed);
|
|
134
|
+
const after = state.effectOrder.slice(since + 1);
|
|
135
|
+
// A crash drill's own step places its chaos submission: that is not a turn.
|
|
136
|
+
const placed = after.filter((e) => e.placed.length > 0 && !e.name.startsWith('tool.') && e.name !== 'chaos').flatMap((e) => e.placed);
|
|
137
|
+
const calls = after.filter((e) => e.name.startsWith('tool.'));
|
|
138
|
+
const entries = transcript.filter((r) => (end ? r.seq > end.seq : true) && r.at > failed);
|
|
139
|
+
if (placed.length === 0 && calls.length === 0 && entries.length === 0)
|
|
140
|
+
return undefined;
|
|
141
|
+
return { since, placed, calls, entries };
|
|
142
|
+
}
|