@valescoagency/runway 0.4.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 +87 -6
- package/dist/commands/run.js +39 -12
- package/dist/config.js +53 -67
- package/dist/git.js +43 -29
- package/dist/github.js +136 -21
- package/dist/linear.js +255 -64
- package/dist/orchestrator.js +260 -165
- package/dist/subprocess.js +40 -0
- package/dist/telemetry.js +31 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -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
|
|
@@ -237,8 +246,11 @@ invocation — re-run runway after fixing the underlying config to retry
|
|
|
237
246
|
it.
|
|
238
247
|
|
|
239
248
|
The CLI exits with 0 even if some issues hit HITL or errored — those
|
|
240
|
-
are normal outcomes.
|
|
241
|
-
|
|
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.
|
|
242
254
|
|
|
243
255
|
## Linear conventions
|
|
244
256
|
|
|
@@ -262,6 +274,48 @@ These names are configurable per env var; the queries match by name so
|
|
|
262
274
|
your Linear workspace's actual state names need to line up with what
|
|
263
275
|
you set.
|
|
264
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
|
+
|
|
265
319
|
## Base branch
|
|
266
320
|
|
|
267
321
|
Runway auto-detects the repo's default branch at the start of every
|
|
@@ -277,6 +331,30 @@ when `origin/HEAD` isn't set and you don't want to run
|
|
|
277
331
|
resolved base branch (detected or overridden) in its Environment
|
|
278
332
|
section.
|
|
279
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
|
+
|
|
280
358
|
## Sub-agent review
|
|
281
359
|
|
|
282
360
|
Every implementation run is followed by a fresh Sandcastle run with
|
|
@@ -306,4 +384,7 @@ These are tractable, just not v1.
|
|
|
306
384
|
|
|
307
385
|
## Status
|
|
308
386
|
|
|
309
|
-
|
|
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/run.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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 = {};
|
|
7
9
|
const collectAllow = (raw) => {
|
|
@@ -98,16 +100,41 @@ export async function runCommand(argv) {
|
|
|
98
100
|
const opts = parseRunArgs(argv);
|
|
99
101
|
const cwd = process.cwd();
|
|
100
102
|
assertSandcastleInitialised(cwd);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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);
|
|
112
139
|
console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
|
|
113
140
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,71 +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
|
-
// VA-354: default to the Flightplan canonical state label
|
|
50
|
-
// `ready-for-human`. The previous default (`needs-human`) doesn't
|
|
51
|
-
// exist on Flightplan-aligned Linear workspaces (the common case
|
|
52
|
-
// for Valesco repos), and `linear.applyLabel` failures cascaded
|
|
53
|
-
// into the substantive rejection reason being lost. Workspaces that
|
|
54
|
-
// use a different label override via `RUNWAY_HITL_LABEL`.
|
|
55
|
-
hitlLabel: z.string().default("ready-for-human"),
|
|
56
|
-
maxIterations: z.coerce.number().int().positive().default(5),
|
|
57
|
-
});
|
|
58
55
|
export function loadConfig() {
|
|
59
|
-
return
|
|
60
|
-
linearApiKey: process.env.LINEAR_API_KEY,
|
|
61
|
-
opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
|
|
62
|
-
linearTeam: process.env.RUNWAY_LINEAR_TEAM,
|
|
63
|
-
linearProject: process.env.RUNWAY_LINEAR_PROJECT,
|
|
64
|
-
baseBranch: process.env.RUNWAY_BASE_BRANCH,
|
|
65
|
-
readyStatus: process.env.RUNWAY_READY_STATUS,
|
|
66
|
-
inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
|
|
67
|
-
inReviewStatus: process.env.RUNWAY_IN_REVIEW_STATUS,
|
|
68
|
-
hitlLabel: process.env.RUNWAY_HITL_LABEL,
|
|
69
|
-
maxIterations: process.env.RUNWAY_MAX_ITERATIONS,
|
|
70
|
-
});
|
|
56
|
+
return Effect.runSync(configEffect);
|
|
71
57
|
}
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { runExecaScoped } from "./subprocess.js";
|
|
3
|
+
/**
|
|
4
|
+
* VA-358: thin typed error for `detectBaseBranch`. We don't model
|
|
5
|
+
* every git failure mode — the orchestrator just wants to know "did
|
|
6
|
+
* we get a branch name back?" If not, surface a single error so the
|
|
7
|
+
* caller can fail fast with a helpful message. As a `Data.TaggedError`
|
|
8
|
+
* it's an Error instance, so vitest's `.rejects.toThrow(/regex/)`
|
|
9
|
+
* works the same as before this refactor.
|
|
10
|
+
*/
|
|
11
|
+
export class BaseBranchDetectionFailed extends Data.TaggedError("BaseBranchDetectionFailed") {
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* Resolve the default branch name of the cwd repo. Tries
|
|
4
15
|
* `git symbolic-ref` against `origin/HEAD` first (fast, works on any
|
|
@@ -6,36 +17,39 @@ import { execa } from "execa";
|
|
|
6
17
|
* `git remote show origin` (slower, hits the network but works on
|
|
7
18
|
* fresh clones that never had `origin/HEAD` set locally).
|
|
8
19
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
20
|
+
* VA-358: now an Effect so the orchestrator program can stay in
|
|
21
|
+
* Effect-land end-to-end. Subprocess kills propagate via
|
|
22
|
+
* `runExecaScoped` if the orchestrator fiber is interrupted.
|
|
12
23
|
*/
|
|
13
|
-
export
|
|
14
|
-
// Fast path: local symbolic ref. Returns e.g. `origin/main
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
export const detectBaseBranch = (repoPath) => Effect.gen(function* () {
|
|
25
|
+
// Fast path: local symbolic ref. Returns e.g. `origin/main`.
|
|
26
|
+
const symbolic = yield* runExecaScoped("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoPath, reject: false }, (err) => new BaseBranchDetectionFailed({
|
|
27
|
+
message: err instanceof Error ? err.message : String(err),
|
|
28
|
+
})).pipe(Effect.either);
|
|
29
|
+
if (symbolic._tag === "Right" && symbolic.right.exitCode === 0) {
|
|
30
|
+
// execa's `stdout` type is a union (encoding-dependent). We don't
|
|
31
|
+
// change `encoding`, so it's `string` at runtime — narrow here
|
|
32
|
+
// rather than fighting the generic types upstream.
|
|
33
|
+
const raw = symbolic.right.stdout;
|
|
34
|
+
const out = typeof raw === "string" ? raw : "";
|
|
35
|
+
const name = out.trim().replace(/^origin\//, "");
|
|
36
|
+
if (name)
|
|
37
|
+
return name;
|
|
22
38
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const match = stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
|
|
39
|
+
// Slow path: ask the remote. Output line: ` HEAD branch: master`.
|
|
40
|
+
const remoteShow = yield* runExecaScoped("git", ["remote", "show", "origin"], { cwd: repoPath }, (err) => new BaseBranchDetectionFailed({
|
|
41
|
+
message: err instanceof Error ? err.message : String(err),
|
|
42
|
+
})).pipe(Effect.either);
|
|
43
|
+
if (remoteShow._tag === "Right") {
|
|
44
|
+
const raw = remoteShow.right.stdout;
|
|
45
|
+
const out = typeof raw === "string" ? raw : "";
|
|
46
|
+
const match = out.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
|
|
32
47
|
if (match?.[1])
|
|
33
48
|
return match[1];
|
|
34
49
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
50
|
+
return yield* Effect.fail(new BaseBranchDetectionFailed({
|
|
51
|
+
message: `Could not detect the default branch of ${repoPath}. ` +
|
|
52
|
+
`Set RUNWAY_BASE_BRANCH explicitly, or run ` +
|
|
53
|
+
`\`git remote set-head origin --auto\` to populate origin/HEAD.`,
|
|
54
|
+
}));
|
|
55
|
+
});
|
package/dist/github.js
CHANGED
|
@@ -1,4 +1,70 @@
|
|
|
1
|
+
import { Data, Effect, Schedule } from "effect";
|
|
1
2
|
import { execa } from "execa";
|
|
3
|
+
// VA-356: typed error ADT for the GitHub gateway. `GhCliMissing` is
|
|
4
|
+
// its own branch so the orchestrator (and `runway doctor`) can show
|
|
5
|
+
// an install hint instead of an opaque ENOENT. Push and PR failures
|
|
6
|
+
// are split because retry semantics differ — re-pushing the same
|
|
7
|
+
// branch is idempotent, re-running `gh pr create` after a partial
|
|
8
|
+
// failure can create duplicate PRs.
|
|
9
|
+
export class GhCliMissing extends Data.TaggedError("GhCliMissing") {
|
|
10
|
+
}
|
|
11
|
+
export class PushFailed extends Data.TaggedError("PushFailed") {
|
|
12
|
+
}
|
|
13
|
+
export class PrCreateFailed extends Data.TaggedError("PrCreateFailed") {
|
|
14
|
+
}
|
|
15
|
+
// VA-357: a hung `gh` or `git` subprocess becomes a typed timeout.
|
|
16
|
+
// Step 3 (VA-358) is where we add scoped subprocess cleanup; here the
|
|
17
|
+
// Effect's fiber gets interrupted but the underlying child may still
|
|
18
|
+
// be running. We accept that limitation for Step 2.
|
|
19
|
+
export class GithubTimeout extends Data.TaggedError("GithubTimeout") {
|
|
20
|
+
}
|
|
21
|
+
// VA-357: same jittered exponential shape as the Linear policy.
|
|
22
|
+
export const githubRetrySchedule = Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(5)), Schedule.jittered);
|
|
23
|
+
/**
|
|
24
|
+
* VA-357: timeout + optional retry policy for a GitHub call. Unlike
|
|
25
|
+
* Linear (where all methods are idempotent reads or idempotent
|
|
26
|
+
* updates), GitHub differs by method:
|
|
27
|
+
*
|
|
28
|
+
* - `push` is idempotent (re-pushing the same SHA is a no-op) ⇒ retry
|
|
29
|
+
* on timeout.
|
|
30
|
+
* - `gh pr create` is NOT idempotent — a retry after a partial failure
|
|
31
|
+
* can create a duplicate PR. Just bound it with a timeout, no
|
|
32
|
+
* retries.
|
|
33
|
+
*
|
|
34
|
+
* The caller picks the policy by passing a `retryOn` predicate (or
|
|
35
|
+
* omitting it for timeout-only).
|
|
36
|
+
*/
|
|
37
|
+
export const applyGithubPolicy = (effect, opts) => {
|
|
38
|
+
const withTimeout = effect.pipe(Effect.timeoutFail({
|
|
39
|
+
duration: `${opts.timeoutMs} millis`,
|
|
40
|
+
onTimeout: () => new GithubTimeout({
|
|
41
|
+
call: opts.call,
|
|
42
|
+
afterMs: opts.timeoutMs,
|
|
43
|
+
message: `${opts.call} timed out after ${opts.timeoutMs}ms`,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
if (!opts.retryOn)
|
|
47
|
+
return withTimeout;
|
|
48
|
+
return withTimeout.pipe(Effect.retry({
|
|
49
|
+
schedule: githubRetrySchedule,
|
|
50
|
+
while: opts.retryOn,
|
|
51
|
+
}));
|
|
52
|
+
};
|
|
53
|
+
function isCommandMissing(err) {
|
|
54
|
+
if (!(err instanceof Error))
|
|
55
|
+
return false;
|
|
56
|
+
const e = err;
|
|
57
|
+
if (e.code === "ENOENT")
|
|
58
|
+
return true;
|
|
59
|
+
return /\bcommand not found\b|: not found/i.test(e.message);
|
|
60
|
+
}
|
|
61
|
+
function execaStderr(err) {
|
|
62
|
+
if (err && typeof err === "object" && "stderr" in err) {
|
|
63
|
+
const s = err.stderr;
|
|
64
|
+
return typeof s === "string" ? s : "";
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
2
68
|
/**
|
|
3
69
|
* `gh` CLI-backed gateway. Runway runs on a host with `gh` authenticated
|
|
4
70
|
* (via `GH_TOKEN` or the user's keychain login); we don't reimplement
|
|
@@ -6,29 +72,78 @@ import { execa } from "execa";
|
|
|
6
72
|
*/
|
|
7
73
|
export function createGithubGateway() {
|
|
8
74
|
return {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
75
|
+
pushBranch(repoPath, branch) {
|
|
76
|
+
return applyGithubPolicy(Effect.tryPromise({
|
|
77
|
+
try: async () => {
|
|
78
|
+
await execa("git", ["push", "-u", "origin", branch], {
|
|
79
|
+
cwd: repoPath,
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
catch: (err) => {
|
|
84
|
+
if (isCommandMissing(err)) {
|
|
85
|
+
return new GhCliMissing({
|
|
86
|
+
message: `git not found on PATH: ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return new PushFailed({
|
|
90
|
+
branch,
|
|
91
|
+
stderr: execaStderr(err),
|
|
92
|
+
message: err instanceof Error
|
|
93
|
+
? err.message
|
|
94
|
+
: `push failed: ${String(err)}`,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
}), {
|
|
98
|
+
call: `pushBranch(${branch})`,
|
|
99
|
+
// `git push` can be slow for large branches on slow networks.
|
|
100
|
+
timeoutMs: 60_000,
|
|
101
|
+
// Push is idempotent; retry on timeout only. We don't retry
|
|
102
|
+
// PushFailed because a real failure (auth, conflict) won't
|
|
103
|
+
// resolve itself.
|
|
104
|
+
retryOn: (err) => err._tag === "GithubTimeout",
|
|
13
105
|
});
|
|
14
106
|
},
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
107
|
+
openPullRequest({ repoPath, branch, base, issue, body }) {
|
|
108
|
+
return applyGithubPolicy(Effect.tryPromise({
|
|
109
|
+
try: async () => {
|
|
110
|
+
const title = `${issue.identifier}: ${issue.title}`;
|
|
111
|
+
const { stdout } = await execa("gh", [
|
|
112
|
+
"pr",
|
|
113
|
+
"create",
|
|
114
|
+
"--base",
|
|
115
|
+
base,
|
|
116
|
+
"--head",
|
|
117
|
+
branch,
|
|
118
|
+
"--title",
|
|
119
|
+
title,
|
|
120
|
+
"--body",
|
|
121
|
+
body,
|
|
122
|
+
], { cwd: repoPath });
|
|
123
|
+
// `gh pr create` prints the URL on the last line.
|
|
124
|
+
return stdout.trim().split("\n").at(-1) ?? "";
|
|
125
|
+
},
|
|
126
|
+
catch: (err) => {
|
|
127
|
+
if (isCommandMissing(err)) {
|
|
128
|
+
return new GhCliMissing({
|
|
129
|
+
message: `gh CLI not found on PATH — install https://cli.github.com (${err instanceof Error ? err.message : String(err)})`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return new PrCreateFailed({
|
|
133
|
+
branch,
|
|
134
|
+
stderr: execaStderr(err),
|
|
135
|
+
message: err instanceof Error
|
|
136
|
+
? err.message
|
|
137
|
+
: `gh pr create failed: ${String(err)}`,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
}), {
|
|
141
|
+
call: `openPullRequest(${branch})`,
|
|
142
|
+
timeoutMs: 30_000,
|
|
143
|
+
// No retry: gh pr create is NOT idempotent — a partial
|
|
144
|
+
// success on the previous attempt could leave a PR behind,
|
|
145
|
+
// and a retry would create a duplicate.
|
|
146
|
+
});
|
|
32
147
|
},
|
|
33
148
|
};
|
|
34
149
|
}
|