@wardby/cli 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.
Files changed (223) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/env.js +6 -1
  67. package/dist/generated/prisma/browser.d.ts +23 -0
  68. package/dist/generated/prisma/client.d.ts +23 -0
  69. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  70. package/dist/generated/prisma/internal/class.d.ts +33 -0
  71. package/dist/generated/prisma/internal/class.js +4 -4
  72. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  73. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  75. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  76. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  77. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  78. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  79. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  80. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  81. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  83. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  84. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  85. package/dist/generated/prisma/models/LocalReview.js +1 -0
  86. package/dist/generated/prisma/models/Run.d.ts +223 -0
  87. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  88. package/dist/generated/prisma/models.d.ts +3 -0
  89. package/dist/help-index.json +476 -37
  90. package/dist/import/neutral-schema.d.ts +2 -2
  91. package/dist/mcp/auth/access.d.ts +3 -1
  92. package/dist/mcp/auth/host-account-cli.js +2 -2
  93. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  94. package/dist/mcp/auth/repo-authorization.js +21 -0
  95. package/dist/mcp/index.js +4 -1
  96. package/dist/mcp/tools/agents.js +46 -4
  97. package/dist/mcp/tools/host-accounts.js +2 -2
  98. package/dist/mcp/tools/repositories.js +71 -12
  99. package/dist/mcp/tools/runs.js +23 -0
  100. package/dist/mcp/tools/trigger.js +153 -24
  101. package/dist/providers/engine/types.d.ts +11 -0
  102. package/dist/providers/executor/composition.js +12 -7
  103. package/dist/providers/executor/container.d.ts +40 -3
  104. package/dist/providers/executor/container.js +140 -9
  105. package/dist/providers/executor/types.d.ts +1 -1
  106. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  107. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  108. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  109. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  110. package/dist/providers/jobs/kubernetes-client.js +3 -0
  111. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  112. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  113. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  114. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  115. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  116. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  117. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  118. package/dist/providers/jobs/kubernetes.js +50 -0
  119. package/dist/providers/jobs/types.d.ts +7 -0
  120. package/dist/providers/review-host/ci.d.ts +7 -0
  121. package/dist/providers/review-host/ci.js +20 -0
  122. package/dist/providers/review-host/github-events.d.ts +7 -0
  123. package/dist/providers/review-host/github-events.js +31 -7
  124. package/dist/providers/review-host/github.d.ts +19 -2
  125. package/dist/providers/review-host/github.js +145 -2
  126. package/dist/providers/review-host/index.d.ts +8 -2
  127. package/dist/providers/review-host/index.js +14 -3
  128. package/dist/providers/review-host/local.d.ts +72 -0
  129. package/dist/providers/review-host/local.js +434 -0
  130. package/dist/providers/review-host/types.d.ts +65 -3
  131. package/dist/providers/review-host/types.js +5 -3
  132. package/dist/providers/vcs/git.d.ts +10 -20
  133. package/dist/providers/vcs/git.js +71 -127
  134. package/dist/providers/vcs/github-remote.d.ts +67 -0
  135. package/dist/providers/vcs/github-remote.js +201 -0
  136. package/dist/providers/vcs/github.d.ts +81 -0
  137. package/dist/providers/vcs/github.js +203 -6
  138. package/dist/providers/vcs/index.d.ts +16 -1
  139. package/dist/providers/vcs/index.js +43 -15
  140. package/dist/providers/vcs/local-remote.d.ts +44 -0
  141. package/dist/providers/vcs/local-remote.js +108 -0
  142. package/dist/providers/vcs/remote.d.ts +58 -0
  143. package/dist/providers/vcs/remote.js +1 -0
  144. package/dist/providers/vcs/routing.d.ts +33 -0
  145. package/dist/providers/vcs/routing.js +52 -0
  146. package/dist/providers/vcs/types.d.ts +12 -1
  147. package/dist/quickstart/coding-db.d.ts +11 -0
  148. package/dist/quickstart/coding-db.js +75 -0
  149. package/dist/quickstart/coding-doctor.d.ts +13 -0
  150. package/dist/quickstart/coding-doctor.js +88 -0
  151. package/dist/quickstart/coding-images.d.ts +15 -0
  152. package/dist/quickstart/coding-images.js +79 -0
  153. package/dist/quickstart/coding-seed.d.ts +63 -0
  154. package/dist/quickstart/coding-seed.js +85 -0
  155. package/dist/quickstart/coding.d.ts +66 -0
  156. package/dist/quickstart/coding.js +430 -0
  157. package/dist/quickstart/images.d.ts +19 -0
  158. package/dist/quickstart/images.js +63 -0
  159. package/dist/quickstart/index.d.ts +6 -0
  160. package/dist/quickstart/index.js +72 -40
  161. package/dist/quickstart/starter-services.d.ts +45 -0
  162. package/dist/quickstart/starter-services.js +186 -0
  163. package/dist/quickstart-images.json +1 -0
  164. package/dist/serve.js +14 -1
  165. package/dist/viewer/api-schema.d.ts +176 -0
  166. package/dist/viewer/api-schema.js +27 -2
  167. package/dist/viewer/graph.d.ts +7 -2
  168. package/dist/viewer/graph.js +25 -11
  169. package/dist/viewer/http.d.ts +7 -1
  170. package/dist/viewer/http.js +8 -3
  171. package/dist/viewer/infra.d.ts +2 -0
  172. package/dist/viewer/infra.js +23 -0
  173. package/dist/viewer/run-detail.d.ts +2 -1
  174. package/dist/viewer/run-detail.js +2 -2
  175. package/docs/agent-recipes.md +123 -2
  176. package/docs/code-review-agents.md +293 -38
  177. package/docs/coding-agent-setup.md +141 -2
  178. package/docs/coding-packages.md +21 -0
  179. package/docs/coding-services.md +3 -0
  180. package/docs/coding-worker-isolation.md +87 -24
  181. package/docs/getting-started-gke.md +24 -0
  182. package/docs/getting-started.md +82 -8
  183. package/docs/jira-agents.md +26 -5
  184. package/docs/security-deployment.md +9 -0
  185. package/docs/viewer-api.md +21 -2
  186. package/help/admin-viewer.md +11 -1
  187. package/help/agent-recipes.md +25 -1
  188. package/help/builder-agent.md +4 -0
  189. package/help/code-review-agents.md +82 -2
  190. package/help/coding-packages.md +12 -1
  191. package/help/coding-services.md +4 -0
  192. package/help/cost-attribution.md +3 -1
  193. package/help/creating-agents.md +3 -0
  194. package/help/deploy-gke.md +2 -1
  195. package/help/errors/coding-provider-not-configured.md +60 -0
  196. package/help/errors/coding-turn-limit.md +25 -0
  197. package/help/errors/continuation-closed.md +75 -0
  198. package/help/errors/local-branch-conflict.md +37 -0
  199. package/help/errors/local-path-invalid.md +30 -0
  200. package/help/errors/local-ref-invalid.md +36 -0
  201. package/help/errors/local-ref-not-found.md +41 -0
  202. package/help/errors/local-repo-not-allowed.md +46 -0
  203. package/help/errors/local-repo-not-found.md +39 -0
  204. package/help/errors/model-unavailable.md +7 -6
  205. package/help/errors/vcs-github-not-configured.md +34 -0
  206. package/help/getting-started.md +4 -0
  207. package/help/github.md +22 -5
  208. package/help/jira.md +9 -2
  209. package/help/local-repositories.md +182 -0
  210. package/help/related-pull-requests.md +89 -0
  211. package/help/review-fix-rounds.md +69 -0
  212. package/help/troubleshooting/budgets.md +11 -0
  213. package/help/troubleshooting/coding-workers.md +22 -0
  214. package/help/troubleshooting/repository-access.md +5 -0
  215. package/package.json +5 -3
  216. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  217. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  218. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  219. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  220. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  221. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  222. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  223. package/prisma/schema.prisma +98 -1
@@ -6,7 +6,7 @@ import { gitExcludePathspecs, normalizeCollectExclusions } from "../../coding/co
6
6
  import { WARDBY_PROTECTED_PATHS, isProtectedPathException, isWellFormedProtectedPath, protectedPathBody, protectsSomePath, } from "../../coding/protected-paths.js";
7
7
  import { ensurePrivateDirectory } from "../../core/private-directory.js";
8
8
  import { globRegex } from "../../core/glob.js";
9
- import { MAX_REDACTED_SPAN, redactTokenShapedValues, normalizeGitHubRepository, normalizeGitRef, } from "../../coding/protocol.js";
9
+ import { MAX_REDACTED_SPAN, redactTokenShapedValues, normalizeGitRef } from "../../coding/protocol.js";
10
10
  import { isSafeGitHubInstallationToken } from "./github.js";
11
11
  export const DEFAULT_MAX_CHANGED_FILES = 1_000;
12
12
  export const DEFAULT_MAX_DIFF_BYTES = 1024 * 1024;
@@ -177,7 +177,7 @@ export function protectedPathMatcher(patterns) {
177
177
  return protectedPatterns.some((matcher) => matcher.test(path)) && !exceptions.has(path);
178
178
  };
179
179
  }
180
- function validateChangedPath(path) {
180
+ export function validateChangedPath(path) {
181
181
  if (!path ||
182
182
  isAbsolute(path) ||
183
183
  path.includes("\\") ||
@@ -195,19 +195,13 @@ function nullSeparated(value) {
195
195
  function directChild(root, child) {
196
196
  return child.startsWith(`${root}${sep}`) && !child.slice(root.length + 1).includes(sep);
197
197
  }
198
- /** Human-readable label for continuation status notifications -- falls back to the opaque run id alone when no agent name is known. */
199
- function runLabel(agentName, runId) {
200
- return agentName ? `${agentName} (wardby run ${runId})` : `wardby run ${runId}`;
201
- }
202
198
  export class GitVcsProvider {
203
- options;
204
199
  rootDir;
205
200
  git;
206
201
  maxChangedFiles;
207
202
  maxDiffBytes;
208
- cloneUrlForRepository;
203
+ remote;
209
204
  constructor(options) {
210
- this.options = options;
211
205
  this.rootDir = resolve(options.rootDir);
212
206
  if (this.rootDir === resolve("/"))
213
207
  throw new Error("vcs_root_invalid");
@@ -220,11 +214,11 @@ export class GitVcsProvider {
220
214
  this.maxDiffBytes <= 0) {
221
215
  throw new Error("vcs_limits_invalid");
222
216
  }
223
- this.cloneUrlForRepository =
224
- options.cloneUrlForRepository ?? ((repository) => `https://github.com/${repository}.git`);
217
+ this.remote = options.remote;
225
218
  }
226
219
  async prepareWorkspace(input) {
227
220
  const normalized = this.validateInput(input);
221
+ await this.remote.assertContinuationOpen(normalized);
228
222
  await ensurePrivateDirectory(this.rootDir);
229
223
  await mkdir(resolve(this.rootDir, ".home"), { recursive: true, mode: 0o700 });
230
224
  const runRoot = resolve(this.rootDir, normalized.runId);
@@ -240,10 +234,9 @@ export class GitVcsProvider {
240
234
  }
241
235
  const workspacePath = resolve(runRoot, "workspace");
242
236
  const gitMetadataPath = resolve(runRoot, "git");
243
- const cloneUrl = this.cloneUrlForRepository(normalized.repository);
244
- if (!this.options.cloneUrlForRepository && cloneUrl !== `https://github.com/${normalized.repository}.git`) {
245
- throw new Error("vcs_remote_invalid");
246
- }
237
+ // The remote validates its own URL (GitHubRemote: vcs_remote_invalid
238
+ // unless it is exactly github.com's, outside the test-only override).
239
+ const cloneUrl = this.remote.cloneUrl(normalized.repository);
247
240
  // Revision-in-place: a continuation clones the EXISTING headRef branch
248
241
  // directly (its current tip becomes baseCommit below) instead of
249
242
  // baseRef -- everything after this line (branch/checkout/reset, and
@@ -252,9 +245,24 @@ export class GitVcsProvider {
252
245
  // round started."
253
246
  const cloneBranch = normalized.continuation ? normalized.headRef : normalized.baseRef;
254
247
  try {
255
- await this.options.github.withRepositoryToken(normalized.repository, async (token) => {
248
+ await this.remote.withAccess(normalized.repository, async (token) => {
249
+ const auth = token !== undefined ? { authToken: token } : {};
250
+ if (this.remote.refNotFoundError) {
251
+ const listed = await this.git.run([
252
+ ...HARDENED_GIT_CONFIG,
253
+ ...this.remote.gitConfig,
254
+ "ls-remote",
255
+ "--heads",
256
+ cloneUrl,
257
+ `refs/heads/${cloneBranch}`,
258
+ ], { cwd: runRoot, ...auth });
259
+ const found = listed.stdout.split("\n").some((line) => line.split("\t")[1] === `refs/heads/${cloneBranch}`);
260
+ if (!found)
261
+ throw this.remote.refNotFoundError(cloneBranch);
262
+ }
256
263
  await this.git.run([
257
264
  ...HARDENED_GIT_CONFIG,
265
+ ...this.remote.gitConfig,
258
266
  "clone",
259
267
  "--no-checkout",
260
268
  "--single-branch",
@@ -271,7 +279,7 @@ export class GitVcsProvider {
271
279
  gitMetadataPath,
272
280
  cloneUrl,
273
281
  workspacePath,
274
- ], { cwd: runRoot, authToken: token });
282
+ ], { cwd: runRoot, ...auth });
275
283
  });
276
284
  await rm(resolve(workspacePath, ".git"), { force: true });
277
285
  const baseCommit = await this.revParseRaw(gitMetadataPath, workspacePath, `refs/remotes/origin/${cloneBranch}^{commit}`);
@@ -417,28 +425,21 @@ export class GitVcsProvider {
417
425
  throw new Error("vcs_finalized_workspace_changed");
418
426
  commitSha = currentHead;
419
427
  }
428
+ await this.remote.assertContinuationOpen(prepared);
420
429
  await this.pushOnce(prepared, commitSha);
421
- // Revision-in-place: identify the PR by the run that originally opened
422
- // it (createOrFindDraftPullRequest's marker-based lookup keys on that
423
- // run's id), not this run's own -- this finds the existing open PR and
424
- // returns it rather than creating a new one, since headRef/baseRef
425
- // already match it exactly. No other change needed here: pushing a new
426
- // commit onto that branch already updates the PR natively -- which is
427
- // also why a PR a person has since marked ready for review is accepted
428
- // (acceptReadyForReview) rather than failing an already-pushed run.
429
- const pullRequest = await this.options.github.createOrFindDraftPullRequest({
430
- runId: prepared.continuation?.rootRunId ?? prepared.runId,
431
- ...(prepared.continuation ? { acceptReadyForReview: true } : {}),
432
- repository: prepared.repository,
433
- baseRef: prepared.baseRef,
434
- headRef: prepared.headRef,
435
- summary: details?.summary,
436
- tests: details?.tests,
437
- tag: details?.tag,
438
- packages: details?.packages,
439
- packageRefusals: details?.packageRefusals,
440
- ...(details?.issue ? { issue: details.issue } : {}),
441
- });
430
+ const published = await this.remote.publish({ workspace: prepared, details, changedPaths: changed });
431
+ if (published.kind === "branch") {
432
+ if (published.branch !== prepared.headRef)
433
+ throw new Error("vcs_head_ref_mismatch");
434
+ return {
435
+ outcome: "branch_pushed",
436
+ repository: prepared.repository,
437
+ baseRef: prepared.baseRef,
438
+ baseCommit: prepared.baseCommit,
439
+ headRef: prepared.headRef,
440
+ commitSha,
441
+ };
442
+ }
442
443
  return {
443
444
  outcome: prepared.continuation ? "pull_request_updated" : "pull_request_opened",
444
445
  repository: prepared.repository,
@@ -446,8 +447,8 @@ export class GitVcsProvider {
446
447
  baseCommit: prepared.baseCommit,
447
448
  headRef: prepared.headRef,
448
449
  commitSha,
449
- pullRequestNumber: pullRequest.number,
450
- pullRequestUrl: pullRequest.url,
450
+ pullRequestNumber: published.number,
451
+ pullRequestUrl: published.url,
451
452
  };
452
453
  }
453
454
  async cleanup(workspace) {
@@ -460,102 +461,36 @@ export class GitVcsProvider {
460
461
  await rm(expected.runRoot, { recursive: true, force: true });
461
462
  }
462
463
  /**
463
- * Best-effort "wardby is working on this PR" signal (see VcsProvider) --
464
- * whole body wrapped so this can NEVER throw or otherwise affect the
465
- * real coding run, mirroring docker.ts's readWorkerFailureDiagnostic
466
- * ("diagnostics are optional and must never affect terminal cleanup").
467
- * No-op for a fresh (non-continuation) workspace: there's no PR to
468
- * attach anything to until its one commit lands.
464
+ * Best-effort "wardby is working on this PR" signal (see VcsProvider),
465
+ * delegated to the remote; a remote without one is a no-op. Wrapped so
466
+ * this can NEVER throw or otherwise affect the real coding run.
469
467
  */
470
468
  async notifyContinuationStarted(workspace, details) {
471
- if (!workspace.continuation)
472
- return;
473
469
  try {
474
- const identity = {
475
- runId: workspace.runId,
476
- rootRunId: workspace.continuation.rootRunId,
477
- repository: workspace.repository,
478
- baseRef: workspace.baseRef,
479
- headRef: workspace.headRef,
480
- };
481
- await Promise.allSettled([
482
- this.options.github.upsertContinuationStatusComment({
483
- ...identity,
484
- body: `🔄 ${runLabel(details?.agentName, workspace.runId)} is working on this PR...`,
485
- }),
486
- this.options.github.createContinuationCheckRun({
487
- repository: workspace.repository,
488
- headSha: workspace.baseCommit,
489
- runId: workspace.runId,
490
- }),
491
- ]);
470
+ await this.remote.notifyContinuationStarted?.(workspace, details);
492
471
  }
493
472
  catch {
494
473
  // Best-effort observability only -- must never affect the real run.
495
474
  }
496
475
  }
497
- /**
498
- * Companion to notifyContinuationStarted -- finds and updates whatever
499
- * that call created, never creates fresh state itself (see
500
- * upsertContinuationStatusComment vs updateContinuationStatusComment,
501
- * and createContinuationCheckRun vs completeContinuationCheckRun in
502
- * github.ts). Safe to call more than once for the same run. Same
503
- * never-throw contract as notifyContinuationStarted.
504
- */
476
+ /** Companion to notifyContinuationStarted, delegated to the remote under the same never-throw contract. */
505
477
  async notifyContinuationFinished(workspace, outcome, details) {
506
- if (!workspace.continuation)
507
- return;
508
478
  try {
509
- const identity = {
510
- runId: workspace.runId,
511
- rootRunId: workspace.continuation.rootRunId,
512
- repository: workspace.repository,
513
- baseRef: workspace.baseRef,
514
- headRef: workspace.headRef,
515
- };
516
- const label = runLabel(details?.agentName, workspace.runId);
517
- const summarySuffix = details?.summary ? `\n\n${details.summary}` : "";
518
- const budgetSuffix = details?.budgetSentence ? ` ${details.budgetSentence}` : "";
519
- const body = outcome === "succeeded"
520
- ? `✅ ${label} finished.${summarySuffix}`
521
- : outcome === "budget_exhausted"
522
- ? `❌ ${label} ran out of budget.${budgetSuffix}${summarySuffix}`
523
- : details?.serviceSentence
524
- ? `❌ ${label} could not start: ${details.serviceSentence}${summarySuffix}`
525
- : details?.providerSentence
526
- ? `❌ ${label} could not run: ${details.providerSentence}${summarySuffix}`
527
- : details?.protectedPathSentence
528
- ? `❌ ${label} could not open its changes: ${details.protectedPathSentence}${summarySuffix}`
529
- : `❌ ${label} failed.${summarySuffix}`;
530
- await Promise.allSettled([
531
- this.options.github.updateContinuationStatusComment({ ...identity, body }),
532
- this.options.github.completeContinuationCheckRun({
533
- repository: workspace.repository,
534
- headSha: workspace.baseCommit,
535
- runId: workspace.runId,
536
- outcome: outcome === "succeeded" ? "succeeded" : "failed",
537
- }),
538
- ]);
479
+ await this.remote.notifyContinuationFinished?.(workspace, outcome, details);
539
480
  }
540
481
  catch {
541
482
  // Best-effort observability only -- must never affect the real run.
542
483
  }
543
484
  }
544
485
  async readRepositoryFile(input) {
545
- const github = this.options.github;
546
- if (!github.readFileAtRef)
486
+ if (!this.remote.readRepositoryFile)
547
487
  throw new Error("vcs_read_file_unsupported");
548
- return github.readFileAtRef({
549
- repository: normalizeGitHubRepository(input.repository),
550
- ref: normalizeGitRef(input.ref),
551
- path: validateChangedPath(input.path),
552
- maxBytes: input.maxBytes,
553
- });
488
+ return this.remote.readRepositoryFile(input);
554
489
  }
555
490
  validateInput(input) {
556
491
  if (!SAFE_RUN_ID.test(input.runId))
557
492
  throw new Error("vcs_run_id_invalid");
558
- const repository = normalizeGitHubRepository(input.repository);
493
+ const repository = this.remote.normalizeRepository(input.repository);
559
494
  const baseRef = normalizeGitRef(input.baseRef);
560
495
  const headRef = normalizeGitRef(input.headRef);
561
496
  // Revision-in-place: a continuation's headRef legitimately belongs to a
@@ -660,7 +595,7 @@ export class GitVcsProvider {
660
595
  await visit(workspacePath);
661
596
  }
662
597
  async assertRemote(workspace) {
663
- const expectedUrl = this.cloneUrlForRepository(workspace.repository);
598
+ const expectedUrl = this.remote.cloneUrl(workspace.repository);
664
599
  const remotes = (await this.gitFor(workspace, ["remote"])).stdout.trim().split("\n").filter(Boolean);
665
600
  const fetchUrls = (await this.gitFor(workspace, ["remote", "get-url", "--all", "origin"])).stdout
666
601
  .trim()
@@ -701,33 +636,42 @@ export class GitVcsProvider {
701
636
  }
702
637
  }
703
638
  async pushOnce(workspace, commitSha) {
704
- await this.options.github.withRepositoryToken(workspace.repository, async (token) => {
705
- const remote = await this.remoteHead(workspace, token);
639
+ this.remote.assertPushRef?.(workspace.headRef);
640
+ await this.remote.withAccess(workspace.repository, async (token) => {
641
+ const auth = token !== undefined ? { authToken: token } : {};
642
+ const remote = await this.remoteHead(workspace, auth);
706
643
  if (remote === commitSha)
707
644
  return;
645
+ await this.remote.beforePush?.(workspace.repository, workspace.headRef);
708
646
  // A continuation's remote branch legitimately already sits at
709
647
  // baseCommit (that's the tip we cloned and committed on top of) --
710
648
  // that's the expected fast-forward pre-push state, not a conflict.
711
649
  // Any OTHER non-null value means something else moved the branch
712
650
  // since we cloned (a human push, a race), which is a real conflict.
713
651
  if (remote && remote !== workspace.baseCommit)
714
- throw new Error("vcs_head_ref_conflict");
652
+ throw this.remote.conflictError();
715
653
  try {
716
- await this.gitFor(workspace, ["push", "origin", `${commitSha}:refs/heads/${workspace.headRef}`], {
717
- authToken: token,
718
- });
654
+ await this.gitFor(workspace, [...this.remote.gitConfig, "push", "origin", `${commitSha}:refs/heads/${workspace.headRef}`], auth);
719
655
  }
720
656
  catch (error) {
721
- if ((await this.remoteHead(workspace, token)) === commitSha)
657
+ const after = await this.remoteHead(workspace, auth);
658
+ if (after === commitSha)
722
659
  return;
660
+ // The branch moved between the check above and the push (to something
661
+ // other than where we started or where we were going): report it as the
662
+ // remote's conflict rather than the push's own (non-fast-forward)
663
+ // failure. A remote still at baseCommit (a continuation) or still absent
664
+ // means the push itself failed -- auth, a protected branch, a network
665
+ // error, a rejecting hook -- so that error is the one to surface.
666
+ if (after !== null && after !== workspace.baseCommit && after !== commitSha) {
667
+ throw this.remote.conflictError();
668
+ }
723
669
  throw error;
724
670
  }
725
671
  });
726
672
  }
727
- async remoteHead(workspace, token) {
728
- const result = await this.gitFor(workspace, ["ls-remote", "--heads", "origin", `refs/heads/${workspace.headRef}`], {
729
- authToken: token,
730
- });
673
+ async remoteHead(workspace, auth) {
674
+ const result = await this.gitFor(workspace, [...this.remote.gitConfig, "ls-remote", "--heads", "origin", `refs/heads/${workspace.headRef}`], auth);
731
675
  const line = result.stdout.trim();
732
676
  if (!line)
733
677
  return null;
@@ -0,0 +1,67 @@
1
+ import { type GitHubRepositoryAccess, type RepositoryFileInput } from "./github.js";
2
+ import type { GitRemote, PublishInput, PublishResult } from "./remote.js";
3
+ import type { ContinuationFinishedDetails, ContinuationOutcome, PreparedWorkspace } from "./types.js";
4
+ export interface GitHubRemoteOptions {
5
+ github: GitHubRepositoryAccess;
6
+ /** Test-only transport override; production composition always uses github.com. */
7
+ cloneUrlForRepository?: (repository: string) => string;
8
+ /** Test-only delay override for assertContinuationOpen's single retry wait. */
9
+ sleep?: (milliseconds: number) => Promise<void>;
10
+ }
11
+ /** github.com through a GitHub App: installation tokens for git, draft pull requests after a push. */
12
+ export declare class GitHubRemote implements GitRemote {
13
+ private readonly options;
14
+ readonly gitConfig: readonly string[];
15
+ private readonly cloneUrlForRepository;
16
+ private readonly sleep;
17
+ constructor(options: GitHubRemoteOptions);
18
+ normalizeRepository(repository: string): string;
19
+ cloneUrl(repository: string): string;
20
+ withAccess<T>(repository: string, fn: (token: string | undefined) => Promise<T>): Promise<T>;
21
+ conflictError(): Error;
22
+ /**
23
+ * A continuation never pushes to a pull request that is no longer open.
24
+ * Fresh runs (no `continuation`) skip this, and so does a GitHub client
25
+ * that exposes no `findOpenPullRequest` (optional on GitHubRepositoryAccess).
26
+ *
27
+ * GitHub's answer is trusted only when it is definite: a found open PR
28
+ * proceeds normally, and a confirmed "no open PR" (a successful call that
29
+ * returns null) refuses outright with CONTINUATION_CLOSED_ERROR. A
30
+ * transient failure -- network error, 5xx, timeout, rate limit, anything
31
+ * that makes the call itself throw rather than answer -- is retried once;
32
+ * if it throws again, this proceeds as if the pull request were still
33
+ * open and logs a warning, rather than failing the run on an unconfirmed
34
+ * answer (especially not here, right before the push, after all the
35
+ * work is already done).
36
+ */
37
+ assertContinuationOpen(input: {
38
+ repository: string;
39
+ baseRef: string;
40
+ headRef: string;
41
+ continuation?: {
42
+ rootRunId: string;
43
+ };
44
+ }): Promise<void>;
45
+ publish({ workspace: prepared, details, changedPaths }: PublishInput): Promise<PublishResult>;
46
+ /**
47
+ * Best-effort "wardby is working on this PR" signal (see VcsProvider) --
48
+ * whole body wrapped so this can NEVER throw or otherwise affect the
49
+ * real coding run, mirroring docker.ts's readWorkerFailureDiagnostic
50
+ * ("diagnostics are optional and must never affect terminal cleanup").
51
+ * No-op for a fresh (non-continuation) workspace: there's no PR to
52
+ * attach anything to until its one commit lands.
53
+ */
54
+ notifyContinuationStarted(workspace: PreparedWorkspace, details?: {
55
+ agentName?: string;
56
+ }): Promise<void>;
57
+ /**
58
+ * Companion to notifyContinuationStarted -- finds and updates whatever
59
+ * that call created, never creates fresh state itself (see
60
+ * upsertContinuationStatusComment vs updateContinuationStatusComment,
61
+ * and createContinuationCheckRun vs completeContinuationCheckRun in
62
+ * github.ts). Safe to call more than once for the same run. Same
63
+ * never-throw contract as notifyContinuationStarted.
64
+ */
65
+ notifyContinuationFinished(workspace: PreparedWorkspace, outcome: ContinuationOutcome, details?: ContinuationFinishedDetails): Promise<void>;
66
+ readRepositoryFile(input: RepositoryFileInput): Promise<string | null>;
67
+ }
@@ -0,0 +1,201 @@
1
+ import { CONTINUATION_CLOSED_ERROR } from "../../coding/continuation-wording.js";
2
+ import { normalizeGitHubRepository, normalizeGitRef } from "../../coding/protocol.js";
3
+ import { logger } from "../../core/logger.js";
4
+ import { validateChangedPath } from "./git.js";
5
+ import { isLockfilePath, } from "./github.js";
6
+ /** How long assertContinuationOpen waits before its single retry on a transient open-PR check failure. */
7
+ const CONTINUATION_OPEN_RETRY_DELAY_MS = 1_000;
8
+ /** Human-readable label for continuation status notifications -- falls back to the opaque run id alone when no agent name is known. */
9
+ function runLabel(agentName, runId) {
10
+ return agentName ? `${agentName} (wardby run ${runId})` : `wardby run ${runId}`;
11
+ }
12
+ /** github.com through a GitHub App: installation tokens for git, draft pull requests after a push. */
13
+ export class GitHubRemote {
14
+ options;
15
+ gitConfig = [];
16
+ cloneUrlForRepository;
17
+ sleep;
18
+ constructor(options) {
19
+ this.options = options;
20
+ this.cloneUrlForRepository =
21
+ options.cloneUrlForRepository ?? ((repository) => `https://github.com/${repository}.git`);
22
+ this.sleep =
23
+ options.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)));
24
+ }
25
+ normalizeRepository(repository) {
26
+ return normalizeGitHubRepository(repository);
27
+ }
28
+ cloneUrl(repository) {
29
+ const cloneUrl = this.cloneUrlForRepository(repository);
30
+ if (!this.options.cloneUrlForRepository && cloneUrl !== `https://github.com/${repository}.git`) {
31
+ throw new Error("vcs_remote_invalid");
32
+ }
33
+ return cloneUrl;
34
+ }
35
+ withAccess(repository, fn) {
36
+ return this.options.github.withRepositoryToken(repository, fn);
37
+ }
38
+ conflictError() {
39
+ return new Error("vcs_head_ref_conflict");
40
+ }
41
+ /**
42
+ * A continuation never pushes to a pull request that is no longer open.
43
+ * Fresh runs (no `continuation`) skip this, and so does a GitHub client
44
+ * that exposes no `findOpenPullRequest` (optional on GitHubRepositoryAccess).
45
+ *
46
+ * GitHub's answer is trusted only when it is definite: a found open PR
47
+ * proceeds normally, and a confirmed "no open PR" (a successful call that
48
+ * returns null) refuses outright with CONTINUATION_CLOSED_ERROR. A
49
+ * transient failure -- network error, 5xx, timeout, rate limit, anything
50
+ * that makes the call itself throw rather than answer -- is retried once;
51
+ * if it throws again, this proceeds as if the pull request were still
52
+ * open and logs a warning, rather than failing the run on an unconfirmed
53
+ * answer (especially not here, right before the push, after all the
54
+ * work is already done).
55
+ */
56
+ async assertContinuationOpen(input) {
57
+ if (!input.continuation || !this.options.github.findOpenPullRequest)
58
+ return;
59
+ const request = {
60
+ runId: input.continuation.rootRunId,
61
+ repository: input.repository,
62
+ baseRef: input.baseRef,
63
+ headRef: input.headRef,
64
+ };
65
+ let open;
66
+ try {
67
+ open = await this.options.github.findOpenPullRequest(request);
68
+ }
69
+ catch {
70
+ await this.sleep(CONTINUATION_OPEN_RETRY_DELAY_MS);
71
+ try {
72
+ open = await this.options.github.findOpenPullRequest(request);
73
+ }
74
+ catch (error) {
75
+ logger.warn({ err: error, runId: request.runId, repository: request.repository }, "continuation open-PR check failed twice in a row; proceeding as if the pull request is still open");
76
+ return;
77
+ }
78
+ }
79
+ if (!open)
80
+ throw new Error(CONTINUATION_CLOSED_ERROR);
81
+ }
82
+ async publish({ workspace: prepared, details, changedPaths }) {
83
+ // Revision-in-place: identify the PR by the run that originally opened
84
+ // it (createOrFindDraftPullRequest's marker-based lookup keys on that
85
+ // run's id), not this run's own -- this finds the existing open PR and
86
+ // returns it rather than creating a new one, since headRef/baseRef
87
+ // already match it exactly. No other change needed here: pushing a new
88
+ // commit onto that branch already updates the PR natively -- which is
89
+ // also why a PR a person has since marked ready for review is accepted
90
+ // (acceptReadyForReview) rather than failing an already-pushed run.
91
+ const pullRequest = await this.options.github.createOrFindDraftPullRequest({
92
+ runId: prepared.continuation?.rootRunId ?? prepared.runId,
93
+ ...(prepared.continuation ? { acceptReadyForReview: true } : {}),
94
+ repository: prepared.repository,
95
+ baseRef: prepared.baseRef,
96
+ headRef: prepared.headRef,
97
+ summary: details?.summary,
98
+ tests: details?.tests,
99
+ tag: details?.tag,
100
+ packages: details?.packages,
101
+ packageRefusals: details?.packageRefusals,
102
+ changedLockfiles: changedPaths.filter(isLockfilePath),
103
+ ...(details?.issue ? { issue: details.issue } : {}),
104
+ ...(details?.related ? { related: details.related } : {}),
105
+ });
106
+ return { kind: "pull_request", number: pullRequest.number, url: pullRequest.url };
107
+ }
108
+ /**
109
+ * Best-effort "wardby is working on this PR" signal (see VcsProvider) --
110
+ * whole body wrapped so this can NEVER throw or otherwise affect the
111
+ * real coding run, mirroring docker.ts's readWorkerFailureDiagnostic
112
+ * ("diagnostics are optional and must never affect terminal cleanup").
113
+ * No-op for a fresh (non-continuation) workspace: there's no PR to
114
+ * attach anything to until its one commit lands.
115
+ */
116
+ async notifyContinuationStarted(workspace, details) {
117
+ if (!workspace.continuation)
118
+ return;
119
+ try {
120
+ const identity = {
121
+ runId: workspace.runId,
122
+ rootRunId: workspace.continuation.rootRunId,
123
+ repository: workspace.repository,
124
+ baseRef: workspace.baseRef,
125
+ headRef: workspace.headRef,
126
+ };
127
+ await Promise.allSettled([
128
+ this.options.github.upsertContinuationStatusComment({
129
+ ...identity,
130
+ body: `🔄 ${runLabel(details?.agentName, workspace.runId)} is working on this PR...`,
131
+ }),
132
+ this.options.github.createContinuationCheckRun({
133
+ repository: workspace.repository,
134
+ headSha: workspace.baseCommit,
135
+ runId: workspace.runId,
136
+ }),
137
+ ]);
138
+ }
139
+ catch {
140
+ // Best-effort observability only -- must never affect the real run.
141
+ }
142
+ }
143
+ /**
144
+ * Companion to notifyContinuationStarted -- finds and updates whatever
145
+ * that call created, never creates fresh state itself (see
146
+ * upsertContinuationStatusComment vs updateContinuationStatusComment,
147
+ * and createContinuationCheckRun vs completeContinuationCheckRun in
148
+ * github.ts). Safe to call more than once for the same run. Same
149
+ * never-throw contract as notifyContinuationStarted.
150
+ */
151
+ async notifyContinuationFinished(workspace, outcome, details) {
152
+ if (!workspace.continuation)
153
+ return;
154
+ try {
155
+ const identity = {
156
+ runId: workspace.runId,
157
+ rootRunId: workspace.continuation.rootRunId,
158
+ repository: workspace.repository,
159
+ baseRef: workspace.baseRef,
160
+ headRef: workspace.headRef,
161
+ };
162
+ const label = runLabel(details?.agentName, workspace.runId);
163
+ const summarySuffix = details?.summary ? `\n\n${details.summary}` : "";
164
+ const budgetSuffix = details?.budgetSentence ? ` ${details.budgetSentence}` : "";
165
+ const body = outcome === "succeeded"
166
+ ? `✅ ${label} finished.${summarySuffix}`
167
+ : outcome === "budget_exhausted"
168
+ ? `❌ ${label} ran out of budget.${budgetSuffix}${summarySuffix}`
169
+ : details?.serviceSentence
170
+ ? `❌ ${label} could not start: ${details.serviceSentence}${summarySuffix}`
171
+ : details?.providerSentence
172
+ ? `❌ ${label} could not run: ${details.providerSentence}${summarySuffix}`
173
+ : details?.protectedPathSentence
174
+ ? `❌ ${label} could not open its changes: ${details.protectedPathSentence}${summarySuffix}`
175
+ : `❌ ${label} failed.${summarySuffix}`;
176
+ await Promise.allSettled([
177
+ this.options.github.updateContinuationStatusComment({ ...identity, body }),
178
+ this.options.github.completeContinuationCheckRun({
179
+ repository: workspace.repository,
180
+ headSha: workspace.baseCommit,
181
+ runId: workspace.runId,
182
+ outcome: outcome === "succeeded" ? "succeeded" : "failed",
183
+ }),
184
+ ]);
185
+ }
186
+ catch {
187
+ // Best-effort observability only -- must never affect the real run.
188
+ }
189
+ }
190
+ async readRepositoryFile(input) {
191
+ const github = this.options.github;
192
+ if (!github.readFileAtRef)
193
+ throw new Error("vcs_read_file_unsupported");
194
+ return github.readFileAtRef({
195
+ repository: normalizeGitHubRepository(input.repository),
196
+ ref: normalizeGitRef(input.ref),
197
+ path: validateChangedPath(input.path),
198
+ maxBytes: input.maxBytes,
199
+ });
200
+ }
201
+ }