@cat-factory/orchestration 0.123.4 → 0.123.5
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/modules/execution/DeployerStepController.d.ts +185 -0
- package/dist/modules/execution/DeployerStepController.d.ts.map +1 -0
- package/dist/modules/execution/DeployerStepController.js +670 -0
- package/dist/modules/execution/DeployerStepController.js.map +1 -0
- package/dist/modules/execution/ExecutionService.d.ts +8 -211
- package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
- package/dist/modules/execution/ExecutionService.js +59 -805
- package/dist/modules/execution/ExecutionService.js.map +1 -1
- package/dist/modules/execution/FollowUpGateController.d.ts +104 -0
- package/dist/modules/execution/FollowUpGateController.d.ts.map +1 -0
- package/dist/modules/execution/FollowUpGateController.js +317 -0
- package/dist/modules/execution/FollowUpGateController.js.map +1 -0
- package/dist/modules/execution/RunAdmission.d.ts +204 -0
- package/dist/modules/execution/RunAdmission.d.ts.map +1 -0
- package/dist/modules/execution/RunAdmission.js +571 -0
- package/dist/modules/execution/RunAdmission.js.map +1 -0
- package/dist/modules/execution/RunDispatcher.d.ts +24 -196
- package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
- package/dist/modules/execution/RunDispatcher.js +70 -929
- package/dist/modules/execution/RunDispatcher.js.map +1 -1
- package/dist/modules/execution/review-kinds.d.ts +41 -0
- package/dist/modules/execution/review-kinds.d.ts.map +1 -0
- package/dist/modules/execution/review-kinds.js +241 -0
- package/dist/modules/execution/review-kinds.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
import { allPullRequests, DEFAULT_COMPANION_MAX_ATTEMPTS,
|
|
2
|
-
import {
|
|
3
|
-
import { assertPipelineLaunchable
|
|
1
|
+
import { allPullRequests, DEFAULT_COMPANION_MAX_ATTEMPTS, pipelineHasVisualStep, } from '@cat-factory/contracts';
|
|
2
|
+
import { companionFor, companionTargets, hasTrait, INTERVIEW_GATE_TRAIT, isCompanionKind, } from '@cat-factory/agents';
|
|
3
|
+
import { assertPipelineLaunchable } from '../pipelines/pipelineShape.js';
|
|
4
4
|
import { shouldRunGatedStep } from './stepGating.logic.js';
|
|
5
5
|
import { resolveIndividualVendors, } from './individualVendors.logic.js';
|
|
6
|
-
import { assertFound, ConflictError,
|
|
6
|
+
import { assertFound, ConflictError, NotFoundError, RunContendedError, ValidationError, } from '@cat-factory/kernel';
|
|
7
7
|
import { DEFAULT_RISK_POLICY } from '@cat-factory/kernel';
|
|
8
|
-
import { REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND,
|
|
8
|
+
import { REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, isTesterKind, HUMAN_TEST_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, HUMAN_REVIEW_AGENT_KIND, } from './ci.logic.js';
|
|
9
9
|
import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND } from './followUp.logic.js';
|
|
10
10
|
import { AgentContextBuilder, } from './AgentContextBuilder.js';
|
|
11
11
|
import { CompanionController } from './CompanionController.js';
|
|
12
12
|
import { StepGraph } from './StepGraph.js';
|
|
13
13
|
import { RunStateMachine } from './RunStateMachine.js';
|
|
14
14
|
import { RunDispatcher } from './RunDispatcher.js';
|
|
15
|
+
import { RunAdmission } from './RunAdmission.js';
|
|
15
16
|
import { inferTechnicalLabel } from './technical.logic.js';
|
|
16
17
|
import { MergeResolver } from './MergeResolver.js';
|
|
17
18
|
import { orderPrsForMerge } from './mergeOrder.logic.js';
|
|
18
19
|
import { ReviewGateController } from './ReviewGateController.js';
|
|
20
|
+
import { buildBrainstormKind, buildClarityKind, buildRequirementsKind } from './review-kinds.js';
|
|
19
21
|
import { ForkDecisionController } from './ForkDecisionController.js';
|
|
20
22
|
import { PrReviewController } from './PrReviewController.js';
|
|
21
23
|
import { BrainstormActions, ClarityReviewActions, RequirementReviewActions, } from './gate-window-facades.js';
|
|
@@ -27,14 +29,10 @@ import { VisualConfirmationController } from './VisualConfirmationController.js'
|
|
|
27
29
|
import { InitiativeInterviewController } from './InitiativeInterviewController.js';
|
|
28
30
|
import { DocInterviewController } from './DocInterviewController.js';
|
|
29
31
|
import { FORK_DECISION_PRODUCER_KIND } from './forkDecision.logic.js';
|
|
30
|
-
import { assertInitiativeShapeAllowed, } from '../initiative/initiative.logic.js';
|
|
31
32
|
import { isAsyncAgentExecutor } from '@cat-factory/kernel';
|
|
32
|
-
import { dependenciesMet, descendantIds, serviceOf
|
|
33
|
+
import { dependenciesMet, descendantIds, serviceOf } from '../board/board.logic.js';
|
|
33
34
|
import { requireWorkspace } from '@cat-factory/kernel';
|
|
34
35
|
import { carryForwardFailures, carryForwardOutputs, planResumedSteps, planRestartFromStep, } from './retry.logic.js';
|
|
35
|
-
import { decideTesterInfra, ENV_CONSUMER_KINDS, needsDeployerBeforeConsumer, TESTER_INFRA_MESSAGES, } from './tester-infra.logic.js';
|
|
36
|
-
import { decideDeployerConfig, deployerServiceConfigIssues, hasEnabledDeployerStep, } from './deployer.logic.js';
|
|
37
|
-
import { hasLiveServiceBinding, hasServiceBinding } from './frontend-infra.logic.js';
|
|
38
36
|
/**
|
|
39
37
|
* The execution engine. It orchestrates a pipeline of agent-performed steps and
|
|
40
38
|
* is fully deterministic: `advanceInstance` moves one run forward by exactly one
|
|
@@ -62,13 +60,15 @@ export class ExecutionService {
|
|
|
62
60
|
events;
|
|
63
61
|
board;
|
|
64
62
|
spend;
|
|
65
|
-
requirementReviewService;
|
|
66
|
-
docInterviewService;
|
|
67
|
-
clarityReviewService;
|
|
68
|
-
brainstormServices;
|
|
69
|
-
environmentProvisioning;
|
|
70
63
|
/** Assembles the per-step agent context (requirements, docs, env, service frame, fragments). */
|
|
71
64
|
contextBuilder;
|
|
65
|
+
/**
|
|
66
|
+
* The run-admission preflights (the `assert*` family): every config/resource precondition
|
|
67
|
+
* a START / RETRY / RESTART must satisfy, extracted to {@link RunAdmission} so the guard
|
|
68
|
+
* family can grow without re-bloating the engine. Also owns the shared
|
|
69
|
+
* {@link RunAdmission.modelIdIsMetered} predicate the spend gates use.
|
|
70
|
+
*/
|
|
71
|
+
admission;
|
|
72
72
|
/** Resolves a `merger` step's assessment into an auto-merge or a `merge_review` notification. */
|
|
73
73
|
mergeResolver;
|
|
74
74
|
/** Drives a companion (reviewer/spec/architect) step: grade → pass / loop producer / park. */
|
|
@@ -106,8 +106,10 @@ export class ExecutionService {
|
|
|
106
106
|
// `resolveRunRepoContext` / `runInitiatorScope` are NOT stored on the engine: their only
|
|
107
107
|
// consumers (the ingest/follow-up/tracker/notification paths + the pre/post-op repo binding +
|
|
108
108
|
// the initiator scope) moved to {@link RunDispatcher} (and the controllers / RunStateMachine),
|
|
109
|
-
// so the constructor forwards the destructured params straight to those collaborators.
|
|
110
|
-
workspaceSettingsService
|
|
109
|
+
// so the constructor forwards the destructured params straight to those collaborators. The
|
|
110
|
+
// admission-only seams (`workspaceSettingsService` / `resolveProviderCapabilities` /
|
|
111
|
+
// `inlineHarnessRef` / `resolveBinaryArtifactStore` / `assertAgentBackendConfigured` /
|
|
112
|
+
// `environmentProvisioning`) likewise live on {@link RunAdmission} (and the controllers).
|
|
111
113
|
prMerger;
|
|
112
114
|
notifications;
|
|
113
115
|
riskPolicyRepository;
|
|
@@ -115,18 +117,7 @@ export class ExecutionService {
|
|
|
115
117
|
issueWriteback;
|
|
116
118
|
subscriptionActivations;
|
|
117
119
|
pokeInitiativeLoop;
|
|
118
|
-
resolveProviderCapabilities;
|
|
119
|
-
inlineHarnessRef;
|
|
120
120
|
resolveWorkspaceModelDefault;
|
|
121
|
-
/** Start-time assertion that a container-agent backend is configured (local-mode pool). */
|
|
122
|
-
assertAgentBackendConfigured;
|
|
123
|
-
/**
|
|
124
|
-
* Resolves the per-account binary-artifact store, used by the start gate to refuse a
|
|
125
|
-
* pipeline carrying a {@link BINARY_STORAGE_TRAIT} kind (e.g. the UI Tester) when the
|
|
126
|
-
* account has no storage configured. Absent (tests/conformance with no store wired) ⇒
|
|
127
|
-
* the gate passes through, like the other optional start guards.
|
|
128
|
-
*/
|
|
129
|
-
resolveBinaryArtifactStore;
|
|
130
121
|
/**
|
|
131
122
|
* The per-step dispatch + completion spine (the four registries, the completion hub, the
|
|
132
123
|
* gate machinery, the deterministic deployer/tracker steps, the pre/post-op cluster, the
|
|
@@ -167,11 +158,6 @@ export class ExecutionService {
|
|
|
167
158
|
this.events = executionEventPublisher;
|
|
168
159
|
this.board = boardService;
|
|
169
160
|
this.spend = spendService;
|
|
170
|
-
this.requirementReviewService = requirementReviewService;
|
|
171
|
-
this.docInterviewService = docInterviewService;
|
|
172
|
-
this.clarityReviewService = clarityReviewService;
|
|
173
|
-
this.brainstormServices = brainstormServices;
|
|
174
|
-
this.environmentProvisioning = environmentProvisioning;
|
|
175
161
|
this.contextBuilder = new AgentContextBuilder({
|
|
176
162
|
workspaceRepository,
|
|
177
163
|
blockRepository,
|
|
@@ -191,6 +177,23 @@ export class ExecutionService {
|
|
|
191
177
|
fragmentResolver,
|
|
192
178
|
skillResolver,
|
|
193
179
|
});
|
|
180
|
+
// The run-admission preflights (the shared start/retry/restart `assert*` gate family).
|
|
181
|
+
// The admission-only seams are forwarded here rather than stored on the engine.
|
|
182
|
+
this.admission = new RunAdmission({
|
|
183
|
+
workspaceRepository,
|
|
184
|
+
blockRepository,
|
|
185
|
+
executionRepository,
|
|
186
|
+
contextBuilder: this.contextBuilder,
|
|
187
|
+
agentKindRegistry,
|
|
188
|
+
spend: spendService,
|
|
189
|
+
environmentProvisioning,
|
|
190
|
+
workspaceSettingsService,
|
|
191
|
+
resolveBinaryArtifactStore,
|
|
192
|
+
resolveProviderCapabilities,
|
|
193
|
+
inlineHarnessRef,
|
|
194
|
+
resolveWorkspaceModelDefault,
|
|
195
|
+
assertAgentBackendConfigured,
|
|
196
|
+
});
|
|
194
197
|
this.mergeResolver = new MergeResolver({
|
|
195
198
|
blockRepository,
|
|
196
199
|
notificationService,
|
|
@@ -317,10 +320,22 @@ export class ExecutionService {
|
|
|
317
320
|
clock,
|
|
318
321
|
notificationService,
|
|
319
322
|
});
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
323
|
+
// The review-gate subjects (requirements / clarity / the two brainstorm stages), built by
|
|
324
|
+
// the factories in review-kinds.ts over one shared closure of collaborators — each subject's
|
|
325
|
+
// differentiators live there, not on the engine.
|
|
326
|
+
const reviewKindDeps = {
|
|
327
|
+
events: executionEventPublisher,
|
|
328
|
+
blockRepository,
|
|
329
|
+
executionRepository,
|
|
330
|
+
requirementReviewService,
|
|
331
|
+
clarityReviewService,
|
|
332
|
+
brainstormServices,
|
|
333
|
+
issueWriteback,
|
|
334
|
+
};
|
|
335
|
+
this.requirementsKind = buildRequirementsKind(reviewKindDeps);
|
|
336
|
+
this.clarityKind = buildClarityKind(reviewKindDeps);
|
|
337
|
+
this.requirementsBrainstormKind = buildBrainstormKind('requirements', REQUIREMENTS_BRAINSTORM_AGENT_KIND, reviewKindDeps);
|
|
338
|
+
this.architectureBrainstormKind = buildBrainstormKind('architecture', ARCHITECTURE_BRAINSTORM_AGENT_KIND, reviewKindDeps);
|
|
324
339
|
// The interactive-planning interviewer gate — wired whenever the initiative store is
|
|
325
340
|
// present (the entity is where its state lives). The interviewer LLM is optional: without
|
|
326
341
|
// it (or without a model) the gate passes through, so planning still runs off the raw
|
|
@@ -399,7 +414,7 @@ export class ExecutionService {
|
|
|
399
414
|
resolveRunRepoContext,
|
|
400
415
|
resolveProviderCapabilities,
|
|
401
416
|
resolveRiskPolicy: (ws, block) => this.resolveRiskPolicy(ws, block),
|
|
402
|
-
modelIdIsMetered: (id, caps) => this.modelIdIsMetered(id, caps),
|
|
417
|
+
modelIdIsMetered: (id, caps) => this.admission.modelIdIsMetered(id, caps),
|
|
403
418
|
});
|
|
404
419
|
// Group the per-feature gate-window actions into cohesive sub-facades (exposed as
|
|
405
420
|
// getters below) so they stop bloating the engine's public surface as ~30 near-identical
|
|
@@ -407,7 +422,6 @@ export class ExecutionService {
|
|
|
407
422
|
this.requirementsReviewActions = new RequirementReviewActions(this.reviewGate, this.requirementsKind);
|
|
408
423
|
this.clarityReviewActions = new ClarityReviewActions(this.reviewGate, this.clarityKind);
|
|
409
424
|
this.brainstormActions = new BrainstormActions(this.reviewGate, (stage) => this.brainstormKindFor(stage));
|
|
410
|
-
this.workspaceSettingsService = workspaceSettingsService;
|
|
411
425
|
this.prMerger = pullRequestMerger;
|
|
412
426
|
this.notifications = notificationService;
|
|
413
427
|
this.riskPolicyRepository = riskPolicyRepository;
|
|
@@ -416,10 +430,6 @@ export class ExecutionService {
|
|
|
416
430
|
this.subscriptionActivations = subscriptionActivationRepository;
|
|
417
431
|
this.pokeInitiativeLoop = pokeInitiativeLoop;
|
|
418
432
|
this.resolveWorkspaceModelDefault = resolveWorkspaceModelDefault;
|
|
419
|
-
this.resolveProviderCapabilities = resolveProviderCapabilities;
|
|
420
|
-
this.inlineHarnessRef = inlineHarnessRef;
|
|
421
|
-
this.assertAgentBackendConfigured = assertAgentBackendConfigured;
|
|
422
|
-
this.resolveBinaryArtifactStore = resolveBinaryArtifactStore;
|
|
423
433
|
}
|
|
424
434
|
// ---- gate-window action sub-facades -------------------------------------
|
|
425
435
|
// Per-feature groupings of the dedicated review/test window actions, consumed by the
|
|
@@ -503,428 +513,6 @@ export class ExecutionService {
|
|
|
503
513
|
const resolveDefault = this.resolveWorkspaceModelDefault;
|
|
504
514
|
return resolveIndividualVendors(blockModelId, agentKinds, resolveDefault ? (kind) => resolveDefault(workspaceId, kind, modelPresetId) : undefined, hasPersonalSubscription);
|
|
505
515
|
}
|
|
506
|
-
/**
|
|
507
|
-
* Guard a run start when the pipeline carries a VISUAL step (`tester-ui` /
|
|
508
|
-
* `visual-confirmation`): such a step exercises a rendered UI, so it only makes sense where
|
|
509
|
-
* there is a UI to drive — a `type: 'frontend'` frame (it owns the app under test) or a frame
|
|
510
|
-
* a `frontend` frame links to (the linked frontend is the UI a change to that service is
|
|
511
|
-
* validated through). On any other frame (a service with no linked frontend, a `library` /
|
|
512
|
-
* `document` repo) a `tester-ui` step would have nothing to drive, so refuse the start with an
|
|
513
|
-
* actionable {@link ConflictError} (`visual_pipeline_no_frontend`). The frontend surfaces the
|
|
514
|
-
* SAME rule (via the shared `frameAllowsVisualPipeline`) so it only offers these pipelines
|
|
515
|
-
* where they can run; this is the server-side guarantee. A non-visual pipeline passes through.
|
|
516
|
-
* The workspace block list is read ONCE (for the frontend→service links), never per-frame.
|
|
517
|
-
*/
|
|
518
|
-
async assertPipelineFrameTypeAllowed(workspaceId, block, agentKinds) {
|
|
519
|
-
if (!pipelineHasVisualStep({ agentKinds: [...agentKinds] }))
|
|
520
|
-
return;
|
|
521
|
-
const frame = await this.contextBuilder.resolveServiceFrame(workspaceId, block.id);
|
|
522
|
-
// A `frontend` frame is always allowed without listing the workspace; only a non-frontend
|
|
523
|
-
// frame needs the link scan, so defer the (single) block-list read until then.
|
|
524
|
-
if (frame?.type === 'frontend')
|
|
525
|
-
return;
|
|
526
|
-
const blocks = await this.blockRepository.listByWorkspace(workspaceId);
|
|
527
|
-
if (frameAllowsVisualPipeline(frame, blocks))
|
|
528
|
-
return;
|
|
529
|
-
throw new ConflictError('This pipeline includes a UI-testing step, so it can only run on a frontend service (or a ' +
|
|
530
|
-
'backend service that has a frontend linked to it). Move the task under a frontend, link ' +
|
|
531
|
-
'a frontend to this service, or pick a pipeline without UI-testing steps.', 'visual_pipeline_no_frontend', { frameType: frame?.type ?? null });
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* Guard a Tester pipeline's start on the service frame's declared provisioning being runnable.
|
|
535
|
-
* The Tester needs SOME way to stand its system up: `infraless` (or none declared) runs with no
|
|
536
|
-
* infra; `docker-compose`/`kubernetes`/`custom` are all provisioned by the single Deployer step
|
|
537
|
-
* through a workspace handler, so one must resolve for the service's type. A `frontend` frame is
|
|
538
|
-
* gated instead on having a live service under test. Throws an actionable {@link ConflictError}
|
|
539
|
-
* (`tester_infra_unsupported` for the frontend case, `provision_type_unhandled` for a missing
|
|
540
|
-
* handler); passes through when the provisioning seam is unwired (tests / no environment
|
|
541
|
-
* integration), like the other optional start guards. `initiatedBy` is threaded into
|
|
542
|
-
* `canProvision` so the run initiator's local per-user handler OVERRIDES resolve exactly as they
|
|
543
|
-
* do at provision time (and as the Deployer-config gate does) — else a valid override-only local
|
|
544
|
-
* setup would be falsely refused here while the deployer would actually provision it.
|
|
545
|
-
*/
|
|
546
|
-
async assertTesterInfraConfigured(workspaceId, block, initiatedBy) {
|
|
547
|
-
// A `frontend` frame (the self-contained UI-test flow) is gated on having a live service
|
|
548
|
-
// under test, NOT on a provision type — resolved first and short-circuiting the backend
|
|
549
|
-
// branch. Only enforce it when the environment seam is wired (else, like the other optional
|
|
550
|
-
// start guards, pass through so tests / no-env deployments run unchanged).
|
|
551
|
-
const frontend = await this.contextBuilder.resolveFrontendConfig(workspaceId, block);
|
|
552
|
-
if (frontend) {
|
|
553
|
-
if (!this.environmentProvisioning)
|
|
554
|
-
return;
|
|
555
|
-
const decision = decideTesterInfra({
|
|
556
|
-
frontend: {
|
|
557
|
-
hasServiceBindings: hasServiceBinding(frontend.config),
|
|
558
|
-
hasLiveService: hasLiveServiceBinding(frontend.bindings),
|
|
559
|
-
},
|
|
560
|
-
provisionType: undefined,
|
|
561
|
-
handlerResolves: true,
|
|
562
|
-
});
|
|
563
|
-
if (decision.ok)
|
|
564
|
-
return;
|
|
565
|
-
throw new ConflictError(TESTER_INFRA_MESSAGES[decision.reason], 'tester_infra_unsupported', {
|
|
566
|
-
infraReason: decision.reason,
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
const service = await this.contextBuilder.resolveServiceConfig(workspaceId, block);
|
|
570
|
-
// A `library` frame (not `liveTestable`) runs its unit/integration suite IN-CONTAINER — any
|
|
571
|
-
// repo-local docker-compose is test infra stood up on localhost, never a Deployer-provisioned
|
|
572
|
-
// env — so the tester never needs a workspace handler. Pass through regardless of provisioning.
|
|
573
|
-
if (service?.type && !frameProfile(service.type).liveTestable)
|
|
574
|
-
return;
|
|
575
|
-
const provisioning = service?.provisioning;
|
|
576
|
-
// `docker-compose`/`kubernetes`/`custom` are all provisioned by the Deployer via a workspace
|
|
577
|
-
// handler, so resolve it lazily — and only when the provisioning seam is wired (else pass
|
|
578
|
-
// through, treating it as resolvable). `infraless`/none needs no handler.
|
|
579
|
-
const needsHandler = provisioning?.type === 'docker-compose' ||
|
|
580
|
-
provisioning?.type === 'kubernetes' ||
|
|
581
|
-
provisioning?.type === 'custom';
|
|
582
|
-
const handlerResolves = needsHandler && this.environmentProvisioning
|
|
583
|
-
? (await this.environmentProvisioning.canProvision(workspaceId, provisioning, initiatedBy))
|
|
584
|
-
.ok
|
|
585
|
-
: true;
|
|
586
|
-
const decision = decideTesterInfra({ provisionType: provisioning?.type, handlerResolves });
|
|
587
|
-
if (decision.ok)
|
|
588
|
-
return;
|
|
589
|
-
// The only backend-branch refusal is a provision type with no resolvable handler.
|
|
590
|
-
throw new ConflictError(TESTER_INFRA_MESSAGES[decision.reason], 'provision_type_unhandled', {
|
|
591
|
-
provisionType: provisioning.type,
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Fail fast when a `docker-compose`/`kubernetes`/`custom` service's chain would dead-end at an
|
|
596
|
-
* env-consumer (tester / human-test / playwright) because no enabled `deployer` provisions the
|
|
597
|
-
* environment before it — the exact silent dead-end this initiative fixes (the tester picks
|
|
598
|
-
* ephemeral mode from the provision type but finds no coordinates). The pure ordering check lives
|
|
599
|
-
* in {@link needsDeployerBeforeConsumer}; here we resolve the service's provision type (only when a
|
|
600
|
-
* consumer is present, so consumer-less chains skip the read) and translate a positive verdict
|
|
601
|
-
* into an actionable {@link ConflictError}. Pass-through for infraless/frontend services and for
|
|
602
|
-
* chains with a deployer before the first consumer.
|
|
603
|
-
*/
|
|
604
|
-
async assertDeployerBeforeConsumer(workspaceId, block, agentKinds, enabled) {
|
|
605
|
-
const hasConsumer = agentKinds.some((kind, i) => enabled?.[i] !== false && ENV_CONSUMER_KINDS.includes(kind));
|
|
606
|
-
if (!hasConsumer)
|
|
607
|
-
return;
|
|
608
|
-
const service = await this.contextBuilder.resolveServiceConfig(workspaceId, block);
|
|
609
|
-
// A `library` frame stands nothing up via the Deployer (its tester runs the suite in-container),
|
|
610
|
-
// so a missing Deployer before the tester is never a dead-end — pass through like `infraless`.
|
|
611
|
-
if (service?.type && !frameProfile(service.type).deployable)
|
|
612
|
-
return;
|
|
613
|
-
if (!needsDeployerBeforeConsumer(agentKinds, enabled, service?.provisioning?.type))
|
|
614
|
-
return;
|
|
615
|
-
throw new ConflictError(`This service provisions a '${service.provisioning.type}' environment, but this pipeline ` +
|
|
616
|
-
'has no Deployer step before its first Tester / human-test step, so the environment would ' +
|
|
617
|
-
'never be stood up. Reseed this pipeline to the latest built-in (which includes a Deployer) ' +
|
|
618
|
-
'and start a new run, or set the service to docker-compose / infraless.', 'deployer_required_before_tester', { provisionType: service.provisioning.type });
|
|
619
|
-
}
|
|
620
|
-
/**
|
|
621
|
-
* Guard a pipeline that INCLUDES an enabled `deployer` step on its ephemeral-environment config
|
|
622
|
-
* being FULL + CORRECT on BOTH sides of the "what/where ÷ how" split, so a misconfigured
|
|
623
|
-
* environment fails LOUDLY at start with a fixable pointer instead of mid-run: a `kubernetes` /
|
|
624
|
-
* `custom` service silently failing its async provision, or a `docker-compose` one whose deployer
|
|
625
|
-
* NO-OPS because no handler resolves (the exact silent dead-ends this initiative closes). It
|
|
626
|
-
* checks, in order of the fix a human would make:
|
|
627
|
-
* 1. the SERVICE's provisioning config is complete for its declared type (manifest source /
|
|
628
|
-
* compose path / custom-manifest id) — else `deployer_service_provisioning_incomplete`;
|
|
629
|
-
* 2. a WORKSPACE handler resolves for the type — else `provision_type_unhandled` (the same
|
|
630
|
-
* reason the Tester gate raises; a MISSING/ambiguous handler, not a broken one);
|
|
631
|
-
* 3. (bonus, best-effort) the resolved deployment integration's live connection PROBES green —
|
|
632
|
-
* else `deployer_connection_test_failed`, carrying the provider's failure detail.
|
|
633
|
-
* Each `ConflictError` carries a machine-readable reason + details the SPA deep-links off to the
|
|
634
|
-
* exact fix surface. Pass-through for `infraless`/undeclared services (the deployer stands nothing
|
|
635
|
-
* up) and when the environment seam is unwired (tests / no-env deployments), like the other
|
|
636
|
-
* optional start guards.
|
|
637
|
-
*/
|
|
638
|
-
async assertDeployerConfigured(workspaceId, block, agentKinds, enabled, initiatedBy) {
|
|
639
|
-
if (!this.environmentProvisioning)
|
|
640
|
-
return;
|
|
641
|
-
if (!hasEnabledDeployerStep(agentKinds, enabled))
|
|
642
|
-
return;
|
|
643
|
-
const service = await this.contextBuilder.resolveServiceConfig(workspaceId, block);
|
|
644
|
-
// A Deployer on a `library` frame (not `deployable`) is a safe no-op regardless of any declared
|
|
645
|
-
// provisioning — the runtime deploy loop records a library skip — so there is nothing to
|
|
646
|
-
// validate. Checked BEFORE the provisioning branch, since a library may declare a compose path
|
|
647
|
-
// as repo-local TEST infra (not a deployable env).
|
|
648
|
-
if (service?.type && !frameProfile(service.type).deployable)
|
|
649
|
-
return;
|
|
650
|
-
const provisioning = service?.provisioning;
|
|
651
|
-
// A Deployer on an `infraless`/undeclared service is a safe no-op (nothing to provision), so
|
|
652
|
-
// there is nothing to validate — matching the deployer's own skip in advanceDeployerFrames.
|
|
653
|
-
if (!provisioning || provisioning.type === 'infraless')
|
|
654
|
-
return;
|
|
655
|
-
const type = provisioning.type;
|
|
656
|
-
const serviceIssues = deployerServiceConfigIssues(provisioning);
|
|
657
|
-
// `canProvision` is a single batched handler read (no decrypt / no N+1); safe to run eagerly.
|
|
658
|
-
// Pass the initiator so a local per-user handler override resolves exactly as provisioning does
|
|
659
|
-
// (else a valid override-only local setup would be falsely reported as unhandled).
|
|
660
|
-
const handlerResolution = await this.environmentProvisioning.canProvision(workspaceId, provisioning, initiatedBy);
|
|
661
|
-
// Only probe the LIVE connection when the structural config is sound — a network probe is
|
|
662
|
-
// wasted (and its verdict misleading) while the service config is incomplete or no handler
|
|
663
|
-
// resolves. A probe FAULT (transient network / provider-build hiccup) is not a definitive
|
|
664
|
-
// "connection broken" verdict, so swallow it and let the async provision surface a real fault
|
|
665
|
-
// rather than blocking the start on a flake.
|
|
666
|
-
let connectionTest;
|
|
667
|
-
if (serviceIssues.length === 0 && handlerResolution.ok) {
|
|
668
|
-
try {
|
|
669
|
-
connectionTest =
|
|
670
|
-
(await this.environmentProvisioning.testProvisioning(workspaceId, provisioning, initiatedBy)) ?? undefined;
|
|
671
|
-
}
|
|
672
|
-
catch {
|
|
673
|
-
connectionTest = undefined;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
const decision = decideDeployerConfig({
|
|
677
|
-
provisionType: type,
|
|
678
|
-
serviceIssues,
|
|
679
|
-
handlerResolution,
|
|
680
|
-
...(connectionTest ? { connectionTest } : {}),
|
|
681
|
-
});
|
|
682
|
-
if (decision.ok)
|
|
683
|
-
return;
|
|
684
|
-
if (decision.reason === 'service-config-incomplete') {
|
|
685
|
-
// Deep-link target: the service FRAME's environment config (the inspector / compose wizard).
|
|
686
|
-
const frameId = block.level === 'frame'
|
|
687
|
-
? block.id
|
|
688
|
-
: ((await this.contextBuilder.resolveServiceFrameId(workspaceId, block.id)) ?? undefined);
|
|
689
|
-
throw new ConflictError(`This service provisions a '${type}' environment via the Deployer, but its environment ` +
|
|
690
|
-
`configuration is incomplete (missing: ${decision.missing.join(', ')}). Complete the ` +
|
|
691
|
-
"service's environment configuration before starting.", 'deployer_service_provisioning_incomplete', { provisionType: type, missing: [...decision.missing], ...(frameId ? { frameId } : {}) });
|
|
692
|
-
}
|
|
693
|
-
if (decision.reason === 'workspace-unhandled') {
|
|
694
|
-
throw new ConflictError(`This service provisions a '${type}' environment via the Deployer, but this workspace has ` +
|
|
695
|
-
(decision.handlerReason === 'type-mismatch'
|
|
696
|
-
? `more than one handler matching it — pin a manifest id to disambiguate, `
|
|
697
|
-
: `no infrastructure handler configured for that type — `) +
|
|
698
|
-
'configure a handler (Infrastructure → Test environments), or set the service to ' +
|
|
699
|
-
'infraless, before starting.', 'provision_type_unhandled', { provisionType: type });
|
|
700
|
-
}
|
|
701
|
-
// decision.reason === 'connection-failed'
|
|
702
|
-
throw new ConflictError(`The '${type}' deployment integration for this service isn't working: ` +
|
|
703
|
-
`${decision.message ?? 'the connection test failed'}. Check the handler's endpoint and ` +
|
|
704
|
-
'credentials (Infrastructure → Test environments) and re-test the connection, then start ' +
|
|
705
|
-
'again.', 'deployer_connection_test_failed', { provisionType: type, ...(decision.message ? { detail: decision.message } : {}) });
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Guard a pipeline's start when it carries an agent kind that RELIES on binary-artifact
|
|
709
|
-
* storage (the {@link BINARY_STORAGE_TRAIT}, e.g. the UI Tester, which uploads its
|
|
710
|
-
* screenshots there). Such a run would otherwise dispatch and then fail/degrade with no
|
|
711
|
-
* place to store its artifacts, so refuse it up-front with a clear, actionable
|
|
712
|
-
* `binary_storage_unconfigured` conflict the SPA turns into a "configure storage" prompt.
|
|
713
|
-
* The check is trait-driven so it stays universal: a future artifact-producing kind just
|
|
714
|
-
* carries the trait. Pass-through when no store resolver is wired (tests/conformance with
|
|
715
|
-
* no storage) — matching the other optional start guards.
|
|
716
|
-
*/
|
|
717
|
-
async assertBinaryStorageConfigured(workspaceId, agentKinds) {
|
|
718
|
-
if (!agentKinds.some((kind) => hasTrait(kind, BINARY_STORAGE_TRAIT, this.agentKindRegistry)))
|
|
719
|
-
return;
|
|
720
|
-
const resolve = this.resolveBinaryArtifactStore;
|
|
721
|
-
if (!resolve)
|
|
722
|
-
return;
|
|
723
|
-
const store = await resolve(workspaceId);
|
|
724
|
-
if (store)
|
|
725
|
-
return;
|
|
726
|
-
throw new ConflictError('This pipeline includes an agent that needs binary storage (e.g. the UI Tester, which uploads its screenshots), but this account has no content storage configured. Configure content storage to run it.', 'binary_storage_unconfigured');
|
|
727
|
-
}
|
|
728
|
-
/**
|
|
729
|
-
* Guard a pipeline's start on having a usable provider for every step's canonical
|
|
730
|
-
* model. The model a step runs is resolved by the same precedence the dispatch path
|
|
731
|
-
* uses (block pin → workspace per-kind default); each canonical id must have a usable
|
|
732
|
-
* provider given what's configured — a direct API key for its provider, a connected
|
|
733
|
-
* subscription vendor, or the opt-in Cloudflare lib enabled. Env-routing defaults (the
|
|
734
|
-
* last fallback, with no catalog id) are operator-level and not gated, matching the
|
|
735
|
-
* personal-credential gate. A throw aborts the start cleanly before any side effects.
|
|
736
|
-
* Skipped when no capability resolver is wired (tests / unconfigured facades).
|
|
737
|
-
*/
|
|
738
|
-
async assertProvidersConfiguredForPipeline(workspaceId, block, agentKinds, initiatedBy) {
|
|
739
|
-
if (!this.resolveProviderCapabilities)
|
|
740
|
-
return;
|
|
741
|
-
const caps = await this.resolveProviderCapabilities(workspaceId, initiatedBy);
|
|
742
|
-
const runsInline = this.inlineHarnessRef;
|
|
743
|
-
// Two failure buckets, so the error can steer the fix precisely:
|
|
744
|
-
// - `unconfigured`: no usable provider AT ALL (container or inline) — add a key/sub/CF.
|
|
745
|
-
// - `inlineUnsatisfiable`: usable for a container step but NOT for an INLINE step — a
|
|
746
|
-
// subscription-only model an inline `generateText` call can't drive (and this
|
|
747
|
-
// deployment can't run the harness inline). The remedy is different (pin an
|
|
748
|
-
// inline-capable model, or a preset whose inline steps resolve to one), so a subscription
|
|
749
|
-
// model that satisfies the container steps but strands the reviewer/brainstorm/estimator
|
|
750
|
-
// is refused up front instead of failing mid-run against an ungated env default.
|
|
751
|
-
// - `policyBlocked`: the account-wide model-family policy blocks the model on its
|
|
752
|
-
// effective route — a distinct, more actionable reason than "unconfigured", so it is
|
|
753
|
-
// checked FIRST and short-circuits the other buckets for that id.
|
|
754
|
-
const unconfigured = new Set();
|
|
755
|
-
const inlineUnsatisfiable = new Set();
|
|
756
|
-
const policyBlocked = new Set();
|
|
757
|
-
const check = (id, inline) => {
|
|
758
|
-
if (!id)
|
|
759
|
-
return;
|
|
760
|
-
if (caps.modelPolicy &&
|
|
761
|
-
!isAllowedByFamilyPolicy(id, resolveModelRef(id, caps)?.provider, caps.modelPolicy)) {
|
|
762
|
-
policyBlocked.add(id);
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
if (!isModelUsable(id, caps))
|
|
766
|
-
unconfigured.add(id);
|
|
767
|
-
else if (inline && !isModelUsableInline(id, caps, runsInline))
|
|
768
|
-
inlineUnsatisfiable.add(id);
|
|
769
|
-
};
|
|
770
|
-
if (block.modelId) {
|
|
771
|
-
// A block-level pin applies to every step; it must satisfy an inline step too when the
|
|
772
|
-
// pipeline has one.
|
|
773
|
-
check(block.modelId, agentKinds.some((kind) => isInlineModelStep(kind, this.agentKindRegistry)));
|
|
774
|
-
}
|
|
775
|
-
else if (this.resolveWorkspaceModelDefault) {
|
|
776
|
-
// Independent per-kind resolutions on the start path — run them concurrently.
|
|
777
|
-
const ids = await Promise.all(agentKinds.map((kind) => this.resolveWorkspaceModelDefault(workspaceId, kind, block.modelPresetId)));
|
|
778
|
-
agentKinds.forEach((kind, i) => check(ids[i], isInlineModelStep(kind, this.agentKindRegistry)));
|
|
779
|
-
}
|
|
780
|
-
if (policyBlocked.size > 0) {
|
|
781
|
-
throw new ConflictError(`This pipeline uses models blocked by the account's model-family policy: ` +
|
|
782
|
-
`${[...policyBlocked].join(', ')}. Pick a model from an allowed family (or a ` +
|
|
783
|
-
'residency-guaranteed route), or ask an account admin to adjust the policy.', 'model_policy_blocked', { models: [...policyBlocked] });
|
|
784
|
-
}
|
|
785
|
-
if (unconfigured.size > 0) {
|
|
786
|
-
throw new ConflictError(`This pipeline uses models with no configured provider: ${[...unconfigured].join(', ')}. ` +
|
|
787
|
-
'Add an API key for the provider, connect a subscription, or enable Cloudflare AI ' +
|
|
788
|
-
'before starting.', 'providers_unconfigured', { models: [...unconfigured] });
|
|
789
|
-
}
|
|
790
|
-
if (inlineUnsatisfiable.size > 0) {
|
|
791
|
-
throw new ConflictError(`This pipeline has inline steps (e.g. the requirements reviewer) whose model ` +
|
|
792
|
-
`cannot run inline: ${[...inlineUnsatisfiable].join(', ')}. A subscription-only model ` +
|
|
793
|
-
'(Claude / GPT / GLM) runs only in the container agents, not the inline reviewers — ' +
|
|
794
|
-
'and this deployment has no inline harness. Pick a model preset whose inline steps ' +
|
|
795
|
-
'resolve to a provider-backed model (a direct API key, OpenRouter, or Cloudflare AI), ' +
|
|
796
|
-
'or run local mode with the ambient Claude Code / Codex CLI enabled.', 'preset_unsatisfiable', { models: [...inlineUnsatisfiable] });
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
/**
|
|
800
|
-
* Refuse to START / RETRY a run when the workspace has reached its spend budget AND the
|
|
801
|
-
* pipeline has at least one budget-METERED step. A `0` (or exhausted) budget is a
|
|
802
|
-
* deliberate "no paid spend" setting, but it must surface as a clear, up-front error here
|
|
803
|
-
* rather than a silent mid-run pause. Steps that incur no metered cost — a connected
|
|
804
|
-
* subscription model, or a keyless local-runner model — are exempt, so a workspace that
|
|
805
|
-
* runs ONLY local/subscription models starts normally even at a `0` budget. Best-effort:
|
|
806
|
-
* with no capability resolver wired (tests/unconfigured) it is skipped and the mid-run
|
|
807
|
-
* gate still guards. Before any side effects, matching the other start guards.
|
|
808
|
-
*/
|
|
809
|
-
async assertBudgetAllowsPipeline(workspaceId, block, agentKinds, initiatedBy) {
|
|
810
|
-
const accountId = await this.workspaceRepository.accountOf(workspaceId);
|
|
811
|
-
if (!(await this.spend.isOverBudget(workspaceId, { accountId, userId: initiatedBy })))
|
|
812
|
-
return;
|
|
813
|
-
if (!this.resolveProviderCapabilities)
|
|
814
|
-
return;
|
|
815
|
-
const caps = await this.resolveProviderCapabilities(workspaceId, initiatedBy);
|
|
816
|
-
const ids = [];
|
|
817
|
-
if (block.modelId) {
|
|
818
|
-
ids.push(block.modelId);
|
|
819
|
-
}
|
|
820
|
-
else if (this.resolveWorkspaceModelDefault) {
|
|
821
|
-
ids.push(...(await Promise.all(agentKinds.map((kind) => this.resolveWorkspaceModelDefault(workspaceId, kind, block.modelPresetId)))));
|
|
822
|
-
}
|
|
823
|
-
else {
|
|
824
|
-
ids.push(undefined);
|
|
825
|
-
}
|
|
826
|
-
if (!ids.some((id) => this.modelIdIsMetered(id, caps)))
|
|
827
|
-
return;
|
|
828
|
-
throw new ConflictError('This run has reached a spend budget (workspace, account, or user). New runs on metered ' +
|
|
829
|
-
'models are paused until the budget is raised or the billing period resets. A task pinned ' +
|
|
830
|
-
'to a local model or a connected subscription still runs.');
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Whether a model id will incur metered monetary cost for THIS workspace. Non-metered:
|
|
834
|
-
* a subscription model whose vendor is connected ("subscriptions always win"), or a
|
|
835
|
-
* local-runner model (keyless, on the user's own endpoint). Everything else — including
|
|
836
|
-
* env-default routing (an absent id) and Cloudflare Workers AI — is treated as metered.
|
|
837
|
-
*/
|
|
838
|
-
modelIdIsMetered(id, caps) {
|
|
839
|
-
const sub = subscriptionOptionFor(id);
|
|
840
|
-
if (sub && caps.subscriptionVendors.has(sub.vendor))
|
|
841
|
-
return false;
|
|
842
|
-
const ref = resolveModelRef(id, caps);
|
|
843
|
-
if (!ref)
|
|
844
|
-
return true;
|
|
845
|
-
if (ref.harness === 'claude-code' || ref.harness === 'codex')
|
|
846
|
-
return false;
|
|
847
|
-
return !isLocalRunner(ref.provider);
|
|
848
|
-
}
|
|
849
|
-
/**
|
|
850
|
-
* The config/resource preconditions a run must satisfy to START, RETRY **or** RESTART:
|
|
851
|
-
* everything that depends on the workspace environment + the steps being run, and NOT on
|
|
852
|
-
* whether this is a fresh run or a replacement. All three entry points call this so they
|
|
853
|
-
* can't drift — a guard added to one but silently missing from the other is exactly how a
|
|
854
|
-
* subscription-only preset slipped past retry and failed mid-run against the routing default.
|
|
855
|
-
* All checks are read-only and run BEFORE any side effects, each throwing an actionable
|
|
856
|
-
* {@link ConflictError}.
|
|
857
|
-
*
|
|
858
|
-
* The `shape` is the effective chain that will run, NOT the current pipeline definition: a
|
|
859
|
-
* fresh start passes the pipeline, while a retry/restart passes the STORED steps (via
|
|
860
|
-
* {@link runnableShapeOf}) so the guard validates exactly what re-executes — a pipeline
|
|
861
|
-
* edited out of band since the run started can't falsely refuse (or silently skip a check
|
|
862
|
-
* for) a step that isn't actually being re-driven.
|
|
863
|
-
*
|
|
864
|
-
* The concurrency (task-limit) and dependency gates are deliberately NOT here — they are
|
|
865
|
-
* start-only (a retry replaces the failed run rather than adding a new concurrent one, and a
|
|
866
|
-
* re-drive of an already-started task isn't re-gated on its dependencies).
|
|
867
|
-
*/
|
|
868
|
-
async assertRunnable(workspaceId, block, shape, initiatedBy) {
|
|
869
|
-
// Reject a structurally-invalid chain (a misplaced companion or estimate-gating without a
|
|
870
|
-
// preceding task-estimator). The builder also rejects these at save, but a pipeline can
|
|
871
|
-
// become invalid out of band.
|
|
872
|
-
validatePipelineShape(shape);
|
|
873
|
-
// The Initiative Planning kinds run ONLY on an `initiative`-level block, and an
|
|
874
|
-
// initiative block accepts ONLY such a chain — bidirectional, and here in the shared
|
|
875
|
-
// guard so start/retry/restart can't drift on it.
|
|
876
|
-
assertInitiativeShapeAllowed(block, shape.agentKinds);
|
|
877
|
-
// A chain with visual steps (`tester-ui` / `visual-confirmation`) needs a UI to exercise:
|
|
878
|
-
// it can only run on a `frontend` frame or a frame a frontend links to — else a `tester-ui`
|
|
879
|
-
// step has no app to drive.
|
|
880
|
-
await this.assertPipelineFrameTypeAllowed(workspaceId, block, shape.agentKinds);
|
|
881
|
-
// A chain with a Tester needs the service's declared provisioning to be runnable
|
|
882
|
-
// (`infraless`/none = no infra; `docker-compose`/`kubernetes`/`custom` = a workspace handler).
|
|
883
|
-
if (shape.agentKinds.some(isTesterKind)) {
|
|
884
|
-
await this.assertTesterInfraConfigured(workspaceId, block, initiatedBy);
|
|
885
|
-
}
|
|
886
|
-
// A `docker-compose`/`kubernetes`/`custom` service whose enabled chain reaches an env-consumer
|
|
887
|
-
// (tester / human-test / playwright) with NO enabled `deployer` before it would dead-end inside
|
|
888
|
-
// the consumer — nothing provisions the environment it reads. Fail fast with an actionable error.
|
|
889
|
-
await this.assertDeployerBeforeConsumer(workspaceId, block, shape.agentKinds, shape.enabled);
|
|
890
|
-
// A chain that INCLUDES an enabled Deployer needs the service's provisioning config (the
|
|
891
|
-
// "what/where") AND the workspace's infra handler (the "how") complete + correct — and, best
|
|
892
|
-
// effort, the deployment integration's live connection working — so a misconfigured environment
|
|
893
|
-
// fails loudly here with a fix-it pointer instead of an async failed env (or a silent no-op).
|
|
894
|
-
await this.assertDeployerConfigured(workspaceId, block, shape.agentKinds, shape.enabled, initiatedBy);
|
|
895
|
-
// A chain carrying an agent that relies on binary-artifact storage (the UI Tester uploads
|
|
896
|
-
// screenshots) needs the account to have storage configured.
|
|
897
|
-
await this.assertBinaryStorageConfigured(workspaceId, shape.agentKinds);
|
|
898
|
-
// A workspace that delegates container agents to a runner pool needs that pool registered
|
|
899
|
-
// (local mode opt-in). No-op on Cloudflare/Node (fixed backend) and when delegation is off.
|
|
900
|
-
await this.assertAgentBackendConfigured?.(workspaceId);
|
|
901
|
-
// Every step's canonical model must have a usable provider — a container step needs any
|
|
902
|
-
// usable flavour, an INLINE step needs an inline-usable one (a subscription-only model can't
|
|
903
|
-
// run inline without an inline harness). This is the gate a retry used to skip.
|
|
904
|
-
await this.assertProvidersConfiguredForPipeline(workspaceId, block, shape.agentKinds, initiatedBy);
|
|
905
|
-
// Refuse a metered run once the spend budget is reached (a clear error rather than a silent
|
|
906
|
-
// mid-run pause). A local/subscription-only pipeline is exempt.
|
|
907
|
-
await this.assertBudgetAllowsPipeline(workspaceId, block, shape.agentKinds, initiatedBy);
|
|
908
|
-
}
|
|
909
|
-
/**
|
|
910
|
-
* The {@link PipelineShape} a retry/restart re-drives: the stored run's steps ARE the enabled,
|
|
911
|
-
* ordered chain that will run again, so {@link assertRunnable} validates exactly what
|
|
912
|
-
* re-executes rather than the current pipeline definition (which may have been edited out of
|
|
913
|
-
* band since the run started). Disabled steps were already filtered out at start, so every
|
|
914
|
-
* stored step is enabled.
|
|
915
|
-
*/
|
|
916
|
-
runnableShapeOf(steps) {
|
|
917
|
-
return {
|
|
918
|
-
agentKinds: steps.map((s) => s.agentKind),
|
|
919
|
-
gating: steps.map((s) => s.gating ?? null),
|
|
920
|
-
// The QC companion's live step-state carries the same `gating` config the pipeline set, so
|
|
921
|
-
// the tester-QC gating validation re-runs on a retry against exactly what re-executes.
|
|
922
|
-
testerQuality: steps.map((s) => s.testerQuality ?? null),
|
|
923
|
-
// The per-step options bag (carrying a `skill` step's `skillId`) is copied onto the run
|
|
924
|
-
// step at start, so the skill-step validation re-runs on retry against what re-executes.
|
|
925
|
-
stepOptions: steps.map((s) => s.stepOptions ?? null),
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
516
|
/** Start a pipeline against a block, replacing any prior run on it. */
|
|
929
517
|
async start(workspaceId, blockId, pipelineId,
|
|
930
518
|
/**
|
|
@@ -976,7 +564,7 @@ export class ExecutionService {
|
|
|
976
564
|
// Shared config/resource preconditions (pipeline shape, frame type, tester infra, binary
|
|
977
565
|
// storage, agent backend, provider/preset satisfiability, budget) — the SAME gate a retry
|
|
978
566
|
// runs, so the two can't drift. See assertRunnable.
|
|
979
|
-
await this.assertRunnable(workspaceId, block, pipeline, initiatedBy);
|
|
567
|
+
await this.admission.assertRunnable(workspaceId, block, pipeline, initiatedBy);
|
|
980
568
|
// A Ralph-loop step needs a programmatic completion command (its exit condition); refuse to
|
|
981
569
|
// start a misconfigured run rather than dispatch a validation-less coding pass that never
|
|
982
570
|
// gates. The command is a per-task agent-config value (the SPA also requires it at creation).
|
|
@@ -990,12 +578,12 @@ export class ExecutionService {
|
|
|
990
578
|
// re-gated on its dependencies.
|
|
991
579
|
// Enforce the workspace's per-service running-task limit (off by default) — a clear,
|
|
992
580
|
// actionable error before any side effects, so the human knows why the start was refused.
|
|
993
|
-
await this.assertWithinTaskLimit(workspaceId, block);
|
|
581
|
+
await this.admission.assertWithinTaskLimit(workspaceId, block);
|
|
994
582
|
// Hard dependency gate: a task cannot start while any block it `dependsOn` is unfinished
|
|
995
583
|
// (not yet `done`/merged). Enforced server-side so it holds for manual starts, recurring
|
|
996
584
|
// fires, auto-start propagation and direct API calls alike — the frontend's runnable
|
|
997
585
|
// check is only a hint. Before any side effects so nothing is torn down on a refusal.
|
|
998
|
-
await this.assertDependenciesMet(workspaceId, block);
|
|
586
|
+
await this.admission.assertDependenciesMet(workspaceId, block);
|
|
999
587
|
// Mint the activation next: if the credential can't be unlocked, fail before
|
|
1000
588
|
// tearing down the block's prior run or creating a new one.
|
|
1001
589
|
const executionId = this.idGenerator.next('exec');
|
|
@@ -1150,103 +738,6 @@ export class ExecutionService {
|
|
|
1150
738
|
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1151
739
|
return instance;
|
|
1152
740
|
}
|
|
1153
|
-
/**
|
|
1154
|
-
* Enforce the workspace's per-service running-task limit before a task run starts.
|
|
1155
|
-
* No-ops unless the settings module is wired, the block is a task, and a limit mode
|
|
1156
|
-
* is active. Counts the tasks under the same service frame that already have a live
|
|
1157
|
-
* run (running / blocked / paused) — bucketed by task type when the mode is
|
|
1158
|
-
* `per_type`, else shared across all types — and throws a {@link ConflictError} (→ 409,
|
|
1159
|
-
* shown as a toast) when the cap is reached. The starting block is excluded from the
|
|
1160
|
-
* count (its prior run is about to be replaced).
|
|
1161
|
-
*/
|
|
1162
|
-
/**
|
|
1163
|
-
* Refuse a task start while any of its dependencies is unfinished. A task may only run
|
|
1164
|
-
* once every block it `dependsOn` has reached `done` (its PR merged). No-ops for
|
|
1165
|
-
* non-task blocks and for tasks with no dependencies. Throws a {@link ConflictError}
|
|
1166
|
-
* (→ 409, shown as a toast) naming the unfinished blockers so the human knows why.
|
|
1167
|
-
*/
|
|
1168
|
-
async assertDependenciesMet(workspaceId, block) {
|
|
1169
|
-
if (block.level !== 'task' || block.dependsOn.length === 0)
|
|
1170
|
-
return;
|
|
1171
|
-
const blocks = await this.augmentWithCrossWorkspaceDeps(await this.blockRepository.listByWorkspace(workspaceId), block.dependsOn);
|
|
1172
|
-
if (dependenciesMet(blocks, block.id))
|
|
1173
|
-
return;
|
|
1174
|
-
const blockers = unmetDependencies(blocks, block.id);
|
|
1175
|
-
const names = blockers.map((b) => `"${b.title}"`).join(', ');
|
|
1176
|
-
throw new ConflictError(`This task is blocked by ${blockers.length} unfinished dependenc${blockers.length === 1 ? 'y' : 'ies'}${names ? ` (${names})` : ''}. Finish them before starting this task.`, 'dependencies_unmet', { count: blockers.length, blockers: blockers.map((b) => b.title) });
|
|
1177
|
-
}
|
|
1178
|
-
/**
|
|
1179
|
-
* Augment a workspace's block list (in place) with any dependency blocks referenced by
|
|
1180
|
-
* `depIds` that aren't already present — a `dependsOn` edge can point at a task homed in a
|
|
1181
|
-
* DIFFERENT workspace (a shared/mounted service). Resolved via the cross-workspace
|
|
1182
|
-
* {@link BlockRepository.findByIds} (one batched query, not a point-read per id), so a
|
|
1183
|
-
* shared-service blocker is evaluated by its real status instead of being silently treated
|
|
1184
|
-
* as satisfied (missing ⇒ done). Returns the same (now-augmented) array for chaining.
|
|
1185
|
-
*/
|
|
1186
|
-
async augmentWithCrossWorkspaceDeps(blocks, depIds) {
|
|
1187
|
-
const have = new Set(blocks.map((b) => b.id));
|
|
1188
|
-
const missing = [...new Set(depIds)].filter((id) => !have.has(id));
|
|
1189
|
-
if (missing.length === 0)
|
|
1190
|
-
return blocks;
|
|
1191
|
-
for (const found of await this.blockRepository.findByIds(missing)) {
|
|
1192
|
-
blocks.push(found.block);
|
|
1193
|
-
}
|
|
1194
|
-
return blocks;
|
|
1195
|
-
}
|
|
1196
|
-
async assertWithinTaskLimit(workspaceId, block) {
|
|
1197
|
-
const settingsService = this.workspaceSettingsService;
|
|
1198
|
-
if (!settingsService || block.level !== 'task')
|
|
1199
|
-
return;
|
|
1200
|
-
const settings = await settingsService.get(workspaceId);
|
|
1201
|
-
if (settings.taskLimitMode === 'off')
|
|
1202
|
-
return;
|
|
1203
|
-
const all = await this.blockRepository.listByWorkspace(workspaceId);
|
|
1204
|
-
const byId = new Map(all.map((b) => [b.id, b]));
|
|
1205
|
-
// Walk up to the owning service frame.
|
|
1206
|
-
let frame = block;
|
|
1207
|
-
let guard = 0;
|
|
1208
|
-
while (frame && frame.level !== 'frame' && guard++ < 1000) {
|
|
1209
|
-
frame = frame.parentId ? byId.get(frame.parentId) : undefined;
|
|
1210
|
-
}
|
|
1211
|
-
if (!frame || frame.level !== 'frame')
|
|
1212
|
-
return; // orphan task — nothing to scope a service limit to
|
|
1213
|
-
const frameId = frame.id;
|
|
1214
|
-
const underFrame = (b) => {
|
|
1215
|
-
let cur = b;
|
|
1216
|
-
let hops = 0;
|
|
1217
|
-
while (cur && hops++ < 1000) {
|
|
1218
|
-
if (cur.id === frameId)
|
|
1219
|
-
return true;
|
|
1220
|
-
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
|
|
1221
|
-
}
|
|
1222
|
-
return false;
|
|
1223
|
-
};
|
|
1224
|
-
// Lean projection of the workspace's live runs (block + status only) — avoids loading and
|
|
1225
|
-
// JSON-decoding every historical run's `detail` just to read the handful of live block ids.
|
|
1226
|
-
const live = await this.executionRepository.listLive(workspaceId);
|
|
1227
|
-
const liveBlockIds = new Set(live.map((e) => e.blockId));
|
|
1228
|
-
const siblingTasks = all.filter((b) => b.level === 'task' && b.id !== block.id && underFrame(b));
|
|
1229
|
-
if (settings.taskLimitMode === 'shared') {
|
|
1230
|
-
const limit = settings.taskLimitShared ?? 0;
|
|
1231
|
-
const running = siblingTasks.filter((b) => liveBlockIds.has(b.id)).length;
|
|
1232
|
-
if (running >= limit) {
|
|
1233
|
-
throw new ConflictError(`"${frame.title}" is already running ${running} of ${limit} allowed task(s). ` +
|
|
1234
|
-
`Wait for one to finish before starting another.`, 'task_limit_reached', { frame: frame.title, limit, running });
|
|
1235
|
-
}
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
// per_type: only the configured types are capped; an unconfigured type is unbounded.
|
|
1239
|
-
const type = block.taskType ?? 'feature';
|
|
1240
|
-
const perType = (settings.taskLimitPerType ?? {});
|
|
1241
|
-
const limit = perType[type];
|
|
1242
|
-
if (limit == null)
|
|
1243
|
-
return;
|
|
1244
|
-
const running = siblingTasks.filter((b) => liveBlockIds.has(b.id) && (b.taskType ?? 'feature') === type).length;
|
|
1245
|
-
if (running >= limit) {
|
|
1246
|
-
throw new ConflictError(`"${frame.title}" is already running ${running} of ${limit} allowed ${type} task(s). ` +
|
|
1247
|
-
`Wait for one to finish before starting another ${type} task.`, 'task_limit_reached', { frame: frame.title, limit, running, taskType: type });
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
741
|
/**
|
|
1251
742
|
* Advance a single run by exactly one step and report what happened. This is
|
|
1252
743
|
* the durable driver's entry point: it reloads the run from storage (so it is
|
|
@@ -1513,178 +1004,6 @@ export class ExecutionService {
|
|
|
1513
1004
|
throw new ConflictError('Resolve the follow-up companion through its window (file / send back / answer / dismiss), not the approval gate');
|
|
1514
1005
|
}
|
|
1515
1006
|
}
|
|
1516
|
-
/**
|
|
1517
|
-
* The requirements subject for {@link reviewGate}: closures over the requirements reviewer
|
|
1518
|
-
* service. The service-not-configured guard preserves the exact 409 the inline reviewer
|
|
1519
|
-
* raised before this extraction.
|
|
1520
|
-
*/
|
|
1521
|
-
buildRequirementsKind() {
|
|
1522
|
-
const require = () => {
|
|
1523
|
-
if (!this.requirementReviewService?.enabled) {
|
|
1524
|
-
throw new ConflictError('The requirements reviewer is not configured');
|
|
1525
|
-
}
|
|
1526
|
-
return this.requirementReviewService;
|
|
1527
|
-
};
|
|
1528
|
-
return {
|
|
1529
|
-
agentKind: REQUIREMENTS_REVIEW_AGENT_KIND,
|
|
1530
|
-
entityName: 'Requirement review',
|
|
1531
|
-
enabled: () => !!this.requirementReviewService?.enabled,
|
|
1532
|
-
getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
|
|
1533
|
-
review: (ws, block, preset) => require().review(ws, block.id, {
|
|
1534
|
-
maxIterations: preset.maxRequirementIterations,
|
|
1535
|
-
concernThreshold: preset.maxRequirementConcernAllowed,
|
|
1536
|
-
}),
|
|
1537
|
-
reReview: (ws, reviewId, preset) => require().reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed }),
|
|
1538
|
-
incorporate: async (ws, _blockId, reviewId, feedback) => {
|
|
1539
|
-
await require().incorporate(ws, reviewId, { feedback });
|
|
1540
|
-
},
|
|
1541
|
-
markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
|
|
1542
|
-
markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
|
|
1543
|
-
markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
|
|
1544
|
-
grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
|
|
1545
|
-
prepareRecommendations: (ws, reviewId, items) => require().prepareRecommendations(ws, reviewId, items),
|
|
1546
|
-
markRecommendationPending: (ws, reviewId, recId, note) => require().markRecommendationPending(ws, reviewId, recId, note),
|
|
1547
|
-
fillRecommendations: async (ws, blockId) => {
|
|
1548
|
-
const svc = require();
|
|
1549
|
-
const review = assertFound(await svc.getForBlock(ws, blockId), 'Requirement review', blockId);
|
|
1550
|
-
await svc.fillPendingRecommendations(ws, review.id, {
|
|
1551
|
-
onProgress: (r) => this.events.requirementReviewChanged?.(ws, r) ?? Promise.resolve(),
|
|
1552
|
-
});
|
|
1553
|
-
return assertFound(await svc.getForBlock(ws, blockId), 'Requirement review', blockId);
|
|
1554
|
-
},
|
|
1555
|
-
autoRecommend: async (ws, blockId) => {
|
|
1556
|
-
const svc = require();
|
|
1557
|
-
const review = assertFound(await svc.getForBlock(ws, blockId), 'Requirement review', blockId);
|
|
1558
|
-
await svc.autoRecommend(ws, review.id, {
|
|
1559
|
-
onProgress: (r) => this.events.requirementReviewChanged?.(ws, r) ?? Promise.resolve(),
|
|
1560
|
-
});
|
|
1561
|
-
},
|
|
1562
|
-
emit: (ws, review) => this.events.requirementReviewChanged?.(ws, review) ?? Promise.resolve(),
|
|
1563
|
-
};
|
|
1564
|
-
}
|
|
1565
|
-
/**
|
|
1566
|
-
* The clarity (bug-report triage) subject for {@link reviewGate}: threads any upstream
|
|
1567
|
-
* `bug-investigator` output into the reviewer/incorporation context, otherwise identical to
|
|
1568
|
-
* the requirements kind.
|
|
1569
|
-
*/
|
|
1570
|
-
buildClarityKind() {
|
|
1571
|
-
const require = () => {
|
|
1572
|
-
if (!this.clarityReviewService) {
|
|
1573
|
-
throw new ConflictError('The clarity reviewer is not configured');
|
|
1574
|
-
}
|
|
1575
|
-
return this.clarityReviewService;
|
|
1576
|
-
};
|
|
1577
|
-
return {
|
|
1578
|
-
agentKind: CLARITY_REVIEW_AGENT_KIND,
|
|
1579
|
-
// Enabled whenever the clarity STORE is wired — the bug-triage seed/auto-pass path is
|
|
1580
|
-
// deterministic (driven by the upstream investigator's structured triage) and needs no
|
|
1581
|
-
// reviewer model, so the gate must activate even with no model configured. The LLM
|
|
1582
|
-
// review/incorporate/re-review paths still resolve their own model (and degrade gracefully
|
|
1583
|
-
// when unwired: no investigation + no model ⇒ the review closure auto-passes).
|
|
1584
|
-
entityName: 'Clarity review',
|
|
1585
|
-
enabled: () => !!this.clarityReviewService,
|
|
1586
|
-
getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
|
|
1587
|
-
review: async (ws, block, preset) => {
|
|
1588
|
-
const svc = require();
|
|
1589
|
-
const structured = await this.structuredInvestigationForBlock(ws, block.id);
|
|
1590
|
-
let review;
|
|
1591
|
-
if (structured) {
|
|
1592
|
-
// An upstream structured `bug-investigator`: seed the gate from its triage — NO reviewer
|
|
1593
|
-
// LLM. `clear` → auto-pass; `needs_clarification` → one blocking finding per question.
|
|
1594
|
-
// The investigator explicitly asked for clarification, so those questions ALWAYS park
|
|
1595
|
-
// for a human — the requirements-review concern tolerance (`maxRequirementConcernAllowed`,
|
|
1596
|
-
// which governs the requirements reviewer, not bug triage) must not silently auto-pass
|
|
1597
|
-
// them — hence a fixed `none` threshold here rather than the preset's.
|
|
1598
|
-
review = await svc.seedReview(ws, block.id, {
|
|
1599
|
-
clarity: structured.clarity,
|
|
1600
|
-
questions: structured.questions,
|
|
1601
|
-
maxIterations: preset.maxRequirementIterations,
|
|
1602
|
-
concernThreshold: 'none',
|
|
1603
|
-
});
|
|
1604
|
-
}
|
|
1605
|
-
else if (!svc.enabled) {
|
|
1606
|
-
// No structured investigation and no reviewer model: nothing to review against, so
|
|
1607
|
-
// auto-pass (equivalent to the old pass-through when the reviewer wasn't configured).
|
|
1608
|
-
review = await svc.seedReview(ws, block.id, {
|
|
1609
|
-
clarity: 'clear',
|
|
1610
|
-
questions: [],
|
|
1611
|
-
maxIterations: preset.maxRequirementIterations,
|
|
1612
|
-
concernThreshold: preset.maxRequirementConcernAllowed,
|
|
1613
|
-
});
|
|
1614
|
-
}
|
|
1615
|
-
else {
|
|
1616
|
-
review = await svc.review(ws, block.id, {
|
|
1617
|
-
maxIterations: preset.maxRequirementIterations,
|
|
1618
|
-
concernThreshold: preset.maxRequirementConcernAllowed,
|
|
1619
|
-
investigation: await this.investigationForBlock(ws, block.id),
|
|
1620
|
-
});
|
|
1621
|
-
}
|
|
1622
|
-
// Whenever the gate parks with open questions — from the deterministic seed OR the LLM
|
|
1623
|
-
// reviewer — best-effort echo them onto the linked tracker issue (answers still arrive
|
|
1624
|
-
// in-app). A settled/auto-passed review echoes nothing; a tracker outage never fails the run.
|
|
1625
|
-
await this.echoClarityQuestions(ws, block.id, review);
|
|
1626
|
-
return review;
|
|
1627
|
-
},
|
|
1628
|
-
reReview: async (ws, reviewId, preset) => {
|
|
1629
|
-
const svc = require();
|
|
1630
|
-
// No reviewer model wired: a re-review can't run, so settle the loop (converge) instead of
|
|
1631
|
-
// throwing — the deterministic seed path can reach a park with no model configured.
|
|
1632
|
-
if (!svc.enabled)
|
|
1633
|
-
return svc.markIncorporated(ws, reviewId);
|
|
1634
|
-
return svc.reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed });
|
|
1635
|
-
},
|
|
1636
|
-
incorporate: async (ws, blockId, reviewId, feedback) => {
|
|
1637
|
-
const svc = require();
|
|
1638
|
-
// No reviewer model: can't LLM-fold the answers into a clarified report, so settle the
|
|
1639
|
-
// review as-is (the run advances on the raw report + the recorded answers) instead of
|
|
1640
|
-
// throwing — keeps the model-free seed path resolvable.
|
|
1641
|
-
if (!svc.enabled) {
|
|
1642
|
-
await svc.markIncorporated(ws, reviewId);
|
|
1643
|
-
return;
|
|
1644
|
-
}
|
|
1645
|
-
const investigation = await this.investigationForBlock(ws, blockId);
|
|
1646
|
-
await svc.incorporate(ws, reviewId, { feedback, investigation });
|
|
1647
|
-
},
|
|
1648
|
-
markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
|
|
1649
|
-
markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
|
|
1650
|
-
markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
|
|
1651
|
-
grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
|
|
1652
|
-
emit: (ws, review) => this.events.clarityReviewChanged?.(ws, review) ?? Promise.resolve(),
|
|
1653
|
-
};
|
|
1654
|
-
}
|
|
1655
|
-
/**
|
|
1656
|
-
* A brainstorm (structured-dialogue) subject for {@link reviewGate}, parameterised by stage.
|
|
1657
|
-
* Otherwise identical to the requirements kind — the service handles its own upstream context
|
|
1658
|
-
* (the architecture stage seeds from the refined requirements). The brainstorm services
|
|
1659
|
-
* resolve their model exactly like the requirements reviewer, so the cap knobs are reused.
|
|
1660
|
-
*/
|
|
1661
|
-
buildBrainstormKind(stage, agentKind) {
|
|
1662
|
-
const require = () => {
|
|
1663
|
-
const svc = this.brainstormServices?.[stage];
|
|
1664
|
-
if (!svc?.enabled)
|
|
1665
|
-
throw new ConflictError('The brainstorm agent is not configured');
|
|
1666
|
-
return svc;
|
|
1667
|
-
};
|
|
1668
|
-
return {
|
|
1669
|
-
agentKind,
|
|
1670
|
-
entityName: 'Brainstorm session',
|
|
1671
|
-
enabled: () => !!this.brainstormServices?.[stage]?.enabled,
|
|
1672
|
-
getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
|
|
1673
|
-
review: (ws, block, preset) => require().review(ws, block.id, {
|
|
1674
|
-
maxIterations: preset.maxRequirementIterations,
|
|
1675
|
-
concernThreshold: preset.maxRequirementConcernAllowed,
|
|
1676
|
-
}),
|
|
1677
|
-
reReview: (ws, reviewId, preset) => require().reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed }),
|
|
1678
|
-
incorporate: async (ws, _blockId, reviewId, feedback) => {
|
|
1679
|
-
await require().incorporate(ws, reviewId, { feedback });
|
|
1680
|
-
},
|
|
1681
|
-
markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
|
|
1682
|
-
markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
|
|
1683
|
-
markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
|
|
1684
|
-
grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
|
|
1685
|
-
emit: (ws, session) => this.events.brainstormSessionChanged?.(ws, session) ?? Promise.resolve(),
|
|
1686
|
-
};
|
|
1687
|
-
}
|
|
1688
1007
|
/** Pick the brainstorm kind for a stage (the dedicated window drives both via the same loop). */
|
|
1689
1008
|
brainstormKindFor(stage) {
|
|
1690
1009
|
return stage === 'architecture'
|
|
@@ -1812,71 +1131,6 @@ export class ExecutionService {
|
|
|
1812
1131
|
});
|
|
1813
1132
|
return result;
|
|
1814
1133
|
}
|
|
1815
|
-
// ---- clarity-review context helpers (bug-report triage) ------------------
|
|
1816
|
-
// The clarity gate triages a block's bug report — optionally enriched by an upstream
|
|
1817
|
-
// `bug-investigator` step's prose output — through the SAME {@link ReviewGateController}
|
|
1818
|
-
// flow as requirements; these two helpers resolve that investigator output as the triage
|
|
1819
|
-
// subject, threaded into the clarity {@link ReviewKind}.
|
|
1820
|
-
/** The latest `bug-investigator` step output on a run (the triage subject), or undefined. */
|
|
1821
|
-
investigationFor(instance) {
|
|
1822
|
-
for (let i = instance.steps.length - 1; i >= 0; i--) {
|
|
1823
|
-
const s = instance.steps[i];
|
|
1824
|
-
if (s.agentKind === BUG_INVESTIGATOR_AGENT_KIND && s.output)
|
|
1825
|
-
return s.output;
|
|
1826
|
-
}
|
|
1827
|
-
return undefined;
|
|
1828
|
-
}
|
|
1829
|
-
/** Resolve a block's investigator output via its current execution (off the gate path). */
|
|
1830
|
-
async investigationForBlock(workspaceId, blockId) {
|
|
1831
|
-
const block = await this.blockRepository.get(workspaceId, blockId);
|
|
1832
|
-
if (!block?.executionId)
|
|
1833
|
-
return undefined;
|
|
1834
|
-
const instance = await this.executionRepository.get(workspaceId, block.executionId);
|
|
1835
|
-
return instance ? this.investigationFor(instance) : undefined;
|
|
1836
|
-
}
|
|
1837
|
-
/**
|
|
1838
|
-
* The latest `bug-investigator` step's STRUCTURED triage on a run — its `clarity` verdict +
|
|
1839
|
-
* `questions` — parsed leniently from `step.custom`. Drives the clarity gate's seed/auto-pass
|
|
1840
|
-
* (see {@link buildClarityKind}): a structured investigator upstream means the gate seeds its
|
|
1841
|
-
* findings from `questions` (or auto-passes on `clarity === 'clear'`) instead of running its
|
|
1842
|
-
* own reviewer LLM. Undefined when no investigator ran or its result wasn't structured (an
|
|
1843
|
-
* older prose investigator, or an unparseable reply) — the gate then falls back to the LLM path.
|
|
1844
|
-
*/
|
|
1845
|
-
structuredInvestigationFor(instance) {
|
|
1846
|
-
for (let i = instance.steps.length - 1; i >= 0; i--) {
|
|
1847
|
-
const s = instance.steps[i];
|
|
1848
|
-
if (s.agentKind !== BUG_INVESTIGATOR_AGENT_KIND || s.custom === undefined)
|
|
1849
|
-
continue;
|
|
1850
|
-
const parsed = bugInvestigation.safeParse(s.custom);
|
|
1851
|
-
if (!parsed)
|
|
1852
|
-
return undefined;
|
|
1853
|
-
return { clarity: parsed.clarity, questions: parsed.questions };
|
|
1854
|
-
}
|
|
1855
|
-
return undefined;
|
|
1856
|
-
}
|
|
1857
|
-
/** Resolve a block's structured investigator triage via its current execution. */
|
|
1858
|
-
async structuredInvestigationForBlock(workspaceId, blockId) {
|
|
1859
|
-
const block = await this.blockRepository.get(workspaceId, blockId);
|
|
1860
|
-
if (!block?.executionId)
|
|
1861
|
-
return undefined;
|
|
1862
|
-
const instance = await this.executionRepository.get(workspaceId, block.executionId);
|
|
1863
|
-
return instance ? this.structuredInvestigationFor(instance) : undefined;
|
|
1864
|
-
}
|
|
1865
|
-
/**
|
|
1866
|
-
* Best-effort echo of a parked clarity review's open questions onto the block's linked tracker
|
|
1867
|
-
* issue (see {@link IssueWritebackProvider.postQuestions}). Fires for BOTH the deterministic
|
|
1868
|
-
* investigator seed and the LLM reviewer, so identical human-parked states behave the same. A
|
|
1869
|
-
* settled/auto-passed review (status `incorporated`) or one with no open items echoes nothing,
|
|
1870
|
-
* and a tracker outage never fails the run.
|
|
1871
|
-
*/
|
|
1872
|
-
async echoClarityQuestions(workspaceId, blockId, review) {
|
|
1873
|
-
if (!this.issueWriteback || review.status === 'incorporated')
|
|
1874
|
-
return;
|
|
1875
|
-
const questions = review.items.filter((i) => i.status === 'open').map((i) => i.detail);
|
|
1876
|
-
if (questions.length === 0)
|
|
1877
|
-
return;
|
|
1878
|
-
await this.issueWriteback.postQuestions(workspaceId, blockId, questions).catch(() => { });
|
|
1879
|
-
}
|
|
1880
1134
|
// The clarity / human-testing / visual-confirmation gate-window actions now live on the
|
|
1881
1135
|
// per-feature sub-facades (`clarityReview` / `humanTest` / `visualConfirm`); see the getters
|
|
1882
1136
|
// above and {@link gate-window-facades}.
|
|
@@ -2050,7 +1304,7 @@ export class ExecutionService {
|
|
|
2050
1304
|
return;
|
|
2051
1305
|
// A dependent's OTHER blockers may live in another workspace (a shared service); resolve
|
|
2052
1306
|
// them so `dependenciesMet` doesn't treat a cross-workspace blocker as missing-⇒-satisfied.
|
|
2053
|
-
await this.augmentWithCrossWorkspaceDeps(blocks, dependents.flatMap((d) => d.dependsOn));
|
|
1307
|
+
await this.admission.augmentWithCrossWorkspaceDeps(blocks, dependents.flatMap((d) => d.dependsOn));
|
|
2054
1308
|
// Resolve every dependent's pipeline from ONE workspace list, not a per-dependent
|
|
2055
1309
|
// point-read in the loop (banned N+1): index the catalog by id, and take the board's
|
|
2056
1310
|
// "Run" default (the first pipeline) for any dependent with no pinned pipeline.
|
|
@@ -2394,7 +1648,7 @@ export class ExecutionService {
|
|
|
2394
1648
|
// STORED steps (what the retry actually re-drives), not the current pipeline definition, so
|
|
2395
1649
|
// an out-of-band pipeline edit can't skew the gate and a deleted pipeline needs no special
|
|
2396
1650
|
// case. Before any side effects.
|
|
2397
|
-
await this.assertRunnable(workspaceId, block, this.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
|
|
1651
|
+
await this.admission.assertRunnable(workspaceId, block, this.admission.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
|
|
2398
1652
|
const { steps, currentStep } = planResumedSteps(previous);
|
|
2399
1653
|
// Mint the activation before replacing the failed run, so a bad password aborts
|
|
2400
1654
|
// the retry without losing the retryable terminal run.
|
|
@@ -2479,7 +1733,7 @@ export class ExecutionService {
|
|
|
2479
1733
|
// so it must be gated identically — otherwise a run whose preset can't run every step (e.g. a
|
|
2480
1734
|
// subscription-only model an inline reviewer can't drive) strands mid-run instead of being
|
|
2481
1735
|
// refused up front. Before any teardown/side effects.
|
|
2482
|
-
await this.assertRunnable(workspaceId, block, this.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
|
|
1736
|
+
await this.admission.assertRunnable(workspaceId, block, this.admission.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
|
|
2483
1737
|
// Tear down whatever was driving the run we're about to replace — its per-run
|
|
2484
1738
|
// container AND its durable driver — before minting the restart. A `done`/`failed`
|
|
2485
1739
|
// run is already terminal (a no-op teardown), but a still-`running` run would
|