gateproof 0.2.2 → 0.5.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 (98) hide show
  1. package/README.md +1396 -320
  2. package/dist/cloudflare/index.d.ts +4 -6
  3. package/dist/cloudflare/index.d.ts.map +1 -1
  4. package/dist/cloudflare/index.js +9 -43
  5. package/dist/cloudflare/index.js.map +1 -1
  6. package/dist/index.d.ts +263 -66
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1327 -204
  9. package/dist/index.js.map +1 -1
  10. package/package.json +18 -40
  11. package/dist/act.d.ts +0 -33
  12. package/dist/act.d.ts.map +0 -1
  13. package/dist/act.js +0 -25
  14. package/dist/act.js.map +0 -1
  15. package/dist/action-executors.d.ts +0 -22
  16. package/dist/action-executors.d.ts.map +0 -1
  17. package/dist/action-executors.js +0 -135
  18. package/dist/action-executors.js.map +0 -1
  19. package/dist/assert.d.ts +0 -39
  20. package/dist/assert.d.ts.map +0 -1
  21. package/dist/assert.js +0 -88
  22. package/dist/assert.js.map +0 -1
  23. package/dist/cli/gateproof.d.ts +0 -3
  24. package/dist/cli/gateproof.d.ts.map +0 -1
  25. package/dist/cli/gateproof.js +0 -472
  26. package/dist/cli/gateproof.js.map +0 -1
  27. package/dist/cloudflare/analytics.d.ts +0 -9
  28. package/dist/cloudflare/analytics.d.ts.map +0 -1
  29. package/dist/cloudflare/analytics.js +0 -98
  30. package/dist/cloudflare/analytics.js.map +0 -1
  31. package/dist/cloudflare/cli-stream.d.ts +0 -7
  32. package/dist/cloudflare/cli-stream.d.ts.map +0 -1
  33. package/dist/cloudflare/cli-stream.js +0 -85
  34. package/dist/cloudflare/cli-stream.js.map +0 -1
  35. package/dist/cloudflare/polling-backend.d.ts +0 -18
  36. package/dist/cloudflare/polling-backend.d.ts.map +0 -1
  37. package/dist/cloudflare/polling-backend.js +0 -53
  38. package/dist/cloudflare/polling-backend.js.map +0 -1
  39. package/dist/cloudflare/workers-logs.d.ts +0 -9
  40. package/dist/cloudflare/workers-logs.d.ts.map +0 -1
  41. package/dist/cloudflare/workers-logs.js +0 -51
  42. package/dist/cloudflare/workers-logs.js.map +0 -1
  43. package/dist/constants.d.ts +0 -11
  44. package/dist/constants.d.ts.map +0 -1
  45. package/dist/constants.js +0 -11
  46. package/dist/constants.js.map +0 -1
  47. package/dist/http-backend.d.ts +0 -23
  48. package/dist/http-backend.d.ts.map +0 -1
  49. package/dist/http-backend.js +0 -124
  50. package/dist/http-backend.js.map +0 -1
  51. package/dist/observe.d.ts +0 -26
  52. package/dist/observe.d.ts.map +0 -1
  53. package/dist/observe.js +0 -84
  54. package/dist/observe.js.map +0 -1
  55. package/dist/prd/define-prd.d.ts +0 -7
  56. package/dist/prd/define-prd.d.ts.map +0 -1
  57. package/dist/prd/define-prd.js +0 -8
  58. package/dist/prd/define-prd.js.map +0 -1
  59. package/dist/prd/index.d.ts +0 -5
  60. package/dist/prd/index.d.ts.map +0 -1
  61. package/dist/prd/index.js +0 -4
  62. package/dist/prd/index.js.map +0 -1
  63. package/dist/prd/runner.d.ts +0 -22
  64. package/dist/prd/runner.d.ts.map +0 -1
  65. package/dist/prd/runner.js +0 -221
  66. package/dist/prd/runner.js.map +0 -1
  67. package/dist/prd/scope-check.d.ts +0 -28
  68. package/dist/prd/scope-check.d.ts.map +0 -1
  69. package/dist/prd/scope-check.js +0 -135
  70. package/dist/prd/scope-check.js.map +0 -1
  71. package/dist/prd/types.d.ts +0 -22
  72. package/dist/prd/types.d.ts.map +0 -1
  73. package/dist/prd/types.js +0 -2
  74. package/dist/prd/types.js.map +0 -1
  75. package/dist/provider.d.ts +0 -6
  76. package/dist/provider.d.ts.map +0 -1
  77. package/dist/provider.js +0 -2
  78. package/dist/provider.js.map +0 -1
  79. package/dist/report.d.ts +0 -67
  80. package/dist/report.d.ts.map +0 -1
  81. package/dist/report.js +0 -51
  82. package/dist/report.js.map +0 -1
  83. package/dist/test-helpers.d.ts +0 -12
  84. package/dist/test-helpers.d.ts.map +0 -1
  85. package/dist/test-helpers.js +0 -33
  86. package/dist/test-helpers.js.map +0 -1
  87. package/dist/types.d.ts +0 -41
  88. package/dist/types.d.ts.map +0 -1
  89. package/dist/types.js +0 -2
  90. package/dist/types.js.map +0 -1
  91. package/dist/utils.d.ts +0 -22
  92. package/dist/utils.d.ts.map +0 -1
  93. package/dist/utils.js +0 -49
  94. package/dist/utils.js.map +0 -1
  95. package/dist/validation.d.ts +0 -6
  96. package/dist/validation.d.ts.map +0 -1
  97. package/dist/validation.js +0 -38
  98. package/dist/validation.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,216 +1,1339 @@
1
- import { Effect, Ref, Either, Stream } from "effect";
2
- import { Assert } from "./assert";
3
- import { Schema } from "@effect/schema";
4
- import { getActionExecutor } from "./action-executors";
5
- import { DEFAULT_IDLE_MS, DEFAULT_MAX_MS, MAX_LOG_BUFFER, LOG_BUFFER_CAPACITY } from "./constants";
6
- import { sortDeterministic, toGateResultV1 } from "./report";
7
- export class GateError extends Schema.TaggedError()("GateError", {
8
- cause: Schema.Unknown
9
- }) {
10
- }
11
- export class LogTimeoutError extends Schema.TaggedError()("LogTimeoutError", {
12
- maxMs: Schema.Number,
13
- idleMs: Schema.Number,
14
- cause: Schema.optional(Schema.Unknown)
15
- }) {
16
- }
17
- function summarize(logs) {
18
- const requestIds = new Set();
19
- const stages = new Set();
20
- const actions = new Set();
21
- const errorTags = new Set();
22
- for (const log of logs) {
23
- if (log.requestId)
24
- requestIds.add(log.requestId);
25
- if (log.stage)
26
- stages.add(log.stage);
27
- if (log.action)
28
- actions.add(log.action);
29
- if (log.error?.tag)
30
- errorTags.add(log.error.tag);
31
- }
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, mkdir, stat, writeFile } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { dirname, relative, resolve } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { Effect } from "effect";
7
+ const createRequestTimeout = (timeoutMs) => {
8
+ const controller = new AbortController();
9
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
32
10
  return {
33
- requestIds: sortDeterministic([...requestIds]),
34
- stagesSeen: sortDeterministic([...stages]),
35
- actionsSeen: sortDeterministic([...actions]),
36
- errorTags: sortDeterministic([...errorTags])
37
- };
38
- }
39
- function makeLogTimeoutError(stop, cause) {
40
- return new LogTimeoutError({
41
- maxMs: stop.maxMs,
42
- idleMs: stop.idleMs,
43
- cause
44
- });
45
- }
46
- function runAction(action) {
47
- const executor = getActionExecutor(action);
48
- return executor.execute(action);
49
- }
50
- /**
51
- * Collects logs from a stream with timeout and idle detection.
52
- *
53
- * Behavior:
54
- * - **maxMs**: Maximum total time to wait for logs. If exceeded, returns LogTimeoutError.
55
- * - **idleMs**: If no logs arrive for this duration, return early (with collected logs if any, or empty array if none).
56
- *
57
- * Examples:
58
- * - Stream produces logs continuously: collects until maxLogs or maxMs exceeded
59
- * - Stream stops producing logs: if idleMs elapsed and we have logs, return them
60
- * - Stream never produces logs: waits for idleMs then returns empty array if maxMs not exceeded, otherwise timeout error
61
- * - Stream error: preserved in LogTimeoutError.cause
62
- */
63
- function collectLogs(stream, stop, maxLogs) {
64
- const startTime = Date.now();
65
- const lastLogTimeRef = Ref.unsafeMake(Date.now());
66
- return Effect.gen(function* () {
67
- const logStream = Stream.fromAsyncIterable(stream, () => Effect.void);
68
- const collected = yield* logStream.pipe(Stream.tap(() => Ref.set(lastLogTimeRef, Date.now())), Stream.take(maxLogs), Stream.buffer({ capacity: LOG_BUFFER_CAPACITY }), Stream.timeout(`${stop.maxMs} millis`), Stream.runCollect, Effect.catchAll((error) => Effect.gen(function* () {
69
- // Stream-level timeout
70
- if (error && typeof error === "object" && "_tag" in error && error._tag === "TimeoutException") {
71
- return yield* Effect.fail(makeLogTimeoutError(stop, error));
72
- }
73
- const now = Date.now();
74
- const totalTime = now - startTime;
75
- if (totalTime > stop.maxMs) {
76
- return yield* Effect.fail(makeLogTimeoutError(stop, error));
77
- }
78
- // Non-timeout stream error before maxMs elapsed – surface as timeout with cause
79
- return yield* Effect.fail(makeLogTimeoutError(stop, error));
80
- })));
81
- const now = Date.now();
82
- const lastLogTime = yield* Ref.get(lastLogTimeRef);
83
- const idleTime = now - lastLogTime;
84
- const totalTime = now - startTime;
85
- if (totalTime > stop.maxMs) {
86
- return yield* Effect.fail(makeLogTimeoutError(stop));
87
- }
88
- // If we have logs and have been idle longer than idleMs, return what we have.
89
- if (idleTime > stop.idleMs && collected.length > 0) {
90
- return Array.from(collected);
91
- }
92
- // If we have any logs at all, return them.
93
- if (collected.length > 0) {
94
- return Array.from(collected);
95
- }
96
- // No logs collected yet. Wait for idleMs before giving up, unless maxMs would be exceeded.
97
- if (idleTime < stop.idleMs) {
98
- const remainingIdle = stop.idleMs - idleTime;
99
- const remainingMax = stop.maxMs - totalTime;
100
- const waitTime = Math.min(remainingIdle, remainingMax);
101
- if (waitTime > 0) {
102
- yield* Effect.sleep(`${waitTime} millis`);
103
- // After waiting, check if maxMs is now exceeded
104
- const afterWaitTime = Date.now() - startTime;
105
- if (afterWaitTime > stop.maxMs) {
106
- return yield* Effect.fail(makeLogTimeoutError(stop));
107
- }
11
+ signal: controller.signal,
12
+ clear: () => clearTimeout(timer),
13
+ };
14
+ };
15
+ const DEFAULT_ALLOWED_PATHS = ["src/", "app/", "components/", "pages/", "lib/"];
16
+ const DEFAULT_FORBIDDEN_PATHS = ["node_modules/", ".git/", "dist/", "build/", ".env"];
17
+ const normalizePath = (value) => value.replaceAll("\\", "/");
18
+ const isPathInside = (cwd, candidate) => {
19
+ const resolved = resolve(cwd, candidate);
20
+ const rel = normalizePath(relative(cwd, resolved));
21
+ return rel === "" || (!rel.startsWith("../") && rel !== "..");
22
+ };
23
+ const ensureTrailingSlash = (value) => value.endsWith("/") ? value : `${value}/`;
24
+ const countLines = (value) => {
25
+ if (value.length === 0) {
26
+ return 0;
27
+ }
28
+ return value.split(/\r?\n/).length;
29
+ };
30
+ const runCommand = (command, args, cwd) => Effect.tryPromise(() => new Promise((resolveCommand) => {
31
+ const child = spawn(command, [...args], {
32
+ cwd,
33
+ env: process.env,
34
+ });
35
+ let stdout = "";
36
+ let stderr = "";
37
+ child.stdout?.on("data", (chunk) => {
38
+ stdout += String(chunk);
39
+ });
40
+ child.stderr?.on("data", (chunk) => {
41
+ stderr += String(chunk);
42
+ });
43
+ child.on("error", (error) => {
44
+ resolveCommand({
45
+ ok: false,
46
+ stdout,
47
+ stderr: error instanceof Error ? error.message : String(error),
48
+ exitCode: null,
49
+ });
50
+ });
51
+ child.on("close", (code) => {
52
+ resolveCommand({
53
+ ok: code === 0,
54
+ stdout,
55
+ stderr,
56
+ exitCode: code,
57
+ });
58
+ });
59
+ })).pipe(Effect.catch(() => Effect.succeed({
60
+ ok: false,
61
+ stdout: "",
62
+ stderr: "command failed unexpectedly",
63
+ exitCode: null,
64
+ })));
65
+ const runCommandBytes = (command, args, cwd) => Effect.tryPromise(() => new Promise((resolveCommand) => {
66
+ const child = spawn(command, [...args], {
67
+ cwd,
68
+ env: process.env,
69
+ });
70
+ const stdoutChunks = [];
71
+ let stderr = "";
72
+ child.stdout?.on("data", (chunk) => {
73
+ stdoutChunks.push(Buffer.from(chunk));
74
+ });
75
+ child.stderr?.on("data", (chunk) => {
76
+ stderr += String(chunk);
77
+ });
78
+ child.on("error", (error) => {
79
+ resolveCommand({
80
+ ok: false,
81
+ stdout: new Uint8Array(),
82
+ stderr: error instanceof Error ? error.message : String(error),
83
+ exitCode: null,
84
+ });
85
+ });
86
+ child.on("close", (code) => {
87
+ resolveCommand({
88
+ ok: code === 0,
89
+ stdout: Buffer.concat(stdoutChunks),
90
+ stderr,
91
+ exitCode: code,
92
+ });
93
+ });
94
+ })).pipe(Effect.catch(() => Effect.succeed({
95
+ ok: false,
96
+ stdout: new Uint8Array(),
97
+ stderr: "command failed unexpectedly",
98
+ exitCode: null,
99
+ })));
100
+ const isGitRepository = (cwd) => runCommand("git", ["rev-parse", "--is-inside-work-tree"], cwd).pipe(Effect.map((result) => result.ok && result.stdout.trim() === "true"));
101
+ const collectWorkingTreeChanges = (cwd) => Effect.gen(function* () {
102
+ const insideGit = yield* isGitRepository(cwd);
103
+ if (!insideGit) {
104
+ return {
105
+ files: [],
106
+ totalLines: 0,
107
+ };
108
+ }
109
+ const statusResult = yield* runCommand("git", ["status", "--porcelain", "--untracked-files=all"], cwd);
110
+ if (!statusResult.ok) {
111
+ return {
112
+ files: [],
113
+ totalLines: 0,
114
+ };
115
+ }
116
+ const files = statusResult.stdout
117
+ .split(/\r?\n/)
118
+ .map((line) => line.trimEnd())
119
+ .filter((line) => line.length >= 4)
120
+ .map((line) => {
121
+ const rawPath = line.slice(3);
122
+ const renameIndex = rawPath.indexOf(" -> ");
123
+ return normalizePath(renameIndex === -1 ? rawPath : rawPath.slice(renameIndex + 4));
124
+ });
125
+ if (files.length === 0) {
126
+ return {
127
+ files: [],
128
+ totalLines: 0,
129
+ };
130
+ }
131
+ const numstatResult = yield* runCommand("git", ["diff", "--numstat", "--relative", "HEAD", "--"], cwd);
132
+ const trackedLineCounts = new Map();
133
+ if (numstatResult.ok) {
134
+ for (const line of numstatResult.stdout.split(/\r?\n/)) {
135
+ if (!line) {
136
+ continue;
108
137
  }
138
+ const [added, removed, path] = line.split("\t");
139
+ if (!path) {
140
+ continue;
141
+ }
142
+ const addedCount = added === "-" ? 0 : Number(added);
143
+ const removedCount = removed === "-" ? 0 : Number(removed);
144
+ trackedLineCounts.set(normalizePath(path), (Number.isFinite(addedCount) ? addedCount : 0) + (Number.isFinite(removedCount) ? removedCount : 0));
145
+ }
146
+ }
147
+ let totalLines = 0;
148
+ for (const file of files) {
149
+ if (trackedLineCounts.has(file)) {
150
+ totalLines += trackedLineCounts.get(file) ?? 0;
151
+ continue;
152
+ }
153
+ const absolutePath = resolve(cwd, file);
154
+ const existing = yield* Effect.tryPromise(() => stat(absolutePath)).pipe(Effect.map(() => true), Effect.catch(() => Effect.succeed(false)));
155
+ if (!existing) {
156
+ continue;
157
+ }
158
+ const contents = yield* Effect.tryPromise(() => readFile(absolutePath, "utf8")).pipe(Effect.catch(() => Effect.succeed("")));
159
+ totalLines += countLines(contents);
160
+ }
161
+ return {
162
+ files,
163
+ totalLines,
164
+ };
165
+ });
166
+ const validateScope = (goal, cwd) => collectWorkingTreeChanges(cwd).pipe(Effect.map((changes) => {
167
+ const scope = goal?.scope;
168
+ const allowedPaths = (scope?.allowedPaths ?? DEFAULT_ALLOWED_PATHS).map(ensureTrailingSlash);
169
+ const forbiddenPaths = (scope?.forbiddenPaths ?? DEFAULT_FORBIDDEN_PATHS).map(ensureTrailingSlash);
170
+ const maxChangedFiles = scope?.maxChangedFiles;
171
+ const maxChangedLines = scope?.maxChangedLines;
172
+ for (const file of changes.files) {
173
+ const normalized = normalizePath(file);
174
+ if (forbiddenPaths.some((entry) => normalized === entry.slice(0, -1) || normalized.startsWith(entry))) {
175
+ return {
176
+ ok: false,
177
+ changes,
178
+ violation: `scope violation: changed forbidden path ${JSON.stringify(normalized)}`,
179
+ };
109
180
  }
110
- // No logs collected after waiting return empty array.
181
+ if (allowedPaths.length > 0 && !allowedPaths.some((entry) => normalized === entry.slice(0, -1) || normalized.startsWith(entry))) {
182
+ return {
183
+ ok: false,
184
+ changes,
185
+ violation: `scope violation: changed path outside allowed scope ${JSON.stringify(normalized)}`,
186
+ };
187
+ }
188
+ }
189
+ if (typeof maxChangedFiles === "number" && changes.files.length > maxChangedFiles) {
190
+ return {
191
+ ok: false,
192
+ changes,
193
+ violation: `scope violation: changed ${changes.files.length} files (max ${maxChangedFiles})`,
194
+ };
195
+ }
196
+ if (typeof maxChangedLines === "number" && changes.totalLines > maxChangedLines) {
197
+ return {
198
+ ok: false,
199
+ changes,
200
+ violation: `scope violation: changed ${changes.totalLines} lines (max ${maxChangedLines})`,
201
+ };
202
+ }
203
+ return {
204
+ ok: true,
205
+ changes,
206
+ };
207
+ }));
208
+ const writeIterationReport = (cwd, iteration, entry) => Effect.gen(function* () {
209
+ const reportDirectory = resolve(cwd, ".gateproof", "iterations");
210
+ const reportPath = resolve(reportDirectory, `${iteration}.json`);
211
+ const latestPath = resolve(cwd, ".gateproof", "latest.json");
212
+ const payload = `${JSON.stringify(entry, null, 2)}\n`;
213
+ yield* Effect.tryPromise(() => mkdir(reportDirectory, { recursive: true })).pipe(Effect.catch(() => Effect.void));
214
+ yield* Effect.tryPromise(() => writeFile(reportPath, payload, "utf8")).pipe(Effect.catch(() => Effect.void));
215
+ yield* Effect.tryPromise(() => writeFile(latestPath, payload, "utf8")).pipe(Effect.catch(() => Effect.void));
216
+ return reportPath;
217
+ });
218
+ const captureProofSnapshot = (cwd, planPath) => Effect.gen(function* () {
219
+ const snapshot = {
220
+ cwd,
221
+ planPath,
222
+ };
223
+ const insideGit = yield* isGitRepository(cwd);
224
+ if (!insideGit) {
225
+ return snapshot;
226
+ }
227
+ const headResult = yield* runCommand("git", ["rev-parse", "HEAD"], cwd);
228
+ if (headResult.ok) {
229
+ snapshot.gitHead = headResult.stdout.trim();
230
+ }
231
+ const diffResult = yield* runCommandBytes("git", ["diff", "--binary", "HEAD"], cwd);
232
+ if (diffResult.ok) {
233
+ snapshot.worktreeDiffHash = createHash("sha256")
234
+ .update(diffResult.stdout)
235
+ .digest("hex");
236
+ }
237
+ return snapshot;
238
+ });
239
+ const createCommit = (cwd, message, options) => Effect.gen(function* () {
240
+ const insideGit = yield* isGitRepository(cwd);
241
+ if (!insideGit || options?.enabled === false) {
242
+ return { created: false };
243
+ }
244
+ yield* runCommand("git", ["add", "-A"], cwd);
245
+ const cachedDiff = yield* runCommand("git", ["diff", "--cached", "--quiet"], cwd);
246
+ const empty = cachedDiff.exitCode === 0;
247
+ const commitArgs = ["commit", "-m", message];
248
+ if (options?.allowEmpty !== false) {
249
+ commitArgs.splice(1, 0, "--allow-empty");
250
+ }
251
+ else if (empty) {
252
+ return { created: false };
253
+ }
254
+ const commitResult = yield* runCommand("git", commitArgs, cwd);
255
+ if (!commitResult.ok) {
256
+ return { created: false };
257
+ }
258
+ const shaResult = yield* runCommand("git", ["rev-parse", "HEAD"], cwd);
259
+ return {
260
+ created: true,
261
+ sha: shaResult.ok ? shaResult.stdout.trim() : undefined,
262
+ message,
263
+ empty,
264
+ };
265
+ });
266
+ const describeStatus = (status) => {
267
+ switch (status) {
268
+ case "pass":
269
+ return "all gates passed";
270
+ case "fail":
271
+ return "one or more gates failed";
272
+ case "skip":
273
+ return "all gates were skipped";
274
+ case "inconclusive":
275
+ return "evidence was missing or ambiguous";
276
+ }
277
+ };
278
+ const isRecord = (value) => typeof value === "object" && value !== null;
279
+ const parseActionToken = (message) => {
280
+ if (!message)
281
+ return undefined;
282
+ const match = message.match(/\baction=([A-Za-z0-9._:-]+)/);
283
+ return match?.[1];
284
+ };
285
+ const parseStageToken = (message) => {
286
+ if (!message)
287
+ return undefined;
288
+ const match = message.match(/\bstage=([A-Za-z0-9._:-]+)/);
289
+ return match?.[1];
290
+ };
291
+ const parseCloudflareTailPayload = (payload) => {
292
+ if (!isRecord(payload) || !Array.isArray(payload.logs)) {
111
293
  return [];
294
+ }
295
+ const eventMetadata = isRecord(payload.event)
296
+ ? payload.event
297
+ : undefined;
298
+ const baseMetadata = {};
299
+ if (typeof payload.outcome === "string") {
300
+ baseMetadata.outcome = payload.outcome;
301
+ }
302
+ if (typeof payload.scriptName === "string") {
303
+ baseMetadata.scriptName = payload.scriptName;
304
+ }
305
+ if (eventMetadata) {
306
+ baseMetadata.event = eventMetadata;
307
+ }
308
+ return payload.logs
309
+ .map((entry) => {
310
+ if (!isRecord(entry)) {
311
+ return null;
312
+ }
313
+ const message = Array.isArray(entry.message)
314
+ ? entry.message
315
+ .map((part) => (typeof part === "string" ? part : JSON.stringify(part)))
316
+ .join(" ")
317
+ : typeof entry.message === "string"
318
+ ? entry.message
319
+ : undefined;
320
+ const metadata = Object.keys(baseMetadata).length > 0
321
+ ? baseMetadata
322
+ : undefined;
323
+ return {
324
+ timestamp: typeof entry.timestamp === "number"
325
+ ? new Date(entry.timestamp).toISOString()
326
+ : typeof entry.timestamp === "string"
327
+ ? entry.timestamp
328
+ : typeof payload.eventTimestamp === "number"
329
+ ? new Date(payload.eventTimestamp).toISOString()
330
+ : undefined,
331
+ level: typeof entry.level === "string" ? entry.level : undefined,
332
+ message,
333
+ action: parseActionToken(message),
334
+ stage: parseStageToken(message),
335
+ metadata,
336
+ };
337
+ })
338
+ .filter((event) => event !== null);
339
+ };
340
+ const deriveProofStrength = (gate, evidence, status) => {
341
+ if (status === "skip")
342
+ return "none";
343
+ if (evidence.http || (evidence.logs && evidence.logs.length > 0))
344
+ return "strong";
345
+ if (evidence.actions.length > 0 && (gate.assert?.length ?? 0) > 0)
346
+ return "moderate";
347
+ if (evidence.actions.length > 0)
348
+ return "weak";
349
+ return "none";
350
+ };
351
+ const matchesActionFilter = (gate, actionIncludes) => {
352
+ if (!actionIncludes)
353
+ return true;
354
+ if (gate.observe?.kind === "http" && gate.observe.url.includes(actionIncludes)) {
355
+ return true;
356
+ }
357
+ return gate.act?.some((action) => action.command.includes(actionIncludes)) ?? false;
358
+ };
359
+ const runExecAction = (action) => Effect.tryPromise(() => new Promise((resolve) => {
360
+ const startedAt = Date.now();
361
+ const child = spawn(action.command, {
362
+ cwd: action.cwd,
363
+ shell: true,
364
+ env: process.env,
365
+ });
366
+ let stdout = "";
367
+ let stderr = "";
368
+ let settled = false;
369
+ const finish = (result) => {
370
+ if (settled)
371
+ return;
372
+ settled = true;
373
+ resolve(result);
374
+ };
375
+ child.stdout?.on("data", (chunk) => {
376
+ stdout += String(chunk);
377
+ });
378
+ child.stderr?.on("data", (chunk) => {
379
+ stderr += String(chunk);
112
380
  });
113
- }
114
- function handleGateError(error, startedAt, logs) {
115
- const errorObj = error instanceof Error ? error : new Error(String(error));
381
+ child.on("error", (error) => {
382
+ finish({
383
+ kind: "exec",
384
+ command: action.command,
385
+ ok: false,
386
+ durationMs: Date.now() - startedAt,
387
+ stdout,
388
+ stderr: `${stderr}${error instanceof Error ? error.message : String(error)}`,
389
+ exitCode: null,
390
+ });
391
+ });
392
+ child.on("close", (code) => {
393
+ finish({
394
+ kind: "exec",
395
+ command: action.command,
396
+ ok: code === 0,
397
+ durationMs: Date.now() - startedAt,
398
+ stdout,
399
+ stderr,
400
+ exitCode: code,
401
+ });
402
+ });
403
+ if (action.timeoutMs && action.timeoutMs > 0) {
404
+ setTimeout(() => {
405
+ if (settled)
406
+ return;
407
+ child.kill("SIGTERM");
408
+ finish({
409
+ kind: "exec",
410
+ command: action.command,
411
+ ok: false,
412
+ durationMs: Date.now() - startedAt,
413
+ stdout,
414
+ stderr: `${stderr}timed out after ${action.timeoutMs}ms`,
415
+ exitCode: null,
416
+ });
417
+ }, action.timeoutMs);
418
+ }
419
+ })).pipe(Effect.catch((error) => Effect.succeed({
420
+ kind: "exec",
421
+ command: action.command,
422
+ ok: false,
423
+ durationMs: 0,
424
+ stdout: "",
425
+ stderr: error instanceof Error ? error.message : String(error),
426
+ exitCode: null,
427
+ })));
428
+ const runHttpObservation = (resource) => Effect.tryPromise(async () => {
429
+ const startedAt = Date.now();
430
+ const response = await fetch(resource.url, {
431
+ headers: resource.headers,
432
+ });
433
+ const body = await response.text();
116
434
  return {
117
- status: "failed",
435
+ kind: "http",
436
+ url: resource.url,
437
+ status: response.status,
118
438
  durationMs: Date.now() - startedAt,
119
- logs,
120
- evidence: summarize(logs),
121
- error: errorObj
122
- };
123
- }
124
- export var Gate;
125
- (function (Gate) {
126
- function run(spec) {
127
- return Effect.runPromise(Effect.scoped(runEffect(spec)));
128
- }
129
- Gate.run = run;
130
- function runEffect(spec) {
131
- return Effect.acquireUseRelease(spec.observe.start().pipe(Effect.catchAll((error) => Effect.fail(new GateError({ cause: error })))), (stream) => Effect.gen(function* () {
132
- const startedAt = Date.now();
133
- const stop = spec.stop ?? { idleMs: DEFAULT_IDLE_MS, maxMs: DEFAULT_MAX_MS };
134
- const maxLogs = spec.maxLogs ?? MAX_LOG_BUFFER;
135
- yield* Effect.sleep("200 millis");
136
- let actionError = null;
137
- for (const action of spec.act) {
138
- const result = yield* runAction(action).pipe(Effect.tap(() => Effect.log(`Action ${action._tag} completed`)), Effect.tapError((error) => Effect.logError(`Action ${action._tag} failed`, error)), Effect.either);
139
- if (Either.isLeft(result)) {
140
- actionError = result.left;
141
- break;
439
+ ok: response.ok,
440
+ body,
441
+ };
442
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)));
443
+ const createCloudflareTailSession = async (resource, startedAt) => {
444
+ const now = Date.now();
445
+ const sinceThreshold = resource.sinceMs
446
+ ? Math.max(startedAt, now - resource.sinceMs)
447
+ : startedAt;
448
+ const createRequest = createRequestTimeout(2_000);
449
+ const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${resource.accountId}/workers/scripts/${resource.workerName}/tails`, {
450
+ method: "POST",
451
+ headers: {
452
+ Authorization: `Bearer ${resource.apiToken}`,
453
+ "Content-Type": "application/json",
454
+ },
455
+ body: JSON.stringify({ filters: [] }),
456
+ signal: createRequest.signal,
457
+ }).finally(createRequest.clear);
458
+ if (!response.ok) {
459
+ throw new Error(`Cloudflare tail request failed: ${response.status}`);
460
+ }
461
+ const payload = await response.json();
462
+ if (!isRecord(payload) ||
463
+ payload.success !== true ||
464
+ !isRecord(payload.result) ||
465
+ typeof payload.result.id !== "string" ||
466
+ typeof payload.result.url !== "string") {
467
+ throw new Error("Cloudflare tail response was missing a websocket URL");
468
+ }
469
+ const tailRecord = {
470
+ success: true,
471
+ result: {
472
+ id: payload.result.id,
473
+ url: payload.result.url,
474
+ },
475
+ };
476
+ const collected = [];
477
+ let openResolved = false;
478
+ const websocket = new WebSocket(tailRecord.result.url, "trace-v1");
479
+ const messageListener = (event) => {
480
+ const data = typeof event.data === "string" ? event.data : String(event.data);
481
+ try {
482
+ const payload = JSON.parse(data);
483
+ for (const logEvent of parseCloudflareTailPayload(payload)) {
484
+ if (!logEvent.timestamp) {
485
+ collected.push(logEvent);
486
+ continue;
487
+ }
488
+ const timestamp = new Date(logEvent.timestamp).getTime();
489
+ if (Number.isFinite(timestamp) && timestamp >= sinceThreshold) {
490
+ collected.push(logEvent);
142
491
  }
143
492
  }
144
- const actionResult = actionError ? Either.left(actionError) : Either.right(undefined);
145
- if (Either.isLeft(actionResult)) {
146
- return handleGateError(actionResult.left, startedAt, []);
493
+ }
494
+ catch {
495
+ // Ignore malformed tail payloads and continue collecting.
496
+ }
497
+ };
498
+ let readyError;
499
+ const ready = new Promise((resolve) => {
500
+ let settled = false;
501
+ const timeout = setTimeout(() => {
502
+ if (settled) {
503
+ return;
147
504
  }
148
- const logsResult = yield* collectLogs(stream, stop, maxLogs).pipe(Effect.timeoutFail({
149
- duration: `${stop.maxMs} millis`,
150
- onTimeout: () => new LogTimeoutError({ maxMs: stop.maxMs, idleMs: stop.idleMs })
151
- }), Effect.catchTag("LogTimeoutError", (error) => Effect.succeed({
152
- status: "timeout",
153
- durationMs: Date.now() - startedAt,
154
- logs: [],
155
- evidence: summarize([]),
156
- error: error instanceof Error ? error : new Error(String(error))
157
- })), Effect.either);
158
- if (Either.isRight(logsResult)) {
159
- const right = logsResult.right;
160
- // Right side can be either collected logs or a pre-built timeout GateResult
161
- if (Array.isArray(right)) {
162
- const logs = right;
163
- const assertResult = yield* Assert.run(spec.assert, logs).pipe(Effect.either);
164
- const evidence = summarize(logs);
165
- const durationMs = Date.now() - startedAt;
166
- if (Either.isLeft(assertResult)) {
167
- const result = handleGateError(assertResult.left, startedAt, logs);
168
- printResult(spec.report, result);
169
- return result;
170
- }
171
- const result = {
172
- status: "success",
173
- durationMs,
174
- logs,
175
- evidence
176
- };
177
- printResult(spec.report, result);
178
- return result;
505
+ settled = true;
506
+ readyError = new Error("Cloudflare tail websocket timed out");
507
+ resolve();
508
+ }, 2_000);
509
+ websocket.addEventListener("open", () => {
510
+ if (settled) {
511
+ return;
512
+ }
513
+ try {
514
+ websocket.send(JSON.stringify({ debug: true }));
515
+ settled = true;
516
+ clearTimeout(timeout);
517
+ openResolved = true;
518
+ // Give the tail a brief moment to begin streaming before the first
519
+ // gate action fires. The very first request in a run can arrive
520
+ // immediately after connect, and a too-short warm-up makes the first
521
+ // gate intermittently miss log evidence even when later gates see it.
522
+ // A slightly longer delay keeps the loop bounded while making the
523
+ // first gate reliable.
524
+ setTimeout(() => resolve(), 500);
525
+ }
526
+ catch (error) {
527
+ settled = true;
528
+ clearTimeout(timeout);
529
+ readyError = error instanceof Error ? error : new Error(String(error));
530
+ resolve();
531
+ }
532
+ });
533
+ websocket.addEventListener("error", () => {
534
+ if (settled) {
535
+ return;
536
+ }
537
+ if (!openResolved) {
538
+ settled = true;
539
+ clearTimeout(timeout);
540
+ readyError = new Error("Cloudflare tail websocket failed to connect");
541
+ resolve();
542
+ }
543
+ });
544
+ websocket.addEventListener("close", () => {
545
+ if (!settled && !openResolved) {
546
+ settled = true;
547
+ clearTimeout(timeout);
548
+ readyError = new Error("Cloudflare tail websocket closed before connecting");
549
+ resolve();
550
+ }
551
+ }, { once: true });
552
+ });
553
+ websocket.addEventListener("message", messageListener);
554
+ const close = async () => {
555
+ const waitForClose = new Promise((resolve) => {
556
+ websocket.addEventListener("close", () => resolve(), { once: true });
557
+ websocket.addEventListener("error", () => resolve(), { once: true });
558
+ });
559
+ const terminable = websocket;
560
+ try {
561
+ terminable.terminate?.();
562
+ if (!terminable.terminate) {
563
+ websocket.close();
564
+ }
565
+ }
566
+ catch {
567
+ try {
568
+ websocket.close();
569
+ }
570
+ catch {
571
+ // ignore close errors
572
+ }
573
+ }
574
+ await Promise.race([waitForClose, delay(250)]);
575
+ websocket.removeEventListener("message", messageListener);
576
+ // Cloudflare tail sessions expire server-side; skipping explicit deletion here keeps
577
+ // the proof loop bounded and avoids hanging the process on teardown network calls.
578
+ };
579
+ const collect = async (timeoutMs) => {
580
+ await ready;
581
+ if (readyError) {
582
+ throw readyError;
583
+ }
584
+ // Cloudflare tail events should arrive quickly after the triggering action.
585
+ // If nothing shows up promptly, return empty evidence instead of burning the
586
+ // full gate timeout and making the proof loop feel wedged.
587
+ const effectiveTimeoutMs = Math.min(timeoutMs, 1_500);
588
+ const deadline = Date.now() + effectiveTimeoutMs;
589
+ const pollInterval = Math.max(50, Math.min(resource.pollInterval ?? 250, 1_000));
590
+ while (Date.now() < deadline) {
591
+ if (collected.length > 0) {
592
+ await delay(Math.min(250, Math.max(0, deadline - Date.now())));
593
+ return [...collected];
594
+ }
595
+ await delay(pollInterval);
596
+ }
597
+ return [...collected];
598
+ };
599
+ return {
600
+ ready: async () => {
601
+ await ready;
602
+ if (readyError) {
603
+ throw readyError;
604
+ }
605
+ },
606
+ collect,
607
+ close,
608
+ };
609
+ };
610
+ const createReadyCloudflareTailSession = async (resource, startedAt) => {
611
+ let lastError;
612
+ for (let attempt = 0; attempt < 2; attempt += 1) {
613
+ let tailSession;
614
+ try {
615
+ tailSession = await createCloudflareTailSession(resource, startedAt);
616
+ await tailSession.ready();
617
+ return tailSession;
618
+ }
619
+ catch (error) {
620
+ lastError = error instanceof Error ? error : new Error(String(error));
621
+ if (tailSession) {
622
+ await tailSession.close().catch(() => undefined);
623
+ }
624
+ if (attempt === 0) {
625
+ await delay(250);
626
+ }
627
+ }
628
+ }
629
+ throw lastError ?? new Error("Cloudflare tail session failed to become ready");
630
+ };
631
+ const getCloudflareTailKey = (resource) => [
632
+ resource.accountId,
633
+ resource.workerName,
634
+ resource.backend ?? "workers-logs",
635
+ ].join(":");
636
+ const runObservation = (resource) => runHttpObservation(resource);
637
+ const extractNumericValue = (assertion, evidence) => {
638
+ let regex;
639
+ try {
640
+ regex = new RegExp(assertion.pattern);
641
+ }
642
+ catch {
643
+ return null;
644
+ }
645
+ if (assertion.source === "httpBody") {
646
+ const bodyText = evidence.http?.body ?? evidence.actions.map((action) => action.stdout).join("\n");
647
+ const match = bodyText.match(regex);
648
+ if (!match)
649
+ return null;
650
+ const value = Number(match[1] ?? match[0]);
651
+ return Number.isFinite(value) ? value : null;
652
+ }
653
+ for (const log of evidence.logs ?? []) {
654
+ const message = log.message;
655
+ if (!message)
656
+ continue;
657
+ const match = message.match(regex);
658
+ if (!match)
659
+ continue;
660
+ const value = Number(match[1] ?? match[0]);
661
+ if (Number.isFinite(value)) {
662
+ return value;
663
+ }
664
+ }
665
+ return null;
666
+ };
667
+ const summarizePlan = (goals) => {
668
+ const statuses = goals.map((goal) => goal.status);
669
+ let status = "pass";
670
+ if (statuses.length === 0) {
671
+ status = "inconclusive";
672
+ }
673
+ else if (statuses.every((value) => value === "skip")) {
674
+ status = "skip";
675
+ }
676
+ else if (statuses.includes("inconclusive")) {
677
+ status = "inconclusive";
678
+ }
679
+ else if (statuses.includes("fail")) {
680
+ status = "fail";
681
+ }
682
+ else if (statuses.includes("pass")) {
683
+ status = "pass";
684
+ }
685
+ const proofStrength = goals.some((goal) => goal.proofStrength === "strong")
686
+ ? "strong"
687
+ : goals.some((goal) => goal.proofStrength === "moderate")
688
+ ? "moderate"
689
+ : goals.some((goal) => goal.proofStrength === "weak")
690
+ ? "weak"
691
+ : "none";
692
+ return {
693
+ status,
694
+ proofStrength,
695
+ summary: describeStatus(status),
696
+ };
697
+ };
698
+ const evaluateGate = (goal, cloudflareTails) => Effect.gen(function* () {
699
+ const gate = goal.gate;
700
+ const prerequisites = gate.prerequisites ?? [];
701
+ const missingRequirements = prerequisites
702
+ .filter((prerequisite) => prerequisite.kind === "env" && !process.env[prerequisite.name])
703
+ .map((prerequisite) => prerequisite.reason ?? `missing required env var ${prerequisite.name}`);
704
+ if (missingRequirements.length > 0) {
705
+ return {
706
+ id: goal.id,
707
+ title: goal.title,
708
+ status: "fail",
709
+ proofStrength: "none",
710
+ summary: missingRequirements.join("; "),
711
+ evidence: { actions: [], errors: missingRequirements },
712
+ };
713
+ }
714
+ const startedAt = Date.now();
715
+ const actions = [];
716
+ const errors = [];
717
+ let cloudflareTail;
718
+ const cloudflareResource = gate.observe?.kind === "cloudflare-workers-logs"
719
+ ? gate.observe
720
+ : undefined;
721
+ if (cloudflareResource) {
722
+ const tailKey = getCloudflareTailKey(cloudflareResource);
723
+ const existingTail = cloudflareTails.get(tailKey);
724
+ if (existingTail) {
725
+ cloudflareTail = existingTail;
726
+ }
727
+ else {
728
+ const readyTail = yield* Effect.tryPromise(() => createReadyCloudflareTailSession(cloudflareResource, startedAt)).pipe(Effect.catch(() => Effect.succeed(undefined)));
729
+ if (readyTail) {
730
+ cloudflareTail = {
731
+ session: readyTail,
732
+ lastSeen: 0,
733
+ };
734
+ cloudflareTails.set(tailKey, cloudflareTail);
735
+ }
736
+ }
737
+ }
738
+ for (const action of gate.act ?? []) {
739
+ const result = yield* runExecAction(action);
740
+ actions.push(result);
741
+ if (!result.ok) {
742
+ errors.push(`action failed: ${result.command}`);
743
+ }
744
+ }
745
+ let http;
746
+ let logs;
747
+ if (gate.observe) {
748
+ const resource = gate.observe;
749
+ if (resource.kind === "http") {
750
+ http = yield* runObservation(resource);
751
+ if (!http) {
752
+ errors.push(`failed to observe ${resource.url}`);
753
+ }
754
+ }
755
+ else {
756
+ if (cloudflareTail) {
757
+ const collected = yield* Effect.tryPromise(() => cloudflareTail.session.collect(gate.timeoutMs ?? 5_000)).pipe(Effect.catch(() => Effect.succeed(undefined)));
758
+ if (collected) {
759
+ logs = collected.slice(cloudflareTail.lastSeen);
760
+ cloudflareTail.lastSeen = collected.length;
179
761
  }
180
- // Timeout case already mapped into a GateResult above
181
- return right;
182
- }
183
- // Left side is a LogTimeoutError or other gate error
184
- return handleGateError(logsResult.left, startedAt, []);
185
- }), () => spec.observe
186
- .stop()
187
- .pipe(Effect.tapError((error) => Effect.logError("Failed to stop observe resource", error)), Effect.catchAll(() => Effect.void)));
188
- }
189
- Gate.runEffect = runEffect;
190
- })(Gate || (Gate = {}));
191
- function printResult(report, result) {
192
- if (report === "pretty") {
193
- console.log(`\n[${result.status.toUpperCase()}] ${result.durationMs}ms`);
194
- console.log(`actions=${result.evidence.actionsSeen.length} stages=${result.evidence.stagesSeen.join(",") || "none"}`);
195
- if (result.evidence.errorTags.length) {
196
- console.log(`errorTags=${result.evidence.errorTags.join(",")}`);
197
- }
198
- if (result.status !== "success" && result.error) {
199
- const errorWithTag = result.error;
200
- const errorTag = errorWithTag._tag ?? "unknown";
201
- console.log(`error=${errorTag}`);
202
- }
203
- }
204
- else {
205
- // Use serializable version for JSON output
206
- const serializable = toGateResultV1(result);
207
- console.log(JSON.stringify(serializable, null, 2));
208
- }
209
- }
210
- export { Act } from "./act";
211
- export { Assert } from "./assert";
212
- export { createEmptyBackend, createEmptyObserveResource, runGateWithErrorHandling } from "./utils";
213
- export { createTestObserveResource } from "./test-helpers";
214
- export { createHttpObserveResource } from "./http-backend";
215
- export { serializeError, toGateResultV1 } from "./report";
762
+ }
763
+ if (!logs) {
764
+ errors.push(`failed to observe Cloudflare worker ${resource.workerName}`);
765
+ }
766
+ }
767
+ }
768
+ const evidence = {
769
+ actions,
770
+ http,
771
+ logs,
772
+ errors,
773
+ };
774
+ const assertions = gate.assert ?? [];
775
+ if (assertions.length === 0) {
776
+ return {
777
+ id: goal.id,
778
+ title: goal.title,
779
+ status: "inconclusive",
780
+ proofStrength: deriveProofStrength(gate, evidence, "inconclusive"),
781
+ summary: "no assertions defined",
782
+ evidence,
783
+ };
784
+ }
785
+ const issues = [];
786
+ let insufficient = false;
787
+ for (const assertion of assertions) {
788
+ if (assertion.kind === "httpResponse") {
789
+ if (!http || !matchesActionFilter(gate, assertion.actionIncludes)) {
790
+ insufficient = true;
791
+ issues.push("missing matching HTTP evidence");
792
+ continue;
793
+ }
794
+ if (http.status !== assertion.status) {
795
+ issues.push(`expected HTTP ${assertion.status}, saw ${http.status}`);
796
+ }
797
+ continue;
798
+ }
799
+ if (assertion.kind === "duration") {
800
+ if (!http || !matchesActionFilter(gate, assertion.actionIncludes)) {
801
+ insufficient = true;
802
+ issues.push("missing timing evidence");
803
+ continue;
804
+ }
805
+ if (http.durationMs > assertion.atMostMs) {
806
+ issues.push(`expected duration <= ${assertion.atMostMs}ms, saw ${http.durationMs}ms`);
807
+ }
808
+ continue;
809
+ }
810
+ if (assertion.kind === "noErrors") {
811
+ if (errors.length > 0) {
812
+ issues.push(errors.join("; "));
813
+ }
814
+ continue;
815
+ }
816
+ if (assertion.kind === "hasAction") {
817
+ if (!logs || logs.length === 0) {
818
+ insufficient = true;
819
+ issues.push("missing log evidence");
820
+ continue;
821
+ }
822
+ const found = logs.some((log) => log.action === assertion.action);
823
+ if (!found) {
824
+ issues.push(`expected action ${assertion.action}`);
825
+ }
826
+ continue;
827
+ }
828
+ if (assertion.kind === "responseBodyIncludes") {
829
+ const bodyText = http?.body ?? actions.map((action) => action.stdout).join("\n");
830
+ if (!bodyText) {
831
+ insufficient = true;
832
+ issues.push("missing response body evidence");
833
+ continue;
834
+ }
835
+ if (!bodyText.includes(assertion.text)) {
836
+ issues.push(`expected response body to include ${JSON.stringify(assertion.text)}`);
837
+ }
838
+ continue;
839
+ }
840
+ if (!process.env[assertion.baselineEnv]) {
841
+ insufficient = true;
842
+ issues.push(`missing baseline env var ${assertion.baselineEnv}`);
843
+ continue;
844
+ }
845
+ const baseline = Number(process.env[assertion.baselineEnv]);
846
+ if (!Number.isFinite(baseline)) {
847
+ issues.push(`baseline env var ${assertion.baselineEnv} is not numeric`);
848
+ continue;
849
+ }
850
+ const measured = extractNumericValue(assertion, evidence);
851
+ if (measured === null) {
852
+ insufficient = true;
853
+ issues.push("missing numeric evidence");
854
+ continue;
855
+ }
856
+ const delta = baseline - measured;
857
+ if (delta < assertion.minimumDelta) {
858
+ issues.push(`expected delta >= ${assertion.minimumDelta}, saw ${delta}`);
859
+ }
860
+ }
861
+ const status = insufficient ? "inconclusive" : issues.length > 0 ? "fail" : "pass";
862
+ return {
863
+ id: goal.id,
864
+ title: goal.title,
865
+ status,
866
+ proofStrength: deriveProofStrength(gate, evidence, status),
867
+ summary: issues.length > 0 ? issues.join("; ") : "gate passed",
868
+ evidence,
869
+ };
870
+ });
871
+ const runPlanOnce = (plan) => Effect.gen(function* () {
872
+ const cloudflareTails = new Map();
873
+ const goals = [];
874
+ for (const goal of plan.goals) {
875
+ goals.push(yield* evaluateGate(goal, cloudflareTails));
876
+ }
877
+ for (const tail of cloudflareTails.values()) {
878
+ yield* Effect.tryPromise(() => tail.session.close()).pipe(Effect.catch(() => Effect.succeed(undefined)));
879
+ }
880
+ const summary = summarizePlan(goals);
881
+ return {
882
+ ...summary,
883
+ iterations: 1,
884
+ goals,
885
+ cleanupErrors: [],
886
+ };
887
+ });
888
+ const runCleanup = (plan) => Effect.gen(function* () {
889
+ const cleanupErrors = [];
890
+ for (const action of plan.cleanup?.actions ?? []) {
891
+ const result = yield* runExecAction(action);
892
+ if (!result.ok) {
893
+ cleanupErrors.push(`cleanup failed: ${action.command}`);
894
+ }
895
+ }
896
+ return cleanupErrors;
897
+ });
898
+ const withCleanup = (plan, result) => Effect.gen(function* () {
899
+ const cleanupErrors = yield* runCleanup(plan);
900
+ const summary = cleanupErrors.length > 0
901
+ ? `${result.summary}; cleanup issues: ${cleanupErrors.join("; ")}`
902
+ : result.summary;
903
+ return {
904
+ ...result,
905
+ summary,
906
+ cleanupErrors,
907
+ };
908
+ });
909
+ const invokeIterationCallback = (callback, status) => {
910
+ if (!callback) {
911
+ return Effect.void;
912
+ }
913
+ return Effect.sync(() => {
914
+ callback(status);
915
+ }).pipe(Effect.catch(() => Effect.void));
916
+ };
917
+ const invokeWorker = (worker, context, timeoutMs) => {
918
+ if (!worker) {
919
+ return Effect.succeed({
920
+ result: {
921
+ changes: [],
922
+ summary: "no worker configured",
923
+ },
924
+ timedOut: false,
925
+ });
926
+ }
927
+ return worker(context).pipe(Effect.timeout(`${timeoutMs} millis`), Effect.map((result) => ({
928
+ result,
929
+ timedOut: false,
930
+ })), Effect.catch(() => Effect.succeed({
931
+ result: {
932
+ changes: [],
933
+ summary: `worker timed out after ${timeoutMs}ms`,
934
+ stop: true,
935
+ },
936
+ timedOut: true,
937
+ })));
938
+ };
939
+ const createDefaultCommitMessage = (firstFailedGoal, iteration) => {
940
+ if (!firstFailedGoal) {
941
+ return `chore: loop iteration ${iteration}`;
942
+ }
943
+ return `fix: attempt ${firstFailedGoal.id} gate`;
944
+ };
945
+ const createIterationReport = (iteration, result, firstFailedGoal, workerEntry, commit, snapshot) => ({
946
+ iteration,
947
+ timestamp: new Date().toISOString(),
948
+ firstFailedGoal: firstFailedGoal
949
+ ? {
950
+ id: firstFailedGoal.id,
951
+ title: firstFailedGoal.title,
952
+ summary: firstFailedGoal.summary,
953
+ }
954
+ : null,
955
+ result,
956
+ worker: workerEntry,
957
+ commit,
958
+ snapshot,
959
+ });
960
+ const runPlanLoop = (plan, options = {}) => Effect.gen(function* () {
961
+ const maxIterations = options.maxIterations ?? plan.loop?.maxIterations ?? 1;
962
+ const cwd = resolve(options.cwd ?? process.cwd());
963
+ const workerTimeoutMs = options.workerTimeoutMs ?? 10 * 60 * 1000;
964
+ let iteration = 0;
965
+ let latest = {
966
+ status: "inconclusive",
967
+ proofStrength: "none",
968
+ iterations: 0,
969
+ goals: [],
970
+ summary: "loop has not run",
971
+ cleanupErrors: [],
972
+ };
973
+ while (iteration < maxIterations) {
974
+ iteration += 1;
975
+ const result = yield* runPlanOnce(plan);
976
+ latest = {
977
+ ...result,
978
+ iterations: iteration,
979
+ };
980
+ const firstFailedGoal = latest.goals.find((goal) => goal.status !== "pass") ?? null;
981
+ const finalizeWithoutWorker = (resultToFinalize, workerEntry, commit) => Effect.gen(function* () {
982
+ const finalizedResult = yield* withCleanup(plan, resultToFinalize);
983
+ const snapshot = yield* captureProofSnapshot(cwd, options.planPath);
984
+ const report = createIterationReport(iteration, finalizedResult, firstFailedGoal, workerEntry, commit, snapshot);
985
+ yield* writeIterationReport(cwd, iteration, report);
986
+ return finalizedResult;
987
+ });
988
+ if (latest.status === "pass" || latest.status === "skip") {
989
+ return yield* finalizeWithoutWorker(latest);
990
+ }
991
+ if (!options.worker && plan.loop?.stopOnFailure && latest.status === "fail") {
992
+ return yield* finalizeWithoutWorker(latest);
993
+ }
994
+ if (iteration >= maxIterations || !options.worker) {
995
+ return yield* finalizeWithoutWorker(latest);
996
+ }
997
+ const failedGoals = latest.goals.filter((goal) => goal.status !== "pass");
998
+ const workerContext = {
999
+ iteration,
1000
+ plan,
1001
+ result: latest,
1002
+ failedGoals,
1003
+ firstFailedGoal,
1004
+ cwd,
1005
+ planPath: options.planPath,
1006
+ };
1007
+ const workerInvocation = yield* invokeWorker(options.worker, workerContext, workerTimeoutMs);
1008
+ const targetGoal = firstFailedGoal
1009
+ ? plan.goals.find((goal) => goal.id === firstFailedGoal.id)
1010
+ : undefined;
1011
+ const scopeValidation = yield* validateScope(targetGoal, cwd);
1012
+ const scopeViolation = scopeValidation.ok ? undefined : scopeValidation.violation;
1013
+ const workerEntry = {
1014
+ called: true,
1015
+ summary: workerInvocation.result.summary,
1016
+ changes: workerInvocation.result.changes,
1017
+ timedOut: workerInvocation.timedOut || undefined,
1018
+ stopped: workerInvocation.result.stop || undefined,
1019
+ scopeViolation,
1020
+ };
1021
+ const commitMessage = workerInvocation.result.commitMessage ??
1022
+ createDefaultCommitMessage(firstFailedGoal, iteration);
1023
+ const commit = yield* createCommit(cwd, commitMessage, options.commit);
1024
+ const report = createIterationReport(iteration, latest, firstFailedGoal, workerEntry, commit, yield* captureProofSnapshot(cwd, options.planPath));
1025
+ const reportPath = yield* writeIterationReport(cwd, iteration, report);
1026
+ if (options.memory) {
1027
+ yield* options.memory.writeIteration({
1028
+ iteration,
1029
+ timestamp: report.timestamp,
1030
+ firstFailedGoal: report.firstFailedGoal,
1031
+ result: latest,
1032
+ worker: workerEntry,
1033
+ commit,
1034
+ }).pipe(Effect.catch(() => Effect.void));
1035
+ }
1036
+ const iterationStatus = {
1037
+ iteration,
1038
+ result: latest,
1039
+ firstFailedGoal,
1040
+ workerCalled: true,
1041
+ workerSummary: workerInvocation.result.summary,
1042
+ committed: commit.created,
1043
+ commitSha: commit.sha,
1044
+ reportPath,
1045
+ };
1046
+ yield* invokeIterationCallback(options.onIteration, iterationStatus);
1047
+ if (!scopeValidation.ok) {
1048
+ return yield* finalizeWithoutWorker({
1049
+ ...latest,
1050
+ status: "fail",
1051
+ summary: scopeViolation ?? latest.summary,
1052
+ }, workerEntry, commit);
1053
+ }
1054
+ if (workerInvocation.result.stop) {
1055
+ return yield* finalizeWithoutWorker(latest, workerEntry, commit);
1056
+ }
1057
+ }
1058
+ const finalizedResult = yield* withCleanup(plan, latest);
1059
+ const finalFailedGoal = finalizedResult.goals.find((goal) => goal.status !== "pass") ?? null;
1060
+ const snapshot = yield* captureProofSnapshot(cwd, options.planPath);
1061
+ const report = createIterationReport(iteration, finalizedResult, finalFailedGoal, undefined, undefined, snapshot);
1062
+ yield* writeIterationReport(cwd, iteration, report);
1063
+ return finalizedResult;
1064
+ });
1065
+ const parseOpenCodeInstruction = (content) => {
1066
+ try {
1067
+ const parsed = JSON.parse(content);
1068
+ if (!isRecord(parsed) || typeof parsed.action !== "string") {
1069
+ return null;
1070
+ }
1071
+ switch (parsed.action) {
1072
+ case "read":
1073
+ return typeof parsed.path === "string"
1074
+ ? { action: "read", path: parsed.path }
1075
+ : null;
1076
+ case "write":
1077
+ return typeof parsed.path === "string" && typeof parsed.content === "string"
1078
+ ? { action: "write", path: parsed.path, content: parsed.content }
1079
+ : null;
1080
+ case "replace":
1081
+ return typeof parsed.path === "string" &&
1082
+ typeof parsed.find === "string" &&
1083
+ typeof parsed.replace === "string"
1084
+ ? {
1085
+ action: "replace",
1086
+ path: parsed.path,
1087
+ find: parsed.find,
1088
+ replace: parsed.replace,
1089
+ }
1090
+ : null;
1091
+ case "exec":
1092
+ return typeof parsed.command === "string"
1093
+ ? { action: "exec", command: parsed.command }
1094
+ : null;
1095
+ case "done":
1096
+ return typeof parsed.summary === "string"
1097
+ ? {
1098
+ action: "done",
1099
+ summary: parsed.summary,
1100
+ commitMessage: typeof parsed.commitMessage === "string" ? parsed.commitMessage : undefined,
1101
+ stop: parsed.stop === true,
1102
+ }
1103
+ : null;
1104
+ default:
1105
+ return null;
1106
+ }
1107
+ }
1108
+ catch {
1109
+ return null;
1110
+ }
1111
+ };
1112
+ const callOpenCodeModel = (options, messages) => {
1113
+ const endpoint = options.endpoint;
1114
+ if (!endpoint) {
1115
+ return Effect.succeed(null);
1116
+ }
1117
+ return Effect.tryPromise(async () => {
1118
+ const response = await fetch(endpoint, {
1119
+ method: "POST",
1120
+ headers: {
1121
+ "Content-Type": "application/json",
1122
+ ...(options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}),
1123
+ },
1124
+ body: JSON.stringify({
1125
+ model: options.model ?? "gpt-5.3-codex",
1126
+ messages,
1127
+ }),
1128
+ });
1129
+ if (!response.ok) {
1130
+ return null;
1131
+ }
1132
+ const payload = (await response.json());
1133
+ const content = payload.choices?.[0]?.message?.content;
1134
+ return typeof content === "string" ? parseOpenCodeInstruction(content) : null;
1135
+ }).pipe(Effect.catch(() => Effect.succeed(null)));
1136
+ };
1137
+ const executeOpenCodeInstruction = (instruction, context) => Effect.gen(function* () {
1138
+ if (instruction.action === "done") {
1139
+ return {
1140
+ response: "completed",
1141
+ changes: [],
1142
+ done: {
1143
+ changes: [],
1144
+ summary: instruction.summary,
1145
+ commitMessage: instruction.commitMessage,
1146
+ stop: instruction.stop,
1147
+ },
1148
+ };
1149
+ }
1150
+ if (instruction.action === "exec") {
1151
+ const result = yield* runCommand("zsh", ["-lc", instruction.command], context.cwd);
1152
+ return {
1153
+ response: JSON.stringify({
1154
+ ok: result.ok,
1155
+ stdout: result.stdout,
1156
+ stderr: result.stderr,
1157
+ exitCode: result.exitCode,
1158
+ }),
1159
+ changes: [{
1160
+ kind: "exec",
1161
+ summary: instruction.command,
1162
+ }],
1163
+ };
1164
+ }
1165
+ const targetPath = normalizePath(instruction.path);
1166
+ if (!isPathInside(context.cwd, targetPath)) {
1167
+ return {
1168
+ response: JSON.stringify({ error: "path is outside cwd" }),
1169
+ changes: [],
1170
+ };
1171
+ }
1172
+ const absolutePath = resolve(context.cwd, targetPath);
1173
+ if (instruction.action === "read") {
1174
+ const contents = yield* Effect.tryPromise(() => readFile(absolutePath, "utf8")).pipe(Effect.catch(() => Effect.succeed("")));
1175
+ return {
1176
+ response: contents,
1177
+ changes: [],
1178
+ };
1179
+ }
1180
+ if (instruction.action === "write") {
1181
+ yield* Effect.tryPromise(() => mkdir(dirname(absolutePath), { recursive: true })).pipe(Effect.catch(() => Effect.void));
1182
+ yield* Effect.tryPromise(() => writeFile(absolutePath, instruction.content, "utf8")).pipe(Effect.catch(() => Effect.void));
1183
+ return {
1184
+ response: "ok",
1185
+ changes: [{
1186
+ kind: "write",
1187
+ path: targetPath,
1188
+ summary: `wrote ${targetPath}`,
1189
+ }],
1190
+ };
1191
+ }
1192
+ const existing = yield* Effect.tryPromise(() => readFile(absolutePath, "utf8")).pipe(Effect.catch(() => Effect.succeed("")));
1193
+ if (!existing.includes(instruction.find)) {
1194
+ return {
1195
+ response: JSON.stringify({ error: "target text not found" }),
1196
+ changes: [],
1197
+ };
1198
+ }
1199
+ const updated = existing.replace(instruction.find, instruction.replace);
1200
+ yield* Effect.tryPromise(() => writeFile(absolutePath, updated, "utf8")).pipe(Effect.catch(() => Effect.void));
1201
+ return {
1202
+ response: "ok",
1203
+ changes: [{
1204
+ kind: "replace",
1205
+ path: targetPath,
1206
+ summary: `updated ${targetPath}`,
1207
+ }],
1208
+ };
1209
+ });
1210
+ export const createOpenCodeWorker = (options) => (context) => Effect.gen(function* () {
1211
+ const maxSteps = Math.max(1, options.maxSteps ?? 4);
1212
+ const systemPrompt = "You are Gateproof's built-in worker. Fix only the first failing gate. " +
1213
+ "Return exactly one JSON object with an action: read, write, replace, exec, or done. " +
1214
+ "Keep changes minimal and stay inside the repository scope.";
1215
+ const initialContext = JSON.stringify({
1216
+ planPath: context.planPath,
1217
+ firstFailedGoal: context.firstFailedGoal,
1218
+ failedGoals: context.failedGoals.map((goal) => ({
1219
+ id: goal.id,
1220
+ title: goal.title,
1221
+ status: goal.status,
1222
+ summary: goal.summary,
1223
+ })),
1224
+ }, null, 2);
1225
+ const messages = [
1226
+ { role: "system", content: systemPrompt },
1227
+ { role: "user", content: initialContext },
1228
+ ];
1229
+ const changes = [];
1230
+ for (let step = 0; step < maxSteps; step += 1) {
1231
+ const instruction = yield* callOpenCodeModel(options, messages);
1232
+ if (!instruction) {
1233
+ return {
1234
+ changes,
1235
+ summary: "worker did not return a usable instruction",
1236
+ stop: true,
1237
+ };
1238
+ }
1239
+ messages.push({
1240
+ role: "assistant",
1241
+ content: JSON.stringify(instruction),
1242
+ });
1243
+ const execution = yield* executeOpenCodeInstruction(instruction, context);
1244
+ changes.push(...execution.changes);
1245
+ if (execution.done) {
1246
+ return {
1247
+ ...execution.done,
1248
+ changes: [...changes, ...execution.done.changes],
1249
+ };
1250
+ }
1251
+ messages.push({
1252
+ role: "user",
1253
+ content: execution.response,
1254
+ });
1255
+ }
1256
+ return {
1257
+ changes,
1258
+ summary: "worker hit the step limit without completing",
1259
+ stop: true,
1260
+ };
1261
+ }).pipe(Effect.timeout(`${options.timeoutMs ?? 10 * 60 * 1000} millis`)).pipe(Effect.catch(() => Effect.succeed({
1262
+ changes: [],
1263
+ summary: `worker timed out after ${options.timeoutMs ?? 10 * 60 * 1000}ms`,
1264
+ stop: true,
1265
+ })));
1266
+ export const Gate = {
1267
+ define(definition) {
1268
+ return definition;
1269
+ },
1270
+ };
1271
+ export const Plan = {
1272
+ define(definition) {
1273
+ return definition;
1274
+ },
1275
+ run(plan) {
1276
+ return runPlanOnce(plan).pipe(Effect.flatMap((result) => withCleanup(plan, result)));
1277
+ },
1278
+ runLoop(plan, options) {
1279
+ return runPlanLoop(plan, options);
1280
+ },
1281
+ };
1282
+ export const Act = {
1283
+ exec(command, options = {}) {
1284
+ return {
1285
+ kind: "exec",
1286
+ command,
1287
+ ...options,
1288
+ };
1289
+ },
1290
+ };
1291
+ export const Assert = {
1292
+ httpResponse(definition) {
1293
+ return {
1294
+ kind: "httpResponse",
1295
+ ...definition,
1296
+ };
1297
+ },
1298
+ duration(definition) {
1299
+ return {
1300
+ kind: "duration",
1301
+ ...definition,
1302
+ };
1303
+ },
1304
+ noErrors() {
1305
+ return { kind: "noErrors" };
1306
+ },
1307
+ hasAction(action) {
1308
+ return {
1309
+ kind: "hasAction",
1310
+ action,
1311
+ };
1312
+ },
1313
+ responseBodyIncludes(text) {
1314
+ return {
1315
+ kind: "responseBodyIncludes",
1316
+ text,
1317
+ };
1318
+ },
1319
+ numericDeltaFromEnv(definition) {
1320
+ return {
1321
+ kind: "numericDeltaFromEnv",
1322
+ ...definition,
1323
+ };
1324
+ },
1325
+ };
1326
+ export const Require = {
1327
+ env(name, reason) {
1328
+ return {
1329
+ kind: "env",
1330
+ name,
1331
+ reason,
1332
+ };
1333
+ },
1334
+ };
1335
+ export const createHttpObserveResource = (definition) => ({
1336
+ kind: "http",
1337
+ ...definition,
1338
+ });
216
1339
  //# sourceMappingURL=index.js.map