@cat-factory/server 0.311.3 → 0.313.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/dist/agents/ContainerRepoBootstrapper.d.ts +83 -7
- package/dist/agents/ContainerRepoBootstrapper.d.ts.map +1 -1
- package/dist/agents/ContainerRepoBootstrapper.js +367 -169
- package/dist/agents/ContainerRepoBootstrapper.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -0
- package/dist/app.js.map +1 -1
- package/dist/modules/bugFishing/BugFishingController.d.ts +14 -0
- package/dist/modules/bugFishing/BugFishingController.d.ts.map +1 -0
- package/dist/modules/bugFishing/BugFishingController.js +56 -0
- package/dist/modules/bugFishing/BugFishingController.js.map +1 -0
- package/dist/modules/github/GitHubController.js +7 -0
- package/dist/modules/github/GitHubController.js.map +1 -1
- package/dist/modules/publicApi/PublicProvisioningController.d.ts.map +1 -1
- package/dist/modules/publicApi/PublicProvisioningController.js +1 -0
- package/dist/modules/publicApi/PublicProvisioningController.js.map +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.d.ts +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.d.ts.map +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.js +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.js.map +1 -1
- package/dist/persistence/mappers.d.ts +2 -0
- package/dist/persistence/mappers.d.ts.map +1 -1
- package/dist/persistence/mappers.js +5 -0
- package/dist/persistence/mappers.js.map +1 -1
- package/package.json +8 -8
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { failureKindFromHarnessCause } from '@cat-factory/kernel';
|
|
1
|
+
import { failureKindFromHarnessCause, runBestEffort } from '@cat-factory/kernel';
|
|
2
2
|
import { isProxyableProvider } from '@cat-factory/agents';
|
|
3
3
|
import { RunnerJobClient } from './RunnerJobClient.js';
|
|
4
4
|
import { logger } from '../observability/logger.js';
|
|
@@ -10,31 +10,89 @@ const ADAPT_SYSTEM_PROMPT = 'You are a repository bootstrapper. You have a fresh
|
|
|
10
10
|
'focused, idiomatic changes that match the existing structure. Do not invent ' +
|
|
11
11
|
'unrelated features.';
|
|
12
12
|
/**
|
|
13
|
-
* The role prompt when writing a new service INTO an existing monorepo
|
|
13
|
+
* The role prompt when writing a new service INTO an existing monorepo, told where its work
|
|
14
|
+
* lands.
|
|
14
15
|
*
|
|
15
|
-
* Distinct from the
|
|
16
|
-
* service's to reshape. The agent has the monorepo (writable
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Distinct from the ones below in the thing it keeps saying: the checkout is not the new
|
|
17
|
+
* service's to reshape. The agent has the monorepo (writable) and the reference template beside
|
|
18
|
+
* it as a read-only sibling, and its whole job is confined to one new subdirectory plus the
|
|
19
|
+
* minimum registration the monorepo's own tooling needs. Every cross-cutting choice it might
|
|
20
|
+
* otherwise make has already been made by a human and is stated in the brief, so the prompt's
|
|
21
|
+
* job is to stop it re-deciding them.
|
|
22
|
+
*
|
|
23
|
+
* Two of its sentences are delivery-specific, and both are the kind that changes what the agent
|
|
24
|
+
* does. WHICH BRANCH it is on is the licence to commit loosely or not: under `direct_push` the
|
|
25
|
+
* harness checkpoints every commit straight to the branch every other service is built from, and
|
|
26
|
+
* an agent told it is on a "fresh work branch" is being told the opposite of that. And WHERE a
|
|
27
|
+
* deviation goes has to be somewhere that exists: routing it to a pull request description on a
|
|
28
|
+
* run that opens no pull request is how a caveat about a settled decision is silently lost.
|
|
29
|
+
*/
|
|
30
|
+
function monorepoSystemPrompt(delivery) {
|
|
31
|
+
const checkout = delivery === 'pull_request'
|
|
32
|
+
? 'Your working directory is the monorepo checkout, already on a fresh work branch. '
|
|
33
|
+
: "Your working directory is the monorepo checkout, on the monorepo's OWN DEFAULT BRANCH: " +
|
|
34
|
+
'every commit you make is pushed to the branch every other service in it is built from, ' +
|
|
35
|
+
'as you make it. So commit only work you would be willing to merge, keep the tree ' +
|
|
36
|
+
'building at every commit, and never rewrite, revert or force-push history that was ' +
|
|
37
|
+
'there before you. ';
|
|
38
|
+
const deviation = delivery === 'pull_request'
|
|
39
|
+
? 'say so in the pull request description'
|
|
40
|
+
: 'say so in the commit message that makes the change';
|
|
41
|
+
return ('You are adding a NEW service to an existing monorepo. ' +
|
|
42
|
+
checkout +
|
|
43
|
+
'A reference template repository is ' +
|
|
44
|
+
'checked out READ-ONLY as a sibling directory beside it: read from it freely, copy from it ' +
|
|
45
|
+
'where the brief says to, and never write to it. ' +
|
|
46
|
+
'Create the new service in the subdirectory the brief names, and touch NOTHING else in the ' +
|
|
47
|
+
'monorepo except the minimum registration its own tooling requires (a workspace list, a ' +
|
|
48
|
+
'build-graph entry, a CI matrix entry). Modifying an existing service is out of scope, and ' +
|
|
49
|
+
'so is reformatting, upgrading or "tidying" anything you did not add. ' +
|
|
50
|
+
'The brief carries adoption decisions a human has already reviewed and settled: for each ' +
|
|
51
|
+
'area they say whether the new service follows the monorepo or the template. Follow them ' +
|
|
52
|
+
'exactly. Do not substitute your own preference for one of them, and if a decision cannot ' +
|
|
53
|
+
`be honoured as written, do the closest thing that respects it and ${deviation} rather than ` +
|
|
54
|
+
'quietly taking the other side. ' +
|
|
55
|
+
'Match the surrounding monorepo in everything the brief does NOT settle: its naming, its ' +
|
|
56
|
+
'file layout, its dependency versions, its lint and test conventions. Leave the new service ' +
|
|
57
|
+
'building and its tests passing.');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The role prompt when filling a NEW repository from a reference template, delivered as a pull
|
|
61
|
+
* request.
|
|
62
|
+
*
|
|
63
|
+
* Distinct from {@link ADAPT_SYSTEM_PROMPT} in what the checkout IS: there the agent works in a
|
|
64
|
+
* clone OF the template and reshapes it, here it works in the new repository (which holds only
|
|
65
|
+
* its initial README) with the template read-only beside it. Saying "adapt this in place" to an
|
|
66
|
+
* agent whose cwd is the empty repository is how a run ends with the template untouched and the
|
|
67
|
+
* repository still empty.
|
|
21
68
|
*/
|
|
22
|
-
const
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
const PR_ADAPT_SYSTEM_PROMPT = 'You are a repository bootstrapper. Your working directory is a BRAND-NEW repository, ' +
|
|
70
|
+
'holding nothing but the initial README/.gitignore/license it was created with, already on a ' +
|
|
71
|
+
'fresh work branch. A reference architecture (a base/golden-template repository) is checked ' +
|
|
72
|
+
'out READ-ONLY as a sibling directory beside it: read from it freely, copy what the new ' +
|
|
73
|
+
'service needs into your working directory, and never write to it. ' +
|
|
74
|
+
'Adapt what you copy into the new service per the instructions: rename packages/modules, ' +
|
|
75
|
+
'leave out pieces that do not apply, write the README and metadata for THIS service, and ' +
|
|
76
|
+
'leave the project building. Make focused, idiomatic changes that match the structure the ' +
|
|
77
|
+
'template establishes. Do not invent unrelated features. ' +
|
|
78
|
+
'Your work is delivered as a pull request a person reviews, so it must stand on its own: no ' +
|
|
79
|
+
'placeholder files you meant to fill in later, and no references to paths that only exist in ' +
|
|
80
|
+
'the template.';
|
|
81
|
+
/**
|
|
82
|
+
* The role prompt when scaffolding a new repository from scratch, delivered as a pull request.
|
|
83
|
+
*
|
|
84
|
+
* Same job as {@link SCAFFOLD_SYSTEM_PROMPT}, said to an agent that is NOT in an empty
|
|
85
|
+
* directory: the repository already carries its initial commit, which is what the pull request
|
|
86
|
+
* is opened against.
|
|
87
|
+
*/
|
|
88
|
+
const PR_SCAFFOLD_SYSTEM_PROMPT = 'You are a repository bootstrapper. Your working directory is a BRAND-NEW repository, ' +
|
|
89
|
+
'holding nothing but the initial README/.gitignore/license it was created with, already on a ' +
|
|
90
|
+
'fresh work branch. Scaffold the service described in the instructions into it: create a ' +
|
|
91
|
+
'sensible, idiomatic project layout with source files, a README, and the metadata and ' +
|
|
92
|
+
'build/config files appropriate for the stack, leaving the project building. Keep the scope ' +
|
|
93
|
+
'to what the instructions describe; do not invent unrelated features. ' +
|
|
94
|
+
'Your work is delivered as a pull request a person reviews, so it must stand on its own: no ' +
|
|
95
|
+
'placeholder files you meant to fill in later.';
|
|
38
96
|
/** The role prompt when scaffolding a brand-new repository from scratch. */
|
|
39
97
|
const SCAFFOLD_SYSTEM_PROMPT = 'You are a repository bootstrapper. You are working in an empty directory and ' +
|
|
40
98
|
'must scaffold a brand-new repository from scratch per the instructions. Create ' +
|
|
@@ -100,6 +158,12 @@ export class ContainerRepoBootstrapper {
|
|
|
100
158
|
* Pre-flight the target repo and dispatch the bootstrap container as a
|
|
101
159
|
* background job (returns once accepted, like `/run`). Throws on a pre-flight
|
|
102
160
|
* failure so the run fails fast before a board frame is created.
|
|
161
|
+
*
|
|
162
|
+
* THREE dispatch shapes, chosen by the target and the run's delivery. A new repository
|
|
163
|
+
* delivered by `direct_push` is the original one (adapt a clone, reinitialise, force-push);
|
|
164
|
+
* everything else is the ordinary coding shape (clone the writable target, template beside it
|
|
165
|
+
* read-only, work branch, pull request), because the target already holds a history nobody may
|
|
166
|
+
* reset: the monorepo's, or the initial commit a pull request has to be opened against.
|
|
103
167
|
*/
|
|
104
168
|
async startBootstrap(request) {
|
|
105
169
|
const log = logger.child({ jobId: request.jobId, workspaceId: request.workspaceId });
|
|
@@ -112,90 +176,37 @@ export class ContainerRepoBootstrapper {
|
|
|
112
176
|
`(Workers AI, or a direct OpenAI-compatible provider); ` +
|
|
113
177
|
`'${this.deps.model.provider}' is not supported.`);
|
|
114
178
|
}
|
|
115
|
-
// A monorepo run has a completely different pre-flight
|
|
116
|
-
//
|
|
117
|
-
// be empty, and force-pushing a fresh history would destroy the target.
|
|
179
|
+
// A monorepo run has a completely different pre-flight, so it branches before the new-repo
|
|
180
|
+
// checks below: there is no repository to create and nothing to be empty.
|
|
118
181
|
if (request.monorepo) {
|
|
119
182
|
return await this.startMonorepoBootstrap(request, request.monorepo, installation);
|
|
120
183
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// empty — the run pushes the bootstrapped contents as the initial commit.
|
|
184
|
+
const target = await this.preflightNewRepoTarget(request, installation, log);
|
|
185
|
+
if (request.delivery.mode === 'pull_request') {
|
|
186
|
+
return await this.startNewRepoPullRequest(request, target, installation, log);
|
|
187
|
+
}
|
|
126
188
|
const owner = installation.accountLogin;
|
|
127
189
|
const repoName = request.target.name;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
`Create a repository named "${repoName}" under ${owner} (an initial README, .gitignore ` +
|
|
137
|
-
`or license is fine), make sure the App is installed on it, then run bootstrap again.`);
|
|
138
|
-
}
|
|
139
|
-
// The repo being *readable* is not enough: bootstrapping ends in a force-push, so
|
|
140
|
-
// the installation must have write access. A public repo the App can read but is
|
|
141
|
-
// not granted (not in the App's selected-repos list, or the App lacks
|
|
142
|
-
// contents:write) reads fine above but 403s on the container's push — pre-flight
|
|
143
|
-
// it here so that case fails fast with an actionable message instead of failing
|
|
144
|
-
// deep inside the run after a board frame has been created.
|
|
145
|
-
if (!(await this.deps.githubClient.canPush(installation.installationId, ref))) {
|
|
146
|
-
throw new Error(`The GitHub App can see ${owner}/${repoName} but does not have write access to it, so the ` +
|
|
147
|
-
`bootstrapped commit cannot be pushed. Grant the App write access to this repository ` +
|
|
148
|
-
`(GitHub → Settings → Applications → the cat-factory App → Configure → Repository access — ` +
|
|
149
|
-
`add "${repoName}" or allow all repositories), or, in local mode, use a GitHub PAT that ` +
|
|
150
|
-
`can push to it. Then run bootstrap again.`);
|
|
151
|
-
}
|
|
152
|
-
// The run replaces the repo's contents with a fresh single-commit history, so
|
|
153
|
-
// the target must be empty — except that GitHub's create-repo page often
|
|
154
|
-
// prepopulates a README, .gitignore and/or license. Those are throwaway
|
|
155
|
-
// boilerplate, so tolerate a repo that holds *only* them (the push force-
|
|
156
|
-
// overwrites them); reject anything with real content to avoid clobbering work.
|
|
157
|
-
const rootEntries = await this.deps.githubClient.listRootEntries(installation.installationId, ref);
|
|
158
|
-
const realContent = rootEntries.filter((entry) => !isBootstrapBoilerplate(entry));
|
|
159
|
-
if (realContent.length > 0) {
|
|
160
|
-
const sample = realContent
|
|
161
|
-
.map((entry) => entry.path)
|
|
162
|
-
.slice(0, 5)
|
|
163
|
-
.join(', ');
|
|
164
|
-
throw new Error(`Repository ${owner}/${repoName} already has content (${sample}). Bootstrapping replaces ` +
|
|
165
|
-
`the repository's contents, so it needs an empty repository — or one prepopulated only ` +
|
|
166
|
-
`with a README, .gitignore, license and/or AGENTS.md.`);
|
|
167
|
-
}
|
|
168
|
-
// Scoped to the one repo being bootstrapped: the run clones and force-pushes exactly this
|
|
169
|
-
// target and touches nothing else, so there is no leg to widen for. `target` came from the
|
|
170
|
-
// pre-flight `getRepo` above, which is why this costs no extra read.
|
|
190
|
+
// With a reference architecture the container clones + adapts it; without one
|
|
191
|
+
// it scaffolds an empty repo from the freeform instructions alone.
|
|
192
|
+
const reference = await this.resolveReferenceTemplate(request, installation.installationId, log);
|
|
193
|
+
// Scoped to the repos this run touches: the target it force-pushes, and the template it
|
|
194
|
+
// CLONES. The template belongs on the scope even though nothing pushes to it, because the
|
|
195
|
+
// clone runs on this same token: leaving it off works only for a public reference
|
|
196
|
+
// architecture and 404s on a private one, with nothing in the failure naming the cause.
|
|
197
|
+
// `target` came from the pre-flight `getRepo` above, which is why it costs no extra read.
|
|
171
198
|
const ghToken = await this.deps.mintInstallationToken(installation.installationId, {
|
|
172
199
|
executionId: request.containerJobId,
|
|
173
200
|
workspaceId: request.workspaceId,
|
|
174
|
-
repoIds: [String(target.githubId)],
|
|
201
|
+
repoIds: [String(target.githubId), ...(reference?.githubId ? [reference.githubId] : [])],
|
|
175
202
|
});
|
|
176
203
|
// Private-registry auth for the scaffolder's installs, exactly as the
|
|
177
204
|
// implementation executor forwards it.
|
|
178
205
|
const packageRegistries = (await this.deps.resolvePackageRegistries?.(request.workspaceId)) ?? [];
|
|
179
|
-
const sessionToken = await this.
|
|
180
|
-
|
|
181
|
-
executionId: request.containerJobId,
|
|
182
|
-
agentKind: 'architect',
|
|
183
|
-
provider: this.deps.model.provider,
|
|
184
|
-
model: this.deps.model.model,
|
|
185
|
-
});
|
|
186
|
-
const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
|
|
206
|
+
const sessionToken = await this.mintSessionToken(request);
|
|
207
|
+
const webBase = this.webBase();
|
|
187
208
|
const targetCloneUrl = `${webBase}/${owner}/${repoName}.git`;
|
|
188
|
-
const defaultBranch = target
|
|
189
|
-
// With a reference architecture the container clones + adapts it; without one
|
|
190
|
-
// it scaffolds an empty repo from the freeform instructions alone.
|
|
191
|
-
const reference = request.referenceRepo
|
|
192
|
-
? {
|
|
193
|
-
owner: request.referenceRepo.owner,
|
|
194
|
-
name: request.referenceRepo.name,
|
|
195
|
-
cloneUrl: `${webBase}/${request.referenceRepo.owner}/${request.referenceRepo.name}.git`,
|
|
196
|
-
baseBranch: 'main',
|
|
197
|
-
}
|
|
198
|
-
: undefined;
|
|
209
|
+
const defaultBranch = defaultBranchOf(target);
|
|
199
210
|
const targetSpec = { owner, name: repoName, cloneUrl: targetCloneUrl, defaultBranch };
|
|
200
211
|
// The generic agent `repo` is the clone source: the reference when adapting one, or the
|
|
201
212
|
// (uncloned) target placeholder when scaffolding from scratch. The real push destination
|
|
@@ -209,12 +220,12 @@ export class ContainerRepoBootstrapper {
|
|
|
209
220
|
}
|
|
210
221
|
: { owner, name: repoName, baseBranch: defaultBranch, cloneUrl: targetCloneUrl };
|
|
211
222
|
// Bootstrap dispatches the generic, manifest-driven `agent` kind in `coding` mode with a
|
|
212
|
-
// `bootstrap` spec (the divergent force-push to a separate target repo)
|
|
223
|
+
// `bootstrap` spec (the divergent force-push to a separate target repo): the SAME path
|
|
213
224
|
// every other built-in coding agent takes, with NO bespoke `/bootstrap` harness handler.
|
|
214
225
|
const body = {
|
|
215
226
|
jobId: request.containerJobId,
|
|
216
227
|
// The run's correlation ids, so the container's own lines join to this bootstrap in the
|
|
217
|
-
// backend's logs
|
|
228
|
+
// backend's logs: the same fields `buildCommonBody` puts on an execution job. A bootstrap
|
|
218
229
|
// is a first-class agent run (one `agent_runs` table, one retry surface), so it must not be
|
|
219
230
|
// the one agent-kind dispatch whose container logs cannot be joined to anything. Its run id
|
|
220
231
|
// IS its job id: a bootstrap has no separate execution row, which is exactly what
|
|
@@ -232,7 +243,7 @@ export class ContainerRepoBootstrapper {
|
|
|
232
243
|
// select a subscription harness. The job schema tolerates `harness` (shared
|
|
233
244
|
// HarnessAuthFields), but bootstrap is the one container flow that always uses
|
|
234
245
|
// the deployment's proxyable model rather than a workspace's pooled subscription
|
|
235
|
-
// token
|
|
246
|
+
// token: there is no per-block model selection on a not-yet-existing repo.
|
|
236
247
|
proxyBaseUrl: this.deps.proxyBaseUrl,
|
|
237
248
|
// This backend serves the phase-tagged completions route (see `ContainerAgentExecutor`),
|
|
238
249
|
// so a bootstrap's calls are attributed rather than landing in the unattributed slice.
|
|
@@ -242,9 +253,9 @@ export class ContainerRepoBootstrapper {
|
|
|
242
253
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
243
254
|
repo: repoSpec,
|
|
244
255
|
branch: repoSpec.baseBranch,
|
|
245
|
-
//
|
|
246
|
-
// history shares no ancestor with the target repo's boilerplate); that is
|
|
247
|
-
//
|
|
256
|
+
// This delivery always resets history to a single commit and force-pushes (the fresh
|
|
257
|
+
// history shares no ancestor with the target repo's boilerplate); that is what the
|
|
258
|
+
// `bootstrap` spec MEANS to the harness, so no per-job flags are needed.
|
|
248
259
|
bootstrap: {
|
|
249
260
|
target: targetSpec,
|
|
250
261
|
...(reference ? {} : { fromScratch: true }),
|
|
@@ -254,7 +265,7 @@ export class ContainerRepoBootstrapper {
|
|
|
254
265
|
// Dispatch through the shared transport (keyed by job id), exactly like the
|
|
255
266
|
// implementation executor: it hits the harness `POST /jobs` (kind `agent`), starts the
|
|
256
267
|
// background job and returns once accepted; we then poll via the same transport.
|
|
257
|
-
// Idempotent per job id
|
|
268
|
+
// Idempotent per job id: a replayed dispatch re-attaches rather than duplicating.
|
|
258
269
|
log.info('bootstrap: dispatching container', {
|
|
259
270
|
reference: reference ? `${reference.owner}/${reference.name}` : null,
|
|
260
271
|
});
|
|
@@ -268,23 +279,122 @@ export class ContainerRepoBootstrapper {
|
|
|
268
279
|
containerJobId: request.containerJobId,
|
|
269
280
|
};
|
|
270
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Pre-flight the pre-created target repository of a NEW-REPO run: it exists, the App can
|
|
284
|
+
* write to it, and it holds no real content.
|
|
285
|
+
*
|
|
286
|
+
* The target repo is created up front: by the user via the host's new-repo page, or, for
|
|
287
|
+
* privileged-tier orgs (ADR 0005), programmatically via the create-repo endpoint behind the
|
|
288
|
+
* modal's "Create repository" button.
|
|
289
|
+
*
|
|
290
|
+
* The emptiness rule binds under both deliveries, because both WRITE a whole service into the
|
|
291
|
+
* repository: a force-push would clobber real content and a pull request would propose
|
|
292
|
+
* deleting it. What differs is the FLOOR: a pull request needs an initial commit to branch
|
|
293
|
+
* from, so that delivery also refuses a repository with none.
|
|
294
|
+
*/
|
|
295
|
+
async preflightNewRepoTarget(request, installation, log) {
|
|
296
|
+
const owner = installation.accountLogin;
|
|
297
|
+
const repoName = request.target.name;
|
|
298
|
+
const ref = { owner, repo: repoName };
|
|
299
|
+
log.info('bootstrap: pre-flighting target repo', { target: `${owner}/${repoName}` });
|
|
300
|
+
let target;
|
|
301
|
+
try {
|
|
302
|
+
target = await this.deps.githubClient.getRepo(installation.installationId, ref);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
throw new Error(`Repository ${owner}/${repoName} was not found or is not accessible to the GitHub App. ` +
|
|
306
|
+
`Create a repository named "${repoName}" under ${owner} (an initial README, .gitignore ` +
|
|
307
|
+
`or license is fine), make sure the App is installed on it, then run bootstrap again.`);
|
|
308
|
+
}
|
|
309
|
+
// The repo being *readable* is not enough: bootstrapping ends in a push, so the
|
|
310
|
+
// installation must have write access. A public repo the App can read but is
|
|
311
|
+
// not granted (not in the App's selected-repos list, or the App lacks
|
|
312
|
+
// contents:write) reads fine above but 403s on the container's push, so pre-flight
|
|
313
|
+
// it here: that case then fails fast with an actionable message instead of failing
|
|
314
|
+
// deep inside the run after a board frame has been created.
|
|
315
|
+
if (!(await this.deps.githubClient.canPush(installation.installationId, ref))) {
|
|
316
|
+
throw new Error(`The GitHub App can see ${owner}/${repoName} but does not have write access to it, so the ` +
|
|
317
|
+
`bootstrapped commit cannot be pushed. Grant the App write access to this repository ` +
|
|
318
|
+
`(GitHub → Settings → Applications → the cat-factory App → Configure → Repository access: ` +
|
|
319
|
+
`add "${repoName}" or allow all repositories), or, in local mode, use a GitHub PAT that ` +
|
|
320
|
+
`can push to it. Then run bootstrap again.`);
|
|
321
|
+
}
|
|
322
|
+
// The run writes a whole repository's worth of content, so the target must be
|
|
323
|
+
// empty, except that GitHub's create-repo page often prepopulates a README,
|
|
324
|
+
// .gitignore and/or license. Those are throwaway boilerplate, so tolerate a repo
|
|
325
|
+
// that holds *only* them; reject anything with real content to avoid clobbering work.
|
|
326
|
+
const rootEntries = await this.deps.githubClient.listRootEntries(installation.installationId, ref);
|
|
327
|
+
const realContent = rootEntries.filter((entry) => !isBootstrapBoilerplate(entry));
|
|
328
|
+
if (realContent.length > 0) {
|
|
329
|
+
const sample = realContent
|
|
330
|
+
.map((entry) => entry.path)
|
|
331
|
+
.slice(0, 5)
|
|
332
|
+
.join(', ');
|
|
333
|
+
throw new Error(`Repository ${owner}/${repoName} already has content (${sample}). Bootstrapping replaces ` +
|
|
334
|
+
`the repository's contents, so it needs an empty repository, or one prepopulated only ` +
|
|
335
|
+
`with a README, .gitignore, license and/or AGENTS.md.`);
|
|
336
|
+
}
|
|
337
|
+
// A pull request is opened BETWEEN two commits, so a repository holding none cannot take
|
|
338
|
+
// one: there is no default branch to clone, to branch from, or to target.
|
|
339
|
+
// `listRootEntries` answers `[]` for exactly that repository (the contents endpoint 404s
|
|
340
|
+
// where there is no commit), so an empty listing is the tell. Refused here, naming both
|
|
341
|
+
// ways out, rather than surfacing later as a clone failure that reads like an outage.
|
|
342
|
+
if (request.delivery.mode === 'pull_request' && rootEntries.length === 0) {
|
|
343
|
+
throw new Error(`Repository ${owner}/${repoName} has no commits yet, so there is no branch to open a ` +
|
|
344
|
+
`pull request against. Either create it with an initial commit (a README is enough), ` +
|
|
345
|
+
`or bootstrap it with "push directly", which writes the repository's first commit.`);
|
|
346
|
+
}
|
|
347
|
+
return target;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Dispatch a NEW-REPO run delivered as a pull request: the ordinary coding shape against the
|
|
351
|
+
* (already-initialised) target repository, with the reference template beside it as a
|
|
352
|
+
* READ-ONLY sibling checkout.
|
|
353
|
+
*
|
|
354
|
+
* Deliberately NOT the `bootstrap` spec, which is the whole reason this path exists
|
|
355
|
+
* separately: that spec reinitialises history and force-pushes, and a branch whose history
|
|
356
|
+
* shares no ancestor with the default branch is not something a pull request can be opened
|
|
357
|
+
* from. Here the target's own initial commit is the base, so the diff a reviewer reads is the
|
|
358
|
+
* service being added.
|
|
359
|
+
*/
|
|
360
|
+
async startNewRepoPullRequest(request, target, installation, log) {
|
|
361
|
+
const owner = installation.accountLogin;
|
|
362
|
+
const repoName = request.target.name;
|
|
363
|
+
log.info('bootstrap(new-repo pr): dispatching container', { target: `${owner}/${repoName}` });
|
|
364
|
+
return await this.dispatchCodingShape(request, installation, log, {
|
|
365
|
+
repo: {
|
|
366
|
+
owner,
|
|
367
|
+
name: repoName,
|
|
368
|
+
baseBranch: defaultBranchOf(target),
|
|
369
|
+
cloneUrl: `${this.webBase()}/${owner}/${repoName}.git`,
|
|
370
|
+
},
|
|
371
|
+
repoGithubId: target.githubId,
|
|
372
|
+
systemPrompt: request.referenceRepo ? PR_ADAPT_SYSTEM_PROMPT : PR_SCAFFOLD_SYSTEM_PROMPT,
|
|
373
|
+
userPrompt: request.instructions ||
|
|
374
|
+
(request.referenceRepo
|
|
375
|
+
? 'Adapt the reference architecture for the new service.'
|
|
376
|
+
: 'Scaffold a new repository for the service.'),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
271
379
|
/**
|
|
272
380
|
* Dispatch a monorepo bootstrap's APPLY phase: an ordinary coding job on the monorepo, with
|
|
273
381
|
* the reference template alongside it as a READ-ONLY sibling checkout.
|
|
274
382
|
*
|
|
275
383
|
* Deliberately the plain coding shape rather than a `bootstrap` spec, and that is the design:
|
|
276
|
-
* the harness already knows how to clone a writable primary at a work branch,
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
384
|
+
* the harness already knows how to clone a writable primary (at a work branch, or at the
|
|
385
|
+
* default branch it commits onto, per the run's delivery), clone `referenceRepos` beside it
|
|
386
|
+
* without ever branching or pushing them, and open one pull request for the primary when it
|
|
387
|
+
* was given one to open. A bespoke bootstrap mode here would be a second implementation of
|
|
388
|
+
* that with one extra way to get the push wrong, against a repository that holds other
|
|
389
|
+
* people's code.
|
|
280
390
|
*
|
|
281
391
|
* `repo.serviceDirectory` is what scopes the agent to the new subdirectory: the same field
|
|
282
392
|
* every monorepo-service run rides, so the working-directory rule is stated in one place.
|
|
283
393
|
*
|
|
284
394
|
* Two pre-flights, both about the monorepo rather than about a new repo: the App must be able
|
|
285
395
|
* to WRITE to it (a read-only grant reads fine and 403s on the push, after a board frame
|
|
286
|
-
* exists), and the target directory must still be absent at dispatch time, because the
|
|
287
|
-
* pre-flighted it before the survey
|
|
396
|
+
* exists), and the target directory must still be absent at dispatch time, because the
|
|
397
|
+
* orchestration pre-flighted it before the survey and a review can be settled days later.
|
|
288
398
|
*/
|
|
289
399
|
async startMonorepoBootstrap(request, monorepo, installation) {
|
|
290
400
|
const log = logger.child({ jobId: request.jobId, workspaceId: request.workspaceId });
|
|
@@ -300,87 +410,150 @@ export class ContainerRepoBootstrapper {
|
|
|
300
410
|
`to it, so the new service cannot be pushed. Grant the App write access to this ` +
|
|
301
411
|
`repository, then retry.`);
|
|
302
412
|
}
|
|
303
|
-
const existing = await this.deps.githubClient.listDirectory(installation.installationId, ref, monorepo.directory,
|
|
413
|
+
const existing = await this.deps.githubClient.listDirectory(installation.installationId, ref, monorepo.directory, defaultBranch);
|
|
304
414
|
if (existing.length > 0) {
|
|
305
415
|
throw new Error(`\`${monorepo.directory}\` already exists in ${monorepo.owner}/${monorepo.name}. It was ` +
|
|
306
416
|
`empty when this bootstrap started; something has since created it. Pick a different ` +
|
|
307
417
|
`directory and start a new bootstrap rather than writing over it.`);
|
|
308
418
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
.
|
|
319
|
-
.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
419
|
+
log.info('bootstrap(monorepo): dispatching container', {
|
|
420
|
+
directory: monorepo.directory,
|
|
421
|
+
delivery: request.delivery.mode,
|
|
422
|
+
});
|
|
423
|
+
return await this.dispatchCodingShape(request, installation, log, {
|
|
424
|
+
repo: {
|
|
425
|
+
owner: monorepo.owner,
|
|
426
|
+
name: monorepo.name,
|
|
427
|
+
baseBranch: defaultBranch,
|
|
428
|
+
cloneUrl: `${this.webBase()}/${monorepo.owner}/${monorepo.name}.git`,
|
|
429
|
+
serviceDirectory: monorepo.directory,
|
|
430
|
+
},
|
|
431
|
+
repoGithubId: target.githubId,
|
|
432
|
+
systemPrompt: monorepoSystemPrompt(request.delivery.mode),
|
|
433
|
+
userPrompt: request.instructions,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The dispatch both non-force-push shapes share: clone the writable target, fetch the
|
|
438
|
+
* reference template beside it read-only, and either open a work branch plus a pull request
|
|
439
|
+
* or commit onto the target's own default branch.
|
|
440
|
+
*
|
|
441
|
+
* ONE builder rather than one per target, because the delivery rule is the interesting part
|
|
442
|
+
* and a second copy of it is a second place for `newBranch` and `pr` to disagree: a body
|
|
443
|
+
* carrying a branch but no PR pushes work onto a branch nobody is told about.
|
|
444
|
+
*/
|
|
445
|
+
async dispatchCodingShape(request, installation, log, spec) {
|
|
446
|
+
// Scoped to the repos this run touches: the one it pushes to, and the template it reads. A
|
|
447
|
+
// template outside the installation simply is not cloneable, which the harness reports as a
|
|
448
|
+
// clone failure rather than silently running without it.
|
|
449
|
+
const reference = await this.resolveReferenceTemplate(request, installation.installationId, log);
|
|
450
|
+
const repoIds = [
|
|
451
|
+
String(spec.repoGithubId),
|
|
452
|
+
...(reference?.githubId ? [reference.githubId] : []),
|
|
453
|
+
];
|
|
454
|
+
const referenceRepos = reference
|
|
455
|
+
? [
|
|
323
456
|
{
|
|
324
457
|
repo: {
|
|
325
458
|
owner: reference.owner,
|
|
326
459
|
name: reference.name,
|
|
327
|
-
baseBranch:
|
|
328
|
-
cloneUrl:
|
|
460
|
+
baseBranch: reference.baseBranch,
|
|
461
|
+
cloneUrl: reference.cloneUrl,
|
|
329
462
|
},
|
|
330
463
|
},
|
|
331
|
-
]
|
|
332
|
-
|
|
464
|
+
]
|
|
465
|
+
: [];
|
|
333
466
|
const ghToken = await this.deps.mintInstallationToken(installation.installationId, {
|
|
334
467
|
executionId: request.containerJobId,
|
|
335
468
|
workspaceId: request.workspaceId,
|
|
336
469
|
repoIds,
|
|
337
470
|
});
|
|
338
471
|
const packageRegistries = (await this.deps.resolvePackageRegistries?.(request.workspaceId)) ?? [];
|
|
339
|
-
const sessionToken = await this.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
472
|
+
const sessionToken = await this.mintSessionToken(request);
|
|
473
|
+
// The delivery decides the two fields TOGETHER: with `newBranch` and `pr` the harness pushes
|
|
474
|
+
// a work branch and opens one pull request; with neither it commits onto `branch`, the
|
|
475
|
+
// target's own default. Omitting only one of the pair is the bug this single site prevents.
|
|
476
|
+
const delivery = request.delivery.mode === 'pull_request'
|
|
477
|
+
? { newBranch: request.delivery.branch, pr: request.delivery.pr }
|
|
478
|
+
: {};
|
|
346
479
|
const body = {
|
|
347
480
|
jobId: request.containerJobId,
|
|
348
481
|
workspaceId: request.workspaceId,
|
|
349
482
|
executionId: request.containerJobId,
|
|
350
483
|
mode: 'coding',
|
|
351
|
-
systemPrompt:
|
|
352
|
-
userPrompt:
|
|
484
|
+
systemPrompt: spec.systemPrompt,
|
|
485
|
+
userPrompt: spec.userPrompt,
|
|
353
486
|
model: this.deps.model.model,
|
|
354
487
|
proxyBaseUrl: this.deps.proxyBaseUrl,
|
|
355
488
|
proxyPhasePath: true,
|
|
356
489
|
sessionToken,
|
|
357
490
|
ghToken,
|
|
358
491
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
359
|
-
repo:
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
baseBranch: defaultBranch,
|
|
363
|
-
cloneUrl: `${webBase}/${monorepo.owner}/${monorepo.name}.git`,
|
|
364
|
-
serviceDirectory: monorepo.directory,
|
|
365
|
-
},
|
|
366
|
-
branch: defaultBranch,
|
|
367
|
-
newBranch: monorepo.branch,
|
|
368
|
-
pr: monorepo.pr,
|
|
492
|
+
repo: spec.repo,
|
|
493
|
+
branch: spec.repo.baseBranch,
|
|
494
|
+
...delivery,
|
|
369
495
|
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
370
496
|
...(this.deps.githubApiBase ? { githubApiBase: this.deps.githubApiBase } : {}),
|
|
371
497
|
};
|
|
372
|
-
|
|
373
|
-
|
|
498
|
+
await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
|
|
499
|
+
// The BRANCH is the field an operator needs first when a run reports done with no pull
|
|
500
|
+
// request (the orchestration then fails it as delivered nowhere) or faults mid-way under
|
|
501
|
+
// `direct_push`: it names where the commits are. Nothing else in the backend's logs does.
|
|
502
|
+
log.info('bootstrap: container accepted job', {
|
|
503
|
+
delivery: request.delivery.mode,
|
|
504
|
+
branch: request.delivery.mode === 'pull_request' ? request.delivery.branch : spec.repo.baseBranch,
|
|
374
505
|
reference: reference ? `${reference.owner}/${reference.name}` : null,
|
|
375
506
|
});
|
|
376
|
-
await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
|
|
377
|
-
log.info('bootstrap(monorepo): container accepted job');
|
|
378
507
|
return {
|
|
379
508
|
workspaceId: request.workspaceId,
|
|
380
509
|
jobId: request.jobId,
|
|
381
510
|
containerJobId: request.containerJobId,
|
|
382
511
|
};
|
|
383
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Resolve the reference template a run reads from: its clone spec, plus the numeric id the
|
|
515
|
+
* run's installation token has to be scoped to.
|
|
516
|
+
*
|
|
517
|
+
* ONE resolution for both dispatch shapes, because both CLONE the template and both mint the
|
|
518
|
+
* token that clone runs on. Two copies is what left the force-push path granting only its push
|
|
519
|
+
* target while cloning the template with that same token, so a PRIVATE reference architecture
|
|
520
|
+
* 404s on clone under one delivery and works under the other, and what left it assuming the
|
|
521
|
+
* template's base branch was `main`.
|
|
522
|
+
*
|
|
523
|
+
* A template the installation cannot read resolves to no id and the conventional branch rather
|
|
524
|
+
* than refusing the run: the clone then fails inside the harness naming the repository, which
|
|
525
|
+
* is a better report than a pre-flight throw here, and the warning says which read failed.
|
|
526
|
+
*/
|
|
527
|
+
async resolveReferenceTemplate(request, installationId, log) {
|
|
528
|
+
const reference = request.referenceRepo;
|
|
529
|
+
if (!reference)
|
|
530
|
+
return undefined;
|
|
531
|
+
const template = await runBestEffort(log, 'bootstrap: read reference template repo', () => this.deps.githubClient.getRepo(installationId, {
|
|
532
|
+
owner: reference.owner,
|
|
533
|
+
repo: reference.name,
|
|
534
|
+
}), { reference: `${reference.owner}/${reference.name}` });
|
|
535
|
+
return {
|
|
536
|
+
owner: reference.owner,
|
|
537
|
+
name: reference.name,
|
|
538
|
+
cloneUrl: `${this.webBase()}/${reference.owner}/${reference.name}.git`,
|
|
539
|
+
baseBranch: template ? defaultBranchOf(template) : 'main',
|
|
540
|
+
githubId: template ? String(template.githubId) : null,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/** The model-locked LLM-proxy session token a bootstrap container runs under. */
|
|
544
|
+
mintSessionToken(request) {
|
|
545
|
+
return this.deps.sessionService.mint({
|
|
546
|
+
workspaceId: request.workspaceId,
|
|
547
|
+
executionId: request.containerJobId,
|
|
548
|
+
agentKind: 'architect',
|
|
549
|
+
provider: this.deps.model.provider,
|
|
550
|
+
model: this.deps.model.model,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/** The host's web base, trailing slashes stripped, for building clone URLs. */
|
|
554
|
+
webBase() {
|
|
555
|
+
return (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
|
|
556
|
+
}
|
|
384
557
|
/** Poll a dispatched bootstrap job, mapping the runner job view into an update. */
|
|
385
558
|
async pollBootstrap(handle) {
|
|
386
559
|
const view = await this.jobs.poll(handle.workspaceId, {
|
|
@@ -420,16 +593,20 @@ export class ContainerRepoBootstrapper {
|
|
|
420
593
|
detail: view.detail ?? result.error,
|
|
421
594
|
};
|
|
422
595
|
}
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// that does not exist
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
596
|
+
// What a completed run can NAME is decided by its target, never by whether a `prUrl` came
|
|
597
|
+
// back. A monorepo run created no repository, so building a `repoUrl` here would name one
|
|
598
|
+
// that does not exist; a new-repo run created one under either delivery, and under
|
|
599
|
+
// `pull_request` it has BOTH a repository and a pull request to report. Reading the record
|
|
600
|
+
// is what tells them apart: `prUrl`'s presence cannot, now that a new-repo run may carry
|
|
601
|
+
// one. A run that promised a pull request and reports none is left to the ORCHESTRATION to
|
|
602
|
+
// fail, which is where "delivered nowhere" is a statement about the run rather than about
|
|
603
|
+
// this poll.
|
|
604
|
+
const record = await this.requireRecord(handle);
|
|
605
|
+
const pr = result.prUrl ? { prUrl: result.prUrl } : {};
|
|
606
|
+
if (record.monorepo)
|
|
607
|
+
return { state: 'done', ...pr };
|
|
608
|
+
const outcome = await this.buildOutcome(handle, record.repoName, result.defaultBranch);
|
|
609
|
+
return { state: 'done', outcome, ...pr };
|
|
433
610
|
}
|
|
434
611
|
/**
|
|
435
612
|
* Best-effort: reclaim the per-run container for a job. Releases through the same
|
|
@@ -474,23 +651,44 @@ export class ContainerRepoBootstrapper {
|
|
|
474
651
|
});
|
|
475
652
|
return { installationId: installation.installationId, githubId: repo.githubId };
|
|
476
653
|
}
|
|
477
|
-
/**
|
|
478
|
-
|
|
654
|
+
/**
|
|
655
|
+
* Construct the success outcome from the installation + the recorded job's repo name.
|
|
656
|
+
*
|
|
657
|
+
* `resultDefaultBranch` is what the HARNESS reported, and only the `bootstrap` spec reports one
|
|
658
|
+
* (it echoes back the branch it force-pushed, which it also created). The plain coding shape a
|
|
659
|
+
* `pull_request` run takes reports none, so the branch is READ off the target repository rather
|
|
660
|
+
* than defaulted to `main`: this field states which branch the work lands on, and a repository
|
|
661
|
+
* whose default is `master`, `trunk` or an org-wide choice would otherwise be recorded as a ref
|
|
662
|
+
* that does not exist. One read, on the terminal poll only. A failed read keeps the
|
|
663
|
+
* conventional fallback and says so in a warning, rather than failing a delivered run over the
|
|
664
|
+
* one field nothing reads back.
|
|
665
|
+
*/
|
|
666
|
+
async buildOutcome(handle, repoName, resultDefaultBranch) {
|
|
479
667
|
const installation = await this.deps.installationRepository.getByWorkspace(handle.workspaceId);
|
|
480
668
|
if (!installation)
|
|
481
669
|
throw new Error(`Workspace '${handle.workspaceId}' is not connected to GitHub`);
|
|
482
|
-
const record = await this.deps.bootstrapJobRepository.get(handle.workspaceId, handle.jobId);
|
|
483
|
-
if (!record)
|
|
484
|
-
throw new Error(`Bootstrap job '${handle.jobId}' not found`);
|
|
485
670
|
const owner = installation.accountLogin;
|
|
486
|
-
const
|
|
671
|
+
const log = logger.child({ jobId: handle.jobId, workspaceId: handle.workspaceId });
|
|
672
|
+
const target = resultDefaultBranch
|
|
673
|
+
? null
|
|
674
|
+
: await runBestEffort(log, 'bootstrap: read the target repo default branch', () => this.deps.githubClient.getRepo(installation.installationId, {
|
|
675
|
+
owner,
|
|
676
|
+
repo: repoName,
|
|
677
|
+
}), { repo: `${owner}/${repoName}` });
|
|
487
678
|
return {
|
|
488
|
-
repoUrl: `${webBase}/${owner}/${
|
|
679
|
+
repoUrl: `${this.webBase()}/${owner}/${repoName}`,
|
|
489
680
|
owner,
|
|
490
|
-
name:
|
|
491
|
-
defaultBranch: resultDefaultBranch ?? 'main',
|
|
681
|
+
name: repoName,
|
|
682
|
+
defaultBranch: resultDefaultBranch ?? (target ? defaultBranchOf(target) : 'main'),
|
|
492
683
|
};
|
|
493
684
|
}
|
|
685
|
+
/** The run's stored row, which a poll addresses only by id. */
|
|
686
|
+
async requireRecord(handle) {
|
|
687
|
+
const record = await this.deps.bootstrapJobRepository.get(handle.workspaceId, handle.jobId);
|
|
688
|
+
if (!record)
|
|
689
|
+
throw new Error(`Bootstrap job '${handle.jobId}' not found`);
|
|
690
|
+
return record;
|
|
691
|
+
}
|
|
494
692
|
}
|
|
495
693
|
/** A repo's default branch, or the conventional fallback when the provider reported none. */
|
|
496
694
|
function defaultBranchOf(repo) {
|