@valescoagency/runway 0.3.0 → 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 +107 -10
- package/dist/commands/doctor.js +203 -2
- package/dist/commands/run.js +70 -15
- package/dist/config.js +53 -61
- package/dist/git.js +43 -29
- package/dist/github.js +136 -21
- package/dist/linear.js +295 -63
- package/dist/orchestrator.js +407 -115
- package/dist/policy.js +76 -0
- package/dist/prompts.js +44 -1
- package/dist/subprocess.js +40 -0
- package/dist/telemetry.js +31 -0
- package/package.json +10 -1
- package/prompts/implement.md +46 -2
- package/templates/Dockerfile.claude-code.base +24 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ zero-secrets-at-rest, and the `gh` CLI for PR creation.
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| `runway doctor` | Read-only preflight diagnostic: host tooling, env vars, repo state, and the agent docker image. Use when something stopped working and you want a sanity report. `--json` for CI / scripted health checks. |
|
|
16
16
|
| `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
|
|
17
|
-
| `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `
|
|
17
|
+
| `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
|
|
18
18
|
| `runway upgrade` | Update the runway CLI itself: `git pull` the local clone, `pnpm install`, typecheck. `--check` for a dry-run, `--force` to override dirty/branch refusals. |
|
|
19
19
|
| `runway upgrade-repo` | Re-render the cwd repo's runway scaffold against the current vendored templates. Use after a runway version bump that changed the Dockerfile or template shape — `init` writes them, `upgrade-repo` keeps them current without re-prompting for op:// values. |
|
|
20
20
|
|
|
@@ -44,14 +44,23 @@ Linear (Todo, team=VA)
|
|
|
44
44
|
runway (this CLI, on your Mac, run from inside the target repo)
|
|
45
45
|
↓ for each issue
|
|
46
46
|
│ sandcastle.run({ agent: claudeCode, sandbox: docker, cwd: process.cwd(), ... })
|
|
47
|
+
│ iter 1 → IMPL: DONE | IMPL: BLOCKED — <reason> | IMPL: CONTINUE
|
|
48
|
+
│ iter 2 → same, with previous iteration's summary injected
|
|
49
|
+
│ …
|
|
47
50
|
│ → branch agent/<issue-id>, commits, tests
|
|
48
51
|
│
|
|
49
|
-
│
|
|
50
|
-
│
|
|
52
|
+
│ if BLOCKED → HITL (skip review)
|
|
53
|
+
│ else:
|
|
54
|
+
│ sandcastle.run({ ..., prompt: review template })
|
|
55
|
+
│ → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
|
|
51
56
|
│
|
|
52
57
|
├── approved → git push → gh pr create → Linear "In Review"
|
|
53
|
-
└── rejected → Linear
|
|
58
|
+
└── rejected → Linear comment with reason, then `ready-for-human` label
|
|
54
59
|
↓ next issue
|
|
60
|
+
|
|
61
|
+
[runway] per-issue outcomes:
|
|
62
|
+
VA-312 APPROVED → PR opened https://github.com/.../pull/42
|
|
63
|
+
VA-313 HITL Sub-agent review rejected: TOCTOU race in …
|
|
55
64
|
```
|
|
56
65
|
|
|
57
66
|
## Prerequisites
|
|
@@ -161,10 +170,19 @@ export LINEAR_API_KEY=lin_api_...
|
|
|
161
170
|
# export RUNWAY_READY_STATUS="Todo"
|
|
162
171
|
# export RUNWAY_IN_PROGRESS_STATUS="In Progress"
|
|
163
172
|
# export RUNWAY_IN_REVIEW_STATUS="In Review"
|
|
164
|
-
# export RUNWAY_HITL_LABEL="
|
|
173
|
+
# export RUNWAY_HITL_LABEL="ready-for-human"
|
|
165
174
|
# export RUNWAY_MAX_ITERATIONS=5
|
|
166
175
|
```
|
|
167
176
|
|
|
177
|
+
`RUNWAY_HITL_LABEL` defaults to `ready-for-human`, matching the
|
|
178
|
+
[Flightplan](https://github.com/valescoagency/flightplan) canonical
|
|
179
|
+
state-label vocabulary (`needs-triage`, `needs-info`,
|
|
180
|
+
`ready-for-agent`, `ready-for-human`, `wontfix`) that Bedrock and
|
|
181
|
+
other Valesco repos use. Override the env var if your workspace uses
|
|
182
|
+
a different label. `runway doctor` validates that the configured
|
|
183
|
+
team, workflow states, and HITL label all exist before any agent run
|
|
184
|
+
— misconfiguration surfaces immediately instead of mid-drain.
|
|
185
|
+
|
|
168
186
|
### From source (development)
|
|
169
187
|
|
|
170
188
|
```bash
|
|
@@ -214,15 +232,25 @@ Skip a single hook invocation with `LEFTHOOK=0 git commit …` (or
|
|
|
214
232
|
```bash
|
|
215
233
|
cd /path/to/the/repo/you/want/agents/working/on
|
|
216
234
|
runway run # drain the entire ready queue
|
|
217
|
-
runway run --max 3 #
|
|
235
|
+
runway run --max 3 # attempt at most 3 issues then exit
|
|
218
236
|
runway --help
|
|
219
237
|
```
|
|
220
238
|
|
|
221
239
|
`runway` (no subcommand) is an alias for `runway run` for back-compat.
|
|
222
240
|
|
|
241
|
+
`--max N` bounds **attempts**, not successes. Every issue picked up
|
|
242
|
+
counts as one attempt, whether it ends in a PR, a `needs-human` label,
|
|
243
|
+
or a revert-to-`Todo` after an infrastructure failure. An issue
|
|
244
|
+
reverted in this invocation will not be re-picked in the same
|
|
245
|
+
invocation — re-run runway after fixing the underlying config to retry
|
|
246
|
+
it.
|
|
247
|
+
|
|
223
248
|
The CLI exits with 0 even if some issues hit HITL or errored — those
|
|
224
|
-
are normal outcomes.
|
|
225
|
-
|
|
249
|
+
are normal outcomes. Every run prints a per-issue verdict trail on
|
|
250
|
+
exit (`APPROVED → PR opened <url>` / `HITL <reason>` /
|
|
251
|
+
`REVERTED → Todo <reason>` / `INFRA_ERROR <reason>`) so you can scan
|
|
252
|
+
results without opening Linear; the same content also lives on the
|
|
253
|
+
issue as a Linear comment.
|
|
226
254
|
|
|
227
255
|
## Linear conventions
|
|
228
256
|
|
|
@@ -239,13 +267,55 @@ It transitions them through:
|
|
|
239
267
|
agent has committed to its branch — startup failures before any
|
|
240
268
|
commits revert the issue back to `Todo` rather than stranding it)
|
|
241
269
|
- `In Review` when the PR opens
|
|
242
|
-
- (label `
|
|
270
|
+
- (label `ready-for-human`) if the agent or reviewer can't finish *after*
|
|
243
271
|
the agent has committed real work
|
|
244
272
|
|
|
245
273
|
These names are configurable per env var; the queries match by name so
|
|
246
274
|
your Linear workspace's actual state names need to line up with what
|
|
247
275
|
you set.
|
|
248
276
|
|
|
277
|
+
## Write-path policy
|
|
278
|
+
|
|
279
|
+
Runway tells the impl agent which paths it must **not** write to. By
|
|
280
|
+
default the denylist is:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
.github/workflows/** .env* *.pem *.key pnpm-lock.yaml .sandcastle/**
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
When an issue's acceptance criteria require modifying a forbidden path,
|
|
287
|
+
the agent is instructed to emit `IMPL: BLOCKED — issue requires
|
|
288
|
+
modifying <path>, which working-style policy forbids` rather than
|
|
289
|
+
silently skipping the work. Runway routes those to HITL with the
|
|
290
|
+
reason attached.
|
|
291
|
+
|
|
292
|
+
Two layers of override:
|
|
293
|
+
|
|
294
|
+
**Per repo** — drop a `.runway/policy.yml` in the target repo root:
|
|
295
|
+
|
|
296
|
+
```yaml
|
|
297
|
+
# Grants write access to specific paths from the default denylist.
|
|
298
|
+
allowedPaths:
|
|
299
|
+
- .github/workflows/**
|
|
300
|
+
|
|
301
|
+
# Or replace the denylist entirely (use with care).
|
|
302
|
+
# forbiddenPaths:
|
|
303
|
+
# - .env*
|
|
304
|
+
# - "*.pem"
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Per invocation** — comma-separated globs, removed from the effective
|
|
308
|
+
denylist for one `runway run`:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
runway run --allow-paths='.github/workflows/**'
|
|
312
|
+
runway run --allow-paths='.github/workflows/**,scripts/ci/*.sh'
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`runway doctor` surfaces the active policy under Environment so you
|
|
316
|
+
can see what an agent run can and can't touch (e.g. `impl policy:
|
|
317
|
+
.runway/policy.yml + --allow-paths (5 forbidden paths)`).
|
|
318
|
+
|
|
249
319
|
## Base branch
|
|
250
320
|
|
|
251
321
|
Runway auto-detects the repo's default branch at the start of every
|
|
@@ -261,6 +331,30 @@ when `origin/HEAD` isn't set and you don't want to run
|
|
|
261
331
|
resolved base branch (detected or overridden) in its Environment
|
|
262
332
|
section.
|
|
263
333
|
|
|
334
|
+
## Implementation pass
|
|
335
|
+
|
|
336
|
+
The impl agent runs in a Sandcastle container with
|
|
337
|
+
[`prompts/implement.md`](prompts/implement.md). It iterates up to
|
|
338
|
+
`RUNWAY_MAX_ITERATIONS` times (default 5) and must end every iteration
|
|
339
|
+
with one of:
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
IMPL: DONE
|
|
343
|
+
IMPL: BLOCKED — <one-line reason>
|
|
344
|
+
IMPL: CONTINUE
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- `DONE` → runway stops the loop and runs the sub-agent reviewer.
|
|
348
|
+
- `BLOCKED — <reason>` → runway routes the issue to HITL with the
|
|
349
|
+
reason attached; the reviewer pass does **not** run.
|
|
350
|
+
- `CONTINUE` → runway runs another iteration (up to `maxIterations`).
|
|
351
|
+
|
|
352
|
+
Between iterations, runway prepends a `## Previous iterations` block
|
|
353
|
+
to the next prompt — running commit log + tail of the last iteration's
|
|
354
|
+
final message — so the agent doesn't re-explore the repository from
|
|
355
|
+
scratch every time. Converged issues typically exit after 1–2
|
|
356
|
+
iterations.
|
|
357
|
+
|
|
264
358
|
## Sub-agent review
|
|
265
359
|
|
|
266
360
|
Every implementation run is followed by a fresh Sandcastle run with
|
|
@@ -290,4 +384,7 @@ These are tractable, just not v1.
|
|
|
290
384
|
|
|
291
385
|
## Status
|
|
292
386
|
|
|
293
|
-
|
|
387
|
+
0.5.0 — production-shaped and dogfooded against live Linear queues.
|
|
388
|
+
The end-to-end pipeline (init → run → review → PR) is stable; surface
|
|
389
|
+
may still shift as the orchestrator's policy and iteration mechanics
|
|
390
|
+
mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,6 +2,9 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { execa } from "execa";
|
|
4
4
|
import { detectBaseBranch } from "../git.js";
|
|
5
|
+
import { loadPolicy } from "../policy.js";
|
|
6
|
+
import { loadConfig } from "../config.js";
|
|
7
|
+
import { validateLinearConfig } from "../linear.js";
|
|
5
8
|
// ---------------------------------------------------------------------------
|
|
6
9
|
// Usage
|
|
7
10
|
// ---------------------------------------------------------------------------
|
|
@@ -87,12 +90,14 @@ export async function doctorCommand(argv) {
|
|
|
87
90
|
sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
|
|
88
91
|
sections.push(await checkRepoState(cwd, repo));
|
|
89
92
|
sections.push(await checkDockerImage(cwd));
|
|
93
|
+
sections.push(await checkLinearConfig());
|
|
90
94
|
}
|
|
91
95
|
else {
|
|
92
96
|
// Push placeholder skipped sections so JSON output stays well-shaped.
|
|
93
97
|
sections.push(skippedSection("Environment"));
|
|
94
98
|
sections.push(skippedSection("Repo state"));
|
|
95
99
|
sections.push(skippedSection("Docker image"));
|
|
100
|
+
sections.push(skippedSection("Linear configuration"));
|
|
96
101
|
}
|
|
97
102
|
// Render
|
|
98
103
|
if (opts.json) {
|
|
@@ -102,8 +107,14 @@ export async function doctorCommand(argv) {
|
|
|
102
107
|
renderText(sections, tier, initialised, opts.detailed);
|
|
103
108
|
}
|
|
104
109
|
// Exit code: required-check failures = 1.
|
|
105
|
-
//
|
|
106
|
-
|
|
110
|
+
// Required: 0 host tooling, 1 environment, 3 docker image, 4 Linear
|
|
111
|
+
// config. Section 2 (repo state) is informational.
|
|
112
|
+
const requiredSections = [
|
|
113
|
+
sections[0],
|
|
114
|
+
sections[1],
|
|
115
|
+
sections[3],
|
|
116
|
+
sections[4],
|
|
117
|
+
];
|
|
107
118
|
const failed = requiredSections.some((s) => s?.ran && [...s.checks.values()].some((c) => c.status === "fail"));
|
|
108
119
|
process.exit(failed ? 1 : 0);
|
|
109
120
|
}
|
|
@@ -321,6 +332,23 @@ async function checkEnvironment(tier, cwd, repo) {
|
|
|
321
332
|
});
|
|
322
333
|
}
|
|
323
334
|
}
|
|
335
|
+
// VA-352: surface the active impl-pass write-path policy so the
|
|
336
|
+
// operator can see whether an agent run can touch CI workflows, etc.
|
|
337
|
+
try {
|
|
338
|
+
const policy = loadPolicy(cwd);
|
|
339
|
+
checks.set("policy", {
|
|
340
|
+
status: "ok",
|
|
341
|
+
label: "impl policy",
|
|
342
|
+
detail: `${policy.source} (${policy.forbiddenPaths.length} forbidden path${policy.forbiddenPaths.length === 1 ? "" : "s"})`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
checks.set("policy", {
|
|
347
|
+
status: "fail",
|
|
348
|
+
label: "impl policy",
|
|
349
|
+
detail: errMsg(err),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
324
352
|
return { title: "Environment", checks, ran: true };
|
|
325
353
|
}
|
|
326
354
|
function envSet(name, missingStatus) {
|
|
@@ -445,6 +473,51 @@ async function checkDockerImage(cwd) {
|
|
|
445
473
|
detail: imageUser ? `User=${imageUser}` : `User unset (root); host=${expected}`,
|
|
446
474
|
});
|
|
447
475
|
}
|
|
476
|
+
// VA-351: container readiness — pnpm on PATH + HOME/cache env
|
|
477
|
+
// baked in. Cheap one-shot run; fails fast if the image is stale.
|
|
478
|
+
try {
|
|
479
|
+
const probe = await execa("docker", [
|
|
480
|
+
"run",
|
|
481
|
+
"--rm",
|
|
482
|
+
imageName,
|
|
483
|
+
"bash",
|
|
484
|
+
"-lc",
|
|
485
|
+
'set -e; which pnpm >/dev/null && printf "HOME=%s\\nXDG_CACHE_HOME=%s\\nTURBO_CACHE_DIR=%s\\n" "$HOME" "$XDG_CACHE_HOME" "$TURBO_CACHE_DIR"',
|
|
486
|
+
], { reject: false });
|
|
487
|
+
const out = probe.stdout ?? "";
|
|
488
|
+
const missing = [];
|
|
489
|
+
if (probe.exitCode !== 0)
|
|
490
|
+
missing.push("pnpm");
|
|
491
|
+
if (!/^HOME=\/home\/agent\s*$/m.test(out))
|
|
492
|
+
missing.push("HOME");
|
|
493
|
+
if (!/^XDG_CACHE_HOME=\/home\/agent\/.cache\s*$/m.test(out)) {
|
|
494
|
+
missing.push("XDG_CACHE_HOME");
|
|
495
|
+
}
|
|
496
|
+
if (!/^TURBO_CACHE_DIR=\/tmp\/turbo-cache\s*$/m.test(out)) {
|
|
497
|
+
missing.push("TURBO_CACHE_DIR");
|
|
498
|
+
}
|
|
499
|
+
if (missing.length === 0) {
|
|
500
|
+
checks.set("container_ready", {
|
|
501
|
+
status: "ok",
|
|
502
|
+
label: "container readiness",
|
|
503
|
+
detail: "pnpm on PATH; HOME, XDG_CACHE_HOME, TURBO_CACHE_DIR set",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
checks.set("container_ready", {
|
|
508
|
+
status: "warn",
|
|
509
|
+
label: "container readiness",
|
|
510
|
+
detail: `missing or wrong inside container: ${missing.join(", ")} — rebuild via \`runway upgrade-repo && docker build .sandcastle -t ${imageName}\``,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
checks.set("container_ready", {
|
|
516
|
+
status: "warn",
|
|
517
|
+
label: "container readiness",
|
|
518
|
+
detail: `probe failed: ${errMsg(err)}`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
448
521
|
}
|
|
449
522
|
catch (err) {
|
|
450
523
|
checks.set("image_present", {
|
|
@@ -455,6 +528,134 @@ async function checkDockerImage(cwd) {
|
|
|
455
528
|
}
|
|
456
529
|
return { title: "Docker image", checks, ran: true };
|
|
457
530
|
}
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// Section: Linear configuration (VA-354)
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
/**
|
|
535
|
+
* Validate that the team, workflow states, and HITL label `runway run`
|
|
536
|
+
* would use actually exist on the Linear workspace. Without this,
|
|
537
|
+
* misconfiguration only surfaces deep inside a long agent run — too
|
|
538
|
+
* late to fix without losing the work.
|
|
539
|
+
*/
|
|
540
|
+
async function checkLinearConfig() {
|
|
541
|
+
const checks = new Map();
|
|
542
|
+
// The config loader's only hard requirement is LINEAR_API_KEY; the
|
|
543
|
+
// rest defaults. If the key is missing, the Environment section
|
|
544
|
+
// already fails — surface a skip here rather than re-failing.
|
|
545
|
+
if (!process.env.LINEAR_API_KEY) {
|
|
546
|
+
checks.set("linear_config", {
|
|
547
|
+
status: "skip",
|
|
548
|
+
label: "Linear config",
|
|
549
|
+
detail: "LINEAR_API_KEY unset — skipped",
|
|
550
|
+
});
|
|
551
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
552
|
+
}
|
|
553
|
+
let config;
|
|
554
|
+
try {
|
|
555
|
+
config = loadConfig();
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
checks.set("linear_config", {
|
|
559
|
+
status: "fail",
|
|
560
|
+
label: "Linear config",
|
|
561
|
+
detail: `failed to load runway config: ${errMsg(err)}`,
|
|
562
|
+
});
|
|
563
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
564
|
+
}
|
|
565
|
+
let result;
|
|
566
|
+
try {
|
|
567
|
+
result = await validateLinearConfig(config);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
checks.set("linear_api", {
|
|
571
|
+
status: "fail",
|
|
572
|
+
label: "Linear API",
|
|
573
|
+
detail: `validation request failed: ${errMsg(err)}`,
|
|
574
|
+
});
|
|
575
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
576
|
+
}
|
|
577
|
+
if (result.team.kind === "missing") {
|
|
578
|
+
checks.set("team", {
|
|
579
|
+
status: "fail",
|
|
580
|
+
label: `team ${config.linearTeam}`,
|
|
581
|
+
detail: `Linear team key "${result.team.key}" not found — set RUNWAY_LINEAR_TEAM`,
|
|
582
|
+
});
|
|
583
|
+
// States/labels are skipped when the team missing; surface
|
|
584
|
+
// explicitly so the user knows they weren't checked.
|
|
585
|
+
checks.set("states", {
|
|
586
|
+
status: "skip",
|
|
587
|
+
label: "workflow states",
|
|
588
|
+
detail: "skipped (team missing)",
|
|
589
|
+
});
|
|
590
|
+
checks.set("hitl_label", {
|
|
591
|
+
status: "skip",
|
|
592
|
+
label: "HITL label",
|
|
593
|
+
detail: "skipped (team missing)",
|
|
594
|
+
});
|
|
595
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
596
|
+
}
|
|
597
|
+
checks.set("team", {
|
|
598
|
+
status: "ok",
|
|
599
|
+
label: `team ${config.linearTeam}`,
|
|
600
|
+
detail: `id=${result.team.id}`,
|
|
601
|
+
});
|
|
602
|
+
for (const [key, configured, state] of [
|
|
603
|
+
["ready_state", config.readyStatus, result.readyStatus],
|
|
604
|
+
["in_progress_state", config.inProgressStatus, result.inProgressStatus],
|
|
605
|
+
["in_review_state", config.inReviewStatus, result.inReviewStatus],
|
|
606
|
+
]) {
|
|
607
|
+
if (state.kind === "ok") {
|
|
608
|
+
checks.set(key, {
|
|
609
|
+
status: "ok",
|
|
610
|
+
label: `workflow state "${configured}"`,
|
|
611
|
+
detail: "present",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
else if (state.kind === "skipped") {
|
|
615
|
+
checks.set(key, {
|
|
616
|
+
status: "skip",
|
|
617
|
+
label: `workflow state "${configured}"`,
|
|
618
|
+
detail: state.reason,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
checks.set(key, {
|
|
623
|
+
status: "fail",
|
|
624
|
+
label: `workflow state "${configured}"`,
|
|
625
|
+
detail: `not found on team; available: ${formatList(state.available)}`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (result.hitlLabel.kind === "ok") {
|
|
630
|
+
checks.set("hitl_label", {
|
|
631
|
+
status: "ok",
|
|
632
|
+
label: `HITL label "${config.hitlLabel}"`,
|
|
633
|
+
detail: "present",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else if (result.hitlLabel.kind === "skipped") {
|
|
637
|
+
checks.set("hitl_label", {
|
|
638
|
+
status: "skip",
|
|
639
|
+
label: `HITL label "${config.hitlLabel}"`,
|
|
640
|
+
detail: result.hitlLabel.reason,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
checks.set("hitl_label", {
|
|
645
|
+
status: "fail",
|
|
646
|
+
label: `HITL label "${config.hitlLabel}"`,
|
|
647
|
+
detail: `not found on team — set RUNWAY_HITL_LABEL or create the label. Available: ${formatList(result.hitlLabel.available)}`,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
651
|
+
}
|
|
652
|
+
function formatList(items) {
|
|
653
|
+
if (items.length === 0)
|
|
654
|
+
return "(none)";
|
|
655
|
+
if (items.length <= 8)
|
|
656
|
+
return items.join(", ");
|
|
657
|
+
return `${items.slice(0, 8).join(", ")}, …(+${items.length - 8} more)`;
|
|
658
|
+
}
|
|
458
659
|
/**
|
|
459
660
|
* Sanitize the cwd's basename the same way sandcastle's `defaultImageName`
|
|
460
661
|
* does: lowercase, replace any char outside `[a-z0-9_.-]` with `-`, fall
|
package/dist/commands/run.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, Layer, Logger, RateLimiter } from "effect";
|
|
2
|
+
import { ConfigLive, ConfigTag } from "../config.js";
|
|
2
3
|
import { createLinearGateway } from "../linear.js";
|
|
3
4
|
import { createGithubGateway } from "../github.js";
|
|
4
5
|
import { assertSandcastleInitialised, drainQueue, } from "../orchestrator.js";
|
|
6
|
+
import { TelemetryLive } from "../telemetry.js";
|
|
5
7
|
export function parseRunArgs(argv) {
|
|
6
8
|
const opts = {};
|
|
9
|
+
const collectAllow = (raw) => {
|
|
10
|
+
const paths = raw
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((s) => s.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
if (paths.length === 0) {
|
|
15
|
+
throw new Error("--allow-paths requires at least one glob");
|
|
16
|
+
}
|
|
17
|
+
opts.allowPaths = [...(opts.allowPaths ?? []), ...paths];
|
|
18
|
+
};
|
|
7
19
|
for (let i = 0; i < argv.length; i += 1) {
|
|
8
20
|
const a = argv[i];
|
|
9
21
|
if (a === "--max" || a === "-n") {
|
|
@@ -27,6 +39,16 @@ export function parseRunArgs(argv) {
|
|
|
27
39
|
else if (a?.startsWith("--project=")) {
|
|
28
40
|
opts.project = a.slice("--project=".length);
|
|
29
41
|
}
|
|
42
|
+
else if (a === "--allow-paths") {
|
|
43
|
+
const v = argv[i + 1];
|
|
44
|
+
if (!v)
|
|
45
|
+
throw new Error("--allow-paths requires a value");
|
|
46
|
+
collectAllow(v);
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
else if (a?.startsWith("--allow-paths=")) {
|
|
50
|
+
collectAllow(a.slice("--allow-paths=".length));
|
|
51
|
+
}
|
|
30
52
|
else if (a === "--help" || a === "-h") {
|
|
31
53
|
printRunUsage();
|
|
32
54
|
process.exit(0);
|
|
@@ -45,10 +67,18 @@ USAGE
|
|
|
45
67
|
runway run [--max N]
|
|
46
68
|
|
|
47
69
|
OPTIONS
|
|
48
|
-
--max, -n N
|
|
70
|
+
--max, -n N Attempt at most N issues then exit (counts every
|
|
71
|
+
attempt — success, HITL, or revert-to-Todo).
|
|
72
|
+
Default: drain queue.
|
|
49
73
|
--project ID Scope the queue to a single Linear project under the
|
|
50
74
|
team. Accepts project UUID, slug, or name. Overrides
|
|
51
75
|
RUNWAY_LINEAR_PROJECT. Default: team-wide.
|
|
76
|
+
--allow-paths GLOBS
|
|
77
|
+
Comma-separated globs removed from the impl policy's
|
|
78
|
+
forbidden-paths list for this invocation only.
|
|
79
|
+
Example: --allow-paths='.github/workflows/**' lets
|
|
80
|
+
the agent touch CI for issues whose AC require it.
|
|
81
|
+
Repeatable; pairs with .runway/policy.yml.
|
|
52
82
|
--help, -h Show this help.
|
|
53
83
|
|
|
54
84
|
ENVIRONMENT
|
|
@@ -62,7 +92,7 @@ ENVIRONMENT
|
|
|
62
92
|
RUNWAY_READY_STATUS default "Todo"
|
|
63
93
|
RUNWAY_IN_PROGRESS_STATUS default "In Progress"
|
|
64
94
|
RUNWAY_IN_REVIEW_STATUS default "In Review"
|
|
65
|
-
RUNWAY_HITL_LABEL default "
|
|
95
|
+
RUNWAY_HITL_LABEL default "ready-for-human"
|
|
66
96
|
RUNWAY_MAX_ITERATIONS default 5
|
|
67
97
|
`);
|
|
68
98
|
}
|
|
@@ -70,16 +100,41 @@ export async function runCommand(argv) {
|
|
|
70
100
|
const opts = parseRunArgs(argv);
|
|
71
101
|
const cwd = process.cwd();
|
|
72
102
|
assertSandcastleInitialised(cwd);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
// VA-358 / VA-359: a single `Effect.runPromise` at the CLI
|
|
104
|
+
// boundary. Composed layers:
|
|
105
|
+
//
|
|
106
|
+
// - `ConfigLive` (VA-359) resolves every env var via Effect's
|
|
107
|
+
// `Config` module. Secrets (LINEAR_API_KEY,
|
|
108
|
+
// OP_SERVICE_ACCOUNT_TOKEN) are `Redacted<string>` and won't
|
|
109
|
+
// appear in `Effect.log` output or stringified errors.
|
|
110
|
+
// - `Logger.jsonLogger` is wired when `RUNWAY_JSON_LOGS=1` so the
|
|
111
|
+
// operator can pipe runway's output to a log aggregator.
|
|
112
|
+
// - `TelemetryLive` (VA-358) is env-conditional — only wires the
|
|
113
|
+
// OTLP exporter when `OTEL_EXPORTER_OTLP_ENDPOINT` is set.
|
|
114
|
+
// - Linear `RateLimiter` (folded in from VA-357): conservative
|
|
115
|
+
// 30/minute, built inside `Effect.scoped` so its internals are
|
|
116
|
+
// torn down on program exit.
|
|
117
|
+
const LoggerLive = process.env.RUNWAY_JSON_LOGS === "1"
|
|
118
|
+
? Logger.replace(Logger.defaultLogger, Logger.jsonLogger)
|
|
119
|
+
: Layer.empty;
|
|
120
|
+
const MainLayer = Layer.mergeAll(ConfigLive, TelemetryLive, LoggerLive);
|
|
121
|
+
const program = Effect.gen(function* () {
|
|
122
|
+
const baseConfig = yield* ConfigTag;
|
|
123
|
+
const config = opts.project
|
|
124
|
+
? { ...baseConfig, linearProject: opts.project }
|
|
125
|
+
: baseConfig;
|
|
126
|
+
const scope = config.linearProject
|
|
127
|
+
? `team ${config.linearTeam} / project ${config.linearProject}`
|
|
128
|
+
: `team ${config.linearTeam}`;
|
|
129
|
+
yield* Effect.logInfo(`draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
|
|
130
|
+
const linearLimiter = yield* RateLimiter.make({
|
|
131
|
+
limit: 30,
|
|
132
|
+
interval: "1 minute",
|
|
133
|
+
});
|
|
134
|
+
const linear = createLinearGateway(config, linearLimiter);
|
|
135
|
+
const github = createGithubGateway();
|
|
136
|
+
return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
|
|
137
|
+
}).pipe(Effect.scoped, Effect.provide(MainLayer));
|
|
138
|
+
const result = await Effect.runPromise(program);
|
|
139
|
+
console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
|
|
85
140
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,65 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Config as EConfig, Context, Effect, Layer, Option, } from "effect";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
* VA-359: Effect.Config program that reads every var from
|
|
4
|
+
* `process.env`, applies defaults, and yields a typed `RunwayConfig`.
|
|
5
|
+
* The `Option<T>` returns from `Config.option(...)` are flattened to
|
|
6
|
+
* `T | undefined` in the final shape so consumers don't have to
|
|
7
|
+
* import `Option` everywhere.
|
|
8
|
+
*/
|
|
9
|
+
const configEffect = EConfig.all({
|
|
10
|
+
linearApiKey: EConfig.redacted("LINEAR_API_KEY"),
|
|
11
|
+
opServiceAccountToken: EConfig.option(EConfig.redacted("OP_SERVICE_ACCOUNT_TOKEN")),
|
|
12
|
+
linearTeam: EConfig.string("RUNWAY_LINEAR_TEAM").pipe(EConfig.withDefault("VA")),
|
|
13
|
+
linearProject: EConfig.option(EConfig.string("RUNWAY_LINEAR_PROJECT")),
|
|
14
|
+
baseBranch: EConfig.option(EConfig.string("RUNWAY_BASE_BRANCH")),
|
|
15
|
+
readyStatus: EConfig.string("RUNWAY_READY_STATUS").pipe(EConfig.withDefault("Todo")),
|
|
16
|
+
inProgressStatus: EConfig.string("RUNWAY_IN_PROGRESS_STATUS").pipe(EConfig.withDefault("In Progress")),
|
|
17
|
+
inReviewStatus: EConfig.string("RUNWAY_IN_REVIEW_STATUS").pipe(EConfig.withDefault("In Review")),
|
|
18
|
+
hitlLabel: EConfig.string("RUNWAY_HITL_LABEL").pipe(EConfig.withDefault("ready-for-human")),
|
|
19
|
+
maxIterations: EConfig.integer("RUNWAY_MAX_ITERATIONS").pipe(EConfig.withDefault(5), EConfig.validate({
|
|
20
|
+
message: "RUNWAY_MAX_ITERATIONS must be a positive integer",
|
|
21
|
+
validation: (n) => n > 0,
|
|
22
|
+
})),
|
|
23
|
+
}).pipe(Effect.map((raw) => ({
|
|
24
|
+
linearApiKey: raw.linearApiKey,
|
|
25
|
+
opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
|
|
26
|
+
linearTeam: raw.linearTeam,
|
|
27
|
+
linearProject: Option.getOrUndefined(raw.linearProject),
|
|
28
|
+
baseBranch: Option.getOrUndefined(raw.baseBranch),
|
|
29
|
+
readyStatus: raw.readyStatus,
|
|
30
|
+
inProgressStatus: raw.inProgressStatus,
|
|
31
|
+
inReviewStatus: raw.inReviewStatus,
|
|
32
|
+
hitlLabel: raw.hitlLabel,
|
|
33
|
+
maxIterations: raw.maxIterations,
|
|
34
|
+
})));
|
|
35
|
+
/**
|
|
36
|
+
* VA-359: Context tag for the resolved RunwayConfig. Provided by
|
|
37
|
+
* `ConfigLive` at the top of every CLI entry point; consumed by
|
|
38
|
+
* `yield* ConfigTag` inside Effect.gen.
|
|
39
|
+
*/
|
|
40
|
+
export class ConfigTag extends Context.Tag("RunwayConfig")() {
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Layer that resolves env vars once and makes the result available
|
|
44
|
+
* via `ConfigTag` for the rest of the program.
|
|
45
|
+
*/
|
|
46
|
+
export const ConfigLive = Layer.effect(ConfigTag, configEffect);
|
|
47
|
+
/**
|
|
48
|
+
* Sync helper for non-Effect callers (`runway doctor` early
|
|
49
|
+
* validation, the `runway run` CLI bootstrap before it enters
|
|
50
|
+
* Effect-land). `Effect.runSync` throws a `FiberFailure` carrying
|
|
51
|
+
* the `ConfigError` on a missing/invalid env var — the caller's
|
|
52
|
+
* existing `catch (err) { … errMsg(err) … }` shape rendered the
|
|
53
|
+
* Zod issue the same way it'll now render the Effect ConfigError.
|
|
16
54
|
*/
|
|
17
|
-
const ConfigSchema = z.object({
|
|
18
|
-
linearApiKey: z.string().min(1, "LINEAR_API_KEY required"),
|
|
19
|
-
/**
|
|
20
|
-
* Optional. If present, forwarded into the sandcastle container so
|
|
21
|
-
* the in-container varlock + 1Password-CLI shim can resolve agent
|
|
22
|
-
* secrets at run time. If absent, the container falls back to
|
|
23
|
-
* sandcastle's normal `.sandcastle/.env` flow. See
|
|
24
|
-
* docs/secrets-with-varlock.md.
|
|
25
|
-
*/
|
|
26
|
-
opServiceAccountToken: z.string().optional(),
|
|
27
|
-
linearTeam: z.string().default("VA"),
|
|
28
|
-
/**
|
|
29
|
-
* Optional. Scopes the `runway run` queue to a single project under
|
|
30
|
-
* `linearTeam`. Resolved by Linear project ID, slug, or name. When
|
|
31
|
-
* unset, runway drains every `Todo` issue on the team (legacy
|
|
32
|
-
* behavior). Source: `RUNWAY_LINEAR_PROJECT` env var or
|
|
33
|
-
* `--project` CLI flag on `runway run`.
|
|
34
|
-
*/
|
|
35
|
-
linearProject: z.string().optional(),
|
|
36
|
-
/**
|
|
37
|
-
* Optional. Override the auto-detected base branch — the branch
|
|
38
|
-
* runway diffs against, opens PRs against, and uses to count
|
|
39
|
-
* agent-branch commits. Source: `RUNWAY_BASE_BRANCH` env var. When
|
|
40
|
-
* unset, runway resolves the default branch from `origin/HEAD` at
|
|
41
|
-
* orchestrator startup. Set this when the repo's default branch is
|
|
42
|
-
* not on the origin (rare) or when you want to target a release
|
|
43
|
-
* branch instead.
|
|
44
|
-
*/
|
|
45
|
-
baseBranch: z.string().optional(),
|
|
46
|
-
readyStatus: z.string().default("Todo"),
|
|
47
|
-
inProgressStatus: z.string().default("In Progress"),
|
|
48
|
-
inReviewStatus: z.string().default("In Review"),
|
|
49
|
-
hitlLabel: z.string().default("needs-human"),
|
|
50
|
-
maxIterations: z.coerce.number().int().positive().default(5),
|
|
51
|
-
});
|
|
52
55
|
export function loadConfig() {
|
|
53
|
-
return
|
|
54
|
-
linearApiKey: process.env.LINEAR_API_KEY,
|
|
55
|
-
opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
|
|
56
|
-
linearTeam: process.env.RUNWAY_LINEAR_TEAM,
|
|
57
|
-
linearProject: process.env.RUNWAY_LINEAR_PROJECT,
|
|
58
|
-
baseBranch: process.env.RUNWAY_BASE_BRANCH,
|
|
59
|
-
readyStatus: process.env.RUNWAY_READY_STATUS,
|
|
60
|
-
inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
|
|
61
|
-
inReviewStatus: process.env.RUNWAY_IN_REVIEW_STATUS,
|
|
62
|
-
hitlLabel: process.env.RUNWAY_HITL_LABEL,
|
|
63
|
-
maxIterations: process.env.RUNWAY_MAX_ITERATIONS,
|
|
64
|
-
});
|
|
56
|
+
return Effect.runSync(configEffect);
|
|
65
57
|
}
|