@cat-factory/orchestration 0.294.0 → 0.296.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/container/dependencies.d.ts +10 -1
- package/dist/container/dependencies.d.ts.map +1 -1
- package/dist/container/engine-dependent-modules.d.ts +3 -0
- package/dist/container/engine-dependent-modules.d.ts.map +1 -1
- package/dist/container/engine-dependent-modules.js +7 -2
- package/dist/container/engine-dependent-modules.js.map +1 -1
- package/dist/container/modules.d.ts +5 -1
- package/dist/container/modules.d.ts.map +1 -1
- package/dist/container/modules.js +35 -2
- package/dist/container/modules.js.map +1 -1
- package/dist/container.d.ts.map +1 -1
- package/dist/container.js +1 -0
- package/dist/container.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/bootstrap/BootstrapService.d.ts +92 -4
- package/dist/modules/bootstrap/BootstrapService.d.ts.map +1 -1
- package/dist/modules/bootstrap/BootstrapService.js +404 -20
- package/dist/modules/bootstrap/BootstrapService.js.map +1 -1
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts +35 -0
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts.map +1 -0
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js +87 -0
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js.map +1 -0
- package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts +98 -0
- package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts.map +1 -0
- package/dist/modules/bootstrap/MonorepoBootstrapController.js +278 -0
- package/dist/modules/bootstrap/MonorepoBootstrapController.js.map +1 -0
- package/dist/modules/bootstrap/monorepoSurvey.d.ts +41 -0
- package/dist/modules/bootstrap/monorepoSurvey.d.ts.map +1 -0
- package/dist/modules/bootstrap/monorepoSurvey.js +289 -0
- package/dist/modules/bootstrap/monorepoSurvey.js.map +1 -0
- package/dist/modules/preview/PreviewService.d.ts.map +1 -1
- package/dist/modules/preview/PreviewService.js +4 -0
- package/dist/modules/preview/PreviewService.js.map +1 -1
- package/package.json +11 -11
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { assertFound, ConflictError, getErrorMessage, isDispatchFailure, sameSubtasks, } from '@cat-factory/kernel';
|
|
1
|
+
import { assertFound, ConflictError, getErrorMessage, isDispatchFailure, noopLogger, redactSecrets, renderAdoptionBrief, renderAdoptionPrSection, resolveAdoptionReview, runBestEffort, sameSubtasks, } from '@cat-factory/kernel';
|
|
2
2
|
import { registerServiceForFrame, requireWorkspace } from '@cat-factory/kernel';
|
|
3
|
+
import { monorepoBootstrapPrTitle } from '@cat-factory/agents';
|
|
4
|
+
import { MonorepoBootstrapController, } from './MonorepoBootstrapController.js';
|
|
3
5
|
function toReferenceArchitecture(record) {
|
|
4
6
|
return {
|
|
5
7
|
id: record.id,
|
|
@@ -28,18 +30,55 @@ function toBootstrapJob(record) {
|
|
|
28
30
|
subtasks: record.subtasks,
|
|
29
31
|
error: record.error,
|
|
30
32
|
failure: record.failure,
|
|
33
|
+
monorepo: record.monorepo,
|
|
34
|
+
phase: record.phase,
|
|
35
|
+
adoptionPlan: record.adoptionPlan,
|
|
36
|
+
adoptionReview: record.adoptionReview,
|
|
37
|
+
prUrl: record.prUrl,
|
|
31
38
|
createdAt: record.createdAt,
|
|
32
39
|
updatedAt: record.updatedAt,
|
|
33
40
|
};
|
|
34
41
|
}
|
|
42
|
+
/** The fields every new bootstrap run starts with; the monorepo half overrides what it owns. */
|
|
43
|
+
function newRunDefaults(id) {
|
|
44
|
+
return {
|
|
45
|
+
monorepo: null,
|
|
46
|
+
phase: null,
|
|
47
|
+
// A single-drive run keys its driver on its own id, which is what every existing bootstrap
|
|
48
|
+
// did before the monorepo flow needed a second drive.
|
|
49
|
+
driveId: id,
|
|
50
|
+
adoptionPlan: null,
|
|
51
|
+
adoptionReview: null,
|
|
52
|
+
prUrl: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
35
55
|
/** Join the reference architecture's default instructions with per-run extras. */
|
|
36
56
|
function composeInstructions(defaults, extra) {
|
|
37
57
|
return [defaults.trim(), extra.trim()].filter((part) => part.length > 0).join('\n\n');
|
|
38
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* How long a survey claim holds before another drive may take it.
|
|
61
|
+
*
|
|
62
|
+
* Sized against what the claim covers: a bounded set of checkout-free reads plus one inline model
|
|
63
|
+
* call, so minutes rather than hours. It exists because a claimer can die between taking the claim
|
|
64
|
+
* and writing the plan (an evicted isolate, a restarted worker), and a claim with no expiry would
|
|
65
|
+
* leave that run parked on nothing with no way back in.
|
|
66
|
+
*/
|
|
67
|
+
const SURVEY_CLAIM_TTL_MS = 10 * 60_000;
|
|
39
68
|
export class BootstrapService {
|
|
40
69
|
deps;
|
|
70
|
+
/** The monorepo flow's decisions; a plain new-repo run never reaches it. */
|
|
71
|
+
monorepo;
|
|
72
|
+
/** Normalised once, so the best-effort paths stay unit-testable with no logger wired. */
|
|
73
|
+
log;
|
|
41
74
|
constructor(deps) {
|
|
42
75
|
this.deps = deps;
|
|
76
|
+
this.log = deps.logger ?? noopLogger;
|
|
77
|
+
this.monorepo = new MonorepoBootstrapController({
|
|
78
|
+
...deps.monorepo,
|
|
79
|
+
clock: deps.clock,
|
|
80
|
+
logger: deps.logger,
|
|
81
|
+
});
|
|
43
82
|
}
|
|
44
83
|
/** True when a bootstrap run can actually be performed (the bootstrapper is wired). */
|
|
45
84
|
get canBootstrap() {
|
|
@@ -150,9 +189,17 @@ export class BootstrapService {
|
|
|
150
189
|
? assertFound(await this.deps.referenceArchitectureRepository.get(workspaceId, input.referenceArchitectureId), 'Reference architecture', input.referenceArchitectureId)
|
|
151
190
|
: null;
|
|
152
191
|
const instructions = composeInstructions(reference?.defaultInstructions ?? '', input.instructions);
|
|
192
|
+
// Pre-flight the monorepo BEFORE any row is written, the same ordering the new-repo path
|
|
193
|
+
// gets from dispatching before it creates a frame: a refused target (unlinked repo, a
|
|
194
|
+
// directory that already holds a service) leaves neither a job nor a board card behind.
|
|
195
|
+
const monorepo = input.monorepo
|
|
196
|
+
? await this.monorepo.resolveTarget(bootstrapper, workspaceId, input.monorepo)
|
|
197
|
+
: null;
|
|
153
198
|
const now = this.deps.clock.now();
|
|
199
|
+
const id = this.deps.idGenerator.next('boot');
|
|
154
200
|
const record = {
|
|
155
|
-
id
|
|
201
|
+
...newRunDefaults(id),
|
|
202
|
+
id,
|
|
156
203
|
workspaceId,
|
|
157
204
|
referenceArchitectureId: reference?.id ?? null,
|
|
158
205
|
referenceArchitectureName: reference?.name ?? null,
|
|
@@ -165,10 +212,25 @@ export class BootstrapService {
|
|
|
165
212
|
subtasks: null,
|
|
166
213
|
error: null,
|
|
167
214
|
failure: null,
|
|
215
|
+
...(monorepo ? { monorepo: monorepo.ref, phase: 'survey' } : {}),
|
|
168
216
|
createdAt: now,
|
|
169
217
|
updatedAt: now,
|
|
170
218
|
};
|
|
171
219
|
await this.deps.bootstrapJobRepository.insert(record);
|
|
220
|
+
// A monorepo run dispatches NOTHING yet. Its first phase is the survey, which is a bounded
|
|
221
|
+
// set of checkout-free reads plus one inline model call (no container, no clone), so the
|
|
222
|
+
// durable driver runs it on its first poll and the request returns immediately, exactly as
|
|
223
|
+
// the container path does. The frame is materialised now rather than after a dispatch,
|
|
224
|
+
// because the pre-flight above has already taken every refusal this phase can raise.
|
|
225
|
+
if (monorepo) {
|
|
226
|
+
const frame = await this.createServiceFrame(workspaceId, input.repoName, input.type ?? 'service', monorepo.ref);
|
|
227
|
+
const started = { blockId: frame.id, updatedAt: this.deps.clock.now() };
|
|
228
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, started);
|
|
229
|
+
const job = toBootstrapJob({ ...record, ...started });
|
|
230
|
+
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id, record.driveId);
|
|
231
|
+
await this.emitBootstrap(workspaceId, job, frame);
|
|
232
|
+
return job;
|
|
233
|
+
}
|
|
172
234
|
// Dispatch the container first: its pre-flight (target exists, reachable,
|
|
173
235
|
// empty-or-boilerplate) is the gate that most runs fail on, so failing here
|
|
174
236
|
// before creating a board frame keeps the board clean on the common errors.
|
|
@@ -176,6 +238,7 @@ export class BootstrapService {
|
|
|
176
238
|
await bootstrapper.startBootstrap({
|
|
177
239
|
workspaceId,
|
|
178
240
|
jobId: record.id,
|
|
241
|
+
containerJobId: record.driveId,
|
|
179
242
|
referenceRepo: reference
|
|
180
243
|
? { owner: reference.repoOwner, name: reference.repoName }
|
|
181
244
|
: undefined,
|
|
@@ -203,7 +266,7 @@ export class BootstrapService {
|
|
|
203
266
|
};
|
|
204
267
|
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, patch);
|
|
205
268
|
// A failed dispatch may still have spun a container up; reclaim it best-effort.
|
|
206
|
-
await this.stopContainer(workspaceId, record.id);
|
|
269
|
+
await this.stopContainer(workspaceId, record.id, record.driveId);
|
|
207
270
|
const failed = toBootstrapJob({ ...record, ...patch });
|
|
208
271
|
await this.emitBootstrap(workspaceId, failed, null);
|
|
209
272
|
return failed;
|
|
@@ -216,7 +279,7 @@ export class BootstrapService {
|
|
|
216
279
|
const job = toBootstrapJob({ ...record, ...started });
|
|
217
280
|
// Hand off the long poll loop to the durable driver (the worker's
|
|
218
281
|
// BootstrapWorkflow). Without a runner (tests) the caller polls directly.
|
|
219
|
-
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id);
|
|
282
|
+
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id, record.driveId);
|
|
220
283
|
await this.emitBootstrap(workspaceId, job, frame);
|
|
221
284
|
return job;
|
|
222
285
|
}
|
|
@@ -249,8 +312,10 @@ export class BootstrapService {
|
|
|
249
312
|
referenceRepo = { owner: reference.repoOwner, name: reference.repoName };
|
|
250
313
|
}
|
|
251
314
|
const now = this.deps.clock.now();
|
|
315
|
+
const id = this.deps.idGenerator.next('boot');
|
|
252
316
|
const record = {
|
|
253
|
-
id
|
|
317
|
+
...newRunDefaults(id),
|
|
318
|
+
id,
|
|
254
319
|
workspaceId,
|
|
255
320
|
referenceArchitectureId: previous.referenceArchitectureId,
|
|
256
321
|
referenceArchitectureName: previous.referenceArchitectureName,
|
|
@@ -265,16 +330,51 @@ export class BootstrapService {
|
|
|
265
330
|
subtasks: null,
|
|
266
331
|
error: null,
|
|
267
332
|
failure: null,
|
|
333
|
+
// A monorepo retry carries the SETTLED review forward, and the plan it was settled
|
|
334
|
+
// against with it. Re-surveying would throw away a decision a human already made and ask
|
|
335
|
+
// them for it again, which is the one thing a retry must not do: the failure being
|
|
336
|
+
// retried is a container fault, not a change of mind. The phase is preserved too: a run
|
|
337
|
+
// that failed during the survey retries the survey.
|
|
338
|
+
//
|
|
339
|
+
// A plan that is NOT ready is dropped instead, and that is what makes a retry the way out
|
|
340
|
+
// of an unavailable one: the causes are an unwired model, an unreadable repository and an
|
|
341
|
+
// exhausted budget, all of which an operator fixes OUTSIDE the run, so carrying the stale
|
|
342
|
+
// plan forward would re-park on the old failure and no state transition would ever reach
|
|
343
|
+
// the working advisor. The new row also carries no survey claim (it is a new id), so the
|
|
344
|
+
// re-survey is claimable.
|
|
345
|
+
monorepo: previous.monorepo,
|
|
346
|
+
phase: previous.phase,
|
|
347
|
+
adoptionPlan: previous.adoptionPlan?.status === 'ready' ? previous.adoptionPlan : null,
|
|
348
|
+
adoptionReview: previous.adoptionReview,
|
|
268
349
|
createdAt: now,
|
|
269
350
|
updatedAt: now,
|
|
270
351
|
};
|
|
271
352
|
await this.deps.bootstrapJobRepository.insert(record);
|
|
353
|
+
// A monorepo retry re-enters at its own phase rather than dispatching: a survey retry
|
|
354
|
+
// re-runs the reads on the next poll, and an apply retry re-dispatches through the same
|
|
355
|
+
// path the review's resume uses, so neither has a second copy of the dispatch here.
|
|
356
|
+
if (record.monorepo) {
|
|
357
|
+
const frame = previous.blockId
|
|
358
|
+
? await this.markFrame(workspaceId, previous.blockId, 'in_progress', 'Bootstrapping into the monorepo… retrying after a failed run.')
|
|
359
|
+
: null;
|
|
360
|
+
const blockId = frame?.id ?? previous.blockId;
|
|
361
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, { blockId });
|
|
362
|
+
const resumed = { ...record, blockId };
|
|
363
|
+
if (record.phase === 'apply' && record.adoptionReview) {
|
|
364
|
+
return await this.dispatchApply(workspaceId, resumed, record.adoptionReview);
|
|
365
|
+
}
|
|
366
|
+
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id, record.driveId);
|
|
367
|
+
const job = toBootstrapJob(resumed);
|
|
368
|
+
await this.emitBootstrap(workspaceId, job, frame);
|
|
369
|
+
return job;
|
|
370
|
+
}
|
|
272
371
|
// Dispatch a fresh container under the new job id (description/private aren't
|
|
273
372
|
// forwarded — the target repo already exists — so defaults are harmless).
|
|
274
373
|
try {
|
|
275
374
|
await bootstrapper.startBootstrap({
|
|
276
375
|
workspaceId,
|
|
277
376
|
jobId: record.id,
|
|
377
|
+
containerJobId: record.driveId,
|
|
278
378
|
referenceRepo,
|
|
279
379
|
target: { name: record.repoName, description: '', private: true },
|
|
280
380
|
instructions: record.instructions,
|
|
@@ -290,7 +390,7 @@ export class BootstrapService {
|
|
|
290
390
|
updatedAt: this.deps.clock.now(),
|
|
291
391
|
};
|
|
292
392
|
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, patch);
|
|
293
|
-
await this.stopContainer(workspaceId, record.id);
|
|
393
|
+
await this.stopContainer(workspaceId, record.id, record.driveId);
|
|
294
394
|
// Re-mark the reused frame blocked (it briefly belonged to this attempt).
|
|
295
395
|
const block = previous.blockId
|
|
296
396
|
? await this.markFrame(workspaceId, previous.blockId, 'blocked', `Bootstrap failed: ${message}`)
|
|
@@ -311,7 +411,7 @@ export class BootstrapService {
|
|
|
311
411
|
const started = { blockId, updatedAt: this.deps.clock.now() };
|
|
312
412
|
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, started);
|
|
313
413
|
const job = toBootstrapJob({ ...record, ...started });
|
|
314
|
-
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id);
|
|
414
|
+
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id, record.driveId);
|
|
315
415
|
await this.emitBootstrap(workspaceId, job, frame);
|
|
316
416
|
return job;
|
|
317
417
|
}
|
|
@@ -329,10 +429,23 @@ export class BootstrapService {
|
|
|
329
429
|
return { state: 'done' };
|
|
330
430
|
if (record.status === 'failed')
|
|
331
431
|
return { state: 'failed', error: record.error ?? undefined };
|
|
432
|
+
if (record.status === 'awaiting_review')
|
|
433
|
+
return { state: 'awaiting_review' };
|
|
434
|
+
// The monorepo flow's SURVEY phase has no container to poll: it reads both repositories
|
|
435
|
+
// through the checkout-free port and asks a model to judge. Doing it here rather than in
|
|
436
|
+
// `bootstrap()` keeps the start request fast and puts the work on the durable driver, which
|
|
437
|
+
// is what makes it survive an eviction. Re-entering an already-surveyed run is a no-op
|
|
438
|
+
// (the stored plan is the claim), so the driver's retries and replays are safe.
|
|
439
|
+
if (record.phase === 'survey')
|
|
440
|
+
return await this.runSurvey(workspaceId, record);
|
|
332
441
|
const bootstrapper = this.deps.repoBootstrapper;
|
|
333
442
|
if (!bootstrapper)
|
|
334
443
|
throw new Error('Repository bootstrapping is not configured');
|
|
335
|
-
const update = await bootstrapper.pollBootstrap({
|
|
444
|
+
const update = await bootstrapper.pollBootstrap({
|
|
445
|
+
workspaceId,
|
|
446
|
+
jobId,
|
|
447
|
+
containerJobId: record.driveId,
|
|
448
|
+
});
|
|
336
449
|
if (update.state === 'running') {
|
|
337
450
|
// Only persist + push when the counts actually changed, to avoid a write +
|
|
338
451
|
// broadcast on every idle poll.
|
|
@@ -355,11 +468,16 @@ export class BootstrapService {
|
|
|
355
468
|
await this.deps.bootstrapJobRepository.update(workspaceId, jobId, patch);
|
|
356
469
|
// Reclaim the per-run container so a faulted/leaked instance doesn't idle
|
|
357
470
|
// until its sleep timer (best-effort; an evicted container is already gone).
|
|
358
|
-
await this.stopContainer(workspaceId, jobId);
|
|
471
|
+
await this.stopContainer(workspaceId, jobId, record.driveId);
|
|
359
472
|
const block = await this.markFrame(workspaceId, record.blockId, 'blocked', `Bootstrap failed: ${message}`);
|
|
360
473
|
await this.emitBootstrap(workspaceId, toBootstrapJob({ ...record, ...patch }), block);
|
|
361
474
|
return { state: 'failed', error: message };
|
|
362
475
|
}
|
|
476
|
+
// Done on a MONOREPO run: the deliverable is a pull request against a repository that
|
|
477
|
+
// already exists, so there is no repo to create, project or name: the frame is bound to the
|
|
478
|
+
// monorepo it was pre-flighted against, pinned to its directory.
|
|
479
|
+
if (record.monorepo)
|
|
480
|
+
return await this.finishMonorepoApply(workspaceId, record, update.prUrl);
|
|
363
481
|
// Done: record the repo, link it to the frame (so dropped tasks target it),
|
|
364
482
|
// and flip the frame to a ready, droppable service.
|
|
365
483
|
const outcome = update.outcome;
|
|
@@ -375,7 +493,7 @@ export class BootstrapService {
|
|
|
375
493
|
// Reclaim the per-run container on success too (the failure path above already
|
|
376
494
|
// does): a bootstrapped repo otherwise leaves its container to idle out its
|
|
377
495
|
// sleep timer. Best-effort — an evicted/auto-slept container is already gone.
|
|
378
|
-
await this.stopContainer(workspaceId, jobId);
|
|
496
|
+
await this.stopContainer(workspaceId, jobId, record.driveId);
|
|
379
497
|
if (record.blockId) {
|
|
380
498
|
// Best-effort: a failure to link must not flip a successful run to failed —
|
|
381
499
|
// the repo is bootstrapped; the projection reconciles on the next sync. Project
|
|
@@ -392,7 +510,7 @@ export class BootstrapService {
|
|
|
392
510
|
}
|
|
393
511
|
}
|
|
394
512
|
catch {
|
|
395
|
-
// swallow
|
|
513
|
+
// swallow: see above
|
|
396
514
|
}
|
|
397
515
|
}
|
|
398
516
|
const block = await this.markFrame(workspaceId, record.blockId, 'ready', `Service bootstrapped from ${outcome.owner}/${outcome.name}. Drop tasks here to implement against it.`);
|
|
@@ -408,7 +526,7 @@ export class BootstrapService {
|
|
|
408
526
|
await this.deps.onBootstrapSucceeded?.(workspaceId, record.blockId);
|
|
409
527
|
}
|
|
410
528
|
catch {
|
|
411
|
-
// swallow
|
|
529
|
+
// swallow: see above
|
|
412
530
|
}
|
|
413
531
|
}
|
|
414
532
|
return { state: 'done' };
|
|
@@ -428,8 +546,8 @@ export class BootstrapService {
|
|
|
428
546
|
return toBootstrapJob(record);
|
|
429
547
|
// Kill the per-run container first, then the durable driver, so neither is left
|
|
430
548
|
// running once the job is marked terminal. Both are best-effort/idempotent.
|
|
431
|
-
await this.stopContainer(workspaceId, jobId);
|
|
432
|
-
await this.deps.bootstrapRunner?.cancelRun(workspaceId,
|
|
549
|
+
await this.stopContainer(workspaceId, jobId, record.driveId);
|
|
550
|
+
await this.deps.bootstrapRunner?.cancelRun(workspaceId, record.driveId);
|
|
433
551
|
const message = opts.reason ?? 'Stopped by the user.';
|
|
434
552
|
const patch = {
|
|
435
553
|
status: 'failed',
|
|
@@ -442,6 +560,259 @@ export class BootstrapService {
|
|
|
442
560
|
await this.emitBootstrap(workspaceId, toBootstrapJob({ ...record, ...patch }), block);
|
|
443
561
|
return toBootstrapJob({ ...record, ...patch });
|
|
444
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* The durable-driver key a run is CURRENTLY driven under, for the stale-run sweeper.
|
|
565
|
+
*
|
|
566
|
+
* The sweeper reads `agent_runs` generically and only ever learns a run's id, but a monorepo
|
|
567
|
+
* run in its apply phase is driven under a different key, so probing and re-driving it by run
|
|
568
|
+
* id would find no instance and finalize a perfectly healthy run as an orphan. Falls back to
|
|
569
|
+
* the run id for a run it cannot read, which is the key every single-drive run uses.
|
|
570
|
+
*/
|
|
571
|
+
async driveIdOf(workspaceId, jobId) {
|
|
572
|
+
const record = await this.deps.bootstrapJobRepository.get(workspaceId, jobId);
|
|
573
|
+
return record?.driveId ?? jobId;
|
|
574
|
+
}
|
|
575
|
+
// ---- the monorepo flow's three moves ------------------------------------
|
|
576
|
+
/**
|
|
577
|
+
* The SURVEY phase, run on the durable driver's first poll: read the monorepo and the
|
|
578
|
+
* reference template, ask the advisor what the new service should adopt from each, and park
|
|
579
|
+
* the run on the human decision.
|
|
580
|
+
*
|
|
581
|
+
* It never fails the run. A missing model, an unreadable repository or an unusable reply all
|
|
582
|
+
* park with a plan recorded `unavailable` and the cause, because the DECISION is the point of
|
|
583
|
+
* the phase and the suggestion is only an aid: a human bootstrapping into a monorepo on a
|
|
584
|
+
* deployment with no model still gets to make the call, unaided and told so.
|
|
585
|
+
*
|
|
586
|
+
* Guarded by an ATOMIC CLAIM taken BEFORE the model call, not by the plan written after it. A
|
|
587
|
+
* stored plan short-circuits a LATER drive, but two drives racing the FIRST one both read no
|
|
588
|
+
* plan, and the survey's cost is a vendor call plus a `park` that would replace the plan under
|
|
589
|
+
* a reviewer already looking at the other one (whose answers then 422). `claimSurvey` is one
|
|
590
|
+
* conditional UPDATE, so exactly one drive proceeds and the loser leaves the run alone.
|
|
591
|
+
*/
|
|
592
|
+
async runSurvey(workspaceId, record) {
|
|
593
|
+
if (record.adoptionPlan) {
|
|
594
|
+
// A plan is already recorded. Bring the row's status in line with it (a driver that died
|
|
595
|
+
// between producing the plan and recording the park re-enters here) and stop. That holds
|
|
596
|
+
// for an `unavailable` plan too: re-surveying would spend again on a park a human can
|
|
597
|
+
// already settle, and `retry` is the deliberate re-survey (it clears a non-ready plan).
|
|
598
|
+
if (record.status !== 'awaiting_review') {
|
|
599
|
+
await this.park(workspaceId, record, record.adoptionPlan);
|
|
600
|
+
}
|
|
601
|
+
return { state: 'awaiting_review' };
|
|
602
|
+
}
|
|
603
|
+
const now = this.deps.clock.now();
|
|
604
|
+
const claimed = await this.deps.bootstrapJobRepository.claimSurvey(workspaceId, record.id, {
|
|
605
|
+
at: now,
|
|
606
|
+
staleBefore: now - SURVEY_CLAIM_TTL_MS,
|
|
607
|
+
});
|
|
608
|
+
if (!claimed) {
|
|
609
|
+
// Another drive holds the claim and will park the run. Reported as still RUNNING because
|
|
610
|
+
// that is what the row says: the next poll reads the winner's plan and parks.
|
|
611
|
+
return { state: 'running' };
|
|
612
|
+
}
|
|
613
|
+
const reference = record.referenceArchitectureId
|
|
614
|
+
? await this.deps.referenceArchitectureRepository.get(workspaceId, record.referenceArchitectureId)
|
|
615
|
+
: null;
|
|
616
|
+
const plan = await this.monorepo.buildAdoptionPlan(workspaceId, record, reference);
|
|
617
|
+
await this.park(workspaceId, record, plan);
|
|
618
|
+
return { state: 'awaiting_review' };
|
|
619
|
+
}
|
|
620
|
+
/** Record the plan, flip the run + its frame to "waiting for you", and announce it. */
|
|
621
|
+
async park(workspaceId, record, adoptionPlan) {
|
|
622
|
+
const patch = {
|
|
623
|
+
status: 'awaiting_review',
|
|
624
|
+
adoptionPlan,
|
|
625
|
+
updatedAt: this.deps.clock.now(),
|
|
626
|
+
};
|
|
627
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, patch);
|
|
628
|
+
const block = await this.markFrame(workspaceId, record.blockId, 'blocked', adoptionPlan?.status === 'ready'
|
|
629
|
+
? `Waiting for review: which conventions this service should adopt from ${record.monorepo?.repoOwner}/${record.monorepo?.repoName} and which to keep from the template.`
|
|
630
|
+
: `Waiting for review: the platform could not produce an adoption suggestion, so the decisions are yours to make before the service is written.`);
|
|
631
|
+
await this.emitBootstrap(workspaceId, toBootstrapJob({ ...record, ...patch }), block);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Settle a parked run's adoption decisions and resume it.
|
|
635
|
+
*
|
|
636
|
+
* The refusals are the interesting half. A run that is not parked is a 409 naming where it
|
|
637
|
+
* actually is (the reviewer is looking at a stale tab, and applying their answers to a run
|
|
638
|
+
* that has moved on would build under a review given for a different proposal), and an
|
|
639
|
+
* incomplete or mismatched set of choices is a 422 from `resolveAdoptionReview`, never a
|
|
640
|
+
* silent fill from the recommendation, which would erase the difference between a human
|
|
641
|
+
* agreeing with the suggestion and never having read it.
|
|
642
|
+
*/
|
|
643
|
+
async submitAdoptionReview(workspaceId, jobId, input, reviewedByUserId) {
|
|
644
|
+
await requireWorkspace(this.deps.workspaceRepository, workspaceId);
|
|
645
|
+
const record = assertFound(await this.deps.bootstrapJobRepository.get(workspaceId, jobId), 'Bootstrap job', jobId, { reason: 'bootstrap_job_not_found' });
|
|
646
|
+
if (record.status !== 'awaiting_review') {
|
|
647
|
+
throw new ConflictError(`This bootstrap is not waiting for an adoption review (it is '${record.status}').`, 'bootstrap_not_awaiting_review', { status: record.status });
|
|
648
|
+
}
|
|
649
|
+
if (!record.monorepo || !record.adoptionPlan) {
|
|
650
|
+
throw new ConflictError('This bootstrap has no adoption plan recorded, so there is nothing to approve.', 'adoption_plan_unavailable', { unavailableReason: null });
|
|
651
|
+
}
|
|
652
|
+
const resolved = resolveAdoptionReview(record.adoptionPlan, input.choices, {
|
|
653
|
+
reviewedByUserId,
|
|
654
|
+
reviewedAt: this.deps.clock.now(),
|
|
655
|
+
notes: input.notes,
|
|
656
|
+
});
|
|
657
|
+
return await this.dispatchApply(workspaceId, record, resolved);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The APPLY phase: dispatch the container that writes the service into the monorepo under the
|
|
661
|
+
* settled decisions and opens the pull request.
|
|
662
|
+
*
|
|
663
|
+
* Its own drive id, because this is the run's SECOND durable drive: the survey's already went
|
|
664
|
+
* terminal, and neither facade's driver can be re-keyed on a key that has (a Workflows
|
|
665
|
+
* instance id cannot be recreated; a pg-boss singleton would dedupe against the finished job).
|
|
666
|
+
*/
|
|
667
|
+
async dispatchApply(workspaceId, record, resolved) {
|
|
668
|
+
const bootstrapper = this.deps.repoBootstrapper;
|
|
669
|
+
const monorepo = record.monorepo;
|
|
670
|
+
if (!bootstrapper || !monorepo) {
|
|
671
|
+
throw new Error('Repository bootstrapping is not configured');
|
|
672
|
+
}
|
|
673
|
+
const reference = record.referenceArchitectureId
|
|
674
|
+
? await this.deps.referenceArchitectureRepository.get(workspaceId, record.referenceArchitectureId)
|
|
675
|
+
: null;
|
|
676
|
+
const branch = this.monorepo.branchFor(record.id);
|
|
677
|
+
// `-apply`, not `:apply`: this string becomes a Cloudflare Workflows INSTANCE ID, whose
|
|
678
|
+
// accepted character set is narrower than a run id's and does not include a colon, and a
|
|
679
|
+
// rejected `create` is swallowed by design (a duplicate start is normal), so the failure
|
|
680
|
+
// would be an approved bootstrap that silently never dispatches.
|
|
681
|
+
const driveId = `${record.id}-apply`;
|
|
682
|
+
const leg = {
|
|
683
|
+
repoGithubId: monorepo.repoGithubId,
|
|
684
|
+
owner: monorepo.repoOwner,
|
|
685
|
+
name: monorepo.repoName,
|
|
686
|
+
directory: monorepo.directory,
|
|
687
|
+
branch,
|
|
688
|
+
pr: {
|
|
689
|
+
title: monorepoBootstrapPrTitle(record.repoName, monorepo.directory),
|
|
690
|
+
// The HOST rendering (neutralised holes, scrubbed at compose time), never the agent
|
|
691
|
+
// brief: this string lands on a pull request body, where a reviewer's note reading
|
|
692
|
+
// "fixes #412" would close an unrelated issue on merge. It is the FALLBACK body; the
|
|
693
|
+
// engine also publishes the same decisions as its own marker region once the pull
|
|
694
|
+
// request exists, because the harness lets an agent-authored description replace this.
|
|
695
|
+
body: redactSecrets(renderAdoptionPrSection(resolved, monorepo.directory)) ?? '',
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
const started = { ...monorepo, branch };
|
|
699
|
+
const patch = {
|
|
700
|
+
status: 'running',
|
|
701
|
+
phase: 'apply',
|
|
702
|
+
driveId,
|
|
703
|
+
adoptionReview: resolved,
|
|
704
|
+
monorepo: started,
|
|
705
|
+
error: null,
|
|
706
|
+
failure: null,
|
|
707
|
+
updatedAt: this.deps.clock.now(),
|
|
708
|
+
};
|
|
709
|
+
// Record the settled review BEFORE dispatching. The decisions are the human's, and losing
|
|
710
|
+
// them to a dispatch failure would send them back to a review they already gave; with them
|
|
711
|
+
// committed first, a retry re-dispatches under the same decisions.
|
|
712
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, patch);
|
|
713
|
+
try {
|
|
714
|
+
await bootstrapper.startBootstrap({
|
|
715
|
+
workspaceId,
|
|
716
|
+
jobId: record.id,
|
|
717
|
+
containerJobId: driveId,
|
|
718
|
+
referenceRepo: reference
|
|
719
|
+
? { owner: reference.repoOwner, name: reference.repoName }
|
|
720
|
+
: undefined,
|
|
721
|
+
target: { name: record.repoName, description: '', private: true },
|
|
722
|
+
monorepo: leg,
|
|
723
|
+
// The agent's brief is the run's own instructions PLUS the settled decisions, rendered
|
|
724
|
+
// as instructions rather than as context: an agent told only what the areas are decides
|
|
725
|
+
// them again, which is precisely what the review exists to prevent.
|
|
726
|
+
instructions: `${record.instructions}\n\n${renderAdoptionBrief(resolved, monorepo.directory)}`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
const message = getErrorMessage(error);
|
|
731
|
+
const kind = isDispatchFailure(error) ? 'dispatch' : 'preflight';
|
|
732
|
+
const failed = {
|
|
733
|
+
status: 'failed',
|
|
734
|
+
error: message,
|
|
735
|
+
failure: this.buildFailure(kind, message, null, record.subtasks),
|
|
736
|
+
updatedAt: this.deps.clock.now(),
|
|
737
|
+
};
|
|
738
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, failed);
|
|
739
|
+
await this.stopContainer(workspaceId, record.id, driveId);
|
|
740
|
+
const block = await this.markFrame(workspaceId, record.blockId, 'blocked', `Bootstrap failed: ${message}`);
|
|
741
|
+
const job = toBootstrapJob({ ...record, ...patch, ...failed });
|
|
742
|
+
await this.emitBootstrap(workspaceId, job, block);
|
|
743
|
+
return job;
|
|
744
|
+
}
|
|
745
|
+
const frame = await this.markFrame(workspaceId, record.blockId, 'in_progress', `Writing ${monorepo.directory} into ${monorepo.repoOwner}/${monorepo.repoName}…`);
|
|
746
|
+
await this.deps.bootstrapRunner?.startRun(workspaceId, record.id, driveId);
|
|
747
|
+
const job = toBootstrapJob({ ...record, ...patch });
|
|
748
|
+
await this.emitBootstrap(workspaceId, job, frame);
|
|
749
|
+
return job;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Finish a monorepo apply: bind the frame's service to the monorepo AT ITS DIRECTORY and
|
|
753
|
+
* report the pull request.
|
|
754
|
+
*
|
|
755
|
+
* A completed apply with NO pull request is a failure, not a success with a null field: the
|
|
756
|
+
* deliverable of a monorepo bootstrap is the PR (nothing is merged for the reviewer), so a run
|
|
757
|
+
* that reports done without one has left the work somewhere nobody can find it. Failing here
|
|
758
|
+
* says that, where marking the frame ready would claim a service that does not exist.
|
|
759
|
+
*/
|
|
760
|
+
async finishMonorepoApply(workspaceId, record, prUrl) {
|
|
761
|
+
const monorepo = record.monorepo;
|
|
762
|
+
await this.stopContainer(workspaceId, record.id, record.driveId);
|
|
763
|
+
if (!monorepo || !prUrl) {
|
|
764
|
+
const message = 'The bootstrap agent finished without opening a pull request, so the new service was not delivered anywhere.';
|
|
765
|
+
const patch = {
|
|
766
|
+
status: 'failed',
|
|
767
|
+
error: message,
|
|
768
|
+
failure: this.buildFailure('agent', message, null, record.subtasks),
|
|
769
|
+
updatedAt: this.deps.clock.now(),
|
|
770
|
+
};
|
|
771
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, patch);
|
|
772
|
+
const blocked = await this.markFrame(workspaceId, record.blockId, 'blocked', `Bootstrap failed: ${message}`);
|
|
773
|
+
await this.emitBootstrap(workspaceId, toBootstrapJob({ ...record, ...patch }), blocked);
|
|
774
|
+
return { state: 'failed', error: message };
|
|
775
|
+
}
|
|
776
|
+
const patch = {
|
|
777
|
+
status: 'succeeded',
|
|
778
|
+
repoOwner: monorepo.repoOwner,
|
|
779
|
+
// `repoUrl` stays null. It is the public API's "web URL of the created repository", and a
|
|
780
|
+
// monorepo run creates none: writing the pull request there would re-scope a released
|
|
781
|
+
// field in place, and an integration that clones `repoUrl` would clone a PR link. `prUrl`
|
|
782
|
+
// is the field this run's deliverable belongs in, and it is projected publicly beside it.
|
|
783
|
+
prUrl,
|
|
784
|
+
updatedAt: this.deps.clock.now(),
|
|
785
|
+
};
|
|
786
|
+
await this.deps.bootstrapJobRepository.update(workspaceId, record.id, patch);
|
|
787
|
+
const review = record.adoptionReview;
|
|
788
|
+
if (review) {
|
|
789
|
+
// Best-effort: the pull request is open, the decisions are on the run record the board
|
|
790
|
+
// renders, and failing the run over a description write would discard a delivered service.
|
|
791
|
+
// The warning is what makes the omission visible rather than silent.
|
|
792
|
+
await runBestEffort(this.log, 'monorepo bootstrap: publish adoption decisions onto the pull request', () => this.monorepo.publishAdoptionDecisions(workspaceId, monorepo, prUrl, review), { workspaceId, jobId: record.id, prUrl });
|
|
793
|
+
}
|
|
794
|
+
if (record.blockId) {
|
|
795
|
+
// Best-effort, as on the new-repo path: the pull request is open either way, and a
|
|
796
|
+
// linkage failure must not report the run as failed. The `directory` is what makes the
|
|
797
|
+
// linkage a monorepo one: `resolveRepoTarget` scopes every agent working on this service
|
|
798
|
+
// to that subtree, and the repo's monorepo flag was set at pre-flight so it is honoured.
|
|
799
|
+
try {
|
|
800
|
+
const service = await this.deps.serviceRepository?.getByFrameBlock(record.blockId);
|
|
801
|
+
if (service) {
|
|
802
|
+
await this.deps.serviceRepository?.update(service.id, {
|
|
803
|
+
repoGithubId: monorepo.repoGithubId,
|
|
804
|
+
directory: monorepo.directory,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
// swallow: see above
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const block = await this.markFrame(workspaceId, record.blockId, 'ready', `Service bootstrapped into ${monorepo.repoOwner}/${monorepo.repoName} at ${monorepo.directory}. Review and merge the pull request, then drop tasks here.`);
|
|
813
|
+
await this.emitBootstrap(workspaceId, toBootstrapJob({ ...record, ...patch }), block);
|
|
814
|
+
return { state: 'done' };
|
|
815
|
+
}
|
|
445
816
|
// ---- helpers ------------------------------------------------------------
|
|
446
817
|
/** The workspace default fragment ids a new service inherits; empty / never throws. */
|
|
447
818
|
async defaultServiceFragmentIds(workspaceId) {
|
|
@@ -454,8 +825,15 @@ export class BootstrapService {
|
|
|
454
825
|
return [];
|
|
455
826
|
}
|
|
456
827
|
}
|
|
457
|
-
/**
|
|
458
|
-
|
|
828
|
+
/**
|
|
829
|
+
* Create the provisional, in-progress service frame a bootstrap run materialises.
|
|
830
|
+
*
|
|
831
|
+
* A monorepo run's frame carries its `directory` from the start, while the repo binding waits
|
|
832
|
+
* for the run to succeed exactly as the new-repo path's does: the directory is a fact the
|
|
833
|
+
* pre-flight already settled (and what the board card is about), whereas the linkage is a
|
|
834
|
+
* claim that there is code there, which is only true once the pull request exists.
|
|
835
|
+
*/
|
|
836
|
+
async createServiceFrame(workspaceId, repoName, frameType = 'service', monorepo) {
|
|
459
837
|
const blocks = await this.deps.blockRepository.listByWorkspace(workspaceId);
|
|
460
838
|
const frames = blocks.filter((b) => b.level === 'frame').length;
|
|
461
839
|
const type = frameType;
|
|
@@ -464,7 +842,9 @@ export class BootstrapService {
|
|
|
464
842
|
id: this.deps.idGenerator.next('blk'),
|
|
465
843
|
title: repoName,
|
|
466
844
|
type,
|
|
467
|
-
description:
|
|
845
|
+
description: monorepo
|
|
846
|
+
? `Bootstrapping ${monorepo.directory} in ${monorepo.repoOwner}/${monorepo.repoName}… surveying the monorepo's conventions.`
|
|
847
|
+
: 'Bootstrapping repository… a container is adapting and pushing the initial commit.',
|
|
468
848
|
// Stagger so a fresh frame doesn't land exactly on an existing one.
|
|
469
849
|
position: { x: 80 + (frames % 5) * 48, y: 80 + (frames % 5) * 48 },
|
|
470
850
|
status: 'in_progress',
|
|
@@ -483,7 +863,11 @@ export class BootstrapService {
|
|
|
483
863
|
workspaceRepository: this.deps.workspaceRepository,
|
|
484
864
|
idGenerator: this.deps.idGenerator,
|
|
485
865
|
clock: this.deps.clock,
|
|
486
|
-
}, workspaceId, block
|
|
866
|
+
}, workspaceId, block,
|
|
867
|
+
// The repo ids stay unset until the run delivers (see the doc comment): a service
|
|
868
|
+
// pinned to a repo it has not written to yet would dispatch tasks into an empty
|
|
869
|
+
// directory. `directory` is carried now because it is what the frame IS.
|
|
870
|
+
monorepo ? { directory: monorepo.directory } : undefined);
|
|
487
871
|
await this.deps.blockRepository.insert(workspaceId, block, serviceId);
|
|
488
872
|
return block;
|
|
489
873
|
}
|
|
@@ -510,9 +894,9 @@ export class BootstrapService {
|
|
|
510
894
|
};
|
|
511
895
|
}
|
|
512
896
|
/** Best-effort: reclaim a job's per-run container (never throws). */
|
|
513
|
-
async stopContainer(workspaceId, jobId) {
|
|
897
|
+
async stopContainer(workspaceId, jobId, containerJobId) {
|
|
514
898
|
try {
|
|
515
|
-
await this.deps.repoBootstrapper?.stopBootstrap({ workspaceId, jobId });
|
|
899
|
+
await this.deps.repoBootstrapper?.stopBootstrap({ workspaceId, jobId, containerJobId });
|
|
516
900
|
}
|
|
517
901
|
catch {
|
|
518
902
|
// The container may already be gone (the common case for an eviction); the
|