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.
- package/README.md +1396 -320
- package/dist/cloudflare/index.d.ts +4 -6
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +9 -43
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/index.d.ts +263 -66
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1327 -204
- package/dist/index.js.map +1 -1
- package/package.json +18 -40
- package/dist/act.d.ts +0 -33
- package/dist/act.d.ts.map +0 -1
- package/dist/act.js +0 -25
- package/dist/act.js.map +0 -1
- package/dist/action-executors.d.ts +0 -22
- package/dist/action-executors.d.ts.map +0 -1
- package/dist/action-executors.js +0 -135
- package/dist/action-executors.js.map +0 -1
- package/dist/assert.d.ts +0 -39
- package/dist/assert.d.ts.map +0 -1
- package/dist/assert.js +0 -88
- package/dist/assert.js.map +0 -1
- package/dist/cli/gateproof.d.ts +0 -3
- package/dist/cli/gateproof.d.ts.map +0 -1
- package/dist/cli/gateproof.js +0 -472
- package/dist/cli/gateproof.js.map +0 -1
- package/dist/cloudflare/analytics.d.ts +0 -9
- package/dist/cloudflare/analytics.d.ts.map +0 -1
- package/dist/cloudflare/analytics.js +0 -98
- package/dist/cloudflare/analytics.js.map +0 -1
- package/dist/cloudflare/cli-stream.d.ts +0 -7
- package/dist/cloudflare/cli-stream.d.ts.map +0 -1
- package/dist/cloudflare/cli-stream.js +0 -85
- package/dist/cloudflare/cli-stream.js.map +0 -1
- package/dist/cloudflare/polling-backend.d.ts +0 -18
- package/dist/cloudflare/polling-backend.d.ts.map +0 -1
- package/dist/cloudflare/polling-backend.js +0 -53
- package/dist/cloudflare/polling-backend.js.map +0 -1
- package/dist/cloudflare/workers-logs.d.ts +0 -9
- package/dist/cloudflare/workers-logs.d.ts.map +0 -1
- package/dist/cloudflare/workers-logs.js +0 -51
- package/dist/cloudflare/workers-logs.js.map +0 -1
- package/dist/constants.d.ts +0 -11
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -11
- package/dist/constants.js.map +0 -1
- package/dist/http-backend.d.ts +0 -23
- package/dist/http-backend.d.ts.map +0 -1
- package/dist/http-backend.js +0 -124
- package/dist/http-backend.js.map +0 -1
- package/dist/observe.d.ts +0 -26
- package/dist/observe.d.ts.map +0 -1
- package/dist/observe.js +0 -84
- package/dist/observe.js.map +0 -1
- package/dist/prd/define-prd.d.ts +0 -7
- package/dist/prd/define-prd.d.ts.map +0 -1
- package/dist/prd/define-prd.js +0 -8
- package/dist/prd/define-prd.js.map +0 -1
- package/dist/prd/index.d.ts +0 -5
- package/dist/prd/index.d.ts.map +0 -1
- package/dist/prd/index.js +0 -4
- package/dist/prd/index.js.map +0 -1
- package/dist/prd/runner.d.ts +0 -22
- package/dist/prd/runner.d.ts.map +0 -1
- package/dist/prd/runner.js +0 -221
- package/dist/prd/runner.js.map +0 -1
- package/dist/prd/scope-check.d.ts +0 -28
- package/dist/prd/scope-check.d.ts.map +0 -1
- package/dist/prd/scope-check.js +0 -135
- package/dist/prd/scope-check.js.map +0 -1
- package/dist/prd/types.d.ts +0 -22
- package/dist/prd/types.d.ts.map +0 -1
- package/dist/prd/types.js +0 -2
- package/dist/prd/types.js.map +0 -1
- package/dist/provider.d.ts +0 -6
- package/dist/provider.d.ts.map +0 -1
- package/dist/provider.js +0 -2
- package/dist/provider.js.map +0 -1
- package/dist/report.d.ts +0 -67
- package/dist/report.d.ts.map +0 -1
- package/dist/report.js +0 -51
- package/dist/report.js.map +0 -1
- package/dist/test-helpers.d.ts +0 -12
- package/dist/test-helpers.d.ts.map +0 -1
- package/dist/test-helpers.js +0 -33
- package/dist/test-helpers.js.map +0 -1
- package/dist/types.d.ts +0 -41
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/utils.d.ts +0 -22
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -49
- package/dist/utils.js.map +0 -1
- package/dist/validation.d.ts +0 -6
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -38
- package/dist/validation.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,440 +1,1516 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Gateproof
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Gateproof runs the proof, sends in the worker, and keeps going until the live claim is true.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Tutorial
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Goal: Start with one tiny gate that is small on purpose and complete on purpose.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
1. PRD defines stories (what should exist)
|
|
11
|
-
2. Gates verify reality (does it work?)
|
|
12
|
-
3. Agent gets PRD + gate failure (minimal context)
|
|
13
|
-
4. Agent fixes, gates re-run
|
|
14
|
-
5. Iterate until all gates pass
|
|
9
|
+
### examples/hello-world/plan.ts
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
```ts
|
|
12
|
+
import { Effect } from "effect";
|
|
13
|
+
import {
|
|
14
|
+
Act,
|
|
15
|
+
Assert,
|
|
16
|
+
Gate,
|
|
17
|
+
Plan,
|
|
18
|
+
createHttpObserveResource,
|
|
19
|
+
type ScopeFile,
|
|
20
|
+
} from "../../src/index";
|
|
21
|
+
import { HELLO_WORLD_PORT } from "./server";
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
const baseUrl = `http://127.0.0.1:${HELLO_WORLD_PORT}`;
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
25
|
+
const scope = {
|
|
26
|
+
spec: {
|
|
27
|
+
title: "Hello World",
|
|
28
|
+
tutorial: {
|
|
29
|
+
goal: "Prove one tiny thing.",
|
|
30
|
+
outcome: "The run only passes when the live response says hello world.",
|
|
31
|
+
},
|
|
32
|
+
howTo: {
|
|
33
|
+
task: "Run one complete gate from one file.",
|
|
34
|
+
done: "The endpoint returns 200 and the body contains hello world.",
|
|
35
|
+
},
|
|
36
|
+
explanation: {
|
|
37
|
+
summary: "Even the smallest run is still a real proof loop.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
plan: Plan.define({
|
|
41
|
+
goals: [
|
|
42
|
+
{
|
|
43
|
+
id: "hello-world",
|
|
44
|
+
title: "GET / returns hello world",
|
|
45
|
+
gate: Gate.define({
|
|
46
|
+
observe: createHttpObserveResource({
|
|
47
|
+
url: `${baseUrl}/`,
|
|
48
|
+
}),
|
|
49
|
+
act: [Act.exec(`curl -sf ${baseUrl}/`)],
|
|
50
|
+
assert: [
|
|
51
|
+
Assert.httpResponse({ status: 200 }),
|
|
52
|
+
Assert.responseBodyIncludes("hello world"),
|
|
53
|
+
Assert.noErrors(),
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
loop: {
|
|
59
|
+
maxIterations: 1,
|
|
60
|
+
stopOnFailure: true,
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
} satisfies ScopeFile;
|
|
48
64
|
|
|
49
|
-
|
|
65
|
+
export default scope;
|
|
50
66
|
|
|
51
|
-
|
|
67
|
+
if (import.meta.main) {
|
|
68
|
+
const result = await Effect.runPromise(Plan.runLoop(scope.plan));
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
if (result.status !== "pass") {
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
56
75
|
```
|
|
57
76
|
|
|
58
|
-
|
|
77
|
+
Outcome: The loop only passes when the live response says hello world.
|
|
78
|
+
|
|
79
|
+
## First Case Study: Cinder
|
|
80
|
+
|
|
81
|
+
### alchemy.run.ts
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import alchemy from "alchemy";
|
|
85
|
+
import { Buffer } from "node:buffer";
|
|
86
|
+
import { mkdir } from "node:fs/promises";
|
|
87
|
+
import { R2RestStateStore } from "alchemy/state";
|
|
88
|
+
import {
|
|
89
|
+
CloudflareApiError,
|
|
90
|
+
DurableObjectNamespace,
|
|
91
|
+
KVNamespace,
|
|
92
|
+
R2Bucket,
|
|
93
|
+
Worker,
|
|
94
|
+
createBucket,
|
|
95
|
+
createCloudflareApi,
|
|
96
|
+
getBucket,
|
|
97
|
+
} from "alchemy/cloudflare";
|
|
98
|
+
|
|
99
|
+
function requireEnv(name: string): string {
|
|
100
|
+
const value = process.env[name]?.trim();
|
|
101
|
+
|
|
102
|
+
if (!value) {
|
|
103
|
+
throw new Error(`${name} is required for cinder provisioning`);
|
|
104
|
+
}
|
|
59
105
|
|
|
60
|
-
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
const githubPat = requireEnv("GITHUB_PAT");
|
|
110
|
+
const webhookSecret = requireEnv("GITHUB_WEBHOOK_SECRET");
|
|
111
|
+
const internalToken = requireEnv("CINDER_INTERNAL_TOKEN");
|
|
112
|
+
const fixtureRepo = process.env.CINDER_FIXTURE_REPO?.trim() || "acoyfellow/cinder-prd-test";
|
|
113
|
+
const fixtureBranch = process.env.CINDER_FIXTURE_BRANCH?.trim() || "main";
|
|
114
|
+
const fixtureWorkflow =
|
|
115
|
+
process.env.CINDER_FIXTURE_WORKFLOW?.trim() || "cinder-proof.yml";
|
|
116
|
+
const githubApiBase = "https://api.github.com";
|
|
117
|
+
const stateBucketName = process.env.CINDER_STATE_BUCKET?.trim();
|
|
118
|
+
const stateBucketRegion = process.env.CINDER_STATE_REGION?.trim() || "auto";
|
|
119
|
+
|
|
120
|
+
const fixtureCargoToml = `[package]
|
|
121
|
+
name = "cinder-proof"
|
|
122
|
+
version = "0.1.0"
|
|
123
|
+
edition = "2021"
|
|
124
|
+
|
|
125
|
+
[dependencies]
|
|
126
|
+
serde = { version = "1", features = ["derive"] }
|
|
127
|
+
serde_json = "1"
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const fixtureCargoLock = `# This file is automatically @generated by Cargo.
|
|
131
|
+
# It is not intended for manual editing.
|
|
132
|
+
version = 4
|
|
133
|
+
|
|
134
|
+
[[package]]
|
|
135
|
+
name = "cinder-proof"
|
|
136
|
+
version = "0.1.0"
|
|
137
|
+
dependencies = [
|
|
138
|
+
"serde",
|
|
139
|
+
"serde_json",
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
[[package]]
|
|
143
|
+
name = "itoa"
|
|
144
|
+
version = "1.0.17"
|
|
145
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
146
|
+
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
|
147
|
+
|
|
148
|
+
[[package]]
|
|
149
|
+
name = "memchr"
|
|
150
|
+
version = "2.8.0"
|
|
151
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
152
|
+
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
|
153
|
+
|
|
154
|
+
[[package]]
|
|
155
|
+
name = "proc-macro2"
|
|
156
|
+
version = "1.0.106"
|
|
157
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
158
|
+
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
|
159
|
+
dependencies = [
|
|
160
|
+
"unicode-ident",
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
[[package]]
|
|
164
|
+
name = "quote"
|
|
165
|
+
version = "1.0.44"
|
|
166
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
167
|
+
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
|
168
|
+
dependencies = [
|
|
169
|
+
"proc-macro2",
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
[[package]]
|
|
173
|
+
name = "serde"
|
|
174
|
+
version = "1.0.228"
|
|
175
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
176
|
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
|
177
|
+
dependencies = [
|
|
178
|
+
"serde_core",
|
|
179
|
+
"serde_derive",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
[[package]]
|
|
183
|
+
name = "serde_core"
|
|
184
|
+
version = "1.0.228"
|
|
185
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
186
|
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
|
187
|
+
dependencies = [
|
|
188
|
+
"serde_derive",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
[[package]]
|
|
192
|
+
name = "serde_derive"
|
|
193
|
+
version = "1.0.228"
|
|
194
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
195
|
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|
196
|
+
dependencies = [
|
|
197
|
+
"proc-macro2",
|
|
198
|
+
"quote",
|
|
199
|
+
"syn",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
[[package]]
|
|
203
|
+
name = "serde_json"
|
|
204
|
+
version = "1.0.149"
|
|
205
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
206
|
+
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
|
207
|
+
dependencies = [
|
|
208
|
+
"itoa",
|
|
209
|
+
"memchr",
|
|
210
|
+
"serde",
|
|
211
|
+
"serde_core",
|
|
212
|
+
"zmij",
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
[[package]]
|
|
216
|
+
name = "syn"
|
|
217
|
+
version = "2.0.117"
|
|
218
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
219
|
+
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
|
220
|
+
dependencies = [
|
|
221
|
+
"proc-macro2",
|
|
222
|
+
"quote",
|
|
223
|
+
"unicode-ident",
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
[[package]]
|
|
227
|
+
name = "unicode-ident"
|
|
228
|
+
version = "1.0.24"
|
|
229
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
230
|
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
231
|
+
|
|
232
|
+
[[package]]
|
|
233
|
+
name = "zmij"
|
|
234
|
+
version = "1.0.21"
|
|
235
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
236
|
+
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
const fixtureMainRs = `use serde::{Deserialize, Serialize};
|
|
240
|
+
|
|
241
|
+
#[derive(Serialize, Deserialize)]
|
|
242
|
+
struct ProofMessage {
|
|
243
|
+
ok: bool,
|
|
244
|
+
source: &'static str,
|
|
245
|
+
}
|
|
66
246
|
|
|
67
|
-
|
|
247
|
+
fn main() {
|
|
248
|
+
let message = ProofMessage {
|
|
249
|
+
ok: true,
|
|
250
|
+
source: "cinder-proof",
|
|
251
|
+
};
|
|
68
252
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
253
|
+
println!("{}", serde_json::to_string(&message).unwrap());
|
|
254
|
+
}
|
|
255
|
+
`;
|
|
256
|
+
|
|
257
|
+
const fixtureWorkflowContents = `name: cinder-proof
|
|
258
|
+
on:
|
|
259
|
+
workflow_dispatch:
|
|
260
|
+
jobs:
|
|
261
|
+
cargo-build:
|
|
262
|
+
runs-on: [self-hosted, cinder]
|
|
263
|
+
timeout-minutes: 20
|
|
264
|
+
steps:
|
|
265
|
+
- uses: actions/checkout@v4
|
|
266
|
+
- name: Build fixture
|
|
267
|
+
run: cargo build --locked
|
|
268
|
+
- name: Run fixture
|
|
269
|
+
run: cargo run --locked
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
function parseFixtureRepository(repoRef: string) {
|
|
273
|
+
const [owner, name, ...extra] = repoRef.split("/");
|
|
274
|
+
|
|
275
|
+
if (!owner || !name || extra.length > 0) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`CINDER_FIXTURE_REPO must be "owner/name" but received "${repoRef}"`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
72
280
|
|
|
73
|
-
|
|
281
|
+
return { owner, name };
|
|
282
|
+
}
|
|
74
283
|
|
|
75
|
-
|
|
284
|
+
async function githubRequest(
|
|
285
|
+
path: string,
|
|
286
|
+
init: RequestInit = {},
|
|
287
|
+
okStatuses: number[] = [200],
|
|
288
|
+
) {
|
|
289
|
+
const response = await fetch(`${githubApiBase}${path}`, {
|
|
290
|
+
...init,
|
|
291
|
+
headers: {
|
|
292
|
+
Accept: "application/vnd.github+json",
|
|
293
|
+
Authorization: `Bearer ${githubPat}`,
|
|
294
|
+
"User-Agent": "cinder-provisioner",
|
|
295
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
296
|
+
...(init.headers ?? {}),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
76
299
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
3. Agent fixes → makes changes to codebase
|
|
81
|
-
4. Loop repeats → re-run PRD, check if gates pass
|
|
82
|
-
5. All gates pass → done
|
|
300
|
+
if (okStatuses.includes(response.status)) {
|
|
301
|
+
return response;
|
|
302
|
+
}
|
|
83
303
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
304
|
+
const body = await response.text();
|
|
305
|
+
throw new Error(
|
|
306
|
+
`GitHub API ${init.method ?? "GET"} ${path} failed with ${response.status}: ${body}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
89
309
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
while true; do
|
|
94
|
-
bun run prd.ts || {
|
|
95
|
-
# Gate failed - agent gets PRD + failure output
|
|
96
|
-
agent --context prd.ts --failure "$(cat gate-output.txt)"
|
|
97
|
-
# Agent fixes, loop continues
|
|
98
|
-
}
|
|
99
|
-
break # All gates passed
|
|
100
|
-
done
|
|
101
|
-
```
|
|
310
|
+
async function ensureFixtureRepository() {
|
|
311
|
+
const { owner, name } = parseFixtureRepository(fixtureRepo);
|
|
312
|
+
const existing = await githubRequest(`/repos/${owner}/${name}`, {}, [200, 404]);
|
|
102
313
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
- Pause file → manual control
|
|
314
|
+
if (existing.status === 200) {
|
|
315
|
+
return (await existing.json()) as { default_branch: string; full_name: string };
|
|
316
|
+
}
|
|
107
317
|
|
|
108
|
-
|
|
318
|
+
const viewer = (await (
|
|
319
|
+
await githubRequest("/user")
|
|
320
|
+
).json()) as { login: string };
|
|
109
321
|
|
|
110
|
-
|
|
322
|
+
if (viewer.login !== owner) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`Fixture repo ${fixtureRepo} is missing and cannot be auto-created because PAT owner "${viewer.login}" does not match "${owner}"`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
111
327
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
328
|
+
const created = await githubRequest(
|
|
329
|
+
"/user/repos",
|
|
330
|
+
{
|
|
331
|
+
method: "POST",
|
|
332
|
+
body: JSON.stringify({
|
|
333
|
+
name,
|
|
334
|
+
description: "Canonical GitHub proof fixture for Cinder",
|
|
335
|
+
auto_init: true,
|
|
336
|
+
private: false,
|
|
337
|
+
}),
|
|
338
|
+
},
|
|
339
|
+
[201],
|
|
340
|
+
);
|
|
117
341
|
|
|
118
|
-
|
|
342
|
+
return (await created.json()) as { default_branch: string; full_name: string };
|
|
343
|
+
}
|
|
119
344
|
|
|
120
|
-
|
|
345
|
+
async function ensureFixtureBranch(repo: { default_branch: string }) {
|
|
346
|
+
if (fixtureBranch === repo.default_branch) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
121
349
|
|
|
122
|
-
|
|
350
|
+
const { owner, name } = parseFixtureRepository(fixtureRepo);
|
|
351
|
+
const branchResponse = await githubRequest(
|
|
352
|
+
`/repos/${owner}/${name}/git/ref/heads/${encodeURIComponent(fixtureBranch)}`,
|
|
353
|
+
{},
|
|
354
|
+
[200, 404],
|
|
355
|
+
);
|
|
123
356
|
|
|
124
|
-
|
|
357
|
+
if (branchResponse.status === 200) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
125
360
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
361
|
+
const defaultBranchRef = (await (
|
|
362
|
+
await githubRequest(
|
|
363
|
+
`/repos/${owner}/${name}/git/ref/heads/${encodeURIComponent(repo.default_branch)}`,
|
|
364
|
+
)
|
|
365
|
+
).json()) as { object: { sha: string } };
|
|
129
366
|
|
|
130
|
-
|
|
131
|
-
|
|
367
|
+
await githubRequest(
|
|
368
|
+
`/repos/${owner}/${name}/git/refs`,
|
|
132
369
|
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
370
|
+
method: "POST",
|
|
371
|
+
body: JSON.stringify({
|
|
372
|
+
ref: `refs/heads/${fixtureBranch}`,
|
|
373
|
+
sha: defaultBranchRef.object.sha,
|
|
374
|
+
}),
|
|
137
375
|
},
|
|
376
|
+
[201],
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function upsertFixtureFile(path: string, content: string, message: string) {
|
|
381
|
+
const { owner, name } = parseFixtureRepository(fixtureRepo);
|
|
382
|
+
const encodedPath = path
|
|
383
|
+
.split("/")
|
|
384
|
+
.map((segment) => encodeURIComponent(segment))
|
|
385
|
+
.join("/");
|
|
386
|
+
const existing = await githubRequest(
|
|
387
|
+
`/repos/${owner}/${name}/contents/${encodedPath}?ref=${encodeURIComponent(fixtureBranch)}`,
|
|
388
|
+
{},
|
|
389
|
+
[200, 404],
|
|
390
|
+
);
|
|
391
|
+
const sha =
|
|
392
|
+
existing.status === 200
|
|
393
|
+
? ((await existing.json()) as { sha: string }).sha
|
|
394
|
+
: undefined;
|
|
395
|
+
|
|
396
|
+
await githubRequest(
|
|
397
|
+
`/repos/${owner}/${name}/contents/${encodedPath}`,
|
|
138
398
|
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
399
|
+
method: "PUT",
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
message,
|
|
402
|
+
branch: fixtureBranch,
|
|
403
|
+
content: Buffer.from(content, "utf8").toString("base64"),
|
|
404
|
+
sha,
|
|
405
|
+
}),
|
|
144
406
|
},
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Make it executable
|
|
149
|
-
if (import.meta.main) {
|
|
150
|
-
const { runPrd } = await import("gateproof/prd");
|
|
151
|
-
const result = await runPrd(prd);
|
|
152
|
-
if (!result.success) {
|
|
153
|
-
if (result.failedStory) console.error(`Failed at: ${result.failedStory.id}`);
|
|
154
|
-
process.exit(1);
|
|
155
|
-
}
|
|
156
|
-
process.exit(0);
|
|
407
|
+
[200, 201],
|
|
408
|
+
);
|
|
157
409
|
}
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
Each story references a gate file. The gate file uses gateproof's API:
|
|
161
|
-
|
|
162
|
-
```typescript
|
|
163
|
-
// gates/user-signup.gate.ts
|
|
164
|
-
import { Gate, Act, Assert } from "gateproof";
|
|
165
|
-
import { CloudflareProvider } from "gateproof/cloudflare";
|
|
166
410
|
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
411
|
+
async function upsertFixtureWebhook(webhookUrl: string) {
|
|
412
|
+
const { owner, name } = parseFixtureRepository(fixtureRepo);
|
|
413
|
+
const hooks = (await (
|
|
414
|
+
await githubRequest(`/repos/${owner}/${name}/hooks`)
|
|
415
|
+
).json()) as Array<{ id: number; name: string; config?: { url?: string } }>;
|
|
416
|
+
const existing = hooks.find(
|
|
417
|
+
(hook) => hook.name === "web" && hook.config?.url === webhookUrl,
|
|
418
|
+
);
|
|
419
|
+
const body = JSON.stringify({
|
|
420
|
+
active: true,
|
|
421
|
+
events: ["workflow_job"],
|
|
422
|
+
config: {
|
|
423
|
+
url: webhookUrl,
|
|
424
|
+
content_type: "json",
|
|
425
|
+
insecure_ssl: "0",
|
|
426
|
+
secret: webhookSecret,
|
|
427
|
+
},
|
|
171
428
|
});
|
|
172
429
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
430
|
+
if (existing) {
|
|
431
|
+
await githubRequest(
|
|
432
|
+
`/repos/${owner}/${name}/hooks/${existing.id}`,
|
|
433
|
+
{
|
|
434
|
+
method: "PATCH",
|
|
435
|
+
body,
|
|
436
|
+
},
|
|
437
|
+
[200],
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
182
441
|
|
|
183
|
-
|
|
442
|
+
await githubRequest(
|
|
443
|
+
`/repos/${owner}/${name}/hooks`,
|
|
444
|
+
{
|
|
445
|
+
method: "POST",
|
|
446
|
+
body,
|
|
447
|
+
},
|
|
448
|
+
[201],
|
|
449
|
+
);
|
|
184
450
|
}
|
|
185
|
-
```
|
|
186
451
|
|
|
187
|
-
|
|
452
|
+
async function syncFixtureRepository(webhookUrl: string) {
|
|
453
|
+
const repo = await ensureFixtureRepository();
|
|
454
|
+
await ensureFixtureBranch(repo);
|
|
455
|
+
|
|
456
|
+
await upsertFixtureFile("Cargo.toml", fixtureCargoToml, "chore: sync cinder fixture Cargo.toml");
|
|
457
|
+
await upsertFixtureFile("Cargo.lock", fixtureCargoLock, "chore: sync cinder fixture Cargo.lock");
|
|
458
|
+
await upsertFixtureFile("src/main.rs", fixtureMainRs, "chore: sync cinder fixture main.rs");
|
|
459
|
+
await upsertFixtureFile(
|
|
460
|
+
`.github/workflows/${fixtureWorkflow}`,
|
|
461
|
+
fixtureWorkflowContents,
|
|
462
|
+
"chore: sync cinder fixture workflow",
|
|
463
|
+
);
|
|
464
|
+
await upsertFixtureWebhook(webhookUrl);
|
|
465
|
+
}
|
|
188
466
|
|
|
189
|
-
|
|
467
|
+
async function ensureStateBucket(bucketName: string, locationHint: string) {
|
|
468
|
+
const accountId = requireEnv("CLOUDFLARE_ACCOUNT_ID");
|
|
469
|
+
const apiToken = requireEnv("CLOUDFLARE_API_TOKEN");
|
|
470
|
+
const api = await createCloudflareApi({ accountId, apiToken } as any);
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
await getBucket(api, bucketName);
|
|
474
|
+
return { accountId, apiToken };
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (!(error instanceof CloudflareApiError) || error.status !== 404) {
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
190
480
|
|
|
191
|
-
|
|
481
|
+
await createBucket(api, bucketName, { locationHint });
|
|
482
|
+
return { accountId, apiToken };
|
|
483
|
+
}
|
|
192
484
|
|
|
193
|
-
|
|
485
|
+
const stateStoreCredentials = stateBucketName
|
|
486
|
+
? await ensureStateBucket(stateBucketName, stateBucketRegion)
|
|
487
|
+
: null;
|
|
488
|
+
|
|
489
|
+
export const app = await alchemy("cinder", {
|
|
490
|
+
stage: process.env.CINDER_STAGE ?? "production",
|
|
491
|
+
...(stateBucketName && stateStoreCredentials
|
|
492
|
+
? {
|
|
493
|
+
stateStore: (scope) =>
|
|
494
|
+
new R2RestStateStore(scope, {
|
|
495
|
+
accountId: stateStoreCredentials.accountId,
|
|
496
|
+
apiToken: stateStoreCredentials.apiToken,
|
|
497
|
+
bucketName: stateBucketName,
|
|
498
|
+
} as any),
|
|
499
|
+
}
|
|
500
|
+
: {}),
|
|
501
|
+
});
|
|
194
502
|
|
|
195
|
-
|
|
503
|
+
export const cacheBucket = await R2Bucket("cinder-cache", {
|
|
504
|
+
empty: false,
|
|
505
|
+
});
|
|
196
506
|
|
|
197
|
-
|
|
507
|
+
export const runnerState = await KVNamespace("cinder-runner-state");
|
|
198
508
|
|
|
199
|
-
|
|
509
|
+
export const runnerPool = await DurableObjectNamespace("RunnerPool", {
|
|
510
|
+
className: "RunnerPool",
|
|
511
|
+
sqlite: true,
|
|
512
|
+
});
|
|
200
513
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
514
|
+
export const jobQueue = await DurableObjectNamespace("JobQueue", {
|
|
515
|
+
className: "JobQueue",
|
|
516
|
+
sqlite: true,
|
|
517
|
+
});
|
|
204
518
|
|
|
205
|
-
|
|
519
|
+
export const cacheWorker = await Worker("cinder-cache-worker", {
|
|
520
|
+
entrypoint: "./crates/cinder-cache/build/worker/shim.mjs",
|
|
521
|
+
bindings: {
|
|
522
|
+
CACHE_BUCKET: cacheBucket,
|
|
523
|
+
CINDER_INTERNAL_TOKEN: alchemy.secret(internalToken),
|
|
524
|
+
},
|
|
525
|
+
});
|
|
206
526
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
527
|
+
export const orchestrator = await Worker("cinder-orchestrator", {
|
|
528
|
+
entrypoint: "./crates/cinder-orchestrator/build/worker/shim.mjs",
|
|
529
|
+
bindings: {
|
|
530
|
+
CACHE_BUCKET: cacheBucket,
|
|
531
|
+
RUNNER_STATE: runnerState,
|
|
532
|
+
RUNNER_POOL: runnerPool,
|
|
533
|
+
JOB_QUEUE: jobQueue,
|
|
534
|
+
GITHUB_WEBHOOK_SECRET: alchemy.secret(webhookSecret),
|
|
535
|
+
CINDER_INTERNAL_TOKEN: alchemy.secret(internalToken),
|
|
536
|
+
GITHUB_PAT: alchemy.secret(githubPat),
|
|
537
|
+
CINDER_CACHE_WORKER_URL: cacheWorker.url!,
|
|
538
|
+
CINDER_FIXTURE_REPO: fixtureRepo,
|
|
539
|
+
CINDER_FIXTURE_BRANCH: fixtureBranch,
|
|
540
|
+
CINDER_FIXTURE_WORKFLOW: fixtureWorkflow,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
210
543
|
|
|
211
|
-
|
|
544
|
+
await app.finalize();
|
|
545
|
+
await syncFixtureRepository(`${orchestrator.url}/webhook/github`);
|
|
212
546
|
|
|
213
|
-
|
|
547
|
+
const runtimeDirectory = new URL("./.gateproof/", import.meta.url);
|
|
548
|
+
const runtimeFile = new URL("./.gateproof/runtime.json", import.meta.url);
|
|
214
549
|
|
|
215
|
-
|
|
550
|
+
await mkdir(runtimeDirectory, { recursive: true });
|
|
216
551
|
|
|
217
|
-
|
|
218
|
-
|
|
552
|
+
await Bun.write(
|
|
553
|
+
runtimeFile,
|
|
554
|
+
`${JSON.stringify(
|
|
555
|
+
{
|
|
556
|
+
generatedAt: new Date().toISOString(),
|
|
557
|
+
stage: process.env.CINDER_STAGE ?? "production",
|
|
558
|
+
orchestratorName: orchestrator.name,
|
|
559
|
+
orchestratorUrl: orchestrator.url,
|
|
560
|
+
cacheWorkerName: cacheWorker.name,
|
|
561
|
+
cacheWorkerUrl: cacheWorker.url,
|
|
562
|
+
fixtureRepo,
|
|
563
|
+
fixtureBranch,
|
|
564
|
+
fixtureWorkflow,
|
|
565
|
+
},
|
|
566
|
+
null,
|
|
567
|
+
2,
|
|
568
|
+
)}\n`,
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
console.log(`Wrote runtime outputs to ${runtimeFile.pathname}`);
|
|
219
572
|
```
|
|
220
573
|
|
|
221
|
-
|
|
574
|
+
### plan.ts
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
import { Effect } from "effect";
|
|
578
|
+
import crypto from "node:crypto";
|
|
579
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
580
|
+
import type { ScopeFile } from "gateproof";
|
|
581
|
+
import {
|
|
582
|
+
Act,
|
|
583
|
+
Assert,
|
|
584
|
+
Gate,
|
|
585
|
+
Plan,
|
|
586
|
+
Require,
|
|
587
|
+
} from "gateproof";
|
|
588
|
+
import { Cloudflare } from "gateproof/cloudflare";
|
|
589
|
+
|
|
590
|
+
type RuntimeState = {
|
|
591
|
+
orchestratorName?: string;
|
|
592
|
+
orchestratorUrl?: string;
|
|
593
|
+
cacheWorkerUrl?: string;
|
|
594
|
+
fixtureRepo?: string;
|
|
595
|
+
fixtureBranch?: string;
|
|
596
|
+
fixtureWorkflow?: string;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
600
|
+
return typeof value === "object" && value !== null;
|
|
601
|
+
}
|
|
222
602
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"
|
|
226
|
-
|
|
603
|
+
function readOptionalEnv(name: string): string | undefined {
|
|
604
|
+
const value = process.env[name];
|
|
605
|
+
if (typeof value !== "string") {
|
|
606
|
+
return undefined;
|
|
227
607
|
}
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
608
|
|
|
231
|
-
|
|
609
|
+
const trimmed = value.trim();
|
|
610
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
611
|
+
}
|
|
232
612
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
run: bun run prd:validate
|
|
236
|
-
```
|
|
613
|
+
function loadRuntimeState(): RuntimeState | null {
|
|
614
|
+
const runtimeFile = new URL("./.gateproof/runtime.json", import.meta.url);
|
|
237
615
|
|
|
238
|
-
|
|
616
|
+
if (!existsSync(runtimeFile)) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
239
619
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
620
|
+
try {
|
|
621
|
+
const parsed: unknown = JSON.parse(readFileSync(runtimeFile, "utf8"));
|
|
622
|
+
if (!isRecord(parsed)) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
orchestratorName:
|
|
628
|
+
typeof parsed.orchestratorName === "string" ? parsed.orchestratorName : undefined,
|
|
629
|
+
orchestratorUrl:
|
|
630
|
+
typeof parsed.orchestratorUrl === "string" ? parsed.orchestratorUrl : undefined,
|
|
631
|
+
cacheWorkerUrl:
|
|
632
|
+
typeof parsed.cacheWorkerUrl === "string" ? parsed.cacheWorkerUrl : undefined,
|
|
633
|
+
fixtureRepo: typeof parsed.fixtureRepo === "string" ? parsed.fixtureRepo : undefined,
|
|
634
|
+
fixtureBranch:
|
|
635
|
+
typeof parsed.fixtureBranch === "string" ? parsed.fixtureBranch : undefined,
|
|
636
|
+
fixtureWorkflow:
|
|
637
|
+
typeof parsed.fixtureWorkflow === "string" ? parsed.fixtureWorkflow : undefined,
|
|
638
|
+
};
|
|
639
|
+
} catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
243
643
|
|
|
244
|
-
|
|
644
|
+
function resolveLocalRunnerId(): string {
|
|
645
|
+
try {
|
|
646
|
+
const hostname = readFileSync("/etc/hostname", "utf8").trim();
|
|
647
|
+
return `cinder-${hostname || "unknown"}`;
|
|
648
|
+
} catch {
|
|
649
|
+
return "cinder-unknown";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
245
652
|
|
|
246
|
-
|
|
653
|
+
const runtimeState = loadRuntimeState();
|
|
654
|
+
const baseUrl = readOptionalEnv("CINDER_BASE_URL") ?? runtimeState?.orchestratorUrl ?? "";
|
|
655
|
+
const cacheWorkerUrl =
|
|
656
|
+
readOptionalEnv("CINDER_CACHE_WORKER_URL") ?? runtimeState?.cacheWorkerUrl ?? "";
|
|
657
|
+
const workerName =
|
|
658
|
+
readOptionalEnv("CINDER_WORKER_NAME") ?? runtimeState?.orchestratorName ?? "cinder-orchestrator";
|
|
659
|
+
const fixtureRepo =
|
|
660
|
+
readOptionalEnv("CINDER_FIXTURE_REPO") ?? runtimeState?.fixtureRepo ?? "acoyfellow/cinder-prd-test";
|
|
661
|
+
const fixtureBranch = readOptionalEnv("CINDER_FIXTURE_BRANCH") ?? runtimeState?.fixtureBranch ?? "";
|
|
662
|
+
const fixtureWorkflow =
|
|
663
|
+
readOptionalEnv("CINDER_FIXTURE_WORKFLOW") ?? runtimeState?.fixtureWorkflow ?? "";
|
|
664
|
+
const internalToken = readOptionalEnv("CINDER_INTERNAL_TOKEN") ?? "";
|
|
665
|
+
|
|
666
|
+
const missKey = crypto.randomBytes(32).toString("hex");
|
|
667
|
+
const newKey = crypto.randomBytes(32).toString("hex");
|
|
668
|
+
const speedThresholdMs = Number(process.env.SPEED_THRESHOLD_MS ?? "60000");
|
|
669
|
+
const testRepo = process.env.TEST_REPO ?? "";
|
|
670
|
+
const harnessBaseUrl = "http://127.0.0.1:9000";
|
|
671
|
+
const harnessRunUrl = `${harnessBaseUrl}/test/run`;
|
|
672
|
+
const localRunnerId = resolveLocalRunnerId();
|
|
673
|
+
const agentLogPath = "/tmp/cinder-agent-proof.log";
|
|
674
|
+
const agentPidPath = "/tmp/cinder-agent-proof.pid";
|
|
675
|
+
const runnerJobPath = "/tmp/cinder-proof-runner-job.json";
|
|
676
|
+
const queuePayloadPath = "/tmp/cinder-proof-queue-payload.json";
|
|
677
|
+
|
|
678
|
+
let managedHarness: ReturnType<typeof Bun.spawn> | null = null;
|
|
679
|
+
|
|
680
|
+
async function canReachLocalHarness(): Promise<boolean> {
|
|
681
|
+
try {
|
|
682
|
+
const response = await fetch(harnessBaseUrl);
|
|
683
|
+
return response.ok || response.status === 404;
|
|
684
|
+
} catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
247
688
|
|
|
248
|
-
|
|
689
|
+
async function ensureLocalHarness(): Promise<void> {
|
|
690
|
+
if (await canReachLocalHarness()) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
249
693
|
|
|
250
|
-
|
|
694
|
+
managedHarness = Bun.spawn({
|
|
695
|
+
cmd: ["bun", "harness.ts"],
|
|
696
|
+
cwd: process.cwd(),
|
|
697
|
+
stdout: "inherit",
|
|
698
|
+
stderr: "inherit",
|
|
699
|
+
});
|
|
251
700
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
701
|
+
const deadline = Date.now() + 5_000;
|
|
702
|
+
while (Date.now() < deadline) {
|
|
703
|
+
if (await canReachLocalHarness()) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
255
706
|
|
|
256
|
-
|
|
707
|
+
await Bun.sleep(100);
|
|
708
|
+
}
|
|
257
709
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
- **Enforcement is external**: gateproof returns results; CI/CD decides whether to block merge/deploy.
|
|
710
|
+
throw new Error("cinder proof harness did not start on 127.0.0.1:9000");
|
|
711
|
+
}
|
|
261
712
|
|
|
262
|
-
|
|
713
|
+
function stopManagedHarness(): void {
|
|
714
|
+
if (!managedHarness) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
263
717
|
|
|
264
|
-
|
|
718
|
+
managedHarness.kill();
|
|
719
|
+
managedHarness = null;
|
|
720
|
+
}
|
|
265
721
|
|
|
266
|
-
|
|
722
|
+
function stopManagedAgent(): void {
|
|
723
|
+
if (!existsSync(agentPidPath)) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
267
726
|
|
|
268
|
-
|
|
727
|
+
try {
|
|
728
|
+
const pid = Number.parseInt(readFileSync(agentPidPath, "utf8").trim(), 10);
|
|
729
|
+
if (Number.isFinite(pid)) {
|
|
730
|
+
process.kill(pid);
|
|
731
|
+
}
|
|
732
|
+
} catch {
|
|
733
|
+
// Ignore stale proof agent state.
|
|
734
|
+
}
|
|
269
735
|
|
|
270
|
-
|
|
736
|
+
try {
|
|
737
|
+
rmSync(agentPidPath, { force: true });
|
|
738
|
+
} catch {
|
|
739
|
+
// Ignore pidfile cleanup failures during shutdown.
|
|
740
|
+
}
|
|
741
|
+
}
|
|
271
742
|
|
|
272
|
-
|
|
743
|
+
async function ensureColdBuildBaseline(): Promise<void> {
|
|
744
|
+
if (readOptionalEnv("COLD_BUILD_MS")) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
273
747
|
|
|
274
|
-
|
|
748
|
+
if (!testRepo) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
275
751
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
752
|
+
try {
|
|
753
|
+
const response = await fetch(harnessRunUrl, {
|
|
754
|
+
method: "POST",
|
|
755
|
+
headers: {
|
|
756
|
+
"Content-Type": "application/json",
|
|
757
|
+
},
|
|
758
|
+
body: JSON.stringify({
|
|
759
|
+
repo: testRepo,
|
|
760
|
+
with_cache: false,
|
|
761
|
+
}),
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
if (!response.ok) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const parsed: unknown = await response.json();
|
|
769
|
+
if (!isRecord(parsed)) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const buildDurationMs = parsed.build_duration_ms;
|
|
774
|
+
if (typeof buildDurationMs !== "number" || !Number.isFinite(buildDurationMs)) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
process.env.COLD_BUILD_MS = String(buildDurationMs);
|
|
779
|
+
} catch {
|
|
780
|
+
// Let the existing prerequisite fail clearly if the harness is unavailable.
|
|
781
|
+
}
|
|
782
|
+
}
|
|
279
783
|
|
|
280
|
-
|
|
281
|
-
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
|
|
282
|
-
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
|
|
283
|
-
});
|
|
784
|
+
await ensureColdBuildBaseline();
|
|
284
785
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
786
|
+
const workerLogs = Cloudflare.observe({
|
|
787
|
+
accountId: readOptionalEnv("CLOUDFLARE_ACCOUNT_ID") ?? "",
|
|
788
|
+
apiToken: readOptionalEnv("CLOUDFLARE_API_TOKEN") ?? "",
|
|
789
|
+
workerName,
|
|
790
|
+
sinceMs: 120_000,
|
|
791
|
+
pollInterval: 1_000,
|
|
290
792
|
});
|
|
291
793
|
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
This gate is a story verification. The PRD points at it.
|
|
794
|
+
if (!process.env.CINDER_BASE_URL && baseUrl) {
|
|
795
|
+
process.env.CINDER_BASE_URL = baseUrl;
|
|
796
|
+
}
|
|
296
797
|
|
|
297
|
-
|
|
798
|
+
if (!process.env.CINDER_WORKER_NAME && workerName) {
|
|
799
|
+
process.env.CINDER_WORKER_NAME = workerName;
|
|
800
|
+
}
|
|
298
801
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
802
|
+
if (!process.env.CINDER_FIXTURE_REPO && fixtureRepo) {
|
|
803
|
+
process.env.CINDER_FIXTURE_REPO = fixtureRepo;
|
|
804
|
+
}
|
|
302
805
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
Act.browser({ url, headless? }) // Browser automation (needs playwright)
|
|
307
|
-
Act.wait(ms) // Sleep
|
|
308
|
-
Act.deploy({ worker }) // Deploy marker
|
|
309
|
-
```
|
|
806
|
+
if (!process.env.CINDER_FIXTURE_BRANCH && fixtureBranch) {
|
|
807
|
+
process.env.CINDER_FIXTURE_BRANCH = fixtureBranch;
|
|
808
|
+
}
|
|
310
809
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
Assert.hasAction("name") // Action was logged
|
|
315
|
-
Assert.hasStage("worker") // Stage was seen
|
|
316
|
-
Assert.custom("name", fn) // Custom: (logs) => boolean
|
|
317
|
-
```
|
|
810
|
+
if (!process.env.CINDER_FIXTURE_WORKFLOW && fixtureWorkflow) {
|
|
811
|
+
process.env.CINDER_FIXTURE_WORKFLOW = fixtureWorkflow;
|
|
812
|
+
}
|
|
318
813
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
814
|
+
const scope = {
|
|
815
|
+
spec: {
|
|
816
|
+
title: "Cinder",
|
|
817
|
+
tutorial: {
|
|
818
|
+
goal: "Prove cinder on a live deployment, not just deploy it.",
|
|
819
|
+
outcome:
|
|
820
|
+
"Webhook intake, queueing, runner registration, cache paths, and the speed claim all go green.",
|
|
821
|
+
},
|
|
822
|
+
howTo: {
|
|
823
|
+
task: "Run the cinder proof loop against already-provisioned infrastructure.",
|
|
824
|
+
done:
|
|
825
|
+
"Cinder only exits green when the live system can do the work and the speed claim holds.",
|
|
826
|
+
},
|
|
827
|
+
explanation: {
|
|
828
|
+
summary:
|
|
829
|
+
"alchemy.run.ts creates the infrastructure once and writes .gateproof/runtime.json. This file is only the acceptance loop for the live product.",
|
|
830
|
+
},
|
|
330
831
|
},
|
|
331
|
-
|
|
832
|
+
plan: Plan.define({
|
|
833
|
+
goals: [
|
|
834
|
+
{
|
|
835
|
+
id: "webhook",
|
|
836
|
+
title: "A GitHub webhook queues a runnable job",
|
|
837
|
+
gate: Gate.define({
|
|
838
|
+
observe: workerLogs,
|
|
839
|
+
prerequisites: [
|
|
840
|
+
Require.env(
|
|
841
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
842
|
+
"CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
|
|
843
|
+
),
|
|
844
|
+
Require.env(
|
|
845
|
+
"CLOUDFLARE_API_TOKEN",
|
|
846
|
+
"CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
|
|
847
|
+
),
|
|
848
|
+
Require.env(
|
|
849
|
+
"GITHUB_PAT",
|
|
850
|
+
"GITHUB_PAT is required to dispatch the GitHub proof workflow.",
|
|
851
|
+
),
|
|
852
|
+
Require.env(
|
|
853
|
+
"CINDER_FIXTURE_BRANCH",
|
|
854
|
+
"Run bun run provision first or set CINDER_FIXTURE_BRANCH for the GitHub proof fixture.",
|
|
855
|
+
),
|
|
856
|
+
Require.env(
|
|
857
|
+
"CINDER_FIXTURE_WORKFLOW",
|
|
858
|
+
"Run bun run provision first or set CINDER_FIXTURE_WORKFLOW for the GitHub proof fixture.",
|
|
859
|
+
),
|
|
860
|
+
Require.env(
|
|
861
|
+
"CINDER_INTERNAL_TOKEN",
|
|
862
|
+
"CINDER_INTERNAL_TOKEN is required for internal API access.",
|
|
863
|
+
),
|
|
864
|
+
Require.env(
|
|
865
|
+
"CINDER_BASE_URL",
|
|
866
|
+
"Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
|
|
867
|
+
),
|
|
868
|
+
],
|
|
869
|
+
act: [
|
|
870
|
+
Act.exec(
|
|
871
|
+
`bun -e 'const repo = ${JSON.stringify(fixtureRepo)};
|
|
872
|
+
const workflow = ${JSON.stringify(fixtureWorkflow)};
|
|
873
|
+
const branch = ${JSON.stringify(fixtureBranch)};
|
|
874
|
+
const token = process.env.GITHUB_PAT;
|
|
875
|
+
if (!token) {
|
|
876
|
+
throw new Error("GITHUB_PAT is required");
|
|
332
877
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
878
|
+
const headers = {
|
|
879
|
+
Accept: "application/vnd.github+json",
|
|
880
|
+
Authorization: "Bearer " + token,
|
|
881
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
882
|
+
};
|
|
883
|
+
const listUrl =
|
|
884
|
+
"https://api.github.com/repos/" +
|
|
885
|
+
repo +
|
|
886
|
+
"/actions/workflows/" +
|
|
887
|
+
workflow +
|
|
888
|
+
"/runs?event=workflow_dispatch&branch=" +
|
|
889
|
+
encodeURIComponent(branch) +
|
|
890
|
+
"&per_page=20";
|
|
891
|
+
const response = await fetch(listUrl, { headers });
|
|
892
|
+
if (!response.ok) {
|
|
893
|
+
throw new Error("GitHub workflow run listing failed: " + response.status);
|
|
894
|
+
}
|
|
895
|
+
const payload = await response.json();
|
|
896
|
+
const runs = Array.isArray(payload.workflow_runs) ? payload.workflow_runs : [];
|
|
897
|
+
for (const run of runs) {
|
|
898
|
+
if (typeof run?.id !== "number" || run.status === "completed") {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const cancelResponse = await fetch(
|
|
902
|
+
"https://api.github.com/repos/" + repo + "/actions/runs/" + run.id + "/cancel",
|
|
344
903
|
{
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
gateFile: "./gates/story-1.gate.ts",
|
|
904
|
+
method: "POST",
|
|
905
|
+
headers,
|
|
348
906
|
},
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
907
|
+
);
|
|
908
|
+
if (!cancelResponse.ok && cancelResponse.status !== 409) {
|
|
909
|
+
throw new Error("GitHub workflow cancel failed: " + cancelResponse.status);
|
|
910
|
+
}
|
|
911
|
+
}'`,
|
|
912
|
+
{
|
|
913
|
+
timeoutMs: 60_000,
|
|
914
|
+
},
|
|
915
|
+
),
|
|
916
|
+
Act.exec(
|
|
917
|
+
`curl -sf -X POST https://api.github.com/repos/${fixtureRepo}/actions/workflows/${fixtureWorkflow}/dispatches \
|
|
918
|
+
-H "Accept: application/vnd.github+json" \
|
|
919
|
+
-H "Authorization: Bearer $GITHUB_PAT" \
|
|
920
|
+
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
921
|
+
-d '${JSON.stringify({ ref: fixtureBranch })}'`,
|
|
922
|
+
),
|
|
923
|
+
Act.exec("sleep 25"),
|
|
924
|
+
],
|
|
925
|
+
assert: [
|
|
926
|
+
Assert.noErrors(),
|
|
927
|
+
Assert.hasAction("webhook_received"),
|
|
928
|
+
Assert.hasAction("signature_verified"),
|
|
929
|
+
Assert.hasAction("job_queued"),
|
|
930
|
+
],
|
|
931
|
+
timeoutMs: 40_000,
|
|
932
|
+
}),
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
id: "queue",
|
|
936
|
+
title: "A queued job can be inspected without dequeueing",
|
|
937
|
+
gate: Gate.define({
|
|
938
|
+
observe: workerLogs,
|
|
939
|
+
prerequisites: [
|
|
940
|
+
Require.env(
|
|
941
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
942
|
+
"CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
|
|
943
|
+
),
|
|
944
|
+
Require.env(
|
|
945
|
+
"CLOUDFLARE_API_TOKEN",
|
|
946
|
+
"CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
|
|
947
|
+
),
|
|
948
|
+
Require.env(
|
|
949
|
+
"CINDER_INTERNAL_TOKEN",
|
|
950
|
+
"CINDER_INTERNAL_TOKEN is required for queue inspection.",
|
|
951
|
+
),
|
|
952
|
+
Require.env(
|
|
953
|
+
"CINDER_BASE_URL",
|
|
954
|
+
"Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
|
|
955
|
+
),
|
|
956
|
+
],
|
|
957
|
+
act: [
|
|
958
|
+
Act.exec(
|
|
959
|
+
`sh -c 'curl -sf ${baseUrl}/jobs/peek \
|
|
960
|
+
-H "Authorization: Bearer ${internalToken}" \
|
|
961
|
+
| tee "${queuePayloadPath}"'`,
|
|
962
|
+
),
|
|
963
|
+
],
|
|
964
|
+
assert: [
|
|
965
|
+
Assert.noErrors(),
|
|
966
|
+
Assert.responseBodyIncludes("repo_full_name"),
|
|
967
|
+
Assert.responseBodyIncludes("repo_clone_url"),
|
|
968
|
+
Assert.responseBodyIncludes("runner_registration_url"),
|
|
969
|
+
Assert.responseBodyIncludes("runner_registration_token"),
|
|
970
|
+
Assert.responseBodyIncludes("cache_key"),
|
|
971
|
+
],
|
|
972
|
+
timeoutMs: 8_000,
|
|
973
|
+
}),
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
id: "runner",
|
|
977
|
+
title: "A runner can execute a queued GitHub job",
|
|
978
|
+
gate: Gate.define({
|
|
979
|
+
observe: workerLogs,
|
|
980
|
+
prerequisites: [
|
|
981
|
+
Require.env(
|
|
982
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
983
|
+
"CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
|
|
984
|
+
),
|
|
985
|
+
Require.env(
|
|
986
|
+
"CLOUDFLARE_API_TOKEN",
|
|
987
|
+
"CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
|
|
988
|
+
),
|
|
989
|
+
Require.env(
|
|
990
|
+
"CINDER_INTERNAL_TOKEN",
|
|
991
|
+
"CINDER_INTERNAL_TOKEN is required for runner registration.",
|
|
992
|
+
),
|
|
993
|
+
Require.env(
|
|
994
|
+
"CINDER_BASE_URL",
|
|
995
|
+
"Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
|
|
996
|
+
),
|
|
997
|
+
Require.env(
|
|
998
|
+
"GITHUB_PAT",
|
|
999
|
+
"GITHUB_PAT is required to confirm the queued GitHub run completed.",
|
|
1000
|
+
),
|
|
1001
|
+
],
|
|
1002
|
+
act: [
|
|
1003
|
+
Act.exec(
|
|
1004
|
+
`curl -sf ${baseUrl}/jobs/peek \
|
|
1005
|
+
-H "Authorization: Bearer ${internalToken}" \
|
|
1006
|
+
> "${runnerJobPath}"`,
|
|
1007
|
+
),
|
|
1008
|
+
Act.exec(
|
|
1009
|
+
`bun -e 'import crypto from "node:crypto";
|
|
1010
|
+
import { readFileSync } from "node:fs";
|
|
1011
|
+
const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
|
|
1012
|
+
if (typeof payload.cache_key !== "string" || payload.cache_key.length === 0) {
|
|
1013
|
+
throw new Error("runner job payload missing cache_key");
|
|
1014
|
+
}
|
|
1015
|
+
const key = payload.cache_key;
|
|
1016
|
+
const token = ${JSON.stringify(internalToken)};
|
|
1017
|
+
if (!token) {
|
|
1018
|
+
throw new Error("CINDER_INTERNAL_TOKEN is required for fixture cache reset");
|
|
1019
|
+
}
|
|
1020
|
+
let base = ${JSON.stringify(cacheWorkerUrl)};
|
|
1021
|
+
const restoreProbe = await fetch(
|
|
1022
|
+
${JSON.stringify(baseUrl)} + "/cache/restore/" + key,
|
|
1023
|
+
{
|
|
1024
|
+
method: "POST",
|
|
1025
|
+
headers: {
|
|
1026
|
+
Authorization: "Bearer " + token,
|
|
354
1027
|
},
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1028
|
+
},
|
|
1029
|
+
);
|
|
1030
|
+
if (!restoreProbe.ok) {
|
|
1031
|
+
throw new Error("fixture cache reset probe failed: " + restoreProbe.status);
|
|
1032
|
+
}
|
|
1033
|
+
const restorePayload = await restoreProbe.json();
|
|
1034
|
+
if (typeof restorePayload.url === "string" && restorePayload.url.length > 0) {
|
|
1035
|
+
base = new URL(restorePayload.url).origin;
|
|
1036
|
+
}
|
|
1037
|
+
if (!base) {
|
|
1038
|
+
throw new Error("cache worker base URL is required for fixture cache reset");
|
|
1039
|
+
}
|
|
1040
|
+
const exp = Math.floor(Date.now() / 1000) + 3600;
|
|
1041
|
+
const sig = crypto
|
|
1042
|
+
.createHmac("sha256", token)
|
|
1043
|
+
.update("delete:" + key + ":" + exp)
|
|
1044
|
+
.digest("hex");
|
|
1045
|
+
const response = await fetch(
|
|
1046
|
+
base.replace(/\\/$/, "") +
|
|
1047
|
+
"/objects/" +
|
|
1048
|
+
key +
|
|
1049
|
+
"?op=delete&exp=" +
|
|
1050
|
+
exp +
|
|
1051
|
+
"&sig=" +
|
|
1052
|
+
sig,
|
|
1053
|
+
{
|
|
1054
|
+
method: "DELETE",
|
|
1055
|
+
},
|
|
1056
|
+
);
|
|
1057
|
+
if (!response.ok && response.status !== 404) {
|
|
1058
|
+
throw new Error("fixture cache reset failed: " + response.status);
|
|
1059
|
+
}
|
|
1060
|
+
console.log("fixture cache reset");'`,
|
|
1061
|
+
),
|
|
1062
|
+
Act.exec(
|
|
1063
|
+
`sh -c 'if [ -f "${agentPidPath}" ] && kill -0 "$(cat "${agentPidPath}")" 2>/dev/null; then exit 0; fi; : >"${agentLogPath}"; cargo run --quiet -p cinder-agent -- --url "${baseUrl}" --token "${internalToken}" --poll-ms 250 >"${agentLogPath}" 2>&1 & echo $! >"${agentPidPath}"; sleep 5'`,
|
|
1064
|
+
),
|
|
1065
|
+
Act.exec(
|
|
1066
|
+
`bun -e 'import { existsSync, readFileSync } from "node:fs";
|
|
1067
|
+
const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
|
|
1068
|
+
if (typeof payload.run_id !== "number") {
|
|
1069
|
+
throw new Error("queue payload missing run_id");
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof payload.repo_full_name !== "string" || payload.repo_full_name.length === 0) {
|
|
1072
|
+
throw new Error("queue payload missing repo_full_name");
|
|
1073
|
+
}
|
|
1074
|
+
const token = process.env.GITHUB_PAT;
|
|
1075
|
+
if (!token) {
|
|
1076
|
+
throw new Error("GITHUB_PAT is required");
|
|
1077
|
+
}
|
|
1078
|
+
const headers = {
|
|
1079
|
+
Accept: "application/vnd.github+json",
|
|
1080
|
+
Authorization: "Bearer " + token,
|
|
1081
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1082
|
+
};
|
|
1083
|
+
const deadline = Date.now() + 600000;
|
|
1084
|
+
let run = null;
|
|
1085
|
+
while (Date.now() < deadline) {
|
|
1086
|
+
const response = await fetch(
|
|
1087
|
+
"https://api.github.com/repos/" + payload.repo_full_name + "/actions/runs/" + payload.run_id,
|
|
1088
|
+
{ headers },
|
|
1089
|
+
);
|
|
1090
|
+
if (!response.ok) {
|
|
1091
|
+
if (response.status >= 500) {
|
|
1092
|
+
await Bun.sleep(2000);
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
throw new Error("GitHub workflow run fetch failed: " + response.status);
|
|
1096
|
+
}
|
|
1097
|
+
run = await response.json();
|
|
1098
|
+
if (run.status === "completed") {
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
await Bun.sleep(2000);
|
|
1102
|
+
}
|
|
1103
|
+
if (!run || run.status !== "completed") {
|
|
1104
|
+
throw new Error("GitHub workflow run did not complete");
|
|
1105
|
+
}
|
|
1106
|
+
const logNeedle = "completed with exit code 0";
|
|
1107
|
+
const logDeadline = Date.now() + 30000;
|
|
1108
|
+
while (Date.now() < logDeadline) {
|
|
1109
|
+
if (existsSync(${JSON.stringify(agentLogPath)})) {
|
|
1110
|
+
const logContents = readFileSync(${JSON.stringify(agentLogPath)}, "utf8");
|
|
1111
|
+
if (logContents.includes(logNeedle)) {
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
await Bun.sleep(500);
|
|
1116
|
+
}
|
|
1117
|
+
console.log(JSON.stringify(run));
|
|
1118
|
+
if (existsSync(${JSON.stringify(agentLogPath)})) {
|
|
1119
|
+
console.log(readFileSync(${JSON.stringify(agentLogPath)}, "utf8"));
|
|
1120
|
+
}
|
|
1121
|
+
if (run.conclusion !== "success") {
|
|
361
1122
|
process.exit(1);
|
|
1123
|
+
}'`,
|
|
1124
|
+
{
|
|
1125
|
+
timeoutMs: 600_000,
|
|
1126
|
+
},
|
|
1127
|
+
),
|
|
1128
|
+
],
|
|
1129
|
+
assert: [
|
|
1130
|
+
Assert.noErrors(),
|
|
1131
|
+
Assert.hasAction("runner_registered"),
|
|
1132
|
+
Assert.hasAction("runner_pool_updated"),
|
|
1133
|
+
Assert.hasAction("job_dequeued"),
|
|
1134
|
+
Assert.responseBodyIncludes(`"conclusion":"success"`),
|
|
1135
|
+
Assert.responseBodyIncludes("starting github runner for job"),
|
|
1136
|
+
Assert.responseBodyIncludes("completed with exit code 0"),
|
|
1137
|
+
],
|
|
1138
|
+
timeoutMs: 600_000,
|
|
1139
|
+
}),
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
id: "cache-restore",
|
|
1143
|
+
title: "The fixture cache key currently restores as a cold miss",
|
|
1144
|
+
gate: Gate.define({
|
|
1145
|
+
prerequisites: [
|
|
1146
|
+
Require.env(
|
|
1147
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
1148
|
+
"CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
|
|
1149
|
+
),
|
|
1150
|
+
Require.env(
|
|
1151
|
+
"CLOUDFLARE_API_TOKEN",
|
|
1152
|
+
"CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
|
|
1153
|
+
),
|
|
1154
|
+
Require.env(
|
|
1155
|
+
"CINDER_INTERNAL_TOKEN",
|
|
1156
|
+
"CINDER_INTERNAL_TOKEN is required for cache restore.",
|
|
1157
|
+
),
|
|
1158
|
+
Require.env(
|
|
1159
|
+
"CINDER_BASE_URL",
|
|
1160
|
+
"Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
|
|
1161
|
+
),
|
|
1162
|
+
],
|
|
1163
|
+
act: [
|
|
1164
|
+
Act.exec(
|
|
1165
|
+
`bun -e 'import { readFileSync } from "node:fs";
|
|
1166
|
+
const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
|
|
1167
|
+
if (typeof payload.job_id !== "number") {
|
|
1168
|
+
throw new Error("runner job payload missing job_id");
|
|
362
1169
|
}
|
|
363
|
-
|
|
1170
|
+
const needle = "cache miss for job " + payload.job_id;
|
|
1171
|
+
const deadline = Date.now() + 5000;
|
|
1172
|
+
while (Date.now() < deadline) {
|
|
1173
|
+
const log = readFileSync(${JSON.stringify(agentLogPath)}, "utf8");
|
|
1174
|
+
if (log.includes(needle)) {
|
|
1175
|
+
console.log(needle);
|
|
1176
|
+
process.exit(0);
|
|
1177
|
+
}
|
|
1178
|
+
await Bun.sleep(250);
|
|
1179
|
+
}
|
|
1180
|
+
throw new Error("agent log missing cache miss marker for job " + payload.job_id);'`,
|
|
1181
|
+
),
|
|
1182
|
+
],
|
|
1183
|
+
assert: [
|
|
1184
|
+
Assert.noErrors(),
|
|
1185
|
+
Assert.responseBodyIncludes("cache miss for job"),
|
|
1186
|
+
],
|
|
1187
|
+
timeoutMs: 5_000,
|
|
1188
|
+
}),
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
id: "cache-push",
|
|
1192
|
+
title: "The cache upload path returns a real cache-worker upload URL",
|
|
1193
|
+
gate: Gate.define({
|
|
1194
|
+
observe: workerLogs,
|
|
1195
|
+
prerequisites: [
|
|
1196
|
+
Require.env(
|
|
1197
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
1198
|
+
"CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
|
|
1199
|
+
),
|
|
1200
|
+
Require.env(
|
|
1201
|
+
"CLOUDFLARE_API_TOKEN",
|
|
1202
|
+
"CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
|
|
1203
|
+
),
|
|
1204
|
+
Require.env(
|
|
1205
|
+
"CINDER_INTERNAL_TOKEN",
|
|
1206
|
+
"CINDER_INTERNAL_TOKEN is required for cache upload.",
|
|
1207
|
+
),
|
|
1208
|
+
Require.env(
|
|
1209
|
+
"CINDER_BASE_URL",
|
|
1210
|
+
"Run bun run provision first or set CINDER_BASE_URL to the live orchestrator URL.",
|
|
1211
|
+
),
|
|
1212
|
+
],
|
|
1213
|
+
act: [
|
|
1214
|
+
Act.exec(
|
|
1215
|
+
`sh -c 'rm -f /tmp/cinder-proof-cache-push.tar.xz /tmp/cinder-proof-cache-push-download.tar.xz /tmp/cinder-proof-cache-push-upload.json /tmp/cinder-proof-cache-push-restore.json /tmp/cinder-proof-cache-push-list.txt; tmpdir="$(mktemp -d)"; printf "proof\\n" > "$tmpdir/proof.txt"; tar -cJf /tmp/cinder-proof-cache-push.tar.xz -C "$tmpdir" proof.txt; rm -rf "$tmpdir"'`,
|
|
1216
|
+
),
|
|
1217
|
+
Act.exec(
|
|
1218
|
+
`bun -e 'import { writeFileSync } from "node:fs";
|
|
1219
|
+
const response = await fetch(
|
|
1220
|
+
${JSON.stringify(baseUrl)} + "/cache/upload",
|
|
1221
|
+
{
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
headers: {
|
|
1224
|
+
"Content-Type": "application/json",
|
|
1225
|
+
Authorization: "Bearer " + ${JSON.stringify(internalToken)},
|
|
1226
|
+
},
|
|
1227
|
+
body: JSON.stringify({
|
|
1228
|
+
key: ${JSON.stringify(newKey)},
|
|
1229
|
+
content_type: "application/x-xz",
|
|
1230
|
+
size_bytes: 1024,
|
|
1231
|
+
}),
|
|
1232
|
+
},
|
|
1233
|
+
);
|
|
1234
|
+
if (!response.ok) {
|
|
1235
|
+
throw new Error("cache upload failed: " + response.status);
|
|
1236
|
+
}
|
|
1237
|
+
const upload = await response.json();
|
|
1238
|
+
if (typeof upload.url !== "string" || upload.url.length === 0) {
|
|
1239
|
+
throw new Error("cache upload response missing url");
|
|
1240
|
+
}
|
|
1241
|
+
if (!upload.url.includes("/objects/")) {
|
|
1242
|
+
throw new Error("cache upload returned non-worker url");
|
|
1243
|
+
}
|
|
1244
|
+
writeFileSync("/tmp/cinder-proof-cache-push-upload.json", JSON.stringify(upload));
|
|
1245
|
+
console.log(JSON.stringify(upload));'`,
|
|
1246
|
+
),
|
|
1247
|
+
Act.exec(
|
|
1248
|
+
`bun -e 'import { readFileSync } from "node:fs";
|
|
1249
|
+
const upload = JSON.parse(readFileSync("/tmp/cinder-proof-cache-push-upload.json", "utf8"));
|
|
1250
|
+
const archive = readFileSync("/tmp/cinder-proof-cache-push.tar.xz");
|
|
1251
|
+
const response = await fetch(upload.url, {
|
|
1252
|
+
method: "PUT",
|
|
1253
|
+
body: archive,
|
|
1254
|
+
});
|
|
1255
|
+
if (!response.ok) {
|
|
1256
|
+
throw new Error("cache object upload failed: " + response.status);
|
|
1257
|
+
}
|
|
1258
|
+
console.log("cache object uploaded");'`,
|
|
1259
|
+
),
|
|
1260
|
+
Act.exec(
|
|
1261
|
+
`bun -e 'import { writeFileSync } from "node:fs";
|
|
1262
|
+
const response = await fetch(
|
|
1263
|
+
${JSON.stringify(baseUrl)} + "/cache/restore/" + ${JSON.stringify(newKey)},
|
|
1264
|
+
{
|
|
1265
|
+
method: "POST",
|
|
1266
|
+
headers: {
|
|
1267
|
+
Authorization: "Bearer " + ${JSON.stringify(internalToken)},
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
);
|
|
1271
|
+
if (!response.ok) {
|
|
1272
|
+
throw new Error("cache restore failed: " + response.status);
|
|
1273
|
+
}
|
|
1274
|
+
const restore = await response.json();
|
|
1275
|
+
if (restore.miss === true) {
|
|
1276
|
+
throw new Error("cache restore returned miss after upload");
|
|
1277
|
+
}
|
|
1278
|
+
if (typeof restore.url !== "string" || restore.url.length === 0) {
|
|
1279
|
+
throw new Error("cache restore response missing url");
|
|
1280
|
+
}
|
|
1281
|
+
writeFileSync("/tmp/cinder-proof-cache-push-restore.json", JSON.stringify(restore));
|
|
1282
|
+
console.log(JSON.stringify(restore));'`,
|
|
1283
|
+
),
|
|
1284
|
+
Act.exec(
|
|
1285
|
+
`bun -e 'import { readFileSync, writeFileSync } from "node:fs";
|
|
1286
|
+
const restore = JSON.parse(readFileSync("/tmp/cinder-proof-cache-push-restore.json", "utf8"));
|
|
1287
|
+
const response = await fetch(restore.url);
|
|
1288
|
+
if (!response.ok) {
|
|
1289
|
+
throw new Error("cache object download failed: " + response.status);
|
|
1290
|
+
}
|
|
1291
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
1292
|
+
writeFileSync("/tmp/cinder-proof-cache-push-download.tar.xz", bytes);
|
|
1293
|
+
console.log("cache object downloaded");'`,
|
|
1294
|
+
),
|
|
1295
|
+
Act.exec(
|
|
1296
|
+
`sh -c 'test -s /tmp/cinder-proof-cache-push-download.tar.xz && tar -tJf /tmp/cinder-proof-cache-push-download.tar.xz > /tmp/cinder-proof-cache-push-list.txt && cat /tmp/cinder-proof-cache-push-list.txt'`,
|
|
1297
|
+
),
|
|
1298
|
+
],
|
|
1299
|
+
assert: [
|
|
1300
|
+
Assert.noErrors(),
|
|
1301
|
+
Assert.responseBodyIncludes("proof.txt"),
|
|
1302
|
+
],
|
|
1303
|
+
timeoutMs: 8_000,
|
|
1304
|
+
}),
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
id: "speed-claim",
|
|
1308
|
+
title: "A warm workflow run can complete with a real cache hit",
|
|
1309
|
+
gate: Gate.define({
|
|
1310
|
+
observe: workerLogs,
|
|
1311
|
+
prerequisites: [
|
|
1312
|
+
Require.env(
|
|
1313
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
1314
|
+
"CLOUDFLARE_ACCOUNT_ID is required for Cloudflare worker log observation.",
|
|
1315
|
+
),
|
|
1316
|
+
Require.env(
|
|
1317
|
+
"CLOUDFLARE_API_TOKEN",
|
|
1318
|
+
"CLOUDFLARE_API_TOKEN is required for Cloudflare worker log observation.",
|
|
1319
|
+
),
|
|
1320
|
+
Require.env(
|
|
1321
|
+
"GITHUB_PAT",
|
|
1322
|
+
"GITHUB_PAT is required to dispatch and confirm the warm workflow run.",
|
|
1323
|
+
),
|
|
1324
|
+
],
|
|
1325
|
+
act: [
|
|
1326
|
+
Act.exec(
|
|
1327
|
+
`bun -e 'import { readFileSync } from "node:fs";
|
|
1328
|
+
const payload = JSON.parse(readFileSync(${JSON.stringify(runnerJobPath)}, "utf8"));
|
|
1329
|
+
if (typeof payload.run_id !== "number") {
|
|
1330
|
+
throw new Error("runner job payload missing run_id");
|
|
1331
|
+
}
|
|
1332
|
+
console.log(String(payload.run_id));' > /tmp/cinder-proof-speed-before.txt`,
|
|
1333
|
+
),
|
|
1334
|
+
Act.exec(
|
|
1335
|
+
`curl -sf -X POST https://api.github.com/repos/${fixtureRepo}/actions/workflows/${fixtureWorkflow}/dispatches \
|
|
1336
|
+
-H "Accept: application/vnd.github+json" \
|
|
1337
|
+
-H "Authorization: Bearer $GITHUB_PAT" \
|
|
1338
|
+
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
1339
|
+
-d '${JSON.stringify({ ref: fixtureBranch })}'`,
|
|
1340
|
+
),
|
|
1341
|
+
Act.exec("sleep 5"),
|
|
1342
|
+
Act.exec(
|
|
1343
|
+
`bun -e 'import { readFileSync } from "node:fs";
|
|
1344
|
+
const repo = ${JSON.stringify(fixtureRepo)};
|
|
1345
|
+
const workflow = ${JSON.stringify(fixtureWorkflow)};
|
|
1346
|
+
const branch = ${JSON.stringify(fixtureBranch)};
|
|
1347
|
+
const token = process.env.GITHUB_PAT;
|
|
1348
|
+
if (!token) {
|
|
1349
|
+
throw new Error("GITHUB_PAT is required");
|
|
1350
|
+
}
|
|
1351
|
+
const previousId = readFileSync("/tmp/cinder-proof-speed-before.txt", "utf8").trim();
|
|
1352
|
+
const headers = {
|
|
1353
|
+
Accept: "application/vnd.github+json",
|
|
1354
|
+
Authorization: "Bearer " + token,
|
|
1355
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1356
|
+
};
|
|
1357
|
+
const listUrl =
|
|
1358
|
+
"https://api.github.com/repos/" +
|
|
1359
|
+
repo +
|
|
1360
|
+
"/actions/workflows/" +
|
|
1361
|
+
workflow +
|
|
1362
|
+
"/runs?event=workflow_dispatch&branch=" +
|
|
1363
|
+
encodeURIComponent(branch) +
|
|
1364
|
+
"&per_page=5";
|
|
1365
|
+
const deadline = Date.now() + 600000;
|
|
1366
|
+
let run = null;
|
|
1367
|
+
while (Date.now() < deadline) {
|
|
1368
|
+
const listResponse = await fetch(listUrl, { headers });
|
|
1369
|
+
if (!listResponse.ok) {
|
|
1370
|
+
throw new Error("GitHub workflow run listing failed: " + listResponse.status);
|
|
1371
|
+
}
|
|
1372
|
+
const listPayload = await listResponse.json();
|
|
1373
|
+
const runs = Array.isArray(listPayload.workflow_runs) ? listPayload.workflow_runs : [];
|
|
1374
|
+
const candidate = runs.find((entry) => typeof entry?.id === "number" && String(entry.id) !== previousId);
|
|
1375
|
+
if (candidate && typeof candidate.id === "number") {
|
|
1376
|
+
const runResponse = await fetch(
|
|
1377
|
+
"https://api.github.com/repos/" + repo + "/actions/runs/" + candidate.id,
|
|
1378
|
+
{ headers },
|
|
1379
|
+
);
|
|
1380
|
+
if (!runResponse.ok) {
|
|
1381
|
+
throw new Error("GitHub workflow run fetch failed: " + runResponse.status);
|
|
1382
|
+
}
|
|
1383
|
+
run = await runResponse.json();
|
|
1384
|
+
if (run.status === "completed") {
|
|
1385
|
+
break;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
await Bun.sleep(2000);
|
|
1389
|
+
}
|
|
1390
|
+
if (!run || run.status !== "completed") {
|
|
1391
|
+
throw new Error("warm GitHub workflow run did not complete");
|
|
1392
|
+
}
|
|
1393
|
+
console.log(JSON.stringify(run));
|
|
1394
|
+
console.log(readFileSync(${JSON.stringify(agentLogPath)}, "utf8"));'`,
|
|
1395
|
+
{
|
|
1396
|
+
timeoutMs: 600_000,
|
|
1397
|
+
},
|
|
1398
|
+
),
|
|
1399
|
+
],
|
|
1400
|
+
assert: [
|
|
1401
|
+
Assert.noErrors(),
|
|
1402
|
+
Assert.responseBodyIncludes(`"conclusion":"success"`),
|
|
1403
|
+
Assert.responseBodyIncludes("cache restored for job"),
|
|
1404
|
+
],
|
|
1405
|
+
timeoutMs: 600_000,
|
|
1406
|
+
}),
|
|
1407
|
+
},
|
|
1408
|
+
],
|
|
1409
|
+
loop: {
|
|
1410
|
+
maxIterations: 1,
|
|
1411
|
+
stopOnFailure: true,
|
|
1412
|
+
},
|
|
1413
|
+
cleanup: {
|
|
1414
|
+
actions: [
|
|
1415
|
+
Act.exec(
|
|
1416
|
+
`if [ -n "${internalToken}" ] && [ -n "${baseUrl}" ]; then curl -sf -X DELETE ${baseUrl}/runners/${localRunnerId} -H "Authorization: Bearer ${internalToken}" >/dev/null; else exit 0; fi`,
|
|
1417
|
+
),
|
|
1418
|
+
],
|
|
1419
|
+
},
|
|
1420
|
+
}),
|
|
1421
|
+
} satisfies ScopeFile;
|
|
1422
|
+
|
|
1423
|
+
export default scope;
|
|
1424
|
+
|
|
1425
|
+
if (import.meta.main) {
|
|
1426
|
+
stopManagedAgent();
|
|
1427
|
+
|
|
1428
|
+
if (testRepo) {
|
|
1429
|
+
await ensureLocalHarness();
|
|
1430
|
+
}
|
|
364
1431
|
|
|
365
|
-
|
|
366
|
-
- Validates dependencies (unknown IDs and cycles throw)
|
|
367
|
-
- Topologically sorts stories by `dependsOn`
|
|
368
|
-
- Executes gates in order
|
|
369
|
-
- **Stops on first failure**
|
|
1432
|
+
await ensureColdBuildBaseline();
|
|
370
1433
|
|
|
371
|
-
|
|
1434
|
+
try {
|
|
1435
|
+
const result = await Effect.runPromise(
|
|
1436
|
+
Plan.runLoop(scope.plan, {
|
|
1437
|
+
maxIterations: scope.plan.loop?.maxIterations,
|
|
1438
|
+
}),
|
|
1439
|
+
);
|
|
372
1440
|
|
|
373
|
-
|
|
1441
|
+
console.log(JSON.stringify(result, null, 2));
|
|
374
1442
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1443
|
+
if (result.status !== "pass") {
|
|
1444
|
+
process.exitCode = 1;
|
|
1445
|
+
}
|
|
1446
|
+
} finally {
|
|
1447
|
+
stopManagedAgent();
|
|
1448
|
+
stopManagedHarness();
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
process.exit(process.exitCode ?? 0);
|
|
379
1452
|
}
|
|
380
1453
|
```
|
|
381
1454
|
|
|
382
|
-
|
|
383
|
-
- Cloudflare Analytics Engine
|
|
384
|
-
- Cloudflare Workers Logs API
|
|
385
|
-
- CLI Stream (local dev)
|
|
386
|
-
- Custom backends
|
|
1455
|
+
Status: Historical artifacts are available locally
|
|
387
1456
|
|
|
388
|
-
|
|
1457
|
+
The preserved Cinder files are present and typechecked against the local Gateproof package. Reproducing the live result still requires Cloudflare infrastructure and Cinder environment variables.
|
|
389
1458
|
|
|
390
|
-
|
|
391
|
-
const provider = CloudflareProvider({ accountId, apiToken });
|
|
1459
|
+
## Roadmap
|
|
392
1460
|
|
|
393
|
-
|
|
394
|
-
provider.observe({ backend: "analytics", dataset: "worker_logs" })
|
|
1461
|
+
Gateproof is not ready to fully dogfood itself on a case study like Cinder yet. The next phase is about tightening the guardrails, not adding another rewrite.
|
|
395
1462
|
|
|
396
|
-
|
|
397
|
-
|
|
1463
|
+
- Save the latest real proof result to disk so the loop always has a concrete last-known truth.
|
|
1464
|
+
- Make finalize refuse to ship unless the saved real proof result is fully green.
|
|
1465
|
+
- Separate the real proof path from side experiments so exploration can happen without polluting the proof story.
|
|
1466
|
+
- Let plans choose direct evidence when log tailing is flaky, so a valid live pass does not fail on observation noise alone.
|
|
1467
|
+
- Dogfood Gateproof on Cinder again only after those guardrails are in place.
|
|
398
1468
|
|
|
399
|
-
|
|
400
|
-
provider.observe({ backend: "cli-stream", workerName: "my-worker" })
|
|
401
|
-
```
|
|
1469
|
+
## How To
|
|
402
1470
|
|
|
403
|
-
|
|
1471
|
+
Task: Run one complete gate from one file.
|
|
404
1472
|
|
|
405
|
-
|
|
406
|
-
- `patterns/basic/` - Basic usage patterns
|
|
407
|
-
- `patterns/cloudflare/` - Cloudflare-specific patterns
|
|
408
|
-
- `patterns/ci-cd/` - CI/CD integration
|
|
409
|
-
- `patterns/advanced/` - Advanced patterns
|
|
410
|
-
- `patterns/prd/` - PRD-as-code + agent iteration loop examples
|
|
411
|
-
- `patterns/agent-first/` - Spec interview → PRD stories (agent-first)
|
|
412
|
-
- `examples/hello-world-agent/` - Minimal agent with 5 tools + end-to-end gates
|
|
1473
|
+
Done when: The endpoint returns 200 and the body contains hello world.
|
|
413
1474
|
|
|
414
|
-
Run
|
|
1475
|
+
Run it:
|
|
415
1476
|
|
|
416
1477
|
```bash
|
|
417
|
-
|
|
418
|
-
bun run
|
|
1478
|
+
bun run example:hello-world:worker
|
|
1479
|
+
bun run alchemy.run.ts
|
|
1480
|
+
bun run plan.ts
|
|
419
1481
|
```
|
|
420
1482
|
|
|
421
|
-
##
|
|
1483
|
+
## Breaking Changes In 0.4.0
|
|
422
1484
|
|
|
423
|
-
|
|
1485
|
+
- `Prd.*` is gone
|
|
1486
|
+
- `Claim.*` is gone
|
|
1487
|
+
- `plan.ts` is the canonical entrypoint
|
|
1488
|
+
- `Plan.*` replaces the old front door
|
|
424
1489
|
|
|
425
|
-
|
|
1490
|
+
## Reference
|
|
426
1491
|
|
|
427
|
-
|
|
428
|
-
-
|
|
429
|
-
|
|
430
|
-
|
|
1492
|
+
Files:
|
|
1493
|
+
- `examples/hello-world/plan.ts`
|
|
1494
|
+
- `alchemy.run.ts`
|
|
1495
|
+
- `plan.ts`
|
|
1496
|
+
|
|
1497
|
+
Canonical gates:
|
|
1498
|
+
- GET / returns hello world
|
|
431
1499
|
|
|
432
|
-
|
|
1500
|
+
Loop:
|
|
1501
|
+
- `maxIterations: 1`
|
|
1502
|
+
- `stopOnFailure: true`
|
|
433
1503
|
|
|
434
|
-
|
|
435
|
-
- `
|
|
436
|
-
-
|
|
1504
|
+
Core API:
|
|
1505
|
+
- `Gate.define(...)`
|
|
1506
|
+
- `Plan.define(...)`
|
|
1507
|
+
- `Plan.run(...)`
|
|
1508
|
+
- `Plan.runLoop(...)`
|
|
1509
|
+
- `Cloudflare.observe(...)`
|
|
1510
|
+
- `Assert.hasAction(...)`
|
|
1511
|
+
- `Assert.responseBodyIncludes(...)`
|
|
1512
|
+
- `Assert.numericDeltaFromEnv(...)`
|
|
437
1513
|
|
|
438
|
-
##
|
|
1514
|
+
## Explanation
|
|
439
1515
|
|
|
440
|
-
|
|
1516
|
+
Root plan.ts stays small. Gateproof itself is built forward.
|