@tanstack/ai-sandbox 0.2.4 → 0.3.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/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createCapability } from "@tanstack/ai";
|
|
2
|
+
//#region src/instance-store.ts
|
|
3
|
+
/**
|
|
4
|
+
* Durable sandbox **instance** map — which provider sandbox (and snapshot) to
|
|
5
|
+
* resume for a compound key. Owned by `@tanstack/ai-sandbox` (not chat
|
|
6
|
+
* persistence): domain is runtime placement for `ensure`, not conversation state.
|
|
7
|
+
*
|
|
8
|
+
* Pass to `withSandbox(sandbox, { instances })`, which uses it in `ensure`
|
|
9
|
+
* (in-memory fallback when absent). {@link SandboxInstanceStoreCapability} is
|
|
10
|
+
* the ambient alternative for platform-level wiring.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Type a {@link SandboxInstanceStore} implementation inline: pass the object and
|
|
14
|
+
* get autocomplete + contract checking, with no separate
|
|
15
|
+
* `: SandboxInstanceStore` annotation. Hand the result to
|
|
16
|
+
* `withSandbox(sandbox, { instances })`. Matches `defineLock` /
|
|
17
|
+
* `defineMessageStore` style helpers elsewhere in the monorepo.
|
|
18
|
+
*/
|
|
19
|
+
function defineSandboxInstanceStore(store) {
|
|
20
|
+
return store;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Capability for the instance map — the ambient alternative to
|
|
24
|
+
* `withSandbox(sandbox, { instances })`. Provide it from any middleware with
|
|
25
|
+
* {@link provideSandboxInstanceStore}; `withSandbox` reads it when no explicit
|
|
26
|
+
* option was passed.
|
|
27
|
+
*/
|
|
28
|
+
var SandboxInstanceStoreCapability = createCapability()("sandbox-instance-store");
|
|
29
|
+
/** Destructured accessors: `getSandboxInstanceStore` / `provideSandboxInstanceStore`. */
|
|
30
|
+
var [getSandboxInstanceStore, provideSandboxInstanceStore] = SandboxInstanceStoreCapability;
|
|
31
|
+
/** In-memory {@link SandboxInstanceStore}. Resume works only within one process. */
|
|
32
|
+
var InMemorySandboxInstanceStore = class {
|
|
33
|
+
map = /* @__PURE__ */ new Map();
|
|
34
|
+
get(key) {
|
|
35
|
+
return Promise.resolve(this.map.get(key) ?? null);
|
|
36
|
+
}
|
|
37
|
+
upsert(record) {
|
|
38
|
+
this.map.set(record.key, record);
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
delete(key) {
|
|
42
|
+
this.map.delete(key);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Wiring note: hand the store straight to the consumer —
|
|
48
|
+
* `withSandbox(sandbox, { instances: store })`. That cannot be mis-ordered,
|
|
49
|
+
* unlike a separate provider middleware composed after `withSandbox` (which
|
|
50
|
+
* silently degrades to the in-memory fallback).
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* middleware: [
|
|
54
|
+
* withLocks(locks), // from @tanstack/ai/locks — multi-replica
|
|
55
|
+
* withSandbox(sandbox, { instances: instanceStore }),
|
|
56
|
+
* ]
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* For ambient/platform wiring (a hosting layer injecting infra without touching
|
|
60
|
+
* the call site), any middleware may still
|
|
61
|
+
* `provideSandboxInstanceStore(ctx, store)` on the capability bus; an explicit
|
|
62
|
+
* option takes precedence over it.
|
|
63
|
+
*/
|
|
64
|
+
//#endregion
|
|
65
|
+
export { InMemorySandboxInstanceStore, SandboxInstanceStoreCapability, defineSandboxInstanceStore, getSandboxInstanceStore, provideSandboxInstanceStore };
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=instance-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"instance-store.js","names":[],"sources":["../../src/instance-store.ts"],"sourcesContent":["/**\n * Durable sandbox **instance** map — which provider sandbox (and snapshot) to\n * resume for a compound key. Owned by `@tanstack/ai-sandbox` (not chat\n * persistence): domain is runtime placement for `ensure`, not conversation state.\n *\n * Pass to `withSandbox(sandbox, { instances })`, which uses it in `ensure`\n * (in-memory fallback when absent). {@link SandboxInstanceStoreCapability} is\n * the ambient alternative for platform-level wiring.\n */\nimport { createCapability } from '@tanstack/ai'\n\n/** One persisted sandbox instance, keyed by the compound sandbox instance key. */\nexport interface SandboxInstanceRecord {\n /** Compound key (see `computeSandboxKey`). */\n key: string\n /** Provider name that owns `providerSandboxId`. */\n provider: string\n /** Provider-assigned sandbox id used to resume. */\n providerSandboxId: string\n /** Most recent snapshot id, when the provider supports snapshots. */\n latestSnapshotId?: string\n threadId: string\n latestRunId?: string\n /**\n * Epoch ms of last write (for keepAlive / GC by the host app).\n */\n updatedAt: number\n}\n\n/**\n * Maps a compound key to the provider sandbox that should be resumed.\n *\n * Implement against your own database (BYO). Prove the contract with\n * `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`.\n */\nexport interface SandboxInstanceStore {\n /**\n * Return the record for `key`, or `null` if none exists.\n *\n * INVARIANT: missing keys return `null` (never throw).\n */\n get: (key: string) => Promise<SandboxInstanceRecord | null>\n /**\n * Insert or fully replace the record for `record.key`.\n *\n * INVARIANT (full replace): omitted optional fields (`latestSnapshotId`,\n * `latestRunId`) MUST clear any previously stored values. Do not merge with\n * the prior row — a create-without-snapshot path must not leave a stale\n * snapshot id.\n */\n upsert: (record: SandboxInstanceRecord) => Promise<void>\n /**\n * Remove the record for `key`.\n *\n * INVARIANT: deleting a missing key is a **no-op** (must not throw).\n */\n delete: (key: string) => Promise<void>\n}\n\n/**\n * Type a {@link SandboxInstanceStore} implementation inline: pass the object and\n * get autocomplete + contract checking, with no separate\n * `: SandboxInstanceStore` annotation. Hand the result to\n * `withSandbox(sandbox, { instances })`. Matches `defineLock` /\n * `defineMessageStore` style helpers elsewhere in the monorepo.\n */\nexport function defineSandboxInstanceStore(\n store: SandboxInstanceStore,\n): SandboxInstanceStore {\n return store\n}\n\n/**\n * Capability for the instance map — the ambient alternative to\n * `withSandbox(sandbox, { instances })`. Provide it from any middleware with\n * {@link provideSandboxInstanceStore}; `withSandbox` reads it when no explicit\n * option was passed.\n */\nexport const SandboxInstanceStoreCapability =\n createCapability<SandboxInstanceStore>()('sandbox-instance-store')\n\n/** Destructured accessors: `getSandboxInstanceStore` / `provideSandboxInstanceStore`. */\nexport const [getSandboxInstanceStore, provideSandboxInstanceStore] =\n SandboxInstanceStoreCapability\n\n/** In-memory {@link SandboxInstanceStore}. Resume works only within one process. */\nexport class InMemorySandboxInstanceStore implements SandboxInstanceStore {\n private readonly map = new Map<string, SandboxInstanceRecord>()\n\n get(key: string): Promise<SandboxInstanceRecord | null> {\n return Promise.resolve(this.map.get(key) ?? null)\n }\n\n upsert(record: SandboxInstanceRecord): Promise<void> {\n this.map.set(record.key, record)\n return Promise.resolve()\n }\n\n delete(key: string): Promise<void> {\n this.map.delete(key)\n return Promise.resolve()\n }\n}\n\n/**\n * Wiring note: hand the store straight to the consumer —\n * `withSandbox(sandbox, { instances: store })`. That cannot be mis-ordered,\n * unlike a separate provider middleware composed after `withSandbox` (which\n * silently degrades to the in-memory fallback).\n *\n * ```ts\n * middleware: [\n * withLocks(locks), // from @tanstack/ai/locks — multi-replica\n * withSandbox(sandbox, { instances: instanceStore }),\n * ]\n * ```\n *\n * For ambient/platform wiring (a hosting layer injecting infra without touching\n * the call site), any middleware may still\n * `provideSandboxInstanceStore(ctx, store)` on the capability bus; an explicit\n * option takes precedence over it.\n */\n"],"mappings":";;;;;;;;;;;;;;;;;;AAkEA,SAAgB,2BACd,OACsB;CACtB,OAAO;AACT;;;;;;;AAQA,IAAa,iCACX,iBAAuC,CAAC,CAAC,wBAAwB;;AAGnE,IAAa,CAAC,yBAAyB,+BACrC;;AAGF,IAAa,+BAAb,MAA0E;CACxE,sBAAuB,IAAI,IAAmC;CAE9D,IAAI,KAAoD;EACtD,OAAO,QAAQ,QAAQ,KAAK,IAAI,IAAI,GAAG,KAAK,IAAI;CAClD;CAEA,OAAO,QAA8C;EACnD,KAAK,IAAI,IAAI,OAAO,KAAK,MAAM;EAC/B,OAAO,QAAQ,QAAQ;CACzB;CAEA,OAAO,KAA4B;EACjC,KAAK,IAAI,OAAO,GAAG;EACnB,OAAO,QAAQ,QAAQ;CACzB;AACF"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-exact framing for journal reads.
|
|
3
|
+
*
|
|
4
|
+
* Both read paths end in {@link toJournalLines}, which counts absolute file
|
|
5
|
+
* offsets over BYTES, because a position is only useful if `tail -c +N` can
|
|
6
|
+
* resume from it. The two paths differ only in how they get bytes:
|
|
7
|
+
*
|
|
8
|
+
* - The **bounded** read is base64-framed (see `journal.ts` rule 2) and arrives
|
|
9
|
+
* as one complete `ExecResult.stdout` string, so {@link decodeBase64Stream}
|
|
10
|
+
* recovers the file's exact bytes regardless of how the provider decoded its
|
|
11
|
+
* stdout.
|
|
12
|
+
* - The **follow** read cannot be base64-framed — the encoder's stdio buffer
|
|
13
|
+
* would swallow the stream — so it arrives as provider-decoded text chunks
|
|
14
|
+
* and {@link encodeUtf8Stream} turns them back into bytes.
|
|
15
|
+
*
|
|
16
|
+
* `atob`, not `Buffer`: this module runs on the host, and the host can itself be
|
|
17
|
+
* a Cloudflare Worker (`ai-sandbox-cloudflare` drives its sandbox over Workers
|
|
18
|
+
* RPC from Worker code), where `Buffer` is not a global unless the `nodejs_compat`
|
|
19
|
+
* flag is on. `atob` is a Web/DOM API available in every host runtime this
|
|
20
|
+
* package targets, so it is the portable choice here.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Decode a streaming base64 frame into raw bytes.
|
|
24
|
+
*
|
|
25
|
+
* Whitespace is stripped because `base64(1)` wraps at 76 columns by default and
|
|
26
|
+
* busybox's build does not accept `-w 0`, so the wrapping cannot be turned off
|
|
27
|
+
* portably. Only complete 4-character quanta are decoded; a remainder is held
|
|
28
|
+
* for the next chunk. Padding (`=`) appears only in the final quantum, so an
|
|
29
|
+
* intermediate group always decodes to exactly 3 bytes.
|
|
30
|
+
*/
|
|
31
|
+
export declare function decodeBase64Stream(chunks: AsyncIterable<string>): AsyncIterable<Uint8Array>;
|
|
32
|
+
/**
|
|
33
|
+
* Re-encode provider-decoded text chunks as UTF-8 bytes.
|
|
34
|
+
*
|
|
35
|
+
* This is the follow path's replacement for {@link decodeBase64Stream}: the
|
|
36
|
+
* follow command emits the journal's raw bytes, the provider hands them over as
|
|
37
|
+
* `AsyncIterable<string>`, and `TextEncoder` round-trips that text back to the
|
|
38
|
+
* bytes it was decoded from. Chunk boundaries do not need to fall on character
|
|
39
|
+
* boundaries here — {@link toJournalLines} buffers bytes until a newline, so a
|
|
40
|
+
* multi-byte character split across two chunks is reassembled there, exactly as
|
|
41
|
+
* it is on the base64 path.
|
|
42
|
+
*
|
|
43
|
+
* Empty chunks are dropped rather than forwarded: a zero-length `Uint8Array`
|
|
44
|
+
* carries no bytes and would only make the downstream loop spin.
|
|
45
|
+
*/
|
|
46
|
+
export declare function encodeUtf8Stream(chunks: AsyncIterable<string>): AsyncIterable<Uint8Array>;
|
|
47
|
+
/** One complete journal line plus the absolute byte position just past its newline. */
|
|
48
|
+
export interface JournalLine {
|
|
49
|
+
/** The line's text, newline excluded. */
|
|
50
|
+
line: string;
|
|
51
|
+
/**
|
|
52
|
+
* Absolute byte offset immediately AFTER this line's newline — i.e. the count
|
|
53
|
+
* of journal bytes fully consumed once this line has been handled, and
|
|
54
|
+
* therefore the exact value to resume a `tail -c +N` from.
|
|
55
|
+
*/
|
|
56
|
+
endPosition: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Split a byte stream into newline-terminated lines, tracking absolute
|
|
60
|
+
* positions from `startPosition`.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately unlike `toLines` in `runner.ts`, which yields a trailing
|
|
63
|
+
* unterminated line: here a trailing partial line is a line the agent is still
|
|
64
|
+
* writing. Yielding it would hand a truncated JSON string downstream AND
|
|
65
|
+
* advance the position past bytes the next read must re-see.
|
|
66
|
+
*/
|
|
67
|
+
export declare function toJournalLines(byteChunks: AsyncIterable<Uint8Array>, startPosition: number): AsyncIterable<JournalLine>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//#region src/journal-bytes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Byte-exact framing for journal reads.
|
|
4
|
+
*
|
|
5
|
+
* Both read paths end in {@link toJournalLines}, which counts absolute file
|
|
6
|
+
* offsets over BYTES, because a position is only useful if `tail -c +N` can
|
|
7
|
+
* resume from it. The two paths differ only in how they get bytes:
|
|
8
|
+
*
|
|
9
|
+
* - The **bounded** read is base64-framed (see `journal.ts` rule 2) and arrives
|
|
10
|
+
* as one complete `ExecResult.stdout` string, so {@link decodeBase64Stream}
|
|
11
|
+
* recovers the file's exact bytes regardless of how the provider decoded its
|
|
12
|
+
* stdout.
|
|
13
|
+
* - The **follow** read cannot be base64-framed — the encoder's stdio buffer
|
|
14
|
+
* would swallow the stream — so it arrives as provider-decoded text chunks
|
|
15
|
+
* and {@link encodeUtf8Stream} turns them back into bytes.
|
|
16
|
+
*
|
|
17
|
+
* `atob`, not `Buffer`: this module runs on the host, and the host can itself be
|
|
18
|
+
* a Cloudflare Worker (`ai-sandbox-cloudflare` drives its sandbox over Workers
|
|
19
|
+
* RPC from Worker code), where `Buffer` is not a global unless the `nodejs_compat`
|
|
20
|
+
* flag is on. `atob` is a Web/DOM API available in every host runtime this
|
|
21
|
+
* package targets, so it is the portable choice here.
|
|
22
|
+
*/
|
|
23
|
+
var NEWLINE = 10;
|
|
24
|
+
/** Decode one complete base64 quantum group to bytes. */
|
|
25
|
+
function decodeQuantumGroup(group) {
|
|
26
|
+
const binary = atob(group);
|
|
27
|
+
const out = new Uint8Array(binary.length);
|
|
28
|
+
for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decode a streaming base64 frame into raw bytes.
|
|
33
|
+
*
|
|
34
|
+
* Whitespace is stripped because `base64(1)` wraps at 76 columns by default and
|
|
35
|
+
* busybox's build does not accept `-w 0`, so the wrapping cannot be turned off
|
|
36
|
+
* portably. Only complete 4-character quanta are decoded; a remainder is held
|
|
37
|
+
* for the next chunk. Padding (`=`) appears only in the final quantum, so an
|
|
38
|
+
* intermediate group always decodes to exactly 3 bytes.
|
|
39
|
+
*/
|
|
40
|
+
async function* decodeBase64Stream(chunks) {
|
|
41
|
+
let pending = "";
|
|
42
|
+
for await (const chunk of chunks) {
|
|
43
|
+
pending += chunk.replace(/\s+/g, "");
|
|
44
|
+
const usable = pending.length - pending.length % 4;
|
|
45
|
+
if (usable === 0) continue;
|
|
46
|
+
const group = pending.slice(0, usable);
|
|
47
|
+
pending = pending.slice(usable);
|
|
48
|
+
yield decodeQuantumGroup(group);
|
|
49
|
+
}
|
|
50
|
+
if (pending.length > 0) throw new Error(`journal: base64 frame ended mid-quantum with ${pending.length} character(s) pending`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Re-encode provider-decoded text chunks as UTF-8 bytes.
|
|
54
|
+
*
|
|
55
|
+
* This is the follow path's replacement for {@link decodeBase64Stream}: the
|
|
56
|
+
* follow command emits the journal's raw bytes, the provider hands them over as
|
|
57
|
+
* `AsyncIterable<string>`, and `TextEncoder` round-trips that text back to the
|
|
58
|
+
* bytes it was decoded from. Chunk boundaries do not need to fall on character
|
|
59
|
+
* boundaries here — {@link toJournalLines} buffers bytes until a newline, so a
|
|
60
|
+
* multi-byte character split across two chunks is reassembled there, exactly as
|
|
61
|
+
* it is on the base64 path.
|
|
62
|
+
*
|
|
63
|
+
* Empty chunks are dropped rather than forwarded: a zero-length `Uint8Array`
|
|
64
|
+
* carries no bytes and would only make the downstream loop spin.
|
|
65
|
+
*/
|
|
66
|
+
async function* encodeUtf8Stream(chunks) {
|
|
67
|
+
const encoder = new TextEncoder();
|
|
68
|
+
for await (const chunk of chunks) {
|
|
69
|
+
if (chunk.length === 0) continue;
|
|
70
|
+
yield encoder.encode(chunk);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function concatBytes(left, right) {
|
|
74
|
+
const out = new Uint8Array(left.length + right.length);
|
|
75
|
+
out.set(left, 0);
|
|
76
|
+
out.set(right, left.length);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Split a byte stream into newline-terminated lines, tracking absolute
|
|
81
|
+
* positions from `startPosition`.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately unlike `toLines` in `runner.ts`, which yields a trailing
|
|
84
|
+
* unterminated line: here a trailing partial line is a line the agent is still
|
|
85
|
+
* writing. Yielding it would hand a truncated JSON string downstream AND
|
|
86
|
+
* advance the position past bytes the next read must re-see.
|
|
87
|
+
*/
|
|
88
|
+
async function* toJournalLines(byteChunks, startPosition) {
|
|
89
|
+
const decoder = new TextDecoder();
|
|
90
|
+
let buffer = /* @__PURE__ */ new Uint8Array(0);
|
|
91
|
+
let position = startPosition;
|
|
92
|
+
for await (const bytes of byteChunks) {
|
|
93
|
+
buffer = concatBytes(buffer, bytes);
|
|
94
|
+
let newline = buffer.indexOf(NEWLINE);
|
|
95
|
+
while (newline !== -1) {
|
|
96
|
+
const lineBytes = buffer.subarray(0, newline);
|
|
97
|
+
position += newline + 1;
|
|
98
|
+
yield {
|
|
99
|
+
line: decoder.decode(lineBytes),
|
|
100
|
+
endPosition: position
|
|
101
|
+
};
|
|
102
|
+
buffer = buffer.slice(newline + 1);
|
|
103
|
+
newline = buffer.indexOf(NEWLINE);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { decodeBase64Stream, encodeUtf8Stream, toJournalLines };
|
|
109
|
+
|
|
110
|
+
//# sourceMappingURL=journal-bytes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"journal-bytes.js","names":[],"sources":["../../src/journal-bytes.ts"],"sourcesContent":["/**\n * Byte-exact framing for journal reads.\n *\n * Both read paths end in {@link toJournalLines}, which counts absolute file\n * offsets over BYTES, because a position is only useful if `tail -c +N` can\n * resume from it. The two paths differ only in how they get bytes:\n *\n * - The **bounded** read is base64-framed (see `journal.ts` rule 2) and arrives\n * as one complete `ExecResult.stdout` string, so {@link decodeBase64Stream}\n * recovers the file's exact bytes regardless of how the provider decoded its\n * stdout.\n * - The **follow** read cannot be base64-framed — the encoder's stdio buffer\n * would swallow the stream — so it arrives as provider-decoded text chunks\n * and {@link encodeUtf8Stream} turns them back into bytes.\n *\n * `atob`, not `Buffer`: this module runs on the host, and the host can itself be\n * a Cloudflare Worker (`ai-sandbox-cloudflare` drives its sandbox over Workers\n * RPC from Worker code), where `Buffer` is not a global unless the `nodejs_compat`\n * flag is on. `atob` is a Web/DOM API available in every host runtime this\n * package targets, so it is the portable choice here.\n */\n\nconst NEWLINE = 0x0a\n\n/** Decode one complete base64 quantum group to bytes. */\nfunction decodeQuantumGroup(group: string): Uint8Array {\n const binary = atob(group)\n const out = new Uint8Array(binary.length)\n for (let index = 0; index < binary.length; index += 1) {\n out[index] = binary.charCodeAt(index)\n }\n return out\n}\n\n/**\n * Decode a streaming base64 frame into raw bytes.\n *\n * Whitespace is stripped because `base64(1)` wraps at 76 columns by default and\n * busybox's build does not accept `-w 0`, so the wrapping cannot be turned off\n * portably. Only complete 4-character quanta are decoded; a remainder is held\n * for the next chunk. Padding (`=`) appears only in the final quantum, so an\n * intermediate group always decodes to exactly 3 bytes.\n */\nexport async function* decodeBase64Stream(\n chunks: AsyncIterable<string>,\n): AsyncIterable<Uint8Array> {\n let pending = ''\n for await (const chunk of chunks) {\n pending += chunk.replace(/\\s+/g, '')\n const usable = pending.length - (pending.length % 4)\n if (usable === 0) continue\n const group = pending.slice(0, usable)\n pending = pending.slice(usable)\n yield decodeQuantumGroup(group)\n }\n if (pending.length > 0) {\n // Fail loud. A remainder means the frame was cut off mid-quantum, i.e. the\n // reader died partway through. Rounding it away would silently drop journal\n // bytes and desync every position derived from this stream.\n throw new Error(\n `journal: base64 frame ended mid-quantum with ${pending.length} character(s) pending`,\n )\n }\n}\n\n/**\n * Re-encode provider-decoded text chunks as UTF-8 bytes.\n *\n * This is the follow path's replacement for {@link decodeBase64Stream}: the\n * follow command emits the journal's raw bytes, the provider hands them over as\n * `AsyncIterable<string>`, and `TextEncoder` round-trips that text back to the\n * bytes it was decoded from. Chunk boundaries do not need to fall on character\n * boundaries here — {@link toJournalLines} buffers bytes until a newline, so a\n * multi-byte character split across two chunks is reassembled there, exactly as\n * it is on the base64 path.\n *\n * Empty chunks are dropped rather than forwarded: a zero-length `Uint8Array`\n * carries no bytes and would only make the downstream loop spin.\n */\nexport async function* encodeUtf8Stream(\n chunks: AsyncIterable<string>,\n): AsyncIterable<Uint8Array> {\n const encoder = new TextEncoder()\n for await (const chunk of chunks) {\n if (chunk.length === 0) continue\n yield encoder.encode(chunk)\n }\n}\n\n/** One complete journal line plus the absolute byte position just past its newline. */\nexport interface JournalLine {\n /** The line's text, newline excluded. */\n line: string\n /**\n * Absolute byte offset immediately AFTER this line's newline — i.e. the count\n * of journal bytes fully consumed once this line has been handled, and\n * therefore the exact value to resume a `tail -c +N` from.\n */\n endPosition: number\n}\n\nfunction concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array {\n const out = new Uint8Array(left.length + right.length)\n out.set(left, 0)\n out.set(right, left.length)\n return out\n}\n\n/**\n * Split a byte stream into newline-terminated lines, tracking absolute\n * positions from `startPosition`.\n *\n * Deliberately unlike `toLines` in `runner.ts`, which yields a trailing\n * unterminated line: here a trailing partial line is a line the agent is still\n * writing. Yielding it would hand a truncated JSON string downstream AND\n * advance the position past bytes the next read must re-see.\n */\nexport async function* toJournalLines(\n byteChunks: AsyncIterable<Uint8Array>,\n startPosition: number,\n): AsyncIterable<JournalLine> {\n const decoder = new TextDecoder()\n let buffer: Uint8Array = new Uint8Array(0)\n let position = startPosition\n for await (const bytes of byteChunks) {\n buffer = concatBytes(buffer, bytes)\n let newline = buffer.indexOf(NEWLINE)\n while (newline !== -1) {\n const lineBytes = buffer.subarray(0, newline)\n position += newline + 1\n yield { line: decoder.decode(lineBytes), endPosition: position }\n buffer = buffer.slice(newline + 1)\n newline = buffer.indexOf(NEWLINE)\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,IAAM,UAAU;;AAGhB,SAAS,mBAAmB,OAA2B;CACrD,MAAM,SAAS,KAAK,KAAK;CACzB,MAAM,MAAM,IAAI,WAAW,OAAO,MAAM;CACxC,KAAK,IAAI,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS,GAClD,IAAI,SAAS,OAAO,WAAW,KAAK;CAEtC,OAAO;AACT;;;;;;;;;;AAWA,gBAAuB,mBACrB,QAC2B;CAC3B,IAAI,UAAU;CACd,WAAW,MAAM,SAAS,QAAQ;EAChC,WAAW,MAAM,QAAQ,QAAQ,EAAE;EACnC,MAAM,SAAS,QAAQ,SAAU,QAAQ,SAAS;EAClD,IAAI,WAAW,GAAG;EAClB,MAAM,QAAQ,QAAQ,MAAM,GAAG,MAAM;EACrC,UAAU,QAAQ,MAAM,MAAM;EAC9B,MAAM,mBAAmB,KAAK;CAChC;CACA,IAAI,QAAQ,SAAS,GAInB,MAAM,IAAI,MACR,gDAAgD,QAAQ,OAAO,sBACjE;AAEJ;;;;;;;;;;;;;;;AAgBA,gBAAuB,iBACrB,QAC2B;CAC3B,MAAM,UAAU,IAAI,YAAY;CAChC,WAAW,MAAM,SAAS,QAAQ;EAChC,IAAI,MAAM,WAAW,GAAG;EACxB,MAAM,QAAQ,OAAO,KAAK;CAC5B;AACF;AAcA,SAAS,YAAY,MAAkB,OAA+B;CACpE,MAAM,MAAM,IAAI,WAAW,KAAK,SAAS,MAAM,MAAM;CACrD,IAAI,IAAI,MAAM,CAAC;CACf,IAAI,IAAI,OAAO,KAAK,MAAM;CAC1B,OAAO;AACT;;;;;;;;;;AAWA,gBAAuB,eACrB,YACA,eAC4B;CAC5B,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,yBAAqB,IAAI,WAAW,CAAC;CACzC,IAAI,WAAW;CACf,WAAW,MAAM,SAAS,YAAY;EACpC,SAAS,YAAY,QAAQ,KAAK;EAClC,IAAI,UAAU,OAAO,QAAQ,OAAO;EACpC,OAAO,YAAY,IAAI;GACrB,MAAM,YAAY,OAAO,SAAS,GAAG,OAAO;GAC5C,YAAY,UAAU;GACtB,MAAM;IAAE,MAAM,QAAQ,OAAO,SAAS;IAAG,aAAa;GAAS;GAC/D,SAAS,OAAO,MAAM,UAAU,CAAC;GACjC,UAAU,OAAO,QAAQ,OAAO;EAClC;CACF;AACF"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { JournalPaths } from './journal.js';
|
|
2
|
+
import { JournalLine } from './journal-bytes.js';
|
|
3
|
+
import { SandboxHandle } from './contracts.js';
|
|
4
|
+
/**
|
|
5
|
+
* Poll interval for the bounded-`exec` strategy. Matches the interval
|
|
6
|
+
* `ai-sandbox-cloudflare`'s run-log Durable Object already uses, so the two
|
|
7
|
+
* readers have the same latency profile.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_JOURNAL_POLL_MS = 250;
|
|
10
|
+
export interface ReadJournalOptions {
|
|
11
|
+
paths: JournalPaths;
|
|
12
|
+
/**
|
|
13
|
+
* Count of journal bytes already consumed. The read starts at the next byte.
|
|
14
|
+
* Defaults to 0, which is also what a takeover uses: the alignment step, not
|
|
15
|
+
* the reader, decides what has already been delivered.
|
|
16
|
+
*/
|
|
17
|
+
fromByte?: number;
|
|
18
|
+
/** Stop reading. On the follow strategy this also kills the `tail`. */
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
/** Override the capability-derived strategy. Tests and diagnostics only. */
|
|
21
|
+
strategy?: 'follow' | 'poll';
|
|
22
|
+
/** Poll strategy only. Defaults to {@link DEFAULT_JOURNAL_POLL_MS}. */
|
|
23
|
+
pollIntervalMs?: number;
|
|
24
|
+
/** Working directory for the read command. Paths are absolute, so rarely needed. */
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/**
|
|
27
|
+
* How long to wait for the FIRST byte of the journal before failing with
|
|
28
|
+
* `'journal-stalled'`. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} — the
|
|
29
|
+
* same number that bounds the attach preflight, because it bounds the same
|
|
30
|
+
* question from the other side. `0` or a non-finite value disables the bound;
|
|
31
|
+
* do that only where some OTHER deadline already covers the read, since an
|
|
32
|
+
* unbounded read of an empty journal never returns.
|
|
33
|
+
*
|
|
34
|
+
* Only the first byte is bounded. An agent that streams slowly is never cut
|
|
35
|
+
* off.
|
|
36
|
+
*/
|
|
37
|
+
firstByteTimeoutMs?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Run id, for the stall error's message only. Defaults to naming the journal
|
|
40
|
+
* path, which is always available and always identifies the run uniquely.
|
|
41
|
+
*/
|
|
42
|
+
runId?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Which read strategy a provider supports.
|
|
46
|
+
*
|
|
47
|
+
* Keyed on capabilities, never on `handle.provider`: a BYO provider with the
|
|
48
|
+
* same limitation must get the same treatment, and name-sniffing would silently
|
|
49
|
+
* hand it an unstoppable `tail -f`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function journalReadStrategy(handle: SandboxHandle): 'follow' | 'poll';
|
|
52
|
+
/**
|
|
53
|
+
* Read a run's journal as positioned lines.
|
|
54
|
+
*
|
|
55
|
+
* **This is a public entry point and it CANNOT hang.** It has no `RunStore` in
|
|
56
|
+
* its signature and no runId to look one up with, so it cannot run the
|
|
57
|
+
* `attach-preflight.ts` gate that classifies a stale or mistyped runId as
|
|
58
|
+
* `'unknown-run'`/`'terminal-run'`; what it has instead is the unconditional
|
|
59
|
+
* bound described in the module doc. A runId with no journal therefore fails with
|
|
60
|
+
* {@link JournalAttachUnavailableError} (`reason: 'journal-stalled'`) after
|
|
61
|
+
* {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} rather than tailing an empty file it
|
|
62
|
+
* just created, for ever, with no error and no log line. Callers that DO have a
|
|
63
|
+
* store — `runner.ts` on an attach — run the preflight as well, for the sharper
|
|
64
|
+
* diagnosis.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readJournal(handle: SandboxHandle, options: ReadJournalOptions): AsyncIterable<JournalLine>;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { journalFollowCommand, journalReadCommand } from "./journal.js";
|
|
2
|
+
import { JournalAttachUnavailableError } from "./attach-preflight.js";
|
|
3
|
+
import { decodeBase64Stream, encodeUtf8Stream, toJournalLines } from "./journal-bytes.js";
|
|
4
|
+
//#region src/journal-reader.ts
|
|
5
|
+
/**
|
|
6
|
+
* Read a run's journal, live or after the fact, on one code path.
|
|
7
|
+
*
|
|
8
|
+
* Resume is not a special case: every read is `tail -c +N` for some N, and a
|
|
9
|
+
* fresh run is simply N = 0. That is deliberate — `pid` is `-1` on five of six
|
|
10
|
+
* providers, so re-attaching to an existing reader is impossible and a resumed
|
|
11
|
+
* read always spawns a new `tail` anyway.
|
|
12
|
+
*
|
|
13
|
+
* Two strategies, chosen by capability rather than by provider name:
|
|
14
|
+
*
|
|
15
|
+
* - **follow** (`spawn` + `tail -f`): the default. Streams with no polling cost
|
|
16
|
+
* and is killed when the consumer stops. Its command pipes into nothing — see
|
|
17
|
+
* `journal.ts` rule 2 — so this path re-encodes the provider's decoded text
|
|
18
|
+
* rather than decoding a base64 frame.
|
|
19
|
+
* - **poll** (bounded `exec`, no `-f`): for a provider whose spawned process
|
|
20
|
+
* cannot be stopped. Cloudflare's `kill()` is a documented no-op and it
|
|
21
|
+
* forwards the AbortSignal to neither `exec` nor `spawn`, so a `tail -f`
|
|
22
|
+
* there would run forever inside the container. Every poll command terminates
|
|
23
|
+
* on its own, so nothing needs killing.
|
|
24
|
+
*
|
|
25
|
+
* **Neither strategy may wait forever for its FIRST byte.** This is the bound
|
|
26
|
+
* that used to be missing, and its absence was reachable three ways, one of them
|
|
27
|
+
* self-inflicted:
|
|
28
|
+
*
|
|
29
|
+
* 1. `journalFollowCommand` CREATES the journal before tailing it (`: >> file`),
|
|
30
|
+
* which it must, so a read for a runId whose journal never existed
|
|
31
|
+
* manufactures an empty file and tails it forever. The attach preflight
|
|
32
|
+
* (`attach-preflight.ts`) catches most of those, but it is wired at exactly
|
|
33
|
+
* one call site and only for `attach === true` — the exported
|
|
34
|
+
* {@link readJournal} that `docs/sandbox/journal.md` tells users to write has
|
|
35
|
+
* no preflight, no store, and no runId to look one up with.
|
|
36
|
+
* 2. The preflight's own probe can be unusable, and it deliberately falls through
|
|
37
|
+
* to a bounded wait rather than skipping; a journal that exists but is
|
|
38
|
+
* abandoned still reaches the reader.
|
|
39
|
+
* 3. SIGKILL/OOM of the agent's shell between its last line and its sentinel
|
|
40
|
+
* `printf` leaves a real, non-empty, permanently-silent journal.
|
|
41
|
+
*
|
|
42
|
+
* So a read that receives NO bytes within {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}
|
|
43
|
+
* raises {@link JournalAttachUnavailableError} with reason `'journal-stalled'`
|
|
44
|
+
* instead of parking. The bound is on the FIRST byte only, deliberately: once the
|
|
45
|
+
* journal is producing, how long the agent thinks between lines is the agent's
|
|
46
|
+
* business and no deadline here may cut a healthy run short. A consumer abort is
|
|
47
|
+
* not a stall — it ends the read quietly, as it always did.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Poll interval for the bounded-`exec` strategy. Matches the interval
|
|
51
|
+
* `ai-sandbox-cloudflare`'s run-log Durable Object already uses, so the two
|
|
52
|
+
* readers have the same latency profile.
|
|
53
|
+
*/
|
|
54
|
+
var DEFAULT_JOURNAL_POLL_MS = 250;
|
|
55
|
+
/**
|
|
56
|
+
* Which read strategy a provider supports.
|
|
57
|
+
*
|
|
58
|
+
* Keyed on capabilities, never on `handle.provider`: a BYO provider with the
|
|
59
|
+
* same limitation must get the same treatment, and name-sniffing would silently
|
|
60
|
+
* hand it an unstoppable `tail -f`.
|
|
61
|
+
*/
|
|
62
|
+
function journalReadStrategy(handle) {
|
|
63
|
+
const { backgroundProcesses, killableProcesses } = handle.capabilities;
|
|
64
|
+
return backgroundProcesses && killableProcesses ? "follow" : "poll";
|
|
65
|
+
}
|
|
66
|
+
function processOptions(options) {
|
|
67
|
+
return {
|
|
68
|
+
...options.cwd === void 0 ? {} : { cwd: options.cwd },
|
|
69
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Resolution of the abort race in {@link untilAborted}. Never a stream value. */
|
|
73
|
+
var ABORTED = Symbol("journal-read-aborted");
|
|
74
|
+
/**
|
|
75
|
+
* Iterate `source` but stop the moment `signal` fires, instead of waiting for
|
|
76
|
+
* the stream to close.
|
|
77
|
+
*
|
|
78
|
+
* Without this, aborting a follow read only *asks* the provider to kill `tail`
|
|
79
|
+
* and then blocks on `stdout` until that kill closes the pipe — which is not a
|
|
80
|
+
* guarantee any provider makes. On local-process/Windows, `killTree` falls back
|
|
81
|
+
* to signalling only the `sh` wrapper if `taskkill` is unavailable, leaving the
|
|
82
|
+
* `tail` grandchild holding the stdout pipe open, and the read rides past its
|
|
83
|
+
* own AbortSignal until some outer timeout fires. The signal is the caller's
|
|
84
|
+
* contract with the reader, so the reader honors it itself and treats the kill
|
|
85
|
+
* as best-effort cleanup. (local-process now also verifies the tree is gone and
|
|
86
|
+
* sweeps the MSYS grandchildren `taskkill /T` cannot reach, but that is a
|
|
87
|
+
* provider improving its best effort — not a guarantee this reader may assume of
|
|
88
|
+
* any provider.)
|
|
89
|
+
*/
|
|
90
|
+
async function* untilAborted(source, signal) {
|
|
91
|
+
if (!signal) {
|
|
92
|
+
yield* source;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (signal.aborted) return;
|
|
96
|
+
let onAbort;
|
|
97
|
+
const aborted = new Promise((resolve) => {
|
|
98
|
+
onAbort = () => resolve(ABORTED);
|
|
99
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
100
|
+
});
|
|
101
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
102
|
+
try {
|
|
103
|
+
for (;;) {
|
|
104
|
+
const next = await Promise.race([iterator.next(), aborted]);
|
|
105
|
+
if (next === ABORTED || next.done === true) return;
|
|
106
|
+
yield next.value;
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
110
|
+
iterator.return?.().catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Resolution of the first-byte race in {@link withFirstByteDeadline}. */
|
|
114
|
+
var STALLED = Symbol("journal-read-stalled");
|
|
115
|
+
/** The bound in effect for a read; `undefined` when the caller disabled it. */
|
|
116
|
+
function firstByteTimeout(options) {
|
|
117
|
+
const ms = options.firstByteTimeoutMs ?? 1e4;
|
|
118
|
+
return Number.isFinite(ms) && ms > 0 ? ms : void 0;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The `'journal-stalled'` failure, shared by both strategies so the two report
|
|
122
|
+
* the same diagnosis for the same state.
|
|
123
|
+
*/
|
|
124
|
+
function stalled(options, timeoutMs) {
|
|
125
|
+
return new JournalAttachUnavailableError(options.runId ?? options.paths.journal, "journal-stalled", `its journal (${options.paths.journal}) delivered no bytes within ${timeoutMs}ms. The file exists but nothing is appending to it and no '__exit' sentinel can arrive, so following it would never return: either the read created it itself (a runId with no journal), or the agent's shell was killed before it could write its sentinel.`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pass `source` through unchanged, except that receiving NO value within
|
|
129
|
+
* `timeoutMs` throws.
|
|
130
|
+
*
|
|
131
|
+
* Only the first value is raced. After it, the source is iterated directly, so a
|
|
132
|
+
* long gap between later values costs nothing and cannot fail a healthy read.
|
|
133
|
+
*
|
|
134
|
+
* A source that simply ENDS before the deadline is not a stall — that is the
|
|
135
|
+
* consumer's abort (`untilAborted` returns on abort) or a `tail` that exited —
|
|
136
|
+
* and it returns quietly, preserving the "an abort diagnoses nothing" rule.
|
|
137
|
+
*/
|
|
138
|
+
async function* withFirstByteDeadline(source, timeoutMs, onStall) {
|
|
139
|
+
if (timeoutMs === void 0) {
|
|
140
|
+
yield* source;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
144
|
+
let timer;
|
|
145
|
+
const expired = new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(() => resolve(STALLED), timeoutMs);
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const first = await Promise.race([iterator.next(), expired]);
|
|
150
|
+
if (first === STALLED) throw onStall();
|
|
151
|
+
if (first.done === true) return;
|
|
152
|
+
yield first.value;
|
|
153
|
+
for (;;) {
|
|
154
|
+
const next = await iterator.next();
|
|
155
|
+
if (next.done === true) return;
|
|
156
|
+
yield next.value;
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
iterator.return?.().catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function* followJournal(handle, options) {
|
|
164
|
+
const fromByte = options.fromByte ?? 0;
|
|
165
|
+
const proc = await handle.process.spawn(journalFollowCommand(options.paths, fromByte), processOptions(options));
|
|
166
|
+
const timeoutMs = firstByteTimeout(options);
|
|
167
|
+
try {
|
|
168
|
+
yield* toJournalLines(encodeUtf8Stream(withFirstByteDeadline(untilAborted(proc.stdout, options.signal), timeoutMs, () => stalled(options, timeoutMs ?? 0))), fromByte);
|
|
169
|
+
} finally {
|
|
170
|
+
try {
|
|
171
|
+
await proc.kill();
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function sleep(ms, signal) {
|
|
176
|
+
if (ms <= 0) return Promise.resolve();
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const timer = setTimeout(finish, ms);
|
|
179
|
+
function finish() {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
signal?.removeEventListener("abort", finish);
|
|
182
|
+
resolve();
|
|
183
|
+
}
|
|
184
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function* singleValue(value) {
|
|
188
|
+
yield value;
|
|
189
|
+
}
|
|
190
|
+
async function* pollJournal(handle, options) {
|
|
191
|
+
const intervalMs = options.pollIntervalMs ?? 250;
|
|
192
|
+
const timeoutMs = firstByteTimeout(options);
|
|
193
|
+
const deadline = timeoutMs === void 0 ? void 0 : Date.now() + timeoutMs;
|
|
194
|
+
let sawBytes = false;
|
|
195
|
+
let position = options.fromByte ?? 0;
|
|
196
|
+
while (!options.signal?.aborted) {
|
|
197
|
+
const result = await handle.process.exec(journalReadCommand(options.paths, position), processOptions(options));
|
|
198
|
+
if (result.stdout.trim() !== "") sawBytes = true;
|
|
199
|
+
if (!sawBytes && deadline !== void 0 && timeoutMs !== void 0 && Date.now() >= deadline) throw stalled(options, timeoutMs);
|
|
200
|
+
for await (const line of toJournalLines(decodeBase64Stream(singleValue(result.stdout)), position)) {
|
|
201
|
+
yield line;
|
|
202
|
+
position = line.endPosition;
|
|
203
|
+
}
|
|
204
|
+
if (options.signal?.aborted) return;
|
|
205
|
+
await sleep(intervalMs, options.signal);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Read a run's journal as positioned lines.
|
|
210
|
+
*
|
|
211
|
+
* **This is a public entry point and it CANNOT hang.** It has no `RunStore` in
|
|
212
|
+
* its signature and no runId to look one up with, so it cannot run the
|
|
213
|
+
* `attach-preflight.ts` gate that classifies a stale or mistyped runId as
|
|
214
|
+
* `'unknown-run'`/`'terminal-run'`; what it has instead is the unconditional
|
|
215
|
+
* bound described in the module doc. A runId with no journal therefore fails with
|
|
216
|
+
* {@link JournalAttachUnavailableError} (`reason: 'journal-stalled'`) after
|
|
217
|
+
* {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} rather than tailing an empty file it
|
|
218
|
+
* just created, for ever, with no error and no log line. Callers that DO have a
|
|
219
|
+
* store — `runner.ts` on an attach — run the preflight as well, for the sharper
|
|
220
|
+
* diagnosis.
|
|
221
|
+
*/
|
|
222
|
+
function readJournal(handle, options) {
|
|
223
|
+
return (options.strategy ?? journalReadStrategy(handle)) === "follow" ? followJournal(handle, options) : pollJournal(handle, options);
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
export { DEFAULT_JOURNAL_POLL_MS, journalReadStrategy, readJournal };
|
|
227
|
+
|
|
228
|
+
//# sourceMappingURL=journal-reader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"journal-reader.js","names":[],"sources":["../../src/journal-reader.ts"],"sourcesContent":["/**\n * Read a run's journal, live or after the fact, on one code path.\n *\n * Resume is not a special case: every read is `tail -c +N` for some N, and a\n * fresh run is simply N = 0. That is deliberate — `pid` is `-1` on five of six\n * providers, so re-attaching to an existing reader is impossible and a resumed\n * read always spawns a new `tail` anyway.\n *\n * Two strategies, chosen by capability rather than by provider name:\n *\n * - **follow** (`spawn` + `tail -f`): the default. Streams with no polling cost\n * and is killed when the consumer stops. Its command pipes into nothing — see\n * `journal.ts` rule 2 — so this path re-encodes the provider's decoded text\n * rather than decoding a base64 frame.\n * - **poll** (bounded `exec`, no `-f`): for a provider whose spawned process\n * cannot be stopped. Cloudflare's `kill()` is a documented no-op and it\n * forwards the AbortSignal to neither `exec` nor `spawn`, so a `tail -f`\n * there would run forever inside the container. Every poll command terminates\n * on its own, so nothing needs killing.\n *\n * **Neither strategy may wait forever for its FIRST byte.** This is the bound\n * that used to be missing, and its absence was reachable three ways, one of them\n * self-inflicted:\n *\n * 1. `journalFollowCommand` CREATES the journal before tailing it (`: >> file`),\n * which it must, so a read for a runId whose journal never existed\n * manufactures an empty file and tails it forever. The attach preflight\n * (`attach-preflight.ts`) catches most of those, but it is wired at exactly\n * one call site and only for `attach === true` — the exported\n * {@link readJournal} that `docs/sandbox/journal.md` tells users to write has\n * no preflight, no store, and no runId to look one up with.\n * 2. The preflight's own probe can be unusable, and it deliberately falls through\n * to a bounded wait rather than skipping; a journal that exists but is\n * abandoned still reaches the reader.\n * 3. SIGKILL/OOM of the agent's shell between its last line and its sentinel\n * `printf` leaves a real, non-empty, permanently-silent journal.\n *\n * So a read that receives NO bytes within {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}\n * raises {@link JournalAttachUnavailableError} with reason `'journal-stalled'`\n * instead of parking. The bound is on the FIRST byte only, deliberately: once the\n * journal is producing, how long the agent thinks between lines is the agent's\n * business and no deadline here may cut a healthy run short. A consumer abort is\n * not a stall — it ends the read quietly, as it always did.\n */\nimport {\n DEFAULT_ATTACH_JOURNAL_WAIT_MS,\n JournalAttachUnavailableError,\n} from './attach-preflight'\nimport { journalFollowCommand, journalReadCommand } from './journal'\nimport {\n decodeBase64Stream,\n encodeUtf8Stream,\n toJournalLines,\n} from './journal-bytes'\nimport type { JournalPaths } from './journal'\nimport type { JournalLine } from './journal-bytes'\nimport type { ProcessOptions, SandboxHandle } from './contracts'\n\n/**\n * Poll interval for the bounded-`exec` strategy. Matches the interval\n * `ai-sandbox-cloudflare`'s run-log Durable Object already uses, so the two\n * readers have the same latency profile.\n */\nexport const DEFAULT_JOURNAL_POLL_MS = 250\n\nexport interface ReadJournalOptions {\n paths: JournalPaths\n /**\n * Count of journal bytes already consumed. The read starts at the next byte.\n * Defaults to 0, which is also what a takeover uses: the alignment step, not\n * the reader, decides what has already been delivered.\n */\n fromByte?: number\n /** Stop reading. On the follow strategy this also kills the `tail`. */\n signal?: AbortSignal\n /** Override the capability-derived strategy. Tests and diagnostics only. */\n strategy?: 'follow' | 'poll'\n /** Poll strategy only. Defaults to {@link DEFAULT_JOURNAL_POLL_MS}. */\n pollIntervalMs?: number\n /** Working directory for the read command. Paths are absolute, so rarely needed. */\n cwd?: string\n /**\n * How long to wait for the FIRST byte of the journal before failing with\n * `'journal-stalled'`. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} — the\n * same number that bounds the attach preflight, because it bounds the same\n * question from the other side. `0` or a non-finite value disables the bound;\n * do that only where some OTHER deadline already covers the read, since an\n * unbounded read of an empty journal never returns.\n *\n * Only the first byte is bounded. An agent that streams slowly is never cut\n * off.\n */\n firstByteTimeoutMs?: number\n /**\n * Run id, for the stall error's message only. Defaults to naming the journal\n * path, which is always available and always identifies the run uniquely.\n */\n runId?: string\n}\n\n/**\n * Which read strategy a provider supports.\n *\n * Keyed on capabilities, never on `handle.provider`: a BYO provider with the\n * same limitation must get the same treatment, and name-sniffing would silently\n * hand it an unstoppable `tail -f`.\n */\nexport function journalReadStrategy(handle: SandboxHandle): 'follow' | 'poll' {\n const { backgroundProcesses, killableProcesses } = handle.capabilities\n return backgroundProcesses && killableProcesses ? 'follow' : 'poll'\n}\n\nfunction processOptions(options: ReadJournalOptions): ProcessOptions {\n return {\n ...(options.cwd === undefined ? {} : { cwd: options.cwd }),\n ...(options.signal === undefined ? {} : { signal: options.signal }),\n }\n}\n\n/** Resolution of the abort race in {@link untilAborted}. Never a stream value. */\nconst ABORTED = Symbol('journal-read-aborted')\n\n/**\n * Iterate `source` but stop the moment `signal` fires, instead of waiting for\n * the stream to close.\n *\n * Without this, aborting a follow read only *asks* the provider to kill `tail`\n * and then blocks on `stdout` until that kill closes the pipe — which is not a\n * guarantee any provider makes. On local-process/Windows, `killTree` falls back\n * to signalling only the `sh` wrapper if `taskkill` is unavailable, leaving the\n * `tail` grandchild holding the stdout pipe open, and the read rides past its\n * own AbortSignal until some outer timeout fires. The signal is the caller's\n * contract with the reader, so the reader honors it itself and treats the kill\n * as best-effort cleanup. (local-process now also verifies the tree is gone and\n * sweeps the MSYS grandchildren `taskkill /T` cannot reach, but that is a\n * provider improving its best effort — not a guarantee this reader may assume of\n * any provider.)\n */\nasync function* untilAborted<T>(\n source: AsyncIterable<T>,\n signal: AbortSignal | undefined,\n): AsyncIterable<T> {\n if (!signal) {\n yield* source\n return\n }\n if (signal.aborted) return\n let onAbort: (() => void) | undefined\n const aborted = new Promise<typeof ABORTED>((resolve) => {\n onAbort = () => resolve(ABORTED)\n signal.addEventListener('abort', onAbort, { once: true })\n })\n const iterator = source[Symbol.asyncIterator]()\n try {\n for (;;) {\n const next = await Promise.race([iterator.next(), aborted])\n if (next === ABORTED || next.done === true) return\n yield next.value\n }\n } finally {\n if (onAbort) signal.removeEventListener('abort', onAbort)\n // NOT awaited. On an async generator, `return()` queues behind the pending\n // `next()` we just abandoned, so awaiting it would block for exactly as\n // long as the stream we gave up waiting for — reintroducing the hang this\n // helper exists to remove. The rejection is swallowed for the same reason\n // `kill` is best-effort below: the source may already be gone.\n void iterator.return?.().catch(() => {})\n }\n}\n\n/** Resolution of the first-byte race in {@link withFirstByteDeadline}. */\nconst STALLED = Symbol('journal-read-stalled')\n\n/** The bound in effect for a read; `undefined` when the caller disabled it. */\nfunction firstByteTimeout(options: ReadJournalOptions): number | undefined {\n const ms = options.firstByteTimeoutMs ?? DEFAULT_ATTACH_JOURNAL_WAIT_MS\n return Number.isFinite(ms) && ms > 0 ? ms : undefined\n}\n\n/**\n * The `'journal-stalled'` failure, shared by both strategies so the two report\n * the same diagnosis for the same state.\n */\nfunction stalled(\n options: ReadJournalOptions,\n timeoutMs: number,\n): JournalAttachUnavailableError {\n return new JournalAttachUnavailableError(\n options.runId ?? options.paths.journal,\n 'journal-stalled',\n `its journal (${options.paths.journal}) delivered no bytes within ${timeoutMs}ms. ` +\n `The file exists but nothing is appending to it and no '__exit' sentinel can arrive, ` +\n `so following it would never return: either the read created it itself (a runId with no journal), ` +\n `or the agent's shell was killed before it could write its sentinel.`,\n )\n}\n\n/**\n * Pass `source` through unchanged, except that receiving NO value within\n * `timeoutMs` throws.\n *\n * Only the first value is raced. After it, the source is iterated directly, so a\n * long gap between later values costs nothing and cannot fail a healthy read.\n *\n * A source that simply ENDS before the deadline is not a stall — that is the\n * consumer's abort (`untilAborted` returns on abort) or a `tail` that exited —\n * and it returns quietly, preserving the \"an abort diagnoses nothing\" rule.\n */\nasync function* withFirstByteDeadline<T>(\n source: AsyncIterable<T>,\n timeoutMs: number | undefined,\n onStall: () => JournalAttachUnavailableError,\n): AsyncIterable<T> {\n if (timeoutMs === undefined) {\n yield* source\n return\n }\n const iterator = source[Symbol.asyncIterator]()\n let timer: ReturnType<typeof setTimeout> | undefined\n const expired = new Promise<typeof STALLED>((resolve) => {\n timer = setTimeout(() => resolve(STALLED), timeoutMs)\n })\n try {\n const first = await Promise.race([iterator.next(), expired])\n if (first === STALLED) throw onStall()\n if (first.done === true) return\n yield first.value\n for (;;) {\n const next = await iterator.next()\n if (next.done === true) return\n yield next.value\n }\n } finally {\n clearTimeout(timer)\n // NOT awaited, for the reason `untilAborted` documents: on the stall path the\n // abandoned `next()` is exactly the promise that never settles, so awaiting\n // the `return()` queued behind it would reinstate the hang being reported.\n void iterator.return?.().catch(() => {})\n }\n}\n\nasync function* followJournal(\n handle: SandboxHandle,\n options: ReadJournalOptions,\n): AsyncIterable<JournalLine> {\n const fromByte = options.fromByte ?? 0\n const proc = await handle.process.spawn(\n journalFollowCommand(options.paths, fromByte),\n processOptions(options),\n )\n const timeoutMs = firstByteTimeout(options)\n try {\n yield* toJournalLines(\n encodeUtf8Stream(\n withFirstByteDeadline(\n untilAborted(proc.stdout, options.signal),\n timeoutMs,\n // Narrowed by `withFirstByteDeadline` only calling this when the bound\n // is in effect; `?? 0` keeps that provable without an assertion.\n () => stalled(options, timeoutMs ?? 0),\n ),\n ),\n fromByte,\n )\n } finally {\n // The consumer may stop early (client gone, lease lost). Providers whose\n // `kill` is real stop the `tail` here; the signal covers the rest. Guarded\n // because a `finally` that throws would replace the consumer's own reason\n // for stopping.\n try {\n await proc.kill()\n } catch {\n // Best effort: the process may already be gone.\n }\n }\n}\n\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (ms <= 0) return Promise.resolve()\n return new Promise<void>((resolve) => {\n const timer = setTimeout(finish, ms)\n function finish(): void {\n clearTimeout(timer)\n signal?.removeEventListener('abort', finish)\n resolve()\n }\n signal?.addEventListener('abort', finish, { once: true })\n })\n}\n\nasync function* singleValue(value: string): AsyncIterable<string> {\n yield value\n}\n\nasync function* pollJournal(\n handle: SandboxHandle,\n options: ReadJournalOptions,\n): AsyncIterable<JournalLine> {\n const intervalMs = options.pollIntervalMs ?? DEFAULT_JOURNAL_POLL_MS\n const timeoutMs = firstByteTimeout(options)\n // Same bound as the follow path, expressed the way a polling loop can enforce\n // it: an empty frame every time until the deadline is a stalled journal, and\n // parking here forever is the same defect from the other strategy.\n const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs\n let sawBytes = false\n let position = options.fromByte ?? 0\n while (!options.signal?.aborted) {\n const result = await handle.process.exec(\n journalReadCommand(options.paths, position),\n processOptions(options),\n )\n if (result.stdout.trim() !== '') sawBytes = true\n if (\n !sawBytes &&\n deadline !== undefined &&\n timeoutMs !== undefined &&\n Date.now() >= deadline\n ) {\n throw stalled(options, timeoutMs)\n }\n // Each poll re-reads from `position`, so a line left incomplete by the\n // previous poll is simply re-fetched whole. That is why `position` advances\n // only on a COMPLETE line: advancing on bytes received would strand a\n // partial line's prefix and corrupt every following line.\n for await (const line of toJournalLines(\n decodeBase64Stream(singleValue(result.stdout)),\n position,\n )) {\n yield line\n position = line.endPosition\n }\n if (options.signal?.aborted) return\n await sleep(intervalMs, options.signal)\n }\n}\n\n/**\n * Read a run's journal as positioned lines.\n *\n * **This is a public entry point and it CANNOT hang.** It has no `RunStore` in\n * its signature and no runId to look one up with, so it cannot run the\n * `attach-preflight.ts` gate that classifies a stale or mistyped runId as\n * `'unknown-run'`/`'terminal-run'`; what it has instead is the unconditional\n * bound described in the module doc. A runId with no journal therefore fails with\n * {@link JournalAttachUnavailableError} (`reason: 'journal-stalled'`) after\n * {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} rather than tailing an empty file it\n * just created, for ever, with no error and no log line. Callers that DO have a\n * store — `runner.ts` on an attach — run the preflight as well, for the sharper\n * diagnosis.\n */\nexport function readJournal(\n handle: SandboxHandle,\n options: ReadJournalOptions,\n): AsyncIterable<JournalLine> {\n const strategy = options.strategy ?? journalReadStrategy(handle)\n return strategy === 'follow'\n ? followJournal(handle, options)\n : pollJournal(handle, options)\n}\n"],"mappings":"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"}
|