@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.
Files changed (37) hide show
  1. package/dist/container/dependencies.d.ts +10 -1
  2. package/dist/container/dependencies.d.ts.map +1 -1
  3. package/dist/container/engine-dependent-modules.d.ts +3 -0
  4. package/dist/container/engine-dependent-modules.d.ts.map +1 -1
  5. package/dist/container/engine-dependent-modules.js +7 -2
  6. package/dist/container/engine-dependent-modules.js.map +1 -1
  7. package/dist/container/modules.d.ts +5 -1
  8. package/dist/container/modules.d.ts.map +1 -1
  9. package/dist/container/modules.js +35 -2
  10. package/dist/container/modules.js.map +1 -1
  11. package/dist/container.d.ts.map +1 -1
  12. package/dist/container.js +1 -0
  13. package/dist/container.js.map +1 -1
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/modules/bootstrap/BootstrapService.d.ts +92 -4
  19. package/dist/modules/bootstrap/BootstrapService.d.ts.map +1 -1
  20. package/dist/modules/bootstrap/BootstrapService.js +404 -20
  21. package/dist/modules/bootstrap/BootstrapService.js.map +1 -1
  22. package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts +35 -0
  23. package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts.map +1 -0
  24. package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js +87 -0
  25. package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js.map +1 -0
  26. package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts +98 -0
  27. package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts.map +1 -0
  28. package/dist/modules/bootstrap/MonorepoBootstrapController.js +278 -0
  29. package/dist/modules/bootstrap/MonorepoBootstrapController.js.map +1 -0
  30. package/dist/modules/bootstrap/monorepoSurvey.d.ts +41 -0
  31. package/dist/modules/bootstrap/monorepoSurvey.d.ts.map +1 -0
  32. package/dist/modules/bootstrap/monorepoSurvey.js +289 -0
  33. package/dist/modules/bootstrap/monorepoSurvey.js.map +1 -0
  34. package/dist/modules/preview/PreviewService.d.ts.map +1 -1
  35. package/dist/modules/preview/PreviewService.js +4 -0
  36. package/dist/modules/preview/PreviewService.js.map +1 -1
  37. 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: this.deps.idGenerator.next('boot'),
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: this.deps.idGenerator.next('boot'),
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({ workspaceId, jobId });
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 see above
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 see above
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, jobId);
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
- /** Create the provisional, in-progress service frame a bootstrap run materialises. */
458
- async createServiceFrame(workspaceId, repoName, frameType = 'service') {
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: 'Bootstrapping repository… a container is adapting and pushing the initial commit.',
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