@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 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
- sandcastle.run({ ..., prompt: review template })
50
- → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
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 label "ready-for-human", comment with reason
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. Check Linear for the `ready-for-human` label and the
241
- per-issue comments for what happened.
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
- v0.1scaffold complete, untested against a live queue.
387
+ 0.5.0production-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.
@@ -1,7 +1,9 @@
1
- import { loadConfig } from "../config.js";
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
- const baseConfig = loadConfig();
102
- const config = opts.project
103
- ? { ...baseConfig, linearProject: opts.project }
104
- : baseConfig;
105
- const linear = createLinearGateway(config);
106
- const github = createGithubGateway();
107
- const scope = config.linearProject
108
- ? `team ${config.linearTeam} / project ${config.linearProject}`
109
- : `team ${config.linearTeam}`;
110
- console.log(`[runway] draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
111
- const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
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 { z } from "zod";
1
+ import { Config as EConfig, Context, Effect, Layer, Option, } from "effect";
2
2
  /**
3
- * Runway runtime config. Loaded from process.env at startup. We fail
4
- * fast if a required value is missing — no point starting the loop and
5
- * blowing up halfway through an issue.
6
- *
7
- * Notable absences vs. typical agent runners:
8
- * - No ANTHROPIC_API_KEY here. Sandcastle reads it from the target
9
- * repo's `.sandcastle/.env` per its own conventions.
10
- * - No GH_TOKEN here. We use the `gh` CLI for PR creation; if the
11
- * user is logged in (`gh auth status`), it Just Works. If they
12
- * aren't, `gh pr create` errors out with a clear message — no need
13
- * for runway to second-guess.
14
- * - No RUNWAY_TARGET_REPO. Runway runs from inside the target repo
15
- * (`process.cwd()`), the same way `sandcastle run` does.
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 ConfigSchema.parse({
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 { execa } from "execa";
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
- * Throws if neither path resolves a branch name better to fail
10
- * fast at orchestrator startup than to crash mid-diff with a stale
11
- * "ambiguous argument" git error.
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 async function detectBaseBranch(repoPath) {
14
- // Fast path: local symbolic ref. Returns e.g. `origin/main` or `origin/master`.
15
- try {
16
- const { stdout, exitCode } = await execa("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoPath, reject: false });
17
- if (exitCode === 0) {
18
- const name = stdout.trim().replace(/^origin\//, "");
19
- if (name)
20
- return name;
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
- catch {
24
- // fall through to remote-show fallback
25
- }
26
- // Slow path: ask the remote. Output line looks like ` HEAD branch: master`.
27
- try {
28
- const { stdout } = await execa("git", ["remote", "show", "origin"], {
29
- cwd: repoPath,
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
- catch {
36
- // fall through to error
37
- }
38
- throw new Error(`Could not detect the default branch of ${repoPath}. ` +
39
- `Set RUNWAY_BASE_BRANCH explicitly, or run ` +
40
- `\`git remote set-head origin --auto\` to populate origin/HEAD.`);
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
- async pushBranch(repoPath, branch) {
10
- await execa("git", ["push", "-u", "origin", branch], {
11
- cwd: repoPath,
12
- stdio: "inherit",
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
- async openPullRequest({ repoPath, branch, base, issue, body }) {
16
- const title = `${issue.identifier}: ${issue.title}`;
17
- const { stdout } = await execa("gh", [
18
- "pr",
19
- "create",
20
- "--base",
21
- base,
22
- "--head",
23
- branch,
24
- "--title",
25
- title,
26
- "--body",
27
- body,
28
- ], { cwd: repoPath });
29
- // `gh pr create` prints the URL on the last line.
30
- const url = stdout.trim().split("\n").at(-1) ?? "";
31
- return url;
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
  }