@revisium/orchestrator 0.2.0-alpha.4 → 0.2.0-alpha.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.
Files changed (147) hide show
  1. package/README.md +1 -1
  2. package/control-plane/bootstrap.config.json +5 -1
  3. package/control-plane/default-playbook/catalog/pipelines.json +51 -0
  4. package/control-plane/default-playbook/catalog/roles.json +1 -1
  5. package/control-plane/default-playbook/catalog/run-profiles.json +130 -0
  6. package/control-plane/default-playbook/prompts/integrator.md +1 -1
  7. package/dist/__generated__/client/internal/class.js +4 -4
  8. package/dist/__generated__/client/internal/class.js.map +1 -1
  9. package/dist/__generated__/client/internal/prismaNamespace.js +0 -1
  10. package/dist/__generated__/client/internal/prismaNamespace.js.map +1 -1
  11. package/dist/__generated__/client/internal/prismaNamespaceBrowser.js +0 -1
  12. package/dist/__generated__/client/internal/prismaNamespaceBrowser.js.map +1 -1
  13. package/dist/api/graphql-api/inbox/inputs/resolve-gate.input.js +9 -0
  14. package/dist/api/graphql-api/inbox/inputs/resolve-gate.input.js.map +1 -1
  15. package/dist/api/graphql-api/method/inputs/create-run-profile.input.js +54 -0
  16. package/dist/api/graphql-api/method/inputs/create-run-profile.input.js.map +1 -0
  17. package/dist/api/graphql-api/method/inputs/deprecate-run-profile.input.js +37 -0
  18. package/dist/api/graphql-api/method/inputs/deprecate-run-profile.input.js.map +1 -0
  19. package/dist/api/graphql-api/method/inputs/get-run-profile.input.js +32 -0
  20. package/dist/api/graphql-api/method/inputs/get-run-profile.input.js.map +1 -0
  21. package/dist/api/graphql-api/method/inputs/list-run-profiles.input.js +33 -0
  22. package/dist/api/graphql-api/method/inputs/list-run-profiles.input.js.map +1 -0
  23. package/dist/api/graphql-api/method/inputs/update-run-profile.input.js +59 -0
  24. package/dist/api/graphql-api/method/inputs/update-run-profile.input.js.map +1 -0
  25. package/dist/api/graphql-api/method/inputs/validate-run-profile.input.js +33 -0
  26. package/dist/api/graphql-api/method/inputs/validate-run-profile.input.js.map +1 -0
  27. package/dist/api/graphql-api/method/method.resolver.js +76 -1
  28. package/dist/api/graphql-api/method/method.resolver.js.map +1 -1
  29. package/dist/api/graphql-api/method/model/run-profile-connection.model.js +16 -0
  30. package/dist/api/graphql-api/method/model/run-profile-connection.model.js.map +1 -0
  31. package/dist/api/graphql-api/method/model/run-profile-status.model.js +6 -0
  32. package/dist/api/graphql-api/method/model/run-profile-status.model.js.map +1 -0
  33. package/dist/api/graphql-api/method/model/run-profile.model.js +79 -0
  34. package/dist/api/graphql-api/method/model/run-profile.model.js.map +1 -0
  35. package/dist/api/graphql-api/registerGraphqlEnums.js +4 -0
  36. package/dist/api/graphql-api/registerGraphqlEnums.js.map +1 -1
  37. package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -7
  38. package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
  39. package/dist/api/graphql-api/runs/inputs/simulate-route.input.js +6 -7
  40. package/dist/api/graphql-api/runs/inputs/simulate-route.input.js.map +1 -1
  41. package/dist/control-plane/default-playbook-policy.js +2 -11
  42. package/dist/control-plane/default-playbook-policy.js.map +1 -1
  43. package/dist/control-plane/engine-transport.js +2 -0
  44. package/dist/control-plane/engine-transport.js.map +1 -1
  45. package/dist/control-plane/json-fields.js +1 -1
  46. package/dist/control-plane/json-fields.js.map +1 -1
  47. package/dist/control-plane/pipeline-coverage-registry.js +26 -18
  48. package/dist/control-plane/pipeline-coverage-registry.js.map +1 -1
  49. package/dist/control-plane/run-profiles.js +101 -15
  50. package/dist/control-plane/run-profiles.js.map +1 -1
  51. package/dist/control-plane/transport.js.map +1 -1
  52. package/dist/control-plane/versioned-meaning.js +18 -3
  53. package/dist/control-plane/versioned-meaning.js.map +1 -1
  54. package/dist/e2e/kit/agents.js +3 -2
  55. package/dist/e2e/kit/agents.js.map +1 -1
  56. package/dist/e2e/kit/assertions.js +2 -2
  57. package/dist/e2e/kit/crash.js +8 -4
  58. package/dist/e2e/kit/crash.js.map +1 -1
  59. package/dist/e2e/kit/drive.js.map +1 -1
  60. package/dist/e2e/kit/fake-integrator.js +3 -36
  61. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  62. package/dist/e2e/kit/gh-emulator.js +8 -2
  63. package/dist/e2e/kit/gh-emulator.js.map +1 -1
  64. package/dist/e2e/kit/index.js +1 -0
  65. package/dist/e2e/kit/index.js.map +1 -1
  66. package/dist/e2e/kit/run-profiles.js +50 -0
  67. package/dist/e2e/kit/run-profiles.js.map +1 -0
  68. package/dist/e2e/kit/scenario.js +48 -6
  69. package/dist/e2e/kit/scenario.js.map +1 -1
  70. package/dist/e2e/kit/scenarios.js +26 -27
  71. package/dist/e2e/kit/scenarios.js.map +1 -1
  72. package/dist/e2e/recovery-crash-child.js +2 -2
  73. package/dist/e2e/recovery-crash-child.js.map +1 -1
  74. package/dist/e2e/recovery-dd-crash-child.js +8 -6
  75. package/dist/e2e/recovery-dd-crash-child.js.map +1 -1
  76. package/dist/features/inbox/commands/impl/resolve-gate.command.js.map +1 -1
  77. package/dist/features/method/commands/handlers/method-command.handlers.js +64 -0
  78. package/dist/features/method/commands/handlers/method-command.handlers.js.map +1 -0
  79. package/dist/features/method/commands/impl/create-run-profile.command.js +7 -0
  80. package/dist/features/method/commands/impl/create-run-profile.command.js.map +1 -0
  81. package/dist/features/method/commands/impl/deprecate-run-profile.command.js +7 -0
  82. package/dist/features/method/commands/impl/deprecate-run-profile.command.js.map +1 -0
  83. package/dist/features/method/commands/impl/update-run-profile.command.js +7 -0
  84. package/dist/features/method/commands/impl/update-run-profile.command.js.map +1 -0
  85. package/dist/features/method/commands/index.js +6 -1
  86. package/dist/features/method/commands/index.js.map +1 -1
  87. package/dist/features/method/method-api.service.js +31 -3
  88. package/dist/features/method/method-api.service.js.map +1 -1
  89. package/dist/features/method/queries/handlers/method-query.handlers.js +58 -1
  90. package/dist/features/method/queries/handlers/method-query.handlers.js.map +1 -1
  91. package/dist/features/method/queries/impl/get-run-profile.query.js +7 -0
  92. package/dist/features/method/queries/impl/get-run-profile.query.js.map +1 -0
  93. package/dist/features/method/queries/impl/list-run-profiles.query.js +7 -0
  94. package/dist/features/method/queries/impl/list-run-profiles.query.js.map +1 -0
  95. package/dist/features/method/queries/impl/validate-run-profile.query.js +7 -0
  96. package/dist/features/method/queries/impl/validate-run-profile.query.js.map +1 -0
  97. package/dist/features/method/queries/index.js +4 -1
  98. package/dist/features/method/queries/index.js.map +1 -1
  99. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  100. package/dist/features/runs/queries/impl/simulate-route.query.js.map +1 -1
  101. package/dist/features/shared/connection.js +6 -3
  102. package/dist/features/shared/connection.js.map +1 -1
  103. package/dist/mcp/mcp-capabilities.js +5 -0
  104. package/dist/mcp/mcp-capabilities.js.map +1 -1
  105. package/dist/mcp/mcp-facade.service.js +34 -12
  106. package/dist/mcp/mcp-facade.service.js.map +1 -1
  107. package/dist/mcp/mcp-tool-result.js +22 -0
  108. package/dist/mcp/mcp-tool-result.js.map +1 -0
  109. package/dist/mcp/mcp-tools.js +90 -17
  110. package/dist/mcp/mcp-tools.js.map +1 -1
  111. package/dist/pipeline/await-human.js +27 -8
  112. package/dist/pipeline/await-human.js.map +1 -1
  113. package/dist/pipeline/data-driven-task.workflow.js +318 -69
  114. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  115. package/dist/pipeline/pipeline.service.js +4 -10
  116. package/dist/pipeline/pipeline.service.js.map +1 -1
  117. package/dist/pipeline/route-contract.js +22 -101
  118. package/dist/pipeline/route-contract.js.map +1 -1
  119. package/dist/playbook/catalog-loader.js +1 -1
  120. package/dist/playbook/catalog-loader.js.map +1 -1
  121. package/dist/playbook/catalog-schema-validator.js +69 -39
  122. package/dist/playbook/catalog-schema-validator.js.map +1 -1
  123. package/dist/playbook/import-mapper.js +20 -9
  124. package/dist/playbook/import-mapper.js.map +1 -1
  125. package/dist/revisium/playbooks.service.js +208 -13
  126. package/dist/revisium/playbooks.service.js.map +1 -1
  127. package/dist/run/create-run.js +0 -3
  128. package/dist/run/create-run.js.map +1 -1
  129. package/dist/run/inspect-run.js +1 -1
  130. package/dist/run/inspect-run.js.map +1 -1
  131. package/dist/run/prisma-runtime-data-access.js +0 -3
  132. package/dist/run/prisma-runtime-data-access.js.map +1 -1
  133. package/dist/runners/gh-identity.js +29 -7
  134. package/dist/runners/gh-identity.js.map +1 -1
  135. package/dist/runners/integrator.js +73 -71
  136. package/dist/runners/integrator.js.map +1 -1
  137. package/dist/storage/ensure-storage.js +2 -1
  138. package/dist/storage/ensure-storage.js.map +1 -1
  139. package/dist/task-control-plane/task-control-plane-api.service.js +410 -159
  140. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  141. package/dist/worker/claude-code-runner.js +1 -3
  142. package/dist/worker/claude-code-runner.js.map +1 -1
  143. package/package.json +1 -1
  144. package/prisma/migrations/20260707103000_prisma_runtime_tables/migration.sql +0 -1
  145. package/prisma/schema.prisma +16 -17
  146. package/dist/api/graphql-api/runs/inputs/execution-profile.input.js +0 -85
  147. package/dist/api/graphql-api/runs/inputs/execution-profile.input.js.map +0 -1
@@ -25,13 +25,14 @@ import { validateManualAdoptionAudit } from '../control-plane/manual-adoption-au
25
25
  import { validateMergeOverrideAudit } from '../control-plane/merge-override-audit.js';
26
26
  import { fnv1a64Hex } from '../control-plane/steps.js';
27
27
  import { POLICY_VERSION } from '../control-plane/default-playbook-policy.js';
28
- import { executionProfileFromRunProfile, topologyProfileFromRunProfile } from '../control-plane/run-profiles.js';
28
+ import { launchBindingsFromRunProfile, runProfileHash, topologyProfileFromRunProfile, topologyStageTargetsFromRunProfile, } from '../control-plane/run-profiles.js';
29
29
  import { materializeTemplate, MATERIALIZER_VERSION } from '../pipeline-core/materialize.js';
30
30
  import { DbosService } from '../engine/dbos.service.js';
31
31
  import { PipelineService } from '../pipeline/pipeline.service.js';
32
32
  import { INTEGRATOR_PROGRESS_EVENT_TYPES, RUN_PROGRESS_EVENT_KEY } from '../pipeline/data-driven-task.workflow.js';
33
33
  import { templateFromExecutionPolicy } from '../pipeline/data-driven-template.js';
34
- import { normalizeExecutionProfile, normalizeParams, normalizeRouteGates, resolveBindingForRole, resolveRunnerForRole, RUNNER_PERMISSION_MODES, } from '../pipeline/route-contract.js';
34
+ import { normalizeParams, normalizeRouteGates, resolveBindingForRole, resolveRunnerForRole, RUNNER_PERMISSION_MODES, } from '../pipeline/route-contract.js';
35
+ import { assertValidInlineRunProfile } from '../playbook/catalog-schema-validator.js';
35
36
  import { InboxService } from '../revisium/inbox.service.js';
36
37
  import { PlaybooksService } from '../revisium/playbooks.service.js';
37
38
  import { RolesService } from '../revisium/roles.service.js';
@@ -39,15 +40,17 @@ import { RunService } from '../revisium/run.service.js';
39
40
  import { CreateRunWorkflowError, previewCreateRunIds, } from '../run/create-run.js';
40
41
  import { PrReadinessService } from './pr-readiness.service.js';
41
42
  const execFileAsync = promisify(execFile);
42
- const GATE_TOPICS = new Set(['plan', 'merge', 'question']);
43
- const WORKFLOW_SUCCESS_EVENT_TYPES = new Set(['step_succeeded', 'gate_signaled']);
43
+ const GATE_TOPICS = new Set(['plan', 'merge', 'question', 'retry']);
44
+ const WORKFLOW_SUCCESS_EVENT_TYPES = new Set(['step_succeeded', 'gate_signaled', 'question_signaled']);
44
45
  const WORKFLOW_FAILURE_EVENT_TYPES = new Set(['step_failed', 'attempt_failed']);
45
46
  export const WORKFLOW_PROGRESS_EVENT_TYPES = new Set([
46
47
  'pipeline_blocked',
48
+ 'question_signal_pending',
49
+ 'question_signaled',
47
50
  'pr_polled',
48
51
  ...INTEGRATOR_PROGRESS_EVENT_TYPES,
49
52
  ]);
50
- const BUILTIN_RUNNERS = new Set(['claude-code', 'codex', 'script', 'stub-agent', 'revo-integrator', 'revo-merger', 'revo-deterministic']);
53
+ const BUILTIN_RUNNERS = new Set(['claude-code', 'codex', 'script', 'stub-agent']);
51
54
  function asErrorMessage(error) {
52
55
  if (error instanceof Error)
53
56
  return error.message;
@@ -58,6 +61,9 @@ function asRecord(value) {
58
61
  return null;
59
62
  return value;
60
63
  }
64
+ function nonEmptyString(value) {
65
+ return typeof value === 'string' && value.length > 0 ? value : null;
66
+ }
61
67
  function dateOrEpoch(value) {
62
68
  if (value instanceof Date)
63
69
  return Number.isNaN(value.getTime()) ? new Date(0) : value;
@@ -75,6 +81,39 @@ function gateTopic(item) {
75
81
  return null;
76
82
  return topic;
77
83
  }
84
+ function gateSignalTopic(item, topic) {
85
+ const context = asRecord(item.context);
86
+ const signalTopic = context?.signalTopic;
87
+ return typeof signalTopic === 'string' && signalTopic.length > 0 ? signalTopic : topic;
88
+ }
89
+ function questionSignalTopic(item) {
90
+ if (item.kind !== 'question' || !item.runId)
91
+ return null;
92
+ const context = asRecord(item.context);
93
+ if (context?.topic !== 'question')
94
+ return null;
95
+ const signalTopic = context.signalTopic;
96
+ return typeof signalTopic === 'string' && signalTopic.length > 0 ? signalTopic : null;
97
+ }
98
+ function questionSummary(item) {
99
+ const context = asRecord(item.context);
100
+ return asRecord(context?.summary);
101
+ }
102
+ function questionSignalTaskId(item) {
103
+ if (item.taskId)
104
+ return item.taskId;
105
+ const taskId = questionSummary(item)?.taskId;
106
+ return typeof taskId === 'string' ? taskId : '';
107
+ }
108
+ function questionSignalStepKey(item, signalTopic) {
109
+ const step = questionSummary(item)?.step;
110
+ if (typeof step === 'string' && step.length > 0)
111
+ return step;
112
+ return signalTopic.startsWith('question:') ? signalTopic : `question:${signalTopic}`;
113
+ }
114
+ function questionSignalResolvedBy(item, answer, fallbackResolvedBy) {
115
+ return nonEmptyString(item.resolvedBy) ?? nonEmptyString(asRecord(answer)?.resolvedBy) ?? fallbackResolvedBy;
116
+ }
78
117
  function gateDeclaredOutcomes(item) {
79
118
  const context = asRecord(item.context);
80
119
  const summary = asRecord(context?.summary);
@@ -112,6 +151,13 @@ function assertRequiredGateNote(item, outcome, note) {
112
151
  throw new ControlPlaneError('VALIDATION_FAILURE', `questionGate ${outcome} requires a non-empty note`);
113
152
  }
114
153
  }
154
+ function normalizeGateReconcile(value) {
155
+ if (value === undefined)
156
+ return undefined;
157
+ if (value === 'keep')
158
+ return value;
159
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'gate reconcile must be keep');
160
+ }
115
161
  async function git(cwd, args) {
116
162
  try {
117
163
  const result = await execFileAsync('git', args, {
@@ -249,12 +295,6 @@ function optionalString(value) {
249
295
  function optionalInteger(value) {
250
296
  return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
251
297
  }
252
- function requiredRecord(value, field, runId) {
253
- const record = asRecord(value);
254
- if (record)
255
- return record;
256
- throw new ControlPlaneError('VALIDATION_FAILURE', `Cannot recover run ${runId}: parent ${field} is not a record`);
257
- }
258
298
  function optionalRecord(value) {
259
299
  return asRecord(value) ?? {};
260
300
  }
@@ -419,19 +459,15 @@ function isRouteDecision(value) {
419
459
  const record = asRecord(value);
420
460
  return Boolean(record?.playbookId && record?.pipelineRowId && Array.isArray(record.roleBindings));
421
461
  }
422
- function assertRunnerAvailable(runnerId, profile, roleId) {
423
- const available = profile.availableRunners;
424
- if (available && !available.includes(runnerId)) {
425
- throw new ControlPlaneError('VALIDATION_FAILURE', `runner unavailable for role ${roleId}: ${runnerId}`);
426
- }
427
- if (!available && !BUILTIN_RUNNERS.has(runnerId)) {
462
+ function assertRunnerAvailable(runnerId, roleId) {
463
+ if (!BUILTIN_RUNNERS.has(runnerId)) {
428
464
  throw new ControlPlaneError('VALIDATION_FAILURE', `runner implementation is not registered for role ${roleId}: ${runnerId}`);
429
465
  }
430
466
  }
431
467
  function assertProductionRunnerBinding(runnerId, runnerSource, roleId) {
432
- if (runnerId !== 'stub-agent' || runnerSource === 'execution-profile')
468
+ if (runnerId !== 'stub-agent' || runnerSource === 'profile')
433
469
  return;
434
- throw new ControlPlaneError('VALIDATION_FAILURE', `role ${roleId} binds production runner stub-agent; use an execution profile override for test stubs`);
470
+ throw new ControlPlaneError('VALIDATION_FAILURE', `role ${roleId} binds production runner stub-agent; use a run profile binding for test stubs`);
435
471
  }
436
472
  let TaskControlPlaneApiService = class TaskControlPlaneApiService {
437
473
  runs;
@@ -558,11 +594,11 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
558
594
  playbookId: input.playbookId,
559
595
  pipelineId: input.pipelineId,
560
596
  profileId: input.profileId,
597
+ profile: input.profile,
561
598
  params: input.params,
562
599
  issueRef: input.issueRef,
563
600
  issueAction: input.issueAction,
564
- executionProfile: input.executionProfile,
565
- source: input.pipelineId ? 'explicit' : 'deterministic-installed-playbook',
601
+ source: 'explicit',
566
602
  });
567
603
  const result = await this.runs.createRun({
568
604
  title: input.title,
@@ -576,7 +612,6 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
576
612
  params: route.params,
577
613
  issueRef: route.params.issueRef,
578
614
  routeDecision: route,
579
- executionProfile: route.executionProfile,
580
615
  });
581
616
  if (!input.start)
582
617
  return { ...result, started: false, route };
@@ -691,9 +726,8 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
691
726
  if (!isRouteDecision(routeDecision)) {
692
727
  throw new ControlPlaneError('VALIDATION_FAILURE', `Cannot recover run ${parentRunId}: parent route_decision is invalid`);
693
728
  }
694
- const executionProfile = requiredRecord(parentData.execution_profile, 'execution_profile', parentRunId);
695
729
  const idSuffix = fnv1a64Hex(`${parentRunId}|${blockedEvent.eventId}`).slice(0, 8);
696
- return { title, repo, description, scope, priority, role: recoveryRole, playbookId, pipelineId, params, routeDecision, executionProfile, now, idSuffix };
730
+ return { title, repo, description, scope, priority, role: recoveryRole, playbookId, pipelineId, params, routeDecision, now, idSuffix };
697
731
  }
698
732
  async createRecoveryRunOrReuseExpected(input, expected) {
699
733
  try {
@@ -1063,7 +1097,7 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1063
1097
  const item = await this.getInboxItem(input.inboxId);
1064
1098
  const topic = gateTopic(item);
1065
1099
  if (!topic) {
1066
- throw new ControlPlaneError('VALIDATION_FAILURE', `inbox item is not a plan or merge gate: ${input.inboxId}`);
1100
+ throw new ControlPlaneError('VALIDATION_FAILURE', `inbox item is not a named approval gate: ${input.inboxId}`);
1067
1101
  }
1068
1102
  const outcomes = gateOutcomes(item);
1069
1103
  if (outcomes.length === 0) {
@@ -1074,6 +1108,7 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1074
1108
  throw new ControlPlaneError('VALIDATION_FAILURE', `invalid gate outcome ${outcome}; expected one of: ${outcomes.join(', ')}`);
1075
1109
  }
1076
1110
  const note = input.note?.trim();
1111
+ const reconcile = normalizeGateReconcile(input.reconcile);
1077
1112
  assertRequiredGateNote(item, outcome, note);
1078
1113
  const adoptionAudit = outcome === 'adopt_patch_manually'
1079
1114
  ? validateManualAdoptionAudit(input.adoptionAudit, item)
@@ -1085,6 +1120,7 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1085
1120
  const answer = {
1086
1121
  outcome,
1087
1122
  ...(note ? { note } : {}),
1123
+ ...(reconcile ? { reconcile } : {}),
1088
1124
  ...(adoptionAudit ? { adoptionAudit } : {}),
1089
1125
  ...(mergeOverrideAudit ? { mergeOverrideAudit } : {}),
1090
1126
  resolvedBy,
@@ -1105,6 +1141,8 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1105
1141
  assertLegacyGateWrapperAllowed(item, inboxId, outcomes) {
1106
1142
  if (outcomes.length > 2
1107
1143
  || outcomes.includes('approve_anyway')
1144
+ || outcomes.includes('retry')
1145
+ || outcomes.includes('give_up')
1108
1146
  || outcomes.some((outcome) => isQuestionGateReasonOutcome(item, outcome))) {
1109
1147
  throw new ControlPlaneError('VALIDATION_FAILURE', `inbox item has named gate outcomes; use resolve_gate with an explicit outcome: ${inboxId}`);
1110
1148
  }
@@ -1136,12 +1174,27 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1136
1174
  if (gateTopic(item)) {
1137
1175
  throw new ControlPlaneError('VALIDATION_FAILURE', `inbox item is a gate; use resolve_gate, approve_gate, or reject_gate: ${input.inboxId}`);
1138
1176
  }
1139
- return this.resolveInboxItem({
1177
+ const resolvedBy = input.resolvedBy ?? 'mcp';
1178
+ const answerRecord = asRecord(input.answer);
1179
+ if (typeof answerRecord?.outcome === 'string' && answerRecord.outcome.trim() === 'adopt_patch_manually') {
1180
+ validateManualAdoptionAudit(answerRecord.adoptionAudit, item);
1181
+ }
1182
+ const result = await this.inbox.resolveInbox(input.inboxId, input.answer, resolvedBy);
1183
+ const signalTopic = questionSignalTopic(item);
1184
+ const shouldSignal = signalTopic !== null;
1185
+ if (signalTopic) {
1186
+ const signalItem = result.status === 'pending' ? await this.getInboxItem(input.inboxId) : item;
1187
+ const signalResolvedBy = questionSignalResolvedBy(signalItem, result.answer, resolvedBy);
1188
+ await this.signalQuestion(signalItem, signalTopic, result.answer, input.inboxId, signalResolvedBy);
1189
+ }
1190
+ return {
1140
1191
  inboxId: input.inboxId,
1141
- answer: input.answer,
1142
- resolvedBy: input.resolvedBy ?? 'mcp',
1143
- signalGate: false,
1144
- });
1192
+ previousStatus: result.status,
1193
+ answer: result.answer,
1194
+ signaled: shouldSignal,
1195
+ topic: shouldSignal ? 'question' : null,
1196
+ runId: item.runId,
1197
+ };
1145
1198
  }
1146
1199
  async resolveInboxItem(input) {
1147
1200
  const item = await this.getInboxItem(input.inboxId);
@@ -1162,6 +1215,7 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1162
1215
  }
1163
1216
  this.assertGateOutcome(item, outcome);
1164
1217
  const note = typeof answer?.note === 'string' ? answer.note.trim() : '';
1218
+ const reconcile = normalizeGateReconcile(answer.reconcile);
1165
1219
  assertRequiredGateNote(item, outcome, note);
1166
1220
  const adoptionAudit = outcome === 'adopt_patch_manually'
1167
1221
  ? validateManualAdoptionAudit(answer.adoptionAudit, item)
@@ -1173,6 +1227,7 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1173
1227
  ...answer,
1174
1228
  outcome,
1175
1229
  ...(typeof answer.note === 'string' ? { note } : {}),
1230
+ ...(reconcile ? { reconcile } : {}),
1176
1231
  ...(adoptionAudit ? { adoptionAudit } : {}),
1177
1232
  ...(mergeOverrideAudit ? { mergeOverrideAudit } : {}),
1178
1233
  };
@@ -1193,18 +1248,35 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1193
1248
  };
1194
1249
  }
1195
1250
  async signalGate(item, topic, answer, inboxId) {
1251
+ const signalTopic = gateSignalTopic(item, topic);
1196
1252
  const eventBase = {
1197
1253
  runId: item.runId,
1198
1254
  taskId: item.taskId,
1199
1255
  stepId: item.stepId,
1200
1256
  stepKey: `gate:${topic}`,
1201
1257
  actor: 'mcp',
1202
- payload: { inboxId, topic },
1258
+ payload: { inboxId, topic, ...(signalTopic !== topic ? { signalTopic } : {}) },
1203
1259
  };
1204
1260
  await this.runs.appendEvent({ ...eventBase, type: 'gate_signal_pending' });
1205
- await this.dbos.signal(item.runId, topic, answer, inboxId);
1261
+ await this.dbos.signal(item.runId, signalTopic, answer, inboxId);
1206
1262
  await this.runs.appendEvent({ ...eventBase, type: 'gate_signaled' });
1207
1263
  }
1264
+ async signalQuestion(item, signalTopic, answer, inboxId, resolvedBy) {
1265
+ const stepKey = questionSignalStepKey(item, signalTopic);
1266
+ const eventBase = {
1267
+ runId: item.runId,
1268
+ taskId: questionSignalTaskId(item),
1269
+ stepId: item.stepId,
1270
+ stepKey,
1271
+ actor: 'mcp',
1272
+ idempotencyKey: inboxId,
1273
+ payload: { inboxId, topic: 'question', signalTopic, stepKey },
1274
+ };
1275
+ const signalPayload = { answer, resolvedBy, inboxId };
1276
+ await this.runs.appendEvent({ ...eventBase, type: 'question_signal_pending' });
1277
+ await this.dbos.signal(item.runId, signalTopic, signalPayload, inboxId);
1278
+ await this.runs.appendEvent({ ...eventBase, type: 'question_signaled' });
1279
+ }
1208
1280
  async summarizeGateRisk(inboxId) {
1209
1281
  const item = await this.getInboxItem(inboxId);
1210
1282
  return {
@@ -1235,6 +1307,80 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1235
1307
  listProfiles(input = {}) {
1236
1308
  return this.playbooks.listRunProfiles(input);
1237
1309
  }
1310
+ listProfilesPage(input) {
1311
+ return this.playbooks.listRunProfilesPage(input);
1312
+ }
1313
+ async getProfile(input) {
1314
+ const playbook = await this.playbooks.resolvePlaybook(input.playbookId);
1315
+ const pipeline = await this.playbooks.resolvePipeline({ playbookId: playbook.id, pipelineId: input.pipelineId });
1316
+ return this.playbooks.resolveRunProfile({
1317
+ playbookId: playbook.id,
1318
+ pipelineId: pipeline.pipelineId,
1319
+ profileId: input.profileId,
1320
+ includeDeprecated: true,
1321
+ });
1322
+ }
1323
+ validateProfile(input) {
1324
+ return this.validateRunProfileForPipeline(input);
1325
+ }
1326
+ async createProfile(input) {
1327
+ const route = await this.validateRunProfileForPipeline(input);
1328
+ const profile = asRecord(route.profileSnapshot);
1329
+ if (!profile)
1330
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'validated profile snapshot is missing');
1331
+ return this.playbooks.createRunProfile({
1332
+ playbookId: route.playbookId,
1333
+ pipelineId: route.basePipelineId ?? route.pipelineId,
1334
+ profileId: input.profileId,
1335
+ displayName: input.displayName,
1336
+ summary: input.summary,
1337
+ profile,
1338
+ status: input.status,
1339
+ });
1340
+ }
1341
+ async updateProfile(input) {
1342
+ let playbookId;
1343
+ let pipelineId = input.pipelineId;
1344
+ let profile;
1345
+ if (input.profile !== undefined) {
1346
+ const route = await this.validateRunProfileForPipeline({
1347
+ playbookId: input.playbookId,
1348
+ pipelineId: input.pipelineId,
1349
+ profile: input.profile,
1350
+ });
1351
+ playbookId = route.playbookId;
1352
+ pipelineId = route.basePipelineId ?? route.pipelineId;
1353
+ profile = asRecord(route.profileSnapshot) ?? undefined;
1354
+ if (!profile)
1355
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'validated profile snapshot is missing');
1356
+ }
1357
+ else {
1358
+ const playbook = await this.playbooks.resolvePlaybook(input.playbookId);
1359
+ const pipeline = await this.playbooks.resolvePipeline({ playbookId: playbook.id, pipelineId: input.pipelineId });
1360
+ playbookId = playbook.id;
1361
+ pipelineId = pipeline.pipelineId;
1362
+ }
1363
+ return this.playbooks.updateRunProfile({
1364
+ playbookId,
1365
+ pipelineId,
1366
+ profileId: input.profileId,
1367
+ expectedProfileRevisionHash: input.expectedProfileRevisionHash,
1368
+ displayName: input.displayName,
1369
+ summary: input.summary,
1370
+ profile,
1371
+ status: input.status,
1372
+ });
1373
+ }
1374
+ async deprecateProfile(input) {
1375
+ const playbook = await this.playbooks.resolvePlaybook(input.playbookId);
1376
+ const pipeline = await this.playbooks.resolvePipeline({ playbookId: playbook.id, pipelineId: input.pipelineId });
1377
+ return this.playbooks.deprecateRunProfile({
1378
+ playbookId: playbook.id,
1379
+ pipelineId: pipeline.pipelineId,
1380
+ profileId: input.profileId,
1381
+ expectedProfileRevisionHash: input.expectedProfileRevisionHash,
1382
+ });
1383
+ }
1238
1384
  async getPipeline(pipelineId) {
1239
1385
  const result = await this.playbooks.getPipeline(pipelineId);
1240
1386
  if (!result)
@@ -1248,9 +1394,9 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1248
1394
  playbookId: input.playbookId,
1249
1395
  pipelineId: input.pipeline,
1250
1396
  profileId: input.profileId,
1397
+ profile: input.profile,
1251
1398
  params: input.params,
1252
- executionProfile: input.executionProfile,
1253
- source: input.pipeline ? 'explicit' : 'deterministic-installed-playbook',
1399
+ source: 'explicit',
1254
1400
  });
1255
1401
  }
1256
1402
  async previewPipelineSelection(input) {
@@ -1270,58 +1416,86 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1270
1416
  async routeForRun(run) {
1271
1417
  if (isRouteDecision(run.data.route_decision))
1272
1418
  return run.data.route_decision;
1273
- return this.resolveRouteDecision({
1274
- title: typeof run.data.title === 'string' ? run.data.title : 'Run',
1275
- repo: Array.isArray(run.data.repos) && typeof run.data.repos[0] === 'string' ? run.data.repos[0] : '',
1276
- description: typeof run.data.description === 'string' ? run.data.description : '',
1277
- scope: typeof run.data.scope === 'string' ? run.data.scope : '',
1278
- playbookId: typeof run.data.playbook_id === 'string' ? run.data.playbook_id : undefined,
1279
- pipelineId: typeof run.data.pipeline_id === 'string' ? run.data.pipeline_id : undefined,
1280
- params: run.data.params,
1281
- source: run.data.pipeline_id ? 'explicit' : 'deterministic-installed-playbook',
1282
- });
1419
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'run route_decision is missing or invalid');
1283
1420
  }
1284
1421
  async resolveRouteDecision(input) {
1285
1422
  const params = normalizeParams(input.params, input.issueRef, input.issueAction);
1286
- const callerProfile = normalizeExecutionProfile(input.executionProfile);
1287
1423
  const playbook = await this.playbooks.resolvePlaybook(input.playbookId);
1288
- const requestedPipelineId = input.pipelineId
1289
- ?? (await this.resolveAutoPipeline(playbook.id, [input.title, input.description, input.scope].join(' '))).pipelineId;
1424
+ const requestedPipelineId = input.pipelineId?.trim();
1425
+ if (!requestedPipelineId) {
1426
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'pipelineId is required');
1427
+ }
1428
+ const hasProfileId = input.profileId !== undefined;
1429
+ const hasInlineProfile = input.profile !== undefined;
1430
+ if (hasProfileId === hasInlineProfile) {
1431
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'exactly one of profileId or profile is required');
1432
+ }
1433
+ if (input.profileId?.trim() === '') {
1434
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'profileId must be a non-empty string');
1435
+ }
1290
1436
  const pipeline = await this.playbooks.resolvePipeline({ playbookId: playbook.id, pipelineId: requestedPipelineId });
1291
- let executionPolicy = pipeline.executionPolicy;
1292
- let executionProfile = callerProfile;
1293
- const provenanceFields = {};
1437
+ let profileSnapshot;
1438
+ let profileHash;
1439
+ let provenanceFields;
1294
1440
  if (input.profileId !== undefined) {
1295
1441
  const storedProfile = await this.playbooks.resolveRunProfile({
1296
1442
  playbookId: playbook.id,
1297
1443
  pipelineId: pipeline.pipelineId,
1298
1444
  profileId: input.profileId,
1299
1445
  });
1300
- const topologyProfile = topologyProfileFromRunProfile(storedProfile.profile);
1301
- const baseTemplate = pipeline.executionPolicy.template_json;
1302
- if (!baseTemplate) {
1303
- throw new ControlPlaneError('VALIDATION_FAILURE', `pipeline "${pipeline.pipelineId}" carries no template_json`);
1446
+ profileSnapshot = storedProfile.profile;
1447
+ this.assertStoredRunProfileValid(storedProfile);
1448
+ profileHash = runProfileHash(profileSnapshot, {
1449
+ pipelineId: storedProfile.pipelineId,
1450
+ schemaVersion: storedProfile.schemaVersion,
1451
+ });
1452
+ if (storedProfile.profileHash !== profileHash) {
1453
+ throw new ControlPlaneError('VALIDATION_FAILURE', `stored run profile ${storedProfile.profileId} hash mismatch: expected ${storedProfile.profileHash}, got ${profileHash}`);
1454
+ }
1455
+ provenanceFields = {
1456
+ profileSource: 'stored',
1457
+ profileId: storedProfile.profileId,
1458
+ profileVersion: storedProfile.version,
1459
+ };
1460
+ }
1461
+ else {
1462
+ const inlineProfile = asRecord(input.profile);
1463
+ if (!inlineProfile) {
1464
+ throw new ControlPlaneError('VALIDATION_FAILURE', 'profile must be an object');
1465
+ }
1466
+ try {
1467
+ assertValidInlineRunProfile(inlineProfile, 'profile');
1304
1468
  }
1305
- const { template: materializedTemplate, materializedTemplateHash, diagnostics } = materializeTemplate(baseTemplate, topologyProfile, { allowlist: topologyProfile.toggles.map((toggle) => toggle.target) });
1306
- if (diagnostics.length > 0) {
1307
- throw new ControlPlaneError('VALIDATION_FAILURE', `run profile ${storedProfile.profileId} cannot be materialized: ${diagnostics.map((item) => item.message).join('; ')}`, { details: { diagnostics } });
1469
+ catch (error) {
1470
+ const message = error instanceof Error ? error.message : String(error);
1471
+ throw new ControlPlaneError('VALIDATION_FAILURE', message);
1308
1472
  }
1309
- executionPolicy = { template_json: materializedTemplate };
1310
- executionProfile = executionProfileFromRunProfile(storedProfile.profile, callerProfile);
1311
- provenanceFields.requestedPipelineId = requestedPipelineId;
1312
- provenanceFields.basePipelineId = pipeline.pipelineId;
1313
- provenanceFields.profileId = storedProfile.profileId;
1314
- provenanceFields.profileVersion = storedProfile.version;
1315
- provenanceFields.profileHash = storedProfile.profileHash;
1316
- provenanceFields.profileSnapshot = storedProfile.profile;
1317
- provenanceFields.materializedTemplateHash = materializedTemplateHash;
1318
- provenanceFields.materializedTemplate = materializedTemplate;
1319
- provenanceFields.materializerVersion = MATERIALIZER_VERSION;
1320
- provenanceFields.policyVersion = POLICY_VERSION;
1473
+ profileSnapshot = inlineProfile;
1474
+ profileHash = runProfileHash(profileSnapshot, { pipelineId: pipeline.pipelineId });
1475
+ provenanceFields = { profileSource: 'inline' };
1476
+ }
1477
+ const topologyProfile = topologyProfileFromRunProfile(profileSnapshot, {
1478
+ pipelineId: pipeline.pipelineId,
1479
+ profileId: provenanceFields.profileId ?? 'inline',
1480
+ });
1481
+ const baseTemplate = pipeline.executionPolicy.template_json;
1482
+ if (!baseTemplate) {
1483
+ throw new ControlPlaneError('VALIDATION_FAILURE', `pipeline "${pipeline.pipelineId}" carries no template_json`);
1321
1484
  }
1485
+ this.assertRunProfileStagesClosed(profileSnapshot, baseTemplate);
1486
+ const { template: materializedTemplate, materializedTemplateHash, diagnostics } = materializeTemplate(baseTemplate, topologyProfile, { allowlist: topologyProfile.toggles.map((toggle) => toggle.target) });
1487
+ if (diagnostics.length > 0) {
1488
+ throw new ControlPlaneError('VALIDATION_FAILURE', `run profile cannot be materialized: ${diagnostics.map((item) => item.message).join('; ')}`, { details: { diagnostics } });
1489
+ }
1490
+ const executionPolicy = { ...asRecord(pipeline.executionPolicy), template_json: materializedTemplate };
1491
+ const launchBindings = launchBindingsFromRunProfile(profileSnapshot, {
1492
+ lifecycleNodeIds: Object.entries(materializedTemplate.nodes)
1493
+ .filter(([, node]) => node.kind === 'script')
1494
+ .map(([nodeId]) => nodeId),
1495
+ });
1322
1496
  const allRoles = (await this.roles.listRoles()).filter((role) => role.playbookId === playbook.id);
1323
- await this.assertExecutionProfileClosed(executionProfile, allRoles);
1324
- const roleBindings = await this.resolveRouteRoles(playbook.id, pipeline, executionProfile, allRoles);
1497
+ await this.assertLaunchBindingsClosed(launchBindings, allRoles, materializedTemplate);
1498
+ const roleBindings = await this.resolveRouteRoles(playbook.id, pipeline, launchBindings, allRoles);
1325
1499
  return {
1326
1500
  playbookId: playbook.id,
1327
1501
  pipelineId: requestedPipelineId,
@@ -1332,101 +1506,178 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1332
1506
  optionalRoles: pipeline.optionalRoles,
1333
1507
  routeGates: normalizeRouteGates(pipeline.routeGates),
1334
1508
  executionPolicy,
1335
- executionProfile,
1509
+ launchBindings,
1336
1510
  roleBindings,
1337
1511
  params,
1512
+ requestedPipelineId,
1513
+ basePipelineId: pipeline.pipelineId,
1514
+ profileHash,
1515
+ profileSnapshot,
1516
+ materializedTemplateHash,
1517
+ materializedTemplate,
1518
+ materializerVersion: MATERIALIZER_VERSION,
1519
+ policyVersion: POLICY_VERSION,
1338
1520
  ...provenanceFields,
1339
1521
  };
1340
1522
  }
1341
- async assertExecutionProfileClosed(executionProfile, roles) {
1342
- const overrides = executionProfile.bindingOverrides ?? [];
1343
- if (overrides.length === 0)
1523
+ async validateRunProfileForPipeline(input) {
1524
+ return this.resolveRouteDecision({
1525
+ title: 'Run profile validation',
1526
+ repo: '',
1527
+ playbookId: input.playbookId,
1528
+ pipelineId: input.pipelineId,
1529
+ profile: input.profile,
1530
+ source: 'explicit',
1531
+ });
1532
+ }
1533
+ assertStoredRunProfileValid(storedProfile) {
1534
+ try {
1535
+ assertValidInlineRunProfile(storedProfile.profile, `run_profiles.${storedProfile.profileId}.profile_json`);
1536
+ }
1537
+ catch (error) {
1538
+ throw new ControlPlaneError('VALIDATION_FAILURE', asErrorMessage(error));
1539
+ }
1540
+ }
1541
+ assertRunProfileStagesClosed(profileSnapshot, template) {
1542
+ const nodeIds = new Set(Object.keys(template.nodes));
1543
+ for (const stage of topologyStageTargetsFromRunProfile(profileSnapshot)) {
1544
+ if (nodeIds.has(stage))
1545
+ continue;
1546
+ throw this.profileSchemaClosed(`topology stage "${stage}" does not exist in pipeline ${template.pipelineId}`);
1547
+ }
1548
+ }
1549
+ async assertLaunchBindingsClosed(launchBindings, roles, template) {
1550
+ if (launchBindings.length === 0)
1344
1551
  return;
1345
- const available = executionProfile.availableRunners;
1346
1552
  const byPlaybookRole = new Map(roles.map((r) => [r.playbookRoleId || r.name, r]));
1553
+ const nodes = template.nodes;
1347
1554
  const cachedProfiles = new Map();
1348
- for (const override of overrides) {
1555
+ for (const override of launchBindings) {
1349
1556
  const matchLabel = JSON.stringify(override.match);
1350
- const { roleId: mRoleId, nodeId: mNodeId, runnerId: mRunnerId } = override.match;
1351
- if (!mRoleId && !mNodeId && !mRunnerId) {
1352
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} must specify at least one of roleId, nodeId, runnerId`);
1353
- }
1354
- // Validate match.runnerId too, not just override.runnerId — an unregistered/typo'd match
1355
- // target would otherwise silently match nothing and the override becomes a no-op.
1356
- if (mRunnerId !== undefined) {
1357
- if (available && !available.includes(mRunnerId)) {
1358
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} runnerId "${mRunnerId}" not in availableRunners`);
1359
- }
1360
- if (!available && !BUILTIN_RUNNERS.has(mRunnerId)) {
1361
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} runnerId "${mRunnerId}" is not a registered runner`);
1362
- }
1363
- }
1364
- if (override.runnerId !== undefined) {
1365
- const ovRunner = override.runnerId;
1366
- if (available && !available.includes(ovRunner)) {
1367
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} runnerId "${ovRunner}" not in availableRunners`);
1368
- }
1369
- if (!available && !BUILTIN_RUNNERS.has(ovRunner)) {
1370
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} runnerId "${ovRunner}" is not a registered runner`);
1371
- }
1372
- }
1373
- if (override.modelLevel !== undefined) {
1374
- const lvl = override.modelLevel;
1375
- if (!VALID_MODEL_LEVELS.includes(lvl)) {
1376
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} modelLevel "${lvl}" is unavailable`);
1377
- }
1378
- const cached = cachedProfiles.get(lvl);
1379
- if (cached === undefined) {
1380
- try {
1381
- await this.roles.loadModelProfile(lvl);
1382
- cachedProfiles.set(lvl, true);
1383
- }
1384
- catch (err) {
1385
- const isNotFound = err instanceof ControlPlaneError && (err.code === 'ROW_NOT_FOUND' || err.code === 'VALIDATION_FAILURE');
1386
- if (isNotFound) {
1387
- cachedProfiles.set(lvl, false);
1388
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} modelLevel "${lvl}" is unavailable`);
1389
- }
1390
- throw err;
1391
- }
1392
- }
1393
- else if (!cached) {
1394
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} modelLevel "${lvl}" is unavailable`);
1395
- }
1396
- }
1397
- if (override.timeoutMs !== undefined) {
1398
- const ms = override.timeoutMs;
1399
- if (!Number.isInteger(ms) || ms <= 0 || ms > 86_400_000) {
1400
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} timeoutMs must be a positive integer <= 86400000, got ${ms}`);
1401
- }
1402
- }
1403
- if (override.permissionMode !== undefined) {
1404
- let effectiveRunner;
1405
- if (override.runnerId) {
1406
- effectiveRunner = override.runnerId;
1407
- }
1408
- else if (mRunnerId) {
1409
- effectiveRunner = executionProfile.runnerOverrides[mRunnerId] ?? mRunnerId;
1410
- }
1411
- else if (mRoleId) {
1412
- const role = byPlaybookRole.get(mRoleId);
1413
- if (!role) {
1414
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} permissionMode override requires a resolvable runner; pin runnerId`);
1415
- }
1416
- effectiveRunner = executionProfile.runnerOverrides[role.runner] ?? role.runner;
1417
- }
1418
- else {
1419
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} permissionMode override requires a resolvable runner; pin runnerId`);
1420
- }
1421
- const validModes = RUNNER_PERMISSION_MODES[effectiveRunner];
1422
- if (!validModes) {
1423
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} permissionMode not supported for runner ${effectiveRunner}`);
1424
- }
1425
- if (!validModes.includes(override.permissionMode)) {
1426
- throw new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: bindingOverride match ${matchLabel} permissionMode "${override.permissionMode}" is not valid for runner ${effectiveRunner}; expected one of ${validModes.join(', ')}`);
1427
- }
1557
+ this.assertBindingMatchIsClosed(override, matchLabel);
1558
+ this.assertBindingTargetIsKnown(override, matchLabel, byPlaybookRole, nodes);
1559
+ this.assertScriptBindingShape(override, matchLabel, nodes);
1560
+ this.assertBindingAccounts(override, matchLabel, nodes);
1561
+ this.assertRegisteredRunner(override.match.runnerId, matchLabel);
1562
+ this.assertRegisteredRunner(override.runnerId, matchLabel);
1563
+ await this.assertModelLevelAvailable(override.modelLevel, matchLabel, cachedProfiles);
1564
+ this.assertBindingTimeout(override, matchLabel);
1565
+ this.assertBindingPermissionMode(override, matchLabel, byPlaybookRole, launchBindings);
1566
+ }
1567
+ }
1568
+ profileSchemaClosed(message) {
1569
+ return new ControlPlaneError('VALIDATION_FAILURE', `PROFILE_SCHEMA_CLOSED: ${message}`);
1570
+ }
1571
+ assertBindingMatchIsClosed(override, matchLabel) {
1572
+ const { roleId, nodeId, runnerId } = override.match;
1573
+ if (roleId || nodeId || runnerId)
1574
+ return;
1575
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} must specify at least one of roleId, nodeId, runnerId`);
1576
+ }
1577
+ assertBindingTargetIsKnown(override, matchLabel, byPlaybookRole, nodes) {
1578
+ const { roleId, nodeId } = override.match;
1579
+ if (roleId !== undefined && !byPlaybookRole.has(roleId)) {
1580
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} roleId "${roleId}" does not exist in the selected playbook`);
1581
+ }
1582
+ if (nodeId !== undefined && !nodes[nodeId]) {
1583
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} nodeId "${nodeId}" does not exist in the selected pipeline`);
1584
+ }
1585
+ }
1586
+ assertScriptBindingShape(override, matchLabel, nodes) {
1587
+ const nodeId = override.match.nodeId;
1588
+ if (!nodeId || nodes[nodeId]?.kind !== 'script')
1589
+ return;
1590
+ if (override.runnerId || override.modelLevel || override.timeoutMs !== undefined || override.permissionMode) {
1591
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} targets script node "${nodeId}" and must not set runnerId, modelLevel, timeoutMs, or permissionMode`);
1592
+ }
1593
+ }
1594
+ assertBindingAccounts(override, matchLabel, nodes) {
1595
+ const github = override.accounts?.github;
1596
+ if (github === undefined)
1597
+ return;
1598
+ if (!github.trim()) {
1599
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} accounts.github must be a non-empty string`);
1600
+ }
1601
+ const nodeId = override.match.nodeId;
1602
+ if (!nodeId || nodes[nodeId]?.kind !== 'script') {
1603
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} accounts are only supported for script nodes`);
1604
+ }
1605
+ }
1606
+ assertRegisteredRunner(runnerId, matchLabel) {
1607
+ if (runnerId === undefined || BUILTIN_RUNNERS.has(runnerId))
1608
+ return;
1609
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} runnerId "${runnerId}" is not a registered runner`);
1610
+ }
1611
+ async assertModelLevelAvailable(modelLevel, matchLabel, cachedProfiles) {
1612
+ if (modelLevel === undefined)
1613
+ return;
1614
+ if (!VALID_MODEL_LEVELS.includes(modelLevel)) {
1615
+ throw this.unavailableModelLevel(matchLabel, modelLevel);
1616
+ }
1617
+ const cached = cachedProfiles.get(modelLevel);
1618
+ if (cached === true)
1619
+ return;
1620
+ if (cached === false)
1621
+ throw this.unavailableModelLevel(matchLabel, modelLevel);
1622
+ try {
1623
+ await this.roles.loadModelProfile(modelLevel);
1624
+ cachedProfiles.set(modelLevel, true);
1625
+ }
1626
+ catch (err) {
1627
+ const isUnavailable = err instanceof ControlPlaneError && (err.code === 'ROW_NOT_FOUND' || err.code === 'VALIDATION_FAILURE');
1628
+ if (!isUnavailable)
1629
+ throw err;
1630
+ cachedProfiles.set(modelLevel, false);
1631
+ throw this.unavailableModelLevel(matchLabel, modelLevel);
1632
+ }
1633
+ }
1634
+ unavailableModelLevel(matchLabel, modelLevel) {
1635
+ return this.profileSchemaClosed(`bindingOverride match ${matchLabel} modelLevel "${modelLevel}" is unavailable`);
1636
+ }
1637
+ assertBindingTimeout(override, matchLabel) {
1638
+ const ms = override.timeoutMs;
1639
+ if (ms === undefined || (Number.isInteger(ms) && ms > 0 && ms <= 86_400_000))
1640
+ return;
1641
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} timeoutMs must be a positive integer <= 86400000, got ${ms}`);
1642
+ }
1643
+ assertBindingPermissionMode(override, matchLabel, byPlaybookRole, launchBindings) {
1644
+ const permissionMode = override.permissionMode;
1645
+ if (permissionMode === undefined)
1646
+ return;
1647
+ const effectiveRunner = this.effectivePermissionRunner(override, matchLabel, byPlaybookRole, launchBindings);
1648
+ const validModes = RUNNER_PERMISSION_MODES[effectiveRunner];
1649
+ if (!validModes) {
1650
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} permissionMode not supported for runner ${effectiveRunner}`);
1651
+ }
1652
+ if (!validModes.includes(permissionMode)) {
1653
+ throw this.profileSchemaClosed(`bindingOverride match ${matchLabel} permissionMode "${permissionMode}" is not valid for runner ${effectiveRunner}; expected one of ${validModes.join(', ')}`);
1654
+ }
1655
+ }
1656
+ effectivePermissionRunner(override, matchLabel, byPlaybookRole, launchBindings) {
1657
+ if (override.runnerId)
1658
+ return override.runnerId;
1659
+ if (override.match.runnerId)
1660
+ return override.match.runnerId;
1661
+ const roleId = override.match.roleId;
1662
+ if (!roleId)
1663
+ throw this.unresolvablePermissionRunner(matchLabel);
1664
+ const role = byPlaybookRole.get(roleId);
1665
+ if (!role)
1666
+ throw this.unresolvablePermissionRunner(matchLabel);
1667
+ const roleOverride = this.findLastRoleRunnerOverride(launchBindings, roleId);
1668
+ return roleOverride?.runnerId ?? role.runner;
1669
+ }
1670
+ findLastRoleRunnerOverride(launchBindings, roleId) {
1671
+ for (let index = launchBindings.length - 1; index >= 0; index -= 1) {
1672
+ const candidate = launchBindings[index];
1673
+ if (candidate.match.roleId === roleId && !candidate.match.nodeId && candidate.runnerId !== undefined) {
1674
+ return candidate;
1428
1675
  }
1429
1676
  }
1677
+ return undefined;
1678
+ }
1679
+ unresolvablePermissionRunner(matchLabel) {
1680
+ return this.profileSchemaClosed(`bindingOverride match ${matchLabel} permissionMode override requires a resolvable runner; pin runnerId`);
1430
1681
  }
1431
1682
  async resolveAutoPipeline(playbookId, text) {
1432
1683
  const pipelines = (await this.playbooks.listPipelines())
@@ -1448,7 +1699,7 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1448
1699
  }
1449
1700
  return best.pipeline;
1450
1701
  }
1451
- async resolveRouteRoles(playbookId, pipeline, executionProfile, preloadedRoles) {
1702
+ async resolveRouteRoles(playbookId, pipeline, launchBindings, preloadedRoles) {
1452
1703
  const roles = preloadedRoles ?? (await this.roles.listRoles()).filter((role) => role.playbookId === playbookId);
1453
1704
  const byPlaybookRole = new Map(roles.map((role) => [role.playbookRoleId || role.name, role]));
1454
1705
  const selected = [...pipeline.requiredRoles];
@@ -1468,10 +1719,10 @@ let TaskControlPlaneApiService = class TaskControlPlaneApiService {
1468
1719
  return selected.map((roleId) => {
1469
1720
  const role = byPlaybookRole.get(roleId);
1470
1721
  assertProductionRunnerBinding(role.runner, 'playbook', roleId);
1471
- const runnerResolved = resolveRunnerForRole(role.runner, roleId, executionProfile);
1722
+ const runnerResolved = resolveRunnerForRole(role.runner, roleId, launchBindings);
1472
1723
  assertProductionRunnerBinding(runnerResolved.runnerId, runnerResolved.source, roleId);
1473
- assertRunnerAvailable(runnerResolved.runnerId, executionProfile, roleId);
1474
- const bindingResolution = resolveBindingForRole(role, roleId, runnerResolved.runnerId, executionProfile);
1724
+ assertRunnerAvailable(runnerResolved.runnerId, roleId);
1725
+ const bindingResolution = resolveBindingForRole(role, roleId, runnerResolved.runnerId, launchBindings);
1475
1726
  return {
1476
1727
  roleId,
1477
1728
  rowId: role.id,