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