@wardby/cli 0.4.1 → 0.5.1

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 (221) 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/generated/prisma/browser.d.ts +23 -0
  67. package/dist/generated/prisma/client.d.ts +23 -0
  68. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  69. package/dist/generated/prisma/internal/class.d.ts +33 -0
  70. package/dist/generated/prisma/internal/class.js +4 -4
  71. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  72. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  73. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  75. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  76. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  77. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  78. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  79. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  80. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  81. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  83. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  84. package/dist/generated/prisma/models/LocalReview.js +1 -0
  85. package/dist/generated/prisma/models/Run.d.ts +223 -0
  86. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  87. package/dist/generated/prisma/models.d.ts +3 -0
  88. package/dist/help-index.json +486 -37
  89. package/dist/import/neutral-schema.d.ts +2 -2
  90. package/dist/mcp/auth/access.d.ts +3 -1
  91. package/dist/mcp/auth/host-account-cli.js +2 -2
  92. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  93. package/dist/mcp/auth/repo-authorization.js +21 -0
  94. package/dist/mcp/index.js +4 -1
  95. package/dist/mcp/tools/agents.js +46 -4
  96. package/dist/mcp/tools/host-accounts.js +2 -2
  97. package/dist/mcp/tools/repositories.js +71 -12
  98. package/dist/mcp/tools/runs.js +23 -0
  99. package/dist/mcp/tools/trigger.js +153 -24
  100. package/dist/providers/engine/types.d.ts +11 -0
  101. package/dist/providers/executor/composition.js +12 -7
  102. package/dist/providers/executor/container.d.ts +40 -3
  103. package/dist/providers/executor/container.js +140 -9
  104. package/dist/providers/executor/types.d.ts +1 -1
  105. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  106. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  107. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  108. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  109. package/dist/providers/jobs/kubernetes-client.js +3 -0
  110. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  111. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  112. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  113. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  114. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  115. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  116. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  117. package/dist/providers/jobs/kubernetes.js +50 -0
  118. package/dist/providers/jobs/types.d.ts +7 -0
  119. package/dist/providers/review-host/ci.d.ts +7 -0
  120. package/dist/providers/review-host/ci.js +20 -0
  121. package/dist/providers/review-host/github-events.d.ts +7 -0
  122. package/dist/providers/review-host/github-events.js +31 -7
  123. package/dist/providers/review-host/github.d.ts +19 -2
  124. package/dist/providers/review-host/github.js +145 -2
  125. package/dist/providers/review-host/index.d.ts +8 -2
  126. package/dist/providers/review-host/index.js +14 -3
  127. package/dist/providers/review-host/local.d.ts +72 -0
  128. package/dist/providers/review-host/local.js +434 -0
  129. package/dist/providers/review-host/types.d.ts +65 -3
  130. package/dist/providers/review-host/types.js +5 -3
  131. package/dist/providers/vcs/git.d.ts +10 -20
  132. package/dist/providers/vcs/git.js +71 -127
  133. package/dist/providers/vcs/github-remote.d.ts +67 -0
  134. package/dist/providers/vcs/github-remote.js +201 -0
  135. package/dist/providers/vcs/github.d.ts +81 -0
  136. package/dist/providers/vcs/github.js +203 -6
  137. package/dist/providers/vcs/index.d.ts +16 -1
  138. package/dist/providers/vcs/index.js +43 -15
  139. package/dist/providers/vcs/local-remote.d.ts +44 -0
  140. package/dist/providers/vcs/local-remote.js +108 -0
  141. package/dist/providers/vcs/remote.d.ts +58 -0
  142. package/dist/providers/vcs/remote.js +1 -0
  143. package/dist/providers/vcs/routing.d.ts +33 -0
  144. package/dist/providers/vcs/routing.js +52 -0
  145. package/dist/providers/vcs/types.d.ts +12 -1
  146. package/dist/quickstart/coding-db.d.ts +11 -0
  147. package/dist/quickstart/coding-db.js +75 -0
  148. package/dist/quickstart/coding-doctor.d.ts +13 -0
  149. package/dist/quickstart/coding-doctor.js +88 -0
  150. package/dist/quickstart/coding-images.d.ts +15 -0
  151. package/dist/quickstart/coding-images.js +79 -0
  152. package/dist/quickstart/coding-seed.d.ts +63 -0
  153. package/dist/quickstart/coding-seed.js +85 -0
  154. package/dist/quickstart/coding.d.ts +66 -0
  155. package/dist/quickstart/coding.js +430 -0
  156. package/dist/quickstart/images.d.ts +19 -0
  157. package/dist/quickstart/images.js +63 -0
  158. package/dist/quickstart/index.js +61 -12
  159. package/dist/quickstart/starter-services.d.ts +45 -0
  160. package/dist/quickstart/starter-services.js +186 -0
  161. package/dist/quickstart-images.json +1 -0
  162. package/dist/serve.js +14 -1
  163. package/dist/viewer/api-schema.d.ts +176 -0
  164. package/dist/viewer/api-schema.js +27 -2
  165. package/dist/viewer/graph.d.ts +7 -2
  166. package/dist/viewer/graph.js +25 -11
  167. package/dist/viewer/http.d.ts +7 -1
  168. package/dist/viewer/http.js +8 -3
  169. package/dist/viewer/infra.d.ts +2 -0
  170. package/dist/viewer/infra.js +23 -0
  171. package/dist/viewer/run-detail.d.ts +2 -1
  172. package/dist/viewer/run-detail.js +2 -2
  173. package/docs/agent-recipes.md +127 -5
  174. package/docs/code-review-agents.md +293 -38
  175. package/docs/coding-agent-setup.md +141 -2
  176. package/docs/coding-packages.md +21 -0
  177. package/docs/coding-services.md +3 -0
  178. package/docs/coding-worker-isolation.md +56 -20
  179. package/docs/getting-started-gke.md +24 -0
  180. package/docs/getting-started.md +196 -10
  181. package/docs/jira-agents.md +26 -5
  182. package/docs/security-deployment.md +9 -0
  183. package/docs/viewer-api.md +21 -2
  184. package/help/admin-viewer.md +11 -1
  185. package/help/agent-recipes.md +30 -4
  186. package/help/builder-agent.md +4 -0
  187. package/help/code-review-agents.md +82 -2
  188. package/help/coding-packages.md +12 -1
  189. package/help/coding-services.md +4 -0
  190. package/help/cost-attribution.md +3 -1
  191. package/help/creating-agents.md +3 -0
  192. package/help/deploy-gke.md +2 -1
  193. package/help/errors/coding-provider-not-configured.md +60 -0
  194. package/help/errors/coding-turn-limit.md +25 -0
  195. package/help/errors/continuation-closed.md +75 -0
  196. package/help/errors/local-branch-conflict.md +37 -0
  197. package/help/errors/local-path-invalid.md +30 -0
  198. package/help/errors/local-ref-invalid.md +36 -0
  199. package/help/errors/local-ref-not-found.md +41 -0
  200. package/help/errors/local-repo-not-allowed.md +46 -0
  201. package/help/errors/local-repo-not-found.md +39 -0
  202. package/help/errors/model-unavailable.md +7 -6
  203. package/help/errors/vcs-github-not-configured.md +34 -0
  204. package/help/getting-started.md +28 -0
  205. package/help/github.md +22 -5
  206. package/help/jira.md +9 -2
  207. package/help/local-repositories.md +182 -0
  208. package/help/related-pull-requests.md +89 -0
  209. package/help/review-fix-rounds.md +69 -0
  210. package/help/troubleshooting/budgets.md +11 -0
  211. package/help/troubleshooting/coding-workers.md +22 -0
  212. package/help/troubleshooting/repository-access.md +5 -0
  213. package/package.json +3 -2
  214. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  215. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  216. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  217. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  218. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  219. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  220. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  221. package/prisma/schema.prisma +98 -1
@@ -32,6 +32,8 @@ export interface PullRequestInput {
32
32
  /** Deduplicated by the caller isn't required — pullRequestBody dedupes itself (ecosystem+name+version / +reason). */
33
33
  packages?: readonly PackageReport[];
34
34
  packageRefusals?: readonly PackageRefusal[];
35
+ /** Lock files the run changed (repository paths); with refusals, the PR body warns they may be incomplete. */
36
+ changedLockfiles?: readonly string[];
35
37
  /**
36
38
  * A continuation re-finds the PR its root run opened, and a person may
37
39
  * have marked that PR ready for review since. The push has already updated
@@ -53,6 +55,11 @@ export interface PullRequestInput {
53
55
  url?: string;
54
56
  trackerName?: string;
55
57
  };
58
+ /**
59
+ * The request's other pull requests (control-plane rows only), rendered as
60
+ * the marked "Related pull requests" section. Rendering failures omit it.
61
+ */
62
+ related?: RelatedPullRequestsInput;
56
63
  }
57
64
  export interface PullRequestResult {
58
65
  number: number;
@@ -90,6 +97,17 @@ export interface RepositoryFileInput {
90
97
  export interface GitHubRepositoryAccess {
91
98
  withRepositoryToken<T>(repository: string, action: (token: string) => Promise<T>): Promise<T>;
92
99
  createOrFindDraftPullRequest(input: PullRequestInput): Promise<PullRequestResult>;
100
+ /**
101
+ * The root run's marked pull request if it is still open (draft or ready),
102
+ * else null (merged, closed, or gone). Optional: without it, continuations
103
+ * are not checked.
104
+ */
105
+ findOpenPullRequest?(input: {
106
+ runId: string;
107
+ repository: string;
108
+ baseRef: string;
109
+ headRef: string;
110
+ }): Promise<PullRequestResult | null>;
93
111
  /** Creates the status comment if none exists yet for this run, else updates it. */
94
112
  upsertContinuationStatusComment(input: ContinuationStatusCommentInput): Promise<void>;
95
113
  /** Updates the status comment if one already exists for this run; a no-op otherwise (never creates). */
@@ -115,10 +133,61 @@ export declare function isSafeGitHubInstallationToken(value: unknown): value is
115
133
  */
116
134
  export declare const PR_ISSUE_KEY: RegExp;
117
135
  export declare function pullRequestTitle(input: PullRequestInput): string;
136
+ export declare const RELATED_SECTION_START = "<!-- wardby-related:start -->";
137
+ export declare const RELATED_SECTION_END = "<!-- wardby-related:end -->";
138
+ /** At most this many pull requests are listed; the rest are counted. */
139
+ export declare const MAX_RELATED_PULL_REQUESTS = 20;
140
+ /** GitHub rejects a pull request body longer than this. */
141
+ export declare const MAX_PULL_REQUEST_BODY_CHARS = 65536;
142
+ export type RelatedPullRequestState = "open" | "draft" | "merged" | "closed";
143
+ /** One pull request of the request's set, from stored control-plane rows only. */
144
+ export interface RelatedPullRequestEntry {
145
+ repository: string;
146
+ /** Absent only for `self` while the pull request is being created. */
147
+ number?: number;
148
+ /** Live state; absent when unknown (at creation). */
149
+ state?: RelatedPullRequestState;
150
+ /** The pull request whose description this section is in. */
151
+ self?: boolean;
152
+ }
153
+ export interface RelatedPullRequestsInput {
154
+ /** In suggested merge order (the order the request's coding runs were dispatched). */
155
+ entries: readonly RelatedPullRequestEntry[];
156
+ issue?: {
157
+ key: string;
158
+ url?: string;
159
+ trackerName?: string;
160
+ };
161
+ }
162
+ /**
163
+ * The "Related pull requests" block, markers included, built only from
164
+ * stored rows (never agent text). Open (and draft, and not-yet-known) pull
165
+ * requests form the numbered suggested merge order; merged and closed ones
166
+ * follow as context. Undefined when no other valid pull request is left after
167
+ * validation: a set of one is not a set.
168
+ */
169
+ export declare function renderRelatedSection(input: RelatedPullRequestsInput): string | undefined;
170
+ /**
171
+ * The body with `block` in place of the existing marked section, or inserted
172
+ * after the run marker (and the issue line) when there is none. Null — do not
173
+ * write — when the body has no leading run marker, its section markers are
174
+ * malformed (one missing, reversed, or repeated), or `block` isn't a marked block.
175
+ */
176
+ export declare function upsertRelatedSection(body: string, block: string): string | null;
118
177
  /** At most this many packages, and separately this many refusals, are listed
119
178
  * in a PR body; the rest are counted in a "…and N more" line pointing at
120
179
  * get_run, which returns the full list. */
121
180
  export declare const MAX_PR_PACKAGE_LINES = 100;
181
+ export declare function isLockfilePath(path: string): boolean;
182
+ /**
183
+ * A visible warning when the registry refused packages: the run's own
184
+ * installs were then incomplete, so failed checks in Tests may be the
185
+ * sandbox, not the change, and a changed lock file may not install in CI.
186
+ * Built only from registry records and the run's own diff, never from the
187
+ * agent's summary, so it cannot be softened or left out by the model.
188
+ */
189
+ /** Rendered verbatim in refusalWarning's first line, and used to locate it in tests. */
190
+ export declare const DEPENDENCY_INSTALL_INCOMPLETE = "Dependency install incomplete.";
122
191
  /** The hidden run marker stays first and unconditional: createOrFindDraftPullRequest's idempotent lookup depends on it. */
123
192
  export declare function pullRequestBody(input: PullRequestInput): string;
124
193
  export declare class GitHubAppClient implements GitHubRepositoryAccess {
@@ -169,6 +238,18 @@ export declare class GitHubAppClient implements GitHubRepositoryAccess {
169
238
  createContinuationCheckRun(input: ContinuationCheckRunInput): Promise<void>;
170
239
  completeContinuationCheckRun(input: ContinuationCheckRunCompleteInput): Promise<void>;
171
240
  createOrFindDraftPullRequest(input: PullRequestInput): Promise<PullRequestResult>;
241
+ /**
242
+ * The root run's marked pull request if GitHub's `state=open` list still
243
+ * contains it (draft or ready for review), else null -- merged, closed, or
244
+ * gone. Used by git.ts's revision-in-place continuation to refuse a push to
245
+ * a pull request that is no longer open.
246
+ */
247
+ findOpenPullRequest(input: {
248
+ runId: string;
249
+ repository: string;
250
+ baseRef: string;
251
+ headRef: string;
252
+ }): Promise<PullRequestResult | null>;
172
253
  private appJwt;
173
254
  private mintRepositoryToken;
174
255
  /**
@@ -49,14 +49,22 @@ export function pullRequestTitle(input) {
49
49
  const key = validIssueKey(input);
50
50
  return key ? `[${key}] ${base}` : base;
51
51
  }
52
- function issueLine(input) {
53
- const key = validIssueKey(input);
54
- const url = input.issue?.url;
55
- if (!key || typeof url !== "string" || !url.startsWith("https://") || /[\s<>()]/.test(url))
52
+ /** Validated issue parts; the tracker prefix is "" or "<Name> ". */
53
+ function issueParts(issue) {
54
+ const key = issue?.key;
55
+ if (typeof key !== "string" || !PR_ISSUE_KEY.test(key))
56
56
  return undefined;
57
- const name = input.issue?.trackerName;
57
+ const url = issue?.url;
58
+ const safeUrl = typeof url === "string" && url.startsWith("https://") && !/[\s<>()]/.test(url) ? url : undefined;
59
+ const name = issue?.trackerName;
58
60
  const tracker = typeof name === "string" && /^[A-Za-z][A-Za-z0-9 .-]{0,39}$/.test(name) ? `${name.trim()} ` : "";
59
- return `Resolves ${tracker}issue [${key}](${url})`;
61
+ return { key, ...(safeUrl ? { url: safeUrl } : {}), tracker };
62
+ }
63
+ function issueLine(input) {
64
+ const parts = issueParts(input.issue);
65
+ if (!parts?.url)
66
+ return undefined;
67
+ return `Resolves ${parts.tracker}issue [${parts.key}](${parts.url})`;
60
68
  }
61
69
  /**
62
70
  * Package names/versions/reasons come from registry metadata, not from
@@ -69,6 +77,105 @@ function issueLine(input) {
69
77
  function isSafeMarkdownFragment(value) {
70
78
  return !value.includes("`") && !value.includes("\n") && !value.includes("\r");
71
79
  }
80
+ export const RELATED_SECTION_START = "<!-- wardby-related:start -->";
81
+ export const RELATED_SECTION_END = "<!-- wardby-related:end -->";
82
+ /** At most this many pull requests are listed; the rest are counted. */
83
+ export const MAX_RELATED_PULL_REQUESTS = 20;
84
+ /** GitHub rejects a pull request body longer than this. */
85
+ export const MAX_PULL_REQUEST_BODY_CHARS = 65_536;
86
+ const RELATED_STATES = new Set(["open", "draft", "merged", "closed"]);
87
+ /** The repository normalized, or undefined when it isn't a plain owner/name safe inside a link. */
88
+ function safeRepository(value) {
89
+ try {
90
+ const repository = normalizeGitHubRepository(value);
91
+ return isSafeMarkdownFragment(repository) && !/[[\]()<>\s]/.test(repository) ? repository : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ /** One list line; `prefix` is "1." … for the merge order, "-" for the context list. */
98
+ function relatedLine(entry, prefix) {
99
+ const repository = safeRepository(entry.repository);
100
+ if (!repository)
101
+ return undefined;
102
+ const state = entry.state && RELATED_STATES.has(entry.state) ? ` — ${entry.state}` : "";
103
+ if (entry.self)
104
+ return `${prefix} **This pull request**${state}`;
105
+ if (!Number.isSafeInteger(entry.number) || (entry.number ?? 0) <= 0)
106
+ return undefined;
107
+ return `${prefix} [${repository}#${entry.number}](https://github.com/${repository}/pull/${entry.number})${state}`;
108
+ }
109
+ const isDone = (entry) => entry.state === "merged" || entry.state === "closed";
110
+ /**
111
+ * The "Related pull requests" block, markers included, built only from
112
+ * stored rows (never agent text). Open (and draft, and not-yet-known) pull
113
+ * requests form the numbered suggested merge order; merged and closed ones
114
+ * follow as context. Undefined when no other valid pull request is left after
115
+ * validation: a set of one is not a set.
116
+ */
117
+ export function renderRelatedSection(input) {
118
+ const valid = input.entries.filter((entry) => relatedLine(entry, "-") !== undefined);
119
+ if (!valid.some((entry) => !entry.self))
120
+ return undefined;
121
+ const issue = issueParts(input.issue);
122
+ const issueText = issue ? ` for ${issue.tracker}issue ${issue.url ? `[${issue.key}](${issue.url})` : issue.key}` : "";
123
+ const open = valid.filter((entry) => !isDone(entry)).slice(0, MAX_RELATED_PULL_REQUESTS);
124
+ const done = valid.filter(isDone).slice(0, MAX_RELATED_PULL_REQUESTS - open.length);
125
+ const more = valid.length - open.length - done.length;
126
+ return [
127
+ RELATED_SECTION_START,
128
+ "**Related pull requests**",
129
+ "",
130
+ `Wardby opened these pull requests for the same request${issueText}.`,
131
+ ...(open.length > 0
132
+ ? [
133
+ "",
134
+ "Suggested merge order (the order Wardby's agent opened them in; not a guarantee, so check dependencies before merging):",
135
+ "",
136
+ ...open.map((entry, index) => relatedLine(entry, `${index + 1}.`)),
137
+ ]
138
+ : []),
139
+ ...(done.length > 0
140
+ ? ["", "Already merged or closed (context only):", "", ...done.map((entry) => relatedLine(entry, "-"))]
141
+ : []),
142
+ ...(more > 0 ? ["", `…and ${more} more`] : []),
143
+ "",
144
+ "<sub>Written by Wardby from its run records; this section is replaced when the request's runs finish.</sub>",
145
+ RELATED_SECTION_END,
146
+ ].join("\n");
147
+ }
148
+ /** The run marker, then optionally blank lines and the "Resolves …" line: the section goes right after. */
149
+ const BODY_HEAD = /^[^\S\n]*<!-- (?:wardby|reevo-run):\S+ -->[^\S\n]*(?:\n|$)(?:[^\S\n]*\n)*(?:Resolves [^\n]*(?:\n|$))?/;
150
+ /**
151
+ * The body with `block` in place of the existing marked section, or inserted
152
+ * after the run marker (and the issue line) when there is none. Null — do not
153
+ * write — when the body has no leading run marker, its section markers are
154
+ * malformed (one missing, reversed, or repeated), or `block` isn't a marked block.
155
+ */
156
+ export function upsertRelatedSection(body, block) {
157
+ if (!block.startsWith(RELATED_SECTION_START) || !block.endsWith(RELATED_SECTION_END))
158
+ return null;
159
+ const text = body.replace(/\r\n/g, "\n");
160
+ const start = text.indexOf(RELATED_SECTION_START);
161
+ const end = text.indexOf(RELATED_SECTION_END);
162
+ if (start !== -1 || end !== -1) {
163
+ if (start === -1 ||
164
+ end === -1 ||
165
+ end < start ||
166
+ text.indexOf(RELATED_SECTION_START, start + 1) !== -1 ||
167
+ text.indexOf(RELATED_SECTION_END, end + 1) !== -1) {
168
+ return null;
169
+ }
170
+ return text.slice(0, start) + block + text.slice(end + RELATED_SECTION_END.length);
171
+ }
172
+ const head = BODY_HEAD.exec(text);
173
+ if (!head)
174
+ return null;
175
+ const before = text.slice(0, head[0].length).trimEnd();
176
+ const rest = text.slice(head[0].length).trimStart();
177
+ return rest ? `${before}\n\n${block}\n\n${rest}` : `${before}\n\n${block}`;
178
+ }
72
179
  /** At most this many packages, and separately this many refusals, are listed
73
180
  * in a PR body; the rest are counted in a "…and N more" line pointing at
74
181
  * get_run, which returns the full list. */
@@ -122,6 +229,66 @@ function packagesSection(input) {
122
229
  "</details>",
123
230
  ].join("\n");
124
231
  }
232
+ /** Lock file names whose change, alongside a refusal, means CI's clean install may fail. */
233
+ const LOCKFILE_NAMES = new Set([
234
+ "package-lock.json",
235
+ "npm-shrinkwrap.json",
236
+ "yarn.lock",
237
+ "pnpm-lock.yaml",
238
+ "poetry.lock",
239
+ "uv.lock",
240
+ "Pipfile.lock",
241
+ ]);
242
+ export function isLockfilePath(path) {
243
+ return LOCKFILE_NAMES.has(path.slice(path.lastIndexOf("/") + 1));
244
+ }
245
+ /** What each refusal code means to someone reading the PR, without the registry's internals. */
246
+ const REFUSAL_MEANING = {
247
+ wardby_package_not_allowed: "not on the allowlist, or only reachable through a refused package",
248
+ wardby_version_filtered: "withheld: newer than the release-age limit, or a high-severity advisory",
249
+ wardby_graph_incomplete: "its dependencies could not be confirmed (usually transient)",
250
+ wardby_lockfile_integrity_mismatch: "the lock file's integrity does not match the registry's",
251
+ wardby_upstream_error: "the upstream registry failed (transient)",
252
+ wardby_audit_unavailable: "the advisory check was unavailable (transient)",
253
+ };
254
+ /** At most this many refused packages are named in the warning; the full list stays in the details. */
255
+ const MAX_WARNING_NAMES = 8;
256
+ /**
257
+ * A visible warning when the registry refused packages: the run's own
258
+ * installs were then incomplete, so failed checks in Tests may be the
259
+ * sandbox, not the change, and a changed lock file may not install in CI.
260
+ * Built only from registry records and the run's own diff, never from the
261
+ * agent's summary, so it cannot be softened or left out by the model.
262
+ */
263
+ /** Rendered verbatim in refusalWarning's first line, and used to locate it in tests. */
264
+ export const DEPENDENCY_INSTALL_INCOMPLETE = "Dependency install incomplete.";
265
+ function refusalWarning(input) {
266
+ const refusals = (input.packageRefusals ?? []).filter((r) => isSafeMarkdownFragment(r.ecosystem) && isSafeMarkdownFragment(r.name) && isSafeMarkdownFragment(r.reason));
267
+ if (refusals.length === 0)
268
+ return undefined;
269
+ const byReason = new Map();
270
+ for (const r of refusals) {
271
+ const names = byReason.get(r.reason) ?? new Set();
272
+ names.add(`${r.ecosystem} \`${r.name}\``);
273
+ byReason.set(r.reason, names);
274
+ }
275
+ const lines = [
276
+ "> [!WARNING]",
277
+ `> **${DEPENDENCY_INSTALL_INCOMPLETE}** Wardby's package registry refused packages during this run, so its installs did not finish and failed checks under **Tests** may come from the sandbox rather than this change.`,
278
+ ];
279
+ for (const [reason, names] of byReason) {
280
+ const listed = [...names];
281
+ const shown = listed.slice(0, MAX_WARNING_NAMES).join(", ");
282
+ const more = listed.length > MAX_WARNING_NAMES ? ` and ${listed.length - MAX_WARNING_NAMES} more` : "";
283
+ const meaning = REFUSAL_MEANING[reason];
284
+ lines.push(`> - ${shown}${more}: \`${reason}\`${meaning ? ` (${meaning})` : ""}`);
285
+ }
286
+ const lockfiles = (input.changedLockfiles ?? []).filter(isSafeMarkdownFragment);
287
+ if (lockfiles.length > 0) {
288
+ lines.push(`> ${lockfiles.map((f) => `\`${f}\``).join(", ")} changed in this run and may be incomplete: a clean install (\`npm ci\` or similar) in CI may fail until it is regenerated.`);
289
+ }
290
+ return lines.join("\n");
291
+ }
125
292
  /** The hidden run marker stays first and unconditional: createOrFindDraftPullRequest's idempotent lookup depends on it. */
126
293
  export function pullRequestBody(input) {
127
294
  const sections = [`${RUN_MARKER_PREFIX}${input.runId} -->`];
@@ -129,6 +296,22 @@ export function pullRequestBody(input) {
129
296
  const issue = issueLine(input);
130
297
  if (issue)
131
298
  sections.push(issue);
299
+ try {
300
+ const related = input.related ? renderRelatedSection(input.related) : undefined;
301
+ if (related)
302
+ sections.push(related);
303
+ }
304
+ catch {
305
+ // omitted, like the packages section
306
+ }
307
+ try {
308
+ const warning = refusalWarning(input);
309
+ if (warning)
310
+ sections.push(warning);
311
+ }
312
+ catch {
313
+ // omitted, like the packages section
314
+ }
132
315
  if (input.summary)
133
316
  sections.push(input.summary);
134
317
  if (input.tests?.length) {
@@ -420,6 +603,20 @@ export class GitHubAppClient {
420
603
  throw safeApiError(response);
421
604
  });
422
605
  }
606
+ /**
607
+ * The root run's marked pull request if GitHub's `state=open` list still
608
+ * contains it (draft or ready for review), else null -- merged, closed, or
609
+ * gone. Used by git.ts's revision-in-place continuation to refuse a push to
610
+ * a pull request that is no longer open.
611
+ */
612
+ async findOpenPullRequest(input) {
613
+ const repository = normalizeGitHubRepository(input.repository);
614
+ const baseRef = normalizeGitRef(input.baseRef);
615
+ const headRef = normalizeGitRef(input.headRef);
616
+ if (!SAFE_RUN_ID.test(input.runId))
617
+ throw new Error("github_pull_request_input_invalid");
618
+ return this.withRepositoryToken(repository, (token) => this.findPullRequest(token, { runId: input.runId, repository, baseRef, headRef, acceptReadyForReview: true }));
619
+ }
423
620
  async appJwt() {
424
621
  let keyObject;
425
622
  try {
@@ -3,4 +3,19 @@ import type { VcsProvider } from "./types.js";
3
3
  export * from "./types.js";
4
4
  export { GitHubAppClient } from "./github.js";
5
5
  export { GitVcsProvider, NodeGitCommandRunner } from "./git.js";
6
- export declare function buildVcsProvider(providerConfig: Pick<ProviderConfig, "vcs">, config: GitHubVcsConfig): VcsProvider;
6
+ export { GitHubRemote } from "./github-remote.js";
7
+ export { LocalRemote } from "./local-remote.js";
8
+ export { RoutingVcsProvider, VCS_GITHUB_NOT_CONFIGURED_ERROR } from "./routing.js";
9
+ export type { GitRemote, PublishInput, PublishResult } from "./remote.js";
10
+ export interface BuildVcsProviderOptions {
11
+ /** Startup warnings; the module logger by default. */
12
+ warn?: (message: string) => void;
13
+ }
14
+ /**
15
+ * GitHub is built only with App credentials, local repositories only with
16
+ * LOCAL_REPO_ROOTS; neither being set is allowed (the server still starts) and
17
+ * surfaces when a repository of the missing kind is used (see
18
+ * RoutingVcsProvider). With neither configured no coding run can succeed, so a
19
+ * startup warning names both options. Only container launchers build this.
20
+ */
21
+ export declare function buildVcsProvider(providerConfig: Pick<ProviderConfig, "vcs">, config: GitHubVcsConfig, env?: NodeJS.ProcessEnv, options?: BuildVcsProviderOptions): VcsProvider;
@@ -2,25 +2,53 @@ import { tmpdir } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import { GitHubAppClient } from "./github.js";
4
4
  import { GitVcsProvider } from "./git.js";
5
+ import { loadLocalRepoRoots } from "../../coding/local-repo.js";
6
+ import { GitHubRemote } from "./github-remote.js";
7
+ import { LocalRemote } from "./local-remote.js";
8
+ import { RoutingVcsProvider } from "./routing.js";
9
+ import { logger } from "../../core/logger.js";
10
+ const vcsLog = logger.child({ module: "vcs" });
5
11
  export * from "./types.js";
6
12
  export { GitHubAppClient } from "./github.js";
7
13
  export { GitVcsProvider, NodeGitCommandRunner } from "./git.js";
8
- export function buildVcsProvider(providerConfig, config) {
14
+ export { GitHubRemote } from "./github-remote.js";
15
+ export { LocalRemote } from "./local-remote.js";
16
+ export { RoutingVcsProvider, VCS_GITHUB_NOT_CONFIGURED_ERROR } from "./routing.js";
17
+ /**
18
+ * GitHub is built only with App credentials, local repositories only with
19
+ * LOCAL_REPO_ROOTS; neither being set is allowed (the server still starts) and
20
+ * surfaces when a repository of the missing kind is used (see
21
+ * RoutingVcsProvider). With neither configured no coding run can succeed, so a
22
+ * startup warning names both options. Only container launchers build this.
23
+ */
24
+ export function buildVcsProvider(providerConfig, config, env = process.env, options = {}) {
9
25
  if (providerConfig.vcs !== "github")
10
26
  throw new Error(`VCS_PROVIDER=${String(providerConfig.vcs)} is not supported.`);
11
- if (!config.appId || !config.privateKey) {
12
- throw new Error("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are required by the GitHub VCS adapter.");
13
- }
14
27
  const rootDir = resolve(config.workRoot ?? resolve(tmpdir(), "wardby-vcs"));
15
- const github = new GitHubAppClient({
16
- appId: config.appId,
17
- privateKey: config.privateKey,
18
- apiVersion: config.apiVersion,
19
- });
20
- return new GitVcsProvider({
21
- rootDir,
22
- github,
23
- maxChangedFiles: config.maxChangedFiles,
24
- maxDiffBytes: config.maxDiffBytes,
25
- });
28
+ const limits = { maxChangedFiles: config.maxChangedFiles, maxDiffBytes: config.maxDiffBytes };
29
+ let github = null;
30
+ if (config.appId && config.privateKey) {
31
+ const client = new GitHubAppClient({
32
+ appId: config.appId,
33
+ privateKey: config.privateKey,
34
+ apiVersion: config.apiVersion,
35
+ });
36
+ github = new GitVcsProvider({ rootDir, remote: new GitHubRemote({ github: client }), ...limits });
37
+ }
38
+ let local = null;
39
+ if (loadLocalRepoRoots(env).roots.length > 0) {
40
+ // Re-read per call so a root that disappears (or appears) takes effect without a restart.
41
+ local = new GitVcsProvider({
42
+ rootDir,
43
+ remote: new LocalRemote({ roots: () => loadLocalRepoRoots(env).roots }),
44
+ ...limits,
45
+ });
46
+ }
47
+ if (!github && !local) {
48
+ const warn = options.warn ?? ((message) => vcsLog.warn(message));
49
+ warn("No repositories are configured for coding agents: set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY " +
50
+ "for GitHub repositories, or LOCAL_REPO_ROOTS for local git repositories on this server. " +
51
+ "Coding runs fail until one of them is set.");
52
+ }
53
+ return new RoutingVcsProvider({ github, local });
26
54
  }
@@ -0,0 +1,44 @@
1
+ import type { RepositoryFileInput } from "./github.js";
2
+ import type { GitRemote, PublishInput, PublishResult } from "./remote.js";
3
+ export interface LocalRemoteOptions {
4
+ /** Trusted roots (realpaths), re-read on every call so a change takes effect without a restart. */
5
+ roots: () => readonly string[];
6
+ }
7
+ /**
8
+ * A git repository on the control plane's own disk (`local:/abs/path`).
9
+ *
10
+ * The source repository is touched only by `ls-remote`, the clone's
11
+ * upload-pack, and a push to `refs/heads/wardby/run-*`: the clone goes through
12
+ * a `file://` URL (so `--depth 1` is honored and nothing is hard-linked), never
13
+ * reads the source's working tree, and every hardening check in GitVcsProvider
14
+ * still applies. `protocol.file.allow=always` is set only on this remote's
15
+ * clone/ls-remote/push invocations; HARDENED_GIT_CONFIG's `protocol.allow=never`
16
+ * keeps every other transport off.
17
+ */
18
+ export declare class LocalRemote implements GitRemote {
19
+ private readonly options;
20
+ readonly gitConfig: readonly string[];
21
+ constructor(options: LocalRemoteOptions);
22
+ normalizeRepository(repository: string): string;
23
+ /** The repository must already be named by its realpath (withAccess verifies it against the roots). */
24
+ cloneUrl(repository: string): string;
25
+ withAccess<T>(repository: string, fn: (token: string | undefined) => Promise<T>): Promise<T>;
26
+ /** No pull request to check: a continuation's branch must exist, which prepare's ls-remote pre-check enforces. */
27
+ assertContinuationOpen(): Promise<void>;
28
+ /**
29
+ * One committed file at a ref, read from git objects (never the working tree).
30
+ * null = the file does not exist at that ref; a missing ref is
31
+ * local_ref_not_found; a repository outside the roots is refused.
32
+ */
33
+ readRepositoryFile(input: RepositoryFileInput): Promise<string | null>;
34
+ publish({ workspace }: PublishInput): Promise<PublishResult>;
35
+ conflictError(): Error;
36
+ refNotFoundError(ref: string): Error;
37
+ /**
38
+ * receive.denyCurrentBranch=updateInstead (a user setting) would rewrite the
39
+ * working tree of whichever worktree has the pushed branch checked out --
40
+ * the main checkout or any linked worktree -- so refuse before pushing.
41
+ */
42
+ beforePush(repository: string, headRef: string): Promise<void>;
43
+ assertPushRef(headRef: string): void;
44
+ }
@@ -0,0 +1,108 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { LOCAL_REPO_PREFIX, LocalRepoError, normalizeLocalRepository, resolveLocalRepository, } from "../../coding/local-repo.js";
3
+ import { SHA, isSafeRefName, isSafeRepoPath, localGit, localGitBytes } from "../../coding/local-git.js";
4
+ /** The only refs a local remote ever writes into the user's repository. */
5
+ const LOCAL_PUSH_REF = /^wardby\/run-[A-Za-z0-9_-]+$/;
6
+ /**
7
+ * A git repository on the control plane's own disk (`local:/abs/path`).
8
+ *
9
+ * The source repository is touched only by `ls-remote`, the clone's
10
+ * upload-pack, and a push to `refs/heads/wardby/run-*`: the clone goes through
11
+ * a `file://` URL (so `--depth 1` is honored and nothing is hard-linked), never
12
+ * reads the source's working tree, and every hardening check in GitVcsProvider
13
+ * still applies. `protocol.file.allow=always` is set only on this remote's
14
+ * clone/ls-remote/push invocations; HARDENED_GIT_CONFIG's `protocol.allow=never`
15
+ * keeps every other transport off.
16
+ */
17
+ export class LocalRemote {
18
+ options;
19
+ gitConfig = ["-c", "protocol.file.allow=always"];
20
+ constructor(options) {
21
+ this.options = options;
22
+ }
23
+ normalizeRepository(repository) {
24
+ return normalizeLocalRepository(repository);
25
+ }
26
+ /** The repository must already be named by its realpath (withAccess verifies it against the roots). */
27
+ cloneUrl(repository) {
28
+ return pathToFileURL(normalizeLocalRepository(repository).slice(LOCAL_REPO_PREFIX.length)).href;
29
+ }
30
+ async withAccess(repository, fn) {
31
+ const requested = normalizeLocalRepository(repository);
32
+ const resolved = await resolveLocalRepository(requested, this.options.roots());
33
+ // cloneUrl is built from the requested path, so it must be the path that
34
+ // was just checked: a symlinked or otherwise non-canonical name is refused.
35
+ if (resolved.repository !== requested) {
36
+ throw new LocalRepoError("local_repo_not_allowed", "repository must be named by its real path");
37
+ }
38
+ return fn(undefined);
39
+ }
40
+ /** No pull request to check: a continuation's branch must exist, which prepare's ls-remote pre-check enforces. */
41
+ async assertContinuationOpen() { }
42
+ /**
43
+ * One committed file at a ref, read from git objects (never the working tree).
44
+ * null = the file does not exist at that ref; a missing ref is
45
+ * local_ref_not_found; a repository outside the roots is refused.
46
+ */
47
+ async readRepositoryFile(input) {
48
+ const { path: dir } = await resolveLocalRepository(input.repository, this.options.roots());
49
+ if (!isSafeRepoPath(input.path))
50
+ throw new LocalRepoError("local_path_invalid", "invalid repository file path");
51
+ if (!SHA.test(input.ref) && !(await isSafeRefName(dir, input.ref))) {
52
+ throw new LocalRepoError("local_ref_invalid", "invalid ref");
53
+ }
54
+ let sha;
55
+ try {
56
+ sha = (await localGit(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${input.ref}^{commit}`], 64 * 1024)).trim();
57
+ }
58
+ catch {
59
+ throw new LocalRepoError("local_ref_not_found", `ref ${input.ref} does not exist in the local repository`);
60
+ }
61
+ if (!SHA.test(sha))
62
+ throw new LocalRepoError("local_ref_not_found", `ref ${input.ref} does not exist`);
63
+ const listing = await localGit(dir, ["ls-tree", "-z", "--end-of-options", sha, "--", input.path], 64 * 1024);
64
+ if (!listing)
65
+ return null;
66
+ if (!/^100(?:644|755) blob /.test(listing))
67
+ throw new Error("local_file_not_a_file");
68
+ const object = `${sha}:${input.path}`;
69
+ const size = Number((await localGit(dir, ["cat-file", "-s", "--end-of-options", object], 64 * 1024)).trim());
70
+ if (!Number.isSafeInteger(size) || size > input.maxBytes)
71
+ throw new Error("local_file_too_large");
72
+ const bytes = await localGitBytes(dir, ["cat-file", "blob", "--end-of-options", object], input.maxBytes + 1024);
73
+ if (bytes.byteLength > input.maxBytes)
74
+ throw new Error("local_file_too_large");
75
+ try {
76
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
77
+ }
78
+ catch {
79
+ throw new Error("local_file_not_utf8");
80
+ }
81
+ }
82
+ async publish({ workspace }) {
83
+ this.assertPushRef(workspace.headRef);
84
+ return { kind: "branch", branch: workspace.headRef };
85
+ }
86
+ conflictError() {
87
+ return new LocalRepoError("local_branch_conflict", "the branch moved in the local repository since the run started");
88
+ }
89
+ refNotFoundError(ref) {
90
+ return new LocalRepoError("local_ref_not_found", `branch ${ref} does not exist in the local repository`);
91
+ }
92
+ /**
93
+ * receive.denyCurrentBranch=updateInstead (a user setting) would rewrite the
94
+ * working tree of whichever worktree has the pushed branch checked out --
95
+ * the main checkout or any linked worktree -- so refuse before pushing.
96
+ */
97
+ async beforePush(repository, headRef) {
98
+ const path = normalizeLocalRepository(repository).slice(LOCAL_REPO_PREFIX.length);
99
+ const listing = await localGit(path, ["worktree", "list", "--porcelain"], 1024 * 1024);
100
+ if (listing.split("\n").some((line) => line.trim() === `branch refs/heads/${headRef}`)) {
101
+ throw new LocalRepoError("local_branch_conflict", `branch ${headRef} is checked out in the local repository`);
102
+ }
103
+ }
104
+ assertPushRef(headRef) {
105
+ if (!LOCAL_PUSH_REF.test(headRef))
106
+ throw new Error("vcs_head_ref_invalid");
107
+ }
108
+ }