@dynamicagents/core 0.9.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.
Files changed (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +522 -0
  3. package/dist/a2a/agent-stub.d.ts +60 -0
  4. package/dist/a2a/agent-stub.js +1 -0
  5. package/dist/a2a/caller-token.d.ts +44 -0
  6. package/dist/a2a/caller-token.js +61 -0
  7. package/dist/a2a/caller.d.ts +23 -0
  8. package/dist/a2a/caller.js +33 -0
  9. package/dist/a2a/card.d.ts +158 -0
  10. package/dist/a2a/card.js +163 -0
  11. package/dist/a2a/context.d.ts +19 -0
  12. package/dist/a2a/context.js +61 -0
  13. package/dist/a2a/deliver.d.ts +159 -0
  14. package/dist/a2a/deliver.js +185 -0
  15. package/dist/a2a/executor.d.ts +84 -0
  16. package/dist/a2a/executor.js +106 -0
  17. package/dist/a2a/index.d.ts +35 -0
  18. package/dist/a2a/index.js +34 -0
  19. package/dist/a2a/notify.d.ts +106 -0
  20. package/dist/a2a/notify.js +180 -0
  21. package/dist/a2a/parts.d.ts +45 -0
  22. package/dist/a2a/parts.js +94 -0
  23. package/dist/a2a/push.d.ts +70 -0
  24. package/dist/a2a/push.js +53 -0
  25. package/dist/a2a/self-origin.d.ts +91 -0
  26. package/dist/a2a/self-origin.js +114 -0
  27. package/dist/a2a/task-store.d.ts +44 -0
  28. package/dist/a2a/task-store.js +99 -0
  29. package/dist/a2a/task.d.ts +85 -0
  30. package/dist/a2a/task.js +15 -0
  31. package/dist/a2a/verify.d.ts +80 -0
  32. package/dist/a2a/verify.js +143 -0
  33. package/dist/agent/budget.d.ts +46 -0
  34. package/dist/agent/budget.js +42 -0
  35. package/dist/agent/control.d.ts +109 -0
  36. package/dist/agent/control.js +115 -0
  37. package/dist/agent/errors.d.ts +85 -0
  38. package/dist/agent/errors.js +64 -0
  39. package/dist/agent/final-reply.d.ts +49 -0
  40. package/dist/agent/final-reply.js +68 -0
  41. package/dist/agent/history.d.ts +97 -0
  42. package/dist/agent/history.js +133 -0
  43. package/dist/agent/index.d.ts +29 -0
  44. package/dist/agent/index.js +29 -0
  45. package/dist/agent/inference.d.ts +110 -0
  46. package/dist/agent/inference.js +120 -0
  47. package/dist/agent/model.d.ts +90 -0
  48. package/dist/agent/model.js +1 -0
  49. package/dist/agent/session.d.ts +100 -0
  50. package/dist/agent/session.js +82 -0
  51. package/dist/agent/workers-ai/index.d.ts +23 -0
  52. package/dist/agent/workers-ai/index.js +23 -0
  53. package/dist/agent/workers-ai/runtime.d.ts +42 -0
  54. package/dist/agent/workers-ai/runtime.js +63 -0
  55. package/dist/alarm/index.d.ts +77 -0
  56. package/dist/alarm/index.js +116 -0
  57. package/dist/config.d.ts +202 -0
  58. package/dist/config.js +135 -0
  59. package/dist/contract/index.d.ts +9 -0
  60. package/dist/contract/index.js +8 -0
  61. package/dist/contract/plugin.d.ts +324 -0
  62. package/dist/contract/plugin.js +114 -0
  63. package/dist/contract/recipe.d.ts +180 -0
  64. package/dist/contract/recipe.js +1 -0
  65. package/dist/contract/validation.d.ts +91 -0
  66. package/dist/contract/validation.js +84 -0
  67. package/dist/db/db.d.ts +147 -0
  68. package/dist/db/db.js +90 -0
  69. package/dist/db/index.d.ts +8 -0
  70. package/dist/db/index.js +8 -0
  71. package/dist/db/migrations/index.d.ts +20 -0
  72. package/dist/db/migrations/index.js +60 -0
  73. package/dist/db/models/subtasks.d.ts +100 -0
  74. package/dist/db/models/subtasks.js +241 -0
  75. package/dist/db/models/tasks.d.ts +118 -0
  76. package/dist/db/models/tasks.js +274 -0
  77. package/dist/db/schema.d.ts +468 -0
  78. package/dist/db/schema.js +88 -0
  79. package/dist/env.d.ts +53 -0
  80. package/dist/env.js +47 -0
  81. package/dist/host/agent.d.ts +305 -0
  82. package/dist/host/agent.js +400 -0
  83. package/dist/host/index.d.ts +20 -0
  84. package/dist/host/index.js +19 -0
  85. package/dist/host/plugin-host.d.ts +42 -0
  86. package/dist/host/plugin-host.js +1 -0
  87. package/dist/index.d.ts +19 -0
  88. package/dist/index.js +17 -0
  89. package/dist/job/index.d.ts +21 -0
  90. package/dist/job/index.js +21 -0
  91. package/dist/job/lifecycle.d.ts +176 -0
  92. package/dist/job/lifecycle.js +230 -0
  93. package/dist/job/state.d.ts +92 -0
  94. package/dist/job/state.js +40 -0
  95. package/dist/platform.d.ts +138 -0
  96. package/dist/platform.js +140 -0
  97. package/dist/round/agent.d.ts +271 -0
  98. package/dist/round/agent.js +678 -0
  99. package/dist/round/index.d.ts +25 -0
  100. package/dist/round/index.js +23 -0
  101. package/dist/round/policy.d.ts +98 -0
  102. package/dist/round/policy.js +1 -0
  103. package/dist/round/subagent.d.ts +87 -0
  104. package/dist/round/subagent.js +108 -0
  105. package/dist/round/turn.d.ts +249 -0
  106. package/dist/round/turn.js +564 -0
  107. package/dist/round/workflow.d.ts +147 -0
  108. package/dist/round/workflow.js +387 -0
  109. package/dist/runtime/index.d.ts +127 -0
  110. package/dist/runtime/index.js +186 -0
  111. package/dist/runtime/tool-families.d.ts +32 -0
  112. package/dist/runtime/tool-families.js +61 -0
  113. package/dist/subagent/fingerprint.d.ts +37 -0
  114. package/dist/subagent/fingerprint.js +92 -0
  115. package/dist/subagent/index.d.ts +169 -0
  116. package/dist/subagent/index.js +330 -0
  117. package/dist/subagent/prompt.d.ts +32 -0
  118. package/dist/subagent/prompt.js +37 -0
  119. package/dist/subagent/run.d.ts +157 -0
  120. package/dist/subagent/run.js +540 -0
  121. package/dist/subagent/workspace.d.ts +85 -0
  122. package/dist/subagent/workspace.js +127 -0
  123. package/dist/subtasks/catalog.d.ts +40 -0
  124. package/dist/subtasks/catalog.js +36 -0
  125. package/dist/subtasks/decomposition.d.ts +85 -0
  126. package/dist/subtasks/decomposition.js +156 -0
  127. package/dist/subtasks/delegate.d.ts +120 -0
  128. package/dist/subtasks/delegate.js +131 -0
  129. package/dist/subtasks/index.d.ts +9 -0
  130. package/dist/subtasks/index.js +9 -0
  131. package/dist/subtasks/subtask-types.d.ts +91 -0
  132. package/dist/subtasks/subtask-types.js +103 -0
  133. package/dist/subtasks/types.d.ts +295 -0
  134. package/dist/subtasks/types.js +15 -0
  135. package/dist/testing/auth.d.ts +34 -0
  136. package/dist/testing/auth.js +35 -0
  137. package/dist/testing/do.d.ts +29 -0
  138. package/dist/testing/do.js +25 -0
  139. package/dist/testing/fake-session.d.ts +26 -0
  140. package/dist/testing/fake-session.js +37 -0
  141. package/dist/testing/fixtures.d.ts +64 -0
  142. package/dist/testing/fixtures.js +104 -0
  143. package/dist/testing/harness.d.ts +97 -0
  144. package/dist/testing/harness.js +138 -0
  145. package/dist/testing/index.d.ts +31 -0
  146. package/dist/testing/index.js +35 -0
  147. package/dist/testing/mock-model.d.ts +77 -0
  148. package/dist/testing/mock-model.js +136 -0
  149. package/dist/testing/node.d.ts +56 -0
  150. package/dist/testing/node.js +56 -0
  151. package/dist/testing/vcr-global-setup.d.ts +12 -0
  152. package/dist/testing/vcr-global-setup.js +15 -0
  153. package/dist/testing/vcr-shared.d.ts +38 -0
  154. package/dist/testing/vcr-shared.js +33 -0
  155. package/dist/testing/vcr-spec.d.ts +25 -0
  156. package/dist/testing/vcr-spec.js +124 -0
  157. package/dist/testing/vcr-store.d.ts +86 -0
  158. package/dist/testing/vcr-store.js +191 -0
  159. package/dist/testing/vcr.d.ts +117 -0
  160. package/dist/testing/vcr.js +275 -0
  161. package/dist/worker/define-agent.d.ts +123 -0
  162. package/dist/worker/define-agent.js +20 -0
  163. package/dist/worker/index.d.ts +218 -0
  164. package/dist/worker/index.js +369 -0
  165. package/eslint-rules/index.js +31 -0
  166. package/eslint-rules/no-deprecated-object-properties.js +81 -0
  167. package/package.json +178 -0
  168. package/scripts/generate-keys.mjs +48 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Cassette load / match / write. **Node realm** (`node:fs`, `node:crypto`), no
3
+ * network and no `undici` — the recorder that drives this lives in `vcr.ts`.
4
+ *
5
+ * ## Why this replaced undici's `SnapshotAgent`
6
+ *
7
+ * `SnapshotAgent` keys every entry on a hash of method + URL + *every
8
+ * non-excluded request header*. In a Worker test those headers are runtime
9
+ * artifacts: a cassette recorded through the pool carries `user-agent: undici`,
10
+ * `cf-worker: vitest-pool-workers-runner-.example.com`, `sec-fetch-mode` and
11
+ * `accept-encoding`. Bump miniflare or workerd, any one of them changes, and
12
+ * every entry in every committed cassette misses at once — a whole suite going
13
+ * red with `No snapshot found` and nothing to point at.
14
+ *
15
+ * So the key here is **method + URL + request body**, and nothing else. Headers
16
+ * are still stored (minus secrets, see {@link CassetteOptions.excludeHeaders})
17
+ * because they are useful to read in a diff, but they cannot break a replay.
18
+ *
19
+ * The on-disk format owes `SnapshotAgent` nothing: a flat array of
20
+ * {@link CassetteEntry}. Cassettes it wrote are not readable, and deliberately
21
+ * so — its recorder keyed on `String(opts.body)`, and a Worker's POST body
22
+ * reaches a dispatcher as a `ReadableStream`, so every streamed request it ever
23
+ * captured stored the literal text `[object ReadableStream]` where the payload
24
+ * belonged. Those bodies are not recoverable from the file, and a reader that
25
+ * papered over it would have to match such entries on method + URL alone —
26
+ * quietly reintroducing the ambiguity this store exists to remove. Re-record
27
+ * instead.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import path from "node:path";
32
+ /**
33
+ * Headers that describe how a body was framed on the wire, which is not how it
34
+ * is framed on replay: bodies are stored decoded and are handed back whole, so
35
+ * replaying `transfer-encoding: chunked` or a stale `content-length` mis-frames
36
+ * the response, and `content-encoding: gzip` makes the consumer try to inflate
37
+ * plaintext. Stripped on the way out, kept in the file.
38
+ */
39
+ const HOP_BY_HOP = new Set([
40
+ "connection",
41
+ "content-encoding",
42
+ "content-length",
43
+ "keep-alive",
44
+ "proxy-authenticate",
45
+ "proxy-authorization",
46
+ "te",
47
+ "trailer",
48
+ "transfer-encoding",
49
+ "upgrade"
50
+ ]);
51
+ /** `${METHOD}\n${url}\n${sha256(body)}` — the whole matching rule. */
52
+ export function requestKey(method, url, body) {
53
+ const digest = createHash("sha256").update(body, "utf8").digest("base64url");
54
+ return `${method.toUpperCase()}\n${url}\n${digest}`;
55
+ }
56
+ function filterHeaders(headers, exclude) {
57
+ const kept = {};
58
+ for (const [name, value] of Object.entries(headers)) {
59
+ if (!exclude.has(name.toLowerCase()))
60
+ kept[name.toLowerCase()] = value;
61
+ }
62
+ return kept;
63
+ }
64
+ /**
65
+ * Turn a stored response into the pieces a `Response` is built from: body
66
+ * decoded from base64, headers flattened (a repeated header such as
67
+ * `set-cookie` becomes one entry per value) and {@link HOP_BY_HOP} dropped.
68
+ *
69
+ * Used on both paths, so a response served straight after recording is framed
70
+ * exactly as the same response will be on replay.
71
+ */
72
+ export function replayable(response) {
73
+ const headers = [];
74
+ for (const [name, value] of Object.entries(response.headers)) {
75
+ if (HOP_BY_HOP.has(name.toLowerCase()))
76
+ continue;
77
+ for (const one of Array.isArray(value) ? value : [value]) {
78
+ headers.push([name, one]);
79
+ }
80
+ }
81
+ return {
82
+ status: response.statusCode,
83
+ headers,
84
+ body: Buffer.from(response.body, "base64")
85
+ };
86
+ }
87
+ /**
88
+ * One cassette file, loaded lazily and written only when it has changed.
89
+ *
90
+ * Playback is **read-only**: nothing here mutates on a replay, which is what
91
+ * stops a plain `npm test` from dirtying committed cassettes. The sequence
92
+ * counter ({@link #calls}) is in memory and dies with the process, deliberately
93
+ * — `SnapshotAgent` persisted its equivalent and re-saved on close, so every
94
+ * run left every cassette modified in git.
95
+ */
96
+ export class Cassette {
97
+ #file;
98
+ #exclude;
99
+ /** Insertion-ordered, keyed by {@link requestKey}. */
100
+ #entries = new Map();
101
+ /** How many times each key has been replayed *this run*. Never persisted. */
102
+ #calls = new Map();
103
+ #dirty = false;
104
+ constructor(file, options = {}) {
105
+ this.#file = file;
106
+ this.#exclude = new Set((options.excludeHeaders ?? []).map((h) => h.toLowerCase()));
107
+ }
108
+ get file() {
109
+ return this.#file;
110
+ }
111
+ /** Every request in the file, as `METHOD url`, for error messages. */
112
+ get recorded() {
113
+ return [...this.#entries.values()].map((e) => `${e.request.method} ${e.request.url}`);
114
+ }
115
+ /**
116
+ * Read the file into memory, keying each entry by its own request rather than
117
+ * trusting anything stored alongside it.
118
+ *
119
+ * Two entries that key the same are a mistake rather than a merge: a request
120
+ * that was issued twice belongs in one entry with two `responses`, which is
121
+ * what {@link match} walks. Saying so here turns a hand-edit slip into a named
122
+ * error instead of a replay that silently serves the wrong response second.
123
+ */
124
+ load() {
125
+ if (!existsSync(this.#file))
126
+ return;
127
+ const raw = JSON.parse(readFileSync(this.#file, "utf8"));
128
+ if (!Array.isArray(raw)) {
129
+ throw new Error(`VCR cassette ${this.#file} is not a JSON array`);
130
+ }
131
+ for (const entry of raw) {
132
+ const { request, responses } = entry;
133
+ const key = requestKey(request.method, request.url, request.body ?? "");
134
+ if (this.#entries.has(key)) {
135
+ throw new Error(`VCR cassette ${path.basename(this.#file)} has two entries for ` +
136
+ `${request.method} ${request.url} with the same body. Put the ` +
137
+ `responses in one entry's \`responses\` array instead.`);
138
+ }
139
+ this.#entries.set(key, { request, responses: [...responses] });
140
+ }
141
+ }
142
+ /**
143
+ * The next recorded response for this request, or `null` if it was never
144
+ * recorded.
145
+ *
146
+ * Repeated identical requests walk `responses` in order and then **hold on
147
+ * the last one**, so a recording that captured a single response replays it
148
+ * for every call — which is what a poll loop needs when it runs a different
149
+ * number of times on replay than it did while recording.
150
+ */
151
+ match(method, url, body) {
152
+ const key = requestKey(method, url, body);
153
+ const entry = this.#entries.get(key);
154
+ if (!entry)
155
+ return null;
156
+ const call = this.#calls.get(key) ?? 0;
157
+ this.#calls.set(key, call + 1);
158
+ return replayable(entry.responses[Math.min(call, entry.responses.length - 1)]);
159
+ }
160
+ /** Append a live response. Repeats of a request extend its `responses`. */
161
+ record(request, response) {
162
+ const key = requestKey(request.method, request.url, request.body);
163
+ const stored = {
164
+ ...response,
165
+ headers: filterHeaders(response.headers, this.#exclude)
166
+ };
167
+ const entry = this.#entries.get(key);
168
+ if (entry) {
169
+ entry.responses.push(stored);
170
+ }
171
+ else {
172
+ this.#entries.set(key, {
173
+ request: {
174
+ ...request,
175
+ headers: filterHeaders(request.headers, this.#exclude)
176
+ },
177
+ responses: [stored]
178
+ });
179
+ }
180
+ this.#dirty = true;
181
+ }
182
+ /** Write if anything was recorded since the last flush. A no-op in playback. */
183
+ flush() {
184
+ if (!this.#dirty)
185
+ return;
186
+ mkdirSync(path.dirname(this.#file), { recursive: true });
187
+ const entries = [...this.#entries.values()];
188
+ writeFileSync(this.#file, `${JSON.stringify(entries, null, 2)}\n`);
189
+ this.#dirty = false;
190
+ }
191
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The **Node realm** half of the VCR harness: a Miniflare `outboundService`.
3
+ *
4
+ * ## Why `outboundService` and not `fetchMock`
5
+ *
6
+ * Miniflare 4 implemented `fetchMock` as one line of sugar over this same hook —
7
+ * `outboundService = (req) => fetch(req, { dispatcher: fetchMock })` — and
8
+ * Miniflare 5 dropped `fetchMock` entirely, keeping only `outboundService`.
9
+ * `@cloudflare/vitest-pool-workers` 0.20 removed it from its override options to
10
+ * match, saying so in as many words: *"`fetchMock`: you should use
11
+ * `outboundService` instead"*. Targeting the hook rather than the sugar is what
12
+ * lets one harness serve pool 0.18 through 0.20+.
13
+ *
14
+ * It also drops two constraints that had nothing to do with recording:
15
+ *
16
+ * - **No `undici` peer.** `fetchMock` was validated with
17
+ * `z.instanceof(MockAgent)` against *Miniflare's own* undici, so a second copy
18
+ * in `node_modules` failed with `Input not instance of MockAgent` and the peer
19
+ * range had to track Miniflare's exact pin. `outboundService` has no
20
+ * `instanceof` check in either direction — a foreign `Response` is re-wrapped.
21
+ * - **No silent no-op.** An unknown key in the `miniflare` options is ignored,
22
+ * which is how a harness wired to `fetchMock` on pool 0.20 failed: every
23
+ * request escaped to the real network and died as `internal error;
24
+ * reference = …`, naming nothing. `setupRecording()` now proves the recorder
25
+ * answered before a test runs — see {@link VCR_MARKER_HEADER}.
26
+ *
27
+ * ## How a request is routed
28
+ *
29
+ * Every outbound `fetch()` from workerd arrives here as a standard `Request`,
30
+ * with the true target URL already restored by Miniflare, and is dispatched on
31
+ * host alone:
32
+ *
33
+ * - **The control channel** ({@link VCR_CONTROL_ORIGIN}) — specs run in workerd
34
+ * and have no filesystem, so an in-band `fetch` is the only way to say which
35
+ * cassette the current test is using. See `setupRecording` (vcr-spec.ts).
36
+ * - **A {@link VcrOptions.handlers} host** — a consumer-supplied stub, for
37
+ * things that are fixtures rather than recordings (a gatekeeper JWKS).
38
+ * - **An {@link VcrOptions.allowNetworkHosts} host** — the real network, always.
39
+ * - **Anything else, cassette active** — recorded or replayed against it.
40
+ * - **Anything else, no cassette** — blocked. A test that was never wired for
41
+ * recording cannot reach the network by accident.
42
+ */
43
+ /** Minimal shape of the `Request` Miniflare hands an outbound service. */
44
+ export interface VcrRequestHeaders {
45
+ forEach(callback: (value: string, name: string) => void): void;
46
+ get(name: string): string | null;
47
+ }
48
+ /** Minimal shape of the `Request` Miniflare hands an outbound service. */
49
+ export interface VcrRequest {
50
+ readonly url: string;
51
+ readonly method: string;
52
+ readonly headers: VcrRequestHeaders;
53
+ arrayBuffer(): Promise<ArrayBuffer>;
54
+ }
55
+ /**
56
+ * Structural on purpose: core does not depend on `miniflare`, and this type
57
+ * assigns to `WorkerOptions["outboundService"]` under both Miniflare 4 and 5.
58
+ */
59
+ export type VcrOutboundService = (request: VcrRequest) => Promise<Response>;
60
+ export interface VcrOptions {
61
+ /** Absolute path of the cassettes directory. */
62
+ snapshotsDir: string;
63
+ /**
64
+ * true → every activated cassette captures live traffic, replacing whatever
65
+ * was there. Recording never loads the existing file first, so a recording run
66
+ * always reflects the live API rather than half of a stale one.
67
+ * false → replay, and no request ever leaves the machine.
68
+ */
69
+ record: boolean;
70
+ /** Hosts answered by a stub instead of a cassette (e.g. a gatekeeper JWKS). */
71
+ handlers?: Record<string, (request: VcrRequest) => Response | Promise<Response>>;
72
+ /** Hosts that always reach the real network, recorded or not. */
73
+ allowNetworkHosts?: string[];
74
+ /** Request *and* response headers never written to a cassette (API keys, auth). */
75
+ excludeHeaders?: string[];
76
+ }
77
+ export interface Vcr {
78
+ /** Hand this to `cloudflareTest({ miniflare: { outboundService } })`. */
79
+ readonly outboundService: VcrOutboundService;
80
+ /** Flush every cassette. Safe to call more than once. */
81
+ close(): Promise<void>;
82
+ }
83
+ /**
84
+ * Build the recorder and register it for {@link closeVcr}.
85
+ *
86
+ * ```ts
87
+ * // vitest.config.ts
88
+ * const vcr = createVcr({
89
+ * snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
90
+ * record: recordFromEnv(),
91
+ * excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
92
+ * });
93
+ *
94
+ * export default defineConfig({
95
+ * plugins: [cloudflareTest({ miniflare: { outboundService: vcr.outboundService } })],
96
+ * test: { globalSetup: ["@dynamicagents/core/testing/vcr-global-setup"] }
97
+ * });
98
+ * ```
99
+ */
100
+ export declare function createVcr(options: VcrOptions): Vcr;
101
+ /**
102
+ * Whether this run records, from `RECORD`.
103
+ *
104
+ * Compared against `"1"` rather than tested for truthiness, because every
105
+ * non-empty string is truthy: `RECORD=0` and `RECORD=false` — the two things
106
+ * someone reaches for to turn recording *off* — would otherwise turn it on and
107
+ * overwrite committed cassettes with live traffic. The opposite mistake
108
+ * (`RECORD=true` not recording) fails loudly on the next assertion and costs
109
+ * nothing.
110
+ */
111
+ export declare function recordFromEnv(): boolean;
112
+ /**
113
+ * Flush every cassette. Cassettes are already written when each one is
114
+ * released, so this is a safety net for a run that ended without releasing —
115
+ * and a no-op if no recorder was created.
116
+ */
117
+ export declare function closeVcr(): Promise<void>;
@@ -0,0 +1,275 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Cassette, replayable } from "./vcr-store.js";
4
+ import { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE } from "./vcr-shared.js";
5
+ const CONTROL_HOST = new URL(VCR_CONTROL_ORIGIN).host;
6
+ /**
7
+ * Request headers dropped before a live call. `host` and `content-length` are
8
+ * recomputed by the outgoing fetch and a stale value corrupts it;
9
+ * `accept-encoding` is dropped so the body arrives as plaintext to store; the
10
+ * rest are hop-by-hop or Miniflare's own loopback bookkeeping.
11
+ */
12
+ const SKIP_REQUEST_HEADERS = new Set([
13
+ "accept-encoding",
14
+ "connection",
15
+ "content-length",
16
+ "host",
17
+ "keep-alive",
18
+ "te",
19
+ "trailer",
20
+ "transfer-encoding",
21
+ "upgrade"
22
+ ]);
23
+ function headerRecord(headers) {
24
+ const record = {};
25
+ headers.forEach((value, name) => {
26
+ record[name.toLowerCase()] = value;
27
+ });
28
+ return record;
29
+ }
30
+ /** `set-cookie` is the one header `forEach` folds together destructively. */
31
+ function responseHeaderRecord(headers) {
32
+ const record = {};
33
+ headers.forEach((value, name) => {
34
+ record[name.toLowerCase()] = value;
35
+ });
36
+ const cookies = headers.getSetCookie?.() ?? [];
37
+ if (cookies.length > 0)
38
+ record["set-cookie"] = cookies;
39
+ return record;
40
+ }
41
+ /**
42
+ * The one place a `Response` is built, so recording and replaying a given
43
+ * cassette entry cannot disagree about it.
44
+ *
45
+ * `Response` rejects *any* non-null body on 204/205/304 — an empty `Uint8Array`
46
+ * throws just as a full one does — so a recorded 204 has to be handed back with
47
+ * a null body or it cannot be replayed at all.
48
+ */
49
+ function toResponse({ status, headers, body }) {
50
+ const nullBody = status === 204 || status === 205 || status === 304;
51
+ return new Response(nullBody ? null : body, { status, headers });
52
+ }
53
+ function json(status, value) {
54
+ return new Response(JSON.stringify(value), {
55
+ status,
56
+ headers: {
57
+ "content-type": "application/json",
58
+ [VCR_MARKER_HEADER]: "1"
59
+ }
60
+ });
61
+ }
62
+ class Recorder {
63
+ #snapshotsDir;
64
+ #record;
65
+ #handlers;
66
+ #allowNetwork;
67
+ #excludeHeaders;
68
+ /** One cassette per name, created on first activation. */
69
+ #cassettes = new Map();
70
+ #active = null;
71
+ #activeName = null;
72
+ /** Requests that could not be served since the active cassette was set. */
73
+ #misses = [];
74
+ constructor(options) {
75
+ this.#snapshotsDir = options.snapshotsDir;
76
+ this.#record = options.record;
77
+ this.#handlers = options.handlers ?? {};
78
+ this.#allowNetwork = new Set(options.allowNetworkHosts ?? []);
79
+ this.#excludeHeaders = options.excludeHeaders ?? [];
80
+ }
81
+ handle = async (request) => {
82
+ const url = new URL(request.url);
83
+ if (url.host === CONTROL_HOST)
84
+ return this.#control(url);
85
+ const handler = this.#handlers[url.host];
86
+ if (handler)
87
+ return handler(request);
88
+ // Read once: the body is needed to key the cassette *and* to forward.
89
+ const bytes = Buffer.from(await request.arrayBuffer());
90
+ if (this.#allowNetwork.has(url.host))
91
+ return this.#live(request, bytes);
92
+ const body = bytes.toString("utf8");
93
+ const what = `${request.method} ${request.url}`;
94
+ if (this.#active === null) {
95
+ this.#misses.push(what);
96
+ return this.#blocked(`VCR blocked ${what}: no cassette is active for this test. ` +
97
+ `Call setupRecording() at the top of the spec, or add the host to ` +
98
+ `allowNetworkHosts / handlers.`);
99
+ }
100
+ if (this.#record)
101
+ return this.#recordOne(request, bytes);
102
+ const hit = this.#active.match(request.method, request.url, body);
103
+ if (hit)
104
+ return toResponse(hit);
105
+ this.#misses.push(what);
106
+ return this.#blocked(`VCR has no recording of ${what} in ${path.basename(this.#active.file)}. ` +
107
+ `Re-record with RECORD=1. The cassette holds: ` +
108
+ `${this.#active.recorded.join(", ") || "(nothing)"}.`);
109
+ };
110
+ /**
111
+ * A miss cannot be made to *reject* the Worker's `fetch()` — Miniflare catches
112
+ * whatever an outbound service throws and turns it into a 500 anyway. So the
113
+ * message goes in the body where it can be read, and the miss is also reported
114
+ * on `/release` so the test itself fails with it. That is the half the old
115
+ * harness lacked: `disableNetConnect()` rejected, and the rejection surfaced
116
+ * as `internal error; reference = …` naming nothing.
117
+ */
118
+ #blocked(message) {
119
+ return new Response(message, {
120
+ status: 500,
121
+ headers: { [VCR_MARKER_HEADER]: "1" }
122
+ });
123
+ }
124
+ #control(url) {
125
+ if (url.pathname === "/release") {
126
+ const misses = this.#misses;
127
+ // Write here rather than only at teardown, so a run that is interrupted
128
+ // still keeps every cassette whose test finished. A no-op in playback:
129
+ // `flush()` returns immediately unless something was recorded.
130
+ this.#active?.flush();
131
+ this.#active = null;
132
+ this.#activeName = null;
133
+ this.#misses = [];
134
+ return json(200, { misses });
135
+ }
136
+ if (url.pathname !== "/use")
137
+ return json(404, { error: "unknown" });
138
+ const name = url.searchParams.get("cassette") ?? "";
139
+ if (!CASSETTE_NAME_RE.test(name)) {
140
+ return json(400, { error: `invalid cassette name: ${name}` });
141
+ }
142
+ // Recorded specs are expected to run sequentially: a second cassette
143
+ // activating while one is held means two of them are sharing this recorder,
144
+ // which mis-records rather than failing, so say so instead.
145
+ if (this.#activeName !== null && this.#activeName !== name) {
146
+ return json(409, { error: `cassette ${this.#activeName} still active` });
147
+ }
148
+ const file = path.join(this.#snapshotsDir, name);
149
+ // Playback with no cassette on disk → 404, which the spec turns into a
150
+ // "record it" failure. Recording creates the file on first write.
151
+ if (!this.#record && !existsSync(file)) {
152
+ return json(404, { error: `no cassette ${name}` });
153
+ }
154
+ this.#active = this.#cassetteFor(name, file);
155
+ this.#activeName = name;
156
+ this.#misses = [];
157
+ return json(200, { cassette: name });
158
+ }
159
+ #cassetteFor(name, file) {
160
+ let cassette = this.#cassettes.get(name);
161
+ if (!cassette) {
162
+ cassette = new Cassette(file, { excludeHeaders: this.#excludeHeaders });
163
+ // Recording deliberately starts from nothing rather than topping up an
164
+ // existing file: a cassette that mixes today's traffic with last month's
165
+ // is stale in a way no assertion can see.
166
+ if (!this.#record)
167
+ cassette.load();
168
+ this.#cassettes.set(name, cassette);
169
+ }
170
+ return cassette;
171
+ }
172
+ /** The real call, with the headers the Worker sent minus the unforwardable. */
173
+ #live(request, body) {
174
+ const headers = [];
175
+ request.headers.forEach((value, name) => {
176
+ if (!SKIP_REQUEST_HEADERS.has(name.toLowerCase())) {
177
+ headers.push([name, value]);
178
+ }
179
+ });
180
+ return fetch(request.url, {
181
+ method: request.method,
182
+ headers,
183
+ body: body.byteLength > 0 ? body : undefined,
184
+ // Matches what Miniflare's own `fetch(req, { dispatcher })` sugar did, so
185
+ // a recording captures the same final response the Worker used to see.
186
+ redirect: "follow"
187
+ });
188
+ }
189
+ /**
190
+ * `requestBytes` is forwarded to the live endpoint **as received**. The
191
+ * cassette stores the utf-8 text of it, which is what the format has always
192
+ * held and what keys the entry — but a payload that is not valid utf-8 must
193
+ * not be re-encoded on its way to a real API, so the two are kept separate
194
+ * rather than the string being round-tripped back into bytes.
195
+ */
196
+ async #recordOne(request, requestBytes) {
197
+ const live = await this.#live(request, requestBytes);
198
+ const bytes = Buffer.from(await live.arrayBuffer());
199
+ const recordedRequest = {
200
+ method: request.method,
201
+ url: request.url,
202
+ headers: headerRecord(request.headers),
203
+ body: requestBytes.toString("utf8")
204
+ };
205
+ const recordedResponse = {
206
+ statusCode: live.status,
207
+ headers: responseHeaderRecord(live.headers),
208
+ body: bytes.toString("base64")
209
+ };
210
+ this.#active.record(recordedRequest, recordedResponse);
211
+ // Serve the response through the same path a replay takes, so a recording
212
+ // run and the replay of what it just wrote cannot disagree.
213
+ return toResponse(replayable(recordedResponse));
214
+ }
215
+ async close() {
216
+ for (const cassette of this.#cassettes.values())
217
+ cassette.flush();
218
+ }
219
+ }
220
+ /**
221
+ * Build the recorder and register it for {@link closeVcr}.
222
+ *
223
+ * ```ts
224
+ * // vitest.config.ts
225
+ * const vcr = createVcr({
226
+ * snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
227
+ * record: recordFromEnv(),
228
+ * excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
229
+ * });
230
+ *
231
+ * export default defineConfig({
232
+ * plugins: [cloudflareTest({ miniflare: { outboundService: vcr.outboundService } })],
233
+ * test: { globalSetup: ["@dynamicagents/core/testing/vcr-global-setup"] }
234
+ * });
235
+ * ```
236
+ */
237
+ export function createVcr(options) {
238
+ const recorder = new Recorder(options);
239
+ const vcr = {
240
+ outboundService: recorder.handle,
241
+ close: () => recorder.close()
242
+ };
243
+ globalThis[VCR_KEY] = vcr;
244
+ return vcr;
245
+ }
246
+ /**
247
+ * Whether this run records, from `RECORD`.
248
+ *
249
+ * Compared against `"1"` rather than tested for truthiness, because every
250
+ * non-empty string is truthy: `RECORD=0` and `RECORD=false` — the two things
251
+ * someone reaches for to turn recording *off* — would otherwise turn it on and
252
+ * overwrite committed cassettes with live traffic. The opposite mistake
253
+ * (`RECORD=true` not recording) fails loudly on the next assertion and costs
254
+ * nothing.
255
+ */
256
+ export function recordFromEnv() {
257
+ return process.env.RECORD === "1";
258
+ }
259
+ /** globalThis slot so the Vitest globalSetup teardown can reach the recorder
260
+ * created during config evaluation (same process, possibly a different module
261
+ * realm — globalThis is the reliable channel). */
262
+ const VCR_KEY = "__VCR_AGENT__";
263
+ /**
264
+ * Flush every cassette. Cassettes are already written when each one is
265
+ * released, so this is a safety net for a run that ended without releasing —
266
+ * and a no-op if no recorder was created.
267
+ */
268
+ export async function closeVcr() {
269
+ const g = globalThis;
270
+ const vcr = g[VCR_KEY];
271
+ if (!vcr)
272
+ return;
273
+ g[VCR_KEY] = undefined;
274
+ await vcr.close();
275
+ }