@cat-factory/app 0.215.1 → 0.216.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +109 -1
  2. package/app/components/bootstrap/BootstrapModal.vue +65 -44
  3. package/app/components/github/AddServiceFromRepoModal.vue +49 -27
  4. package/app/components/github/GitHubOnboarding.vue +4 -23
  5. package/app/components/github/GitHubPanel.vue +10 -34
  6. package/app/components/inputGate/InputGateNotice.vue +176 -0
  7. package/app/components/panels/AgentStepDetail.vue +27 -3
  8. package/app/components/panels/inspector/TaskExecution.vue +32 -3
  9. package/app/components/pipeline/PipelineProgress.vue +1 -1
  10. package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
  11. package/app/components/vcs/VcsConnectSurfaces.vue +58 -0
  12. package/app/composables/api/inputGate.ts +25 -0
  13. package/app/composables/useApi.ts +2 -0
  14. package/app/composables/usePipelineErrorToast.ts +8 -0
  15. package/app/stores/github/vcsConnect.ts +62 -0
  16. package/app/stores/github.spec.ts +31 -0
  17. package/app/stores/github.ts +5 -26
  18. package/app/stores/inputGate.ts +58 -0
  19. package/app/stores/ui/resultViews.ts +9 -1
  20. package/app/stores/workspaceSettings.ts +1 -0
  21. package/app/types/domain.ts +1 -0
  22. package/app/utils/inputGate.spec.ts +52 -0
  23. package/app/utils/inputGate.ts +44 -0
  24. package/app/utils/pipelineRender.spec.ts +47 -9
  25. package/app/utils/pipelineRender.ts +23 -2
  26. package/app/utils/vcs.spec.ts +101 -0
  27. package/app/utils/vcs.ts +63 -1
  28. package/i18n/locales/de.json +76 -17
  29. package/i18n/locales/en.json +76 -17
  30. package/i18n/locales/es.json +76 -17
  31. package/i18n/locales/fr.json +76 -17
  32. package/i18n/locales/he.json +76 -17
  33. package/i18n/locales/it.json +76 -17
  34. package/i18n/locales/ja.json +76 -17
  35. package/i18n/locales/pl.json +76 -17
  36. package/i18n/locales/tr.json +76 -17
  37. package/i18n/locales/uk.json +76 -17
  38. package/package.json +2 -2
@@ -0,0 +1,176 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { InputGateIssue, InputGateIssueCode, RunInputGate } from '@cat-factory/contracts'
4
+ import type { InputGateTone } from '~/utils/inputGate'
5
+ import { useInputGateStore } from '~/stores/inputGate'
6
+
7
+ // The PRE-TOKEN INPUT GATE's notice: what the structural check found in the task's authored
8
+ // input, and the two ways out. Shown wherever a run parked on the gate is surfaced (the
9
+ // inspector's execution panel, the step-detail overlay), so it is a plain component over a
10
+ // verdict rather than an overlay of its own, its remedy is to go and edit the task, which is a
11
+ // board action a modal would be in the way of.
12
+ //
13
+ // Every line of copy is keyed off the finding CODE, never off backend prose: the backend does
14
+ // not localize, and its `describeInputGateIssues` summary is a detail line for logs.
15
+
16
+ const props = defineProps<{
17
+ /** The run's verdict. Which verdicts earn a notice is `inputGateNoticeFor`'s decision. */
18
+ gate: RunInputGate
19
+ /**
20
+ * How to present it (see {@link InputGateTone}). Passed in rather than re-derived from
21
+ * `gate.status`, because the advisory tone is NOT a status: it is a `passed` verdict that
22
+ * happens to carry findings, and a component deriving its own tone would have to repeat that
23
+ * rule and would go on rendering advisories as if the run had been cleared with nothing found.
24
+ */
25
+ tone: InputGateTone
26
+ /** The run this verdict belongs to, for the resolve calls. */
27
+ executionId: string
28
+ /** Compact form drops the explanatory paragraph (used inside the step-detail rail). */
29
+ compact?: boolean
30
+ }>()
31
+
32
+ const { t, te } = useI18n()
33
+ const inputGate = useInputGateStore()
34
+
35
+ /**
36
+ * Finding code → its translated copy, as an EXHAUSTIVE `Record` of LITERAL keys. Two guards in
37
+ * one: the Record fails to compile when a code is added without copy, and the literal keys are
38
+ * what the typed-message-key check can see (an assembled `\`inputGate.issue.${code}.title\`` is
39
+ * invisible to it). The `te` fallback below covers the case neither can, a run PERSISTED under
40
+ * a code this build has since retired.
41
+ */
42
+ const ISSUE_KEYS = {
43
+ description_missing: {
44
+ title: 'inputGate.issue.description_missing.title',
45
+ hint: 'inputGate.issue.description_missing.hint',
46
+ },
47
+ description_placeholder: {
48
+ title: 'inputGate.issue.description_placeholder.title',
49
+ hint: 'inputGate.issue.description_placeholder.hint',
50
+ },
51
+ description_thin: {
52
+ title: 'inputGate.issue.description_thin.title',
53
+ hint: 'inputGate.issue.description_thin.hint',
54
+ },
55
+ reproduction_missing: {
56
+ title: 'inputGate.issue.reproduction_missing.title',
57
+ hint: 'inputGate.issue.reproduction_missing.hint',
58
+ },
59
+ review_target_missing: {
60
+ title: 'inputGate.issue.review_target_missing.title',
61
+ hint: 'inputGate.issue.review_target_missing.hint',
62
+ },
63
+ success_criteria_missing: {
64
+ title: 'inputGate.issue.success_criteria_missing.title',
65
+ hint: 'inputGate.issue.success_criteria_missing.hint',
66
+ },
67
+ } as const satisfies Record<InputGateIssueCode, { title: string; hint: string }>
68
+
69
+ /**
70
+ * The findings, blocking first. Sorting here rather than trusting the emitted order keeps the
71
+ * thing a human must fix at the top even when an advisory was found earlier in the check.
72
+ */
73
+ const issues = computed<InputGateIssue[]>(() =>
74
+ [...props.gate.issues].sort((a, b) =>
75
+ a.severity === b.severity ? 0 : a.severity === 'blocking' ? -1 : 1,
76
+ ),
77
+ )
78
+
79
+ /** Only a parked verdict has anything to answer; the other two tones are a record. */
80
+ const blocking = computed(() => props.tone === 'blocked')
81
+
82
+ /** Title + body keys per tone, as literals so the typed-message-key check can see them. */
83
+ const TONE_COPY: Record<InputGateTone, { title: string; body: string }> = {
84
+ blocked: { title: 'inputGate.blockedTitle', body: 'inputGate.blockedBody' },
85
+ waived: { title: 'inputGate.waivedTitle', body: 'inputGate.waivedBody' },
86
+ advisory: { title: 'inputGate.advisoryTitle', body: 'inputGate.advisoryBody' },
87
+ }
88
+ const copy = computed(() => TONE_COPY[props.tone])
89
+
90
+ /** A finding's translated title, falling back to the generic line for a retired code. */
91
+ function issueTitle(code: InputGateIssueCode): string {
92
+ const key = ISSUE_KEYS[code]?.title
93
+ return key && te(key) ? t(key) : t('inputGate.issue.unknown.title')
94
+ }
95
+
96
+ /** A finding's translated remedy hint, on the same fallback. */
97
+ function issueHint(code: InputGateIssueCode): string {
98
+ const key = ISSUE_KEYS[code]?.hint
99
+ return key && te(key) ? t(key) : t('inputGate.issue.unknown.hint')
100
+ }
101
+
102
+ async function resolve(choice: 'recheck' | 'proceed') {
103
+ await inputGate.resolve(props.executionId, choice)
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <div
109
+ class="rounded-lg border p-3"
110
+ :class="
111
+ blocking
112
+ ? 'border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40'
113
+ : 'border-default bg-elevated/40'
114
+ "
115
+ :data-tone="tone"
116
+ data-testid="input-gate-notice"
117
+ >
118
+ <div class="flex items-start gap-2">
119
+ <UIcon
120
+ :name="blocking ? 'i-lucide-file-question' : 'i-lucide-info'"
121
+ class="mt-0.5 size-4 shrink-0"
122
+ :class="blocking ? 'text-amber-600 dark:text-amber-400' : 'text-muted'"
123
+ />
124
+ <div class="min-w-0 flex-1">
125
+ <p class="text-sm font-medium">{{ t(copy.title) }}</p>
126
+ <p v-if="!compact" class="text-muted mt-0.5 text-xs">{{ t(copy.body) }}</p>
127
+
128
+ <ul class="mt-2 space-y-1.5">
129
+ <li v-for="issue in issues" :key="issue.code" class="flex items-start gap-2 text-xs">
130
+ <UBadge
131
+ :color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
132
+ variant="subtle"
133
+ size="sm"
134
+ >
135
+ {{
136
+ issue.severity === 'blocking'
137
+ ? t('inputGate.severity.blocking')
138
+ : t('inputGate.severity.advisory')
139
+ }}
140
+ </UBadge>
141
+ <span class="min-w-0">
142
+ <span class="font-medium">{{ issueTitle(issue.code) }}</span>
143
+ <span class="text-muted">, {{ issueHint(issue.code) }}</span>
144
+ </span>
145
+ </li>
146
+ </ul>
147
+
148
+ <div v-if="blocking" class="mt-3 flex flex-wrap items-center gap-2">
149
+ <UButton
150
+ color="primary"
151
+ size="xs"
152
+ icon="i-lucide-refresh-cw"
153
+ :loading="inputGate.resolving"
154
+ data-testid="input-gate-recheck"
155
+ @click="resolve('recheck')"
156
+ >
157
+ {{ t('inputGate.recheck') }}
158
+ </UButton>
159
+ <UButton
160
+ color="neutral"
161
+ variant="ghost"
162
+ size="xs"
163
+ :disabled="inputGate.resolving"
164
+ data-testid="input-gate-proceed"
165
+ @click="resolve('proceed')"
166
+ >
167
+ {{ t('inputGate.proceed') }}
168
+ </UButton>
169
+ <span class="text-muted text-xs">{{ t('inputGate.recheckHint') }}</span>
170
+ </div>
171
+
172
+ <p v-if="inputGate.error" class="text-error mt-2 text-xs">{{ inputGate.error }}</p>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </template>
@@ -20,6 +20,7 @@ import { useStepTimer } from '~/composables/useStepTimer'
20
20
  import { useStepProse } from '~/composables/useStepProse'
21
21
  import { useStepApproval } from '~/composables/useStepApproval'
22
22
  import { dedicatedParkView } from '~/utils/pipelineRender'
23
+ import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
23
24
 
24
25
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
25
26
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -168,7 +169,21 @@ const companionExceeded = computed(() => approvalPending.value && !!step.value?.
168
169
  // resolver refuses these server-side, so the rail is replaced by a redirect to that window.
169
170
  // Computed live, since a coder step can park on one WHILE this overlay is already open
170
171
  // (the routing in `dispatchStepView` only covers the open click).
171
- const dedicatedPark = computed(() => (step.value ? dedicatedParkView(step.value) : null))
172
+ const dedicatedPark = computed(() =>
173
+ step.value ? dedicatedParkView(step.value, instance.value) : null,
174
+ )
175
+ /**
176
+ * The PRE-TOKEN INPUT GATE's verdict when it is what holds this step. Answered INLINE here
177
+ * (unlike the other dedicated parks, which redirect to a window): its remedy is to edit the
178
+ * task, so there is no second modal to send anyone to.
179
+ *
180
+ * Only the PARK, hence the literal `blocked` tone at the call site: this overlay exists to
181
+ * answer one step's park, and an advisory finding is about the run rather than this step. It is
182
+ * reported once, on the run panel, instead of on every step overlay opened under it.
183
+ */
184
+ const inputGateVerdict = computed(() =>
185
+ dedicatedPark.value === 'input-gate' ? (instance.value?.inputGate ?? null) : null,
186
+ )
172
187
  /** The generic approve/request-changes/reject rail applies (no dedicated surface owns the park). */
173
188
  const genericApprovalPending = computed(
174
189
  () => approvalPending.value && !companionExceeded.value && !dedicatedPark.value,
@@ -181,7 +196,7 @@ function openDedicatedWindow() {
181
196
  if (!c || !park) return
182
197
  close()
183
198
  if (park === 'follow-ups') ui.openFollowUps(c.instanceId, c.stepIndex)
184
- else ui.openForkDecision(c.instanceId, c.stepIndex)
199
+ else if (park === 'fork-decision') ui.openForkDecision(c.instanceId, c.stepIndex)
185
200
  }
186
201
 
187
202
  function close() {
@@ -433,8 +448,17 @@ async function copyOutput() {
433
448
  <!-- a park a dedicated window owns (fork choice / follow-up triage): the
434
449
  generic approval rail can't resolve it (the server refuses), so point
435
450
  the human at the window that can -->
451
+ <!-- the pre-token input gate holds this step: answered here, in place -->
452
+ <InputGateNotice
453
+ v-if="inputGateVerdict && instance"
454
+ :gate="inputGateVerdict"
455
+ tone="blocked"
456
+ :execution-id="instance.id"
457
+ compact
458
+ />
459
+
436
460
  <div
437
- v-if="dedicatedPark"
461
+ v-if="dedicatedPark && dedicatedPark !== 'input-gate'"
438
462
  class="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"
439
463
  data-testid="dedicated-park-redirect"
440
464
  >
@@ -16,6 +16,8 @@ import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
16
16
  import type { PipelineStep } from '~/types/execution'
17
17
  import type { ChangeClass, ReviewEffort } from '~/types/merge'
18
18
  import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
19
+ import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
20
+ import { inputGateNoticeFor } from '~/utils/inputGate'
19
21
 
20
22
  const props = defineProps<{ block: Block }>()
21
23
 
@@ -58,6 +60,14 @@ const isEmpty = computed(
58
60
  // A failed run is no longer executing: a step left mid-flight must stop showing
59
61
  // its live "Spinning up…" phase (the shared failure banner renders below).
60
62
  const runFailed = computed(() => instance.value?.status === 'failed')
63
+ /**
64
+ * The run's PRE-TOKEN INPUT GATE notice: the park while it holds the run, the waiver once
65
+ * somebody overruled it, and the ADVISORY findings a `passed` verdict still carries (which is
66
+ * the entire product of `advisory` mode, and how `standard` mode reports a thin description).
67
+ * Read off the RUN, not a step: the gate guards the first dispatch and leaves nothing
68
+ * kind-specific behind. Which verdicts earn a notice is `inputGateNoticeFor`'s call.
69
+ */
70
+ const inputGateNotice = computed(() => inputGateNoticeFor(instance.value))
61
71
 
62
72
  // A failed pipeline run surfaces the shared failure banner + retry — the
63
73
  // execution failure surface that the old `pr_ready` flip used to hide.
@@ -298,6 +308,16 @@ async function mergePr() {
298
308
  </UButton>
299
309
  </div>
300
310
  </div>
311
+ <!-- What the task's input check found. Rendered above the step list because it is a fact
312
+ about the RUN, and because the remedy for a park is to edit the task, not open a step.
313
+ An advisory verdict renders here too: nothing was parked, but something was found. -->
314
+ <InputGateNotice
315
+ v-if="inputGateNotice"
316
+ :gate="inputGateNotice.gate"
317
+ :tone="inputGateNotice.tone"
318
+ :execution-id="instance.id"
319
+ class="mb-2"
320
+ />
301
321
  <ul class="space-y-1">
302
322
  <li
303
323
  v-for="(s, i) in instance.steps"
@@ -417,7 +437,7 @@ async function mergePr() {
417
437
  v-else-if="
418
438
  s.approval &&
419
439
  s.approval.status === 'pending' &&
420
- dedicatedParkView(s) === 'fork-decision'
440
+ dedicatedParkView(s, instance) === 'fork-decision'
421
441
  "
422
442
  color="primary"
423
443
  variant="soft"
@@ -434,7 +454,7 @@ async function mergePr() {
434
454
  v-else-if="
435
455
  s.approval &&
436
456
  s.approval.status === 'pending' &&
437
- dedicatedParkView(s) === 'follow-ups'
457
+ dedicatedParkView(s, instance) === 'follow-ups'
438
458
  "
439
459
  color="primary"
440
460
  variant="soft"
@@ -461,8 +481,17 @@ async function mergePr() {
461
481
  >
462
482
  {{ t('inspector.execution.reviewFindings') }}
463
483
  </UButton>
484
+ <!-- The generic approve/review rail. Reached only once no dedicated surface owns
485
+ the park: the branches above took the fork and follow-up windows, so the one
486
+ left to exclude is the PRE-TOKEN INPUT GATE, which rides `step.approval` too
487
+ but is refused by the generic resolver server-side (approving it would mark the
488
+ run's first working step done and skip the work). It is answered by the notice
489
+ above the list. Asked of `dedicatedParkView` rather than re-derived here, so
490
+ the rule that decides which surface owns a park lives in exactly one place. -->
464
491
  <UButton
465
- v-else-if="s.approval && s.approval.status === 'pending'"
492
+ v-else-if="
493
+ s.approval && s.approval.status === 'pending' && !dedicatedParkView(s, instance)
494
+ "
466
495
  color="warning"
467
496
  variant="soft"
468
497
  size="xs"
@@ -681,7 +681,7 @@ const ITEM_ICON: Record<string, string> = {
681
681
  s.approval &&
682
682
  s.approval.status === 'pending' &&
683
683
  !prReviewAwaiting(s) &&
684
- !dedicatedParkView(s)
684
+ !dedicatedParkView(s, props.instance)
685
685
  "
686
686
  class="mt-3"
687
687
  >
@@ -11,7 +11,7 @@
11
11
  // standalone modals).
12
12
  import { reactive, ref, watch } from 'vue'
13
13
  import { useReactiveSlots } from '@modular-vue/runtime'
14
- import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
14
+ import type { InputGateMode, ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
15
15
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
16
16
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
17
17
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
@@ -151,6 +151,12 @@ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
151
151
  { value: 'per_type', label: t('settings.workspaceSettings.taskLimit.modes.per_type') },
152
152
  ])
153
153
 
154
+ const INPUT_GATE_MODES = computed<{ value: InputGateMode; label: string }[]>(() => [
155
+ { value: 'standard', label: t('settings.workspaceSettings.inputGate.modes.standard') },
156
+ { value: 'advisory', label: t('settings.workspaceSettings.inputGate.modes.advisory') },
157
+ { value: 'off', label: t('settings.workspaceSettings.inputGate.modes.off') },
158
+ ])
159
+
154
160
  const REVIEW_FRICTION_MODES = computed<{ value: ReviewFrictionMode; label: string }[]>(() => [
155
161
  { value: 'off', label: t('settings.workspaceSettings.reviewFriction.modes.off') },
156
162
  { value: 'warn', label: t('settings.workspaceSettings.reviewFriction.modes.warn') },
@@ -175,6 +181,7 @@ const draft = reactive({
175
181
  artifactRetentionDays: 14,
176
182
  kaizenEnabled: true,
177
183
  allowInitiatorPat: true,
184
+ inputGateMode: 'standard' as InputGateMode,
178
185
  reviewFrictionMode: 'off' as ReviewFrictionMode,
179
186
  reviewFrictionWarnCount: 3,
180
187
  reviewFrictionBlockCountEnabled: false,
@@ -195,6 +202,7 @@ function hydrate() {
195
202
  draft.artifactRetentionDays = s.artifactRetentionDays
196
203
  draft.kaizenEnabled = s.kaizenEnabled
197
204
  draft.allowInitiatorPat = s.allowInitiatorPat
205
+ draft.inputGateMode = s.inputGateMode
198
206
  draft.reviewFrictionMode = s.reviewFrictionMode
199
207
  draft.reviewFrictionWarnCount = s.reviewFrictionWarnCount
200
208
  // The hard-block knobs are nullable (null ⇒ that trigger is off); a per-trigger checkbox is
@@ -252,6 +260,7 @@ async function save() {
252
260
  artifactRetentionDays: draft.artifactRetentionDays,
253
261
  kaizenEnabled: draft.kaizenEnabled,
254
262
  allowInitiatorPat: draft.allowInitiatorPat,
263
+ inputGateMode: draft.inputGateMode,
255
264
  reviewFrictionMode: draft.reviewFrictionMode,
256
265
  reviewFrictionWarnCount: draft.reviewFrictionWarnCount,
257
266
  reviewFrictionBlockCount: blockCount,
@@ -359,6 +368,30 @@ async function save() {
359
368
  </div>
360
369
  </section>
361
370
 
371
+ <!-- The pre-token input gate: the structural check of a task's own wording, run
372
+ before a run's first agent step is dispatched. -->
373
+ <section class="space-y-2">
374
+ <h3 class="text-sm font-semibold text-slate-200">
375
+ {{ t('settings.workspaceSettings.inputGate.heading') }}
376
+ </h3>
377
+ <p class="text-[11px] text-slate-400">
378
+ {{ t('settings.workspaceSettings.inputGate.body') }}
379
+ </p>
380
+ <label class="block w-64">
381
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">{{
382
+ t('settings.workspaceSettings.inputGate.mode')
383
+ }}</span>
384
+ <USelect
385
+ v-model="draft.inputGateMode"
386
+ :items="INPUT_GATE_MODES"
387
+ value-key="value"
388
+ size="sm"
389
+ class="w-full"
390
+ data-testid="input-gate-mode"
391
+ />
392
+ </label>
393
+ </section>
394
+
362
395
  <!-- Review-debt friction on task creation -->
363
396
  <section class="space-y-2">
364
397
  <h3 class="text-sm font-semibold text-slate-200">
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ // The connect surfaces a deployment can actually serve, in one place. A deployment wires a
3
+ // GitHub App, a per-workspace GitLab PAT connect, both, or neither, and only
4
+ // `GET /vcs/connect-options` (via the store's `canConnect*`) can say which — a connection
5
+ // READ says nothing, because the `github` module builds for either provider.
6
+ //
7
+ // Every surface that can strand a user on "not connected yet" renders this: the source-control
8
+ // panel, the onboarding gate, and the two modals that need a connection before they can do
9
+ // anything (add-service-from-repo, bootstrap). They had diverged — both modals hardcoded the
10
+ // GitHub App picker, so a GitLab-only deployment offered an installation flow it cannot serve
11
+ // and no way to connect at all — which is why the fan-out lives here rather than being copied
12
+ // a fourth time.
13
+ //
14
+ // Explicit imports: the auto-import name for `github/GitHubConnect` doesn't match the
15
+ // `<GitHubConnect>` tag (see GitHubPanel), so both children are bound by path.
16
+ import GitHubConnect from '~/components/github/GitHubConnect.vue'
17
+ import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
18
+
19
+ const props = defineProps<{
20
+ /**
21
+ * Copy shown above the GitHub App picker only, since it is the one surface whose flow needs
22
+ * explaining (pick an account, grant repo access). Omitted ⇒ no intro, which is what the
23
+ * modals want: their own prompt already said why a connection is needed.
24
+ */
25
+ appIntro?: string
26
+ }>()
27
+
28
+ const { t } = useI18n()
29
+ const github = useGitHubStore()
30
+
31
+ // Whether the deployment serves neither surface. Rendered as a statement rather than an empty
32
+ // box: "nothing is configured" and "we couldn't read what is configured" both land here, and a
33
+ // blank space would read as the connect UI still loading.
34
+ const nothingConfigured = computed(() => !github.canConnectGitHubApp && !github.canConnectGitLabPat)
35
+ </script>
36
+
37
+ <template>
38
+ <div class="space-y-3">
39
+ <template v-if="github.canConnectGitHubApp">
40
+ <p v-if="props.appIntro" class="text-sm text-slate-400">{{ props.appIntro }}</p>
41
+ <GitHubConnect />
42
+ </template>
43
+
44
+ <USeparator
45
+ v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
46
+ :label="t('vcs.connect.or')"
47
+ />
48
+
49
+ <GitLabConnect v-if="github.canConnectGitLabPat" />
50
+
51
+ <p
52
+ v-if="nothingConfigured"
53
+ class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
54
+ >
55
+ {{ t('vcs.connect.noneConfigured') }}
56
+ </p>
57
+ </div>
58
+ </template>
@@ -0,0 +1,25 @@
1
+ import { resolveInputGateContract, type ResolveInputGateChoice } from '@cat-factory/contracts'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The PRE-TOKEN INPUT GATE: a run whose task states nothing an agent could act on parks before
6
+ * its first dispatch, having spent no tokens. This resolves that park: `recheck` re-evaluates
7
+ * the task as it now stands (the fix is verified, not asserted), `proceed` waives the findings.
8
+ *
9
+ * There is deliberately no read: the verdict rides the run (`ExecutionInstance.inputGate`),
10
+ * which the board snapshot and the live stream already carry.
11
+ */
12
+ export function inputGateApi({ send, ws }: ApiContext) {
13
+ return {
14
+ resolveInputGate: (
15
+ workspaceId: string,
16
+ executionId: string,
17
+ body: { choice: ResolveInputGateChoice },
18
+ ) =>
19
+ send(resolveInputGateContract, {
20
+ pathPrefix: ws(workspaceId),
21
+ pathParams: { executionId },
22
+ body,
23
+ }),
24
+ }
25
+ }
@@ -13,6 +13,7 @@ import { documentsApi } from './api/documents'
13
13
  import { executionApi } from './api/execution'
14
14
  import { followUpsApi } from './api/followUps'
15
15
  import { forkDecisionApi } from './api/forkDecision'
16
+ import { inputGateApi } from './api/inputGate'
16
17
  import { judgeApi } from './api/judge'
17
18
  import { prReviewApi } from './api/prReview'
18
19
  import { fragmentsApi } from './api/fragments'
@@ -129,6 +130,7 @@ export function useApi() {
129
130
  ...reviewsApi(ctx),
130
131
  ...followUpsApi(ctx),
131
132
  ...forkDecisionApi(ctx),
133
+ ...inputGateApi(ctx),
132
134
  ...judgeApi(ctx),
133
135
  ...prReviewApi(ctx),
134
136
  ...humanTestApi(ctx),
@@ -80,6 +80,14 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
80
80
  titleKey: 'errors.conflict.title.dependencies_unmet',
81
81
  descriptionKey: 'errors.conflict.description.dependencies_unmet',
82
82
  },
83
+ input_gate_not_parked: {
84
+ titleKey: 'errors.conflict.title.input_gate_not_parked',
85
+ descriptionKey: 'errors.conflict.description.input_gate_not_parked',
86
+ },
87
+ input_gate_parked: {
88
+ titleKey: 'errors.conflict.title.input_gate_parked',
89
+ descriptionKey: 'errors.conflict.description.input_gate_parked',
90
+ },
83
91
  task_limit_reached: {
84
92
  titleKey: 'errors.conflict.title.task_limit_reached',
85
93
  descriptionKey: 'errors.conflict.description.task_limit_reached',
@@ -1,5 +1,67 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import type { VcsProvider } from '~/types/domain'
1
3
  import type { GitHubStoreContext } from './context'
2
4
 
5
+ /**
6
+ * Everything a component needs to know about WHICH provider it is dealing with. Kept here,
7
+ * beside the connect actions that populate the state it reads, so the three questions stay
8
+ * answered in one place: what is connected, what could be connected, and what a given surface
9
+ * should therefore call itself.
10
+ */
11
+ export interface VcsProviderViews {
12
+ /** The provider backing the current connection; a connection predating the discriminator is App-era GitHub. */
13
+ provider: ComputedRef<VcsProvider>
14
+ canConnectGitHubApp: ComputedRef<boolean>
15
+ canConnectGitLabPat: ComputedRef<boolean>
16
+ soleConnectProvider: ComputedRef<VcsProvider | null>
17
+ surfaceProvider: ComputedRef<VcsProvider | null>
18
+ }
19
+
20
+ /** The derived provider questions above, off the probed connection + capability state. */
21
+ export function createVcsProviderViews(ctx: GitHubStoreContext): VcsProviderViews {
22
+ const { connection, connectOptions } = ctx
23
+
24
+ const provider = computed<VcsProvider>(() => connection.value?.provider ?? 'github')
25
+
26
+ /** Whether the deployment can serve a GitHub App connect / a per-workspace GitLab PAT connect. */
27
+ const canConnectGitHubApp = computed(() =>
28
+ connectOptions.value.some((o) => o.provider === 'github' && o.method === 'app'),
29
+ )
30
+ const canConnectGitLabPat = computed(() =>
31
+ connectOptions.value.some((o) => o.provider === 'gitlab' && o.method === 'pat'),
32
+ )
33
+
34
+ /**
35
+ * The single provider this deployment can connect, or null when it offers several (or none) —
36
+ * what the connect copy keys off so a one-provider deployment never says "choose a provider".
37
+ */
38
+ const soleConnectProvider = computed<VcsProvider | null>(() => {
39
+ const providers = new Set(connectOptions.value.map((o) => o.provider))
40
+ return providers.size === 1 ? [...providers][0]! : null
41
+ })
42
+
43
+ /**
44
+ * The provider a repo-facing surface is ABOUT: the connected one, or (with nothing bound yet)
45
+ * the only one this deployment could connect. Null when it offers several and none is
46
+ * connected, because naming one there would be a guess — such a surface says something neutral
47
+ * instead (`vcs.onboarding.titleAny` is the pattern). Distinct from {@link provider}, which
48
+ * answers "what is connected" and therefore defaults to `github`: reading THAT before a
49
+ * connection exists is what puts "Pick an existing GitHub repository" in front of a
50
+ * GitLab-only deployment.
51
+ */
52
+ const surfaceProvider = computed<VcsProvider | null>(() =>
53
+ connection.value !== null ? provider.value : soleConnectProvider.value,
54
+ )
55
+
56
+ return {
57
+ provider,
58
+ canConnectGitHubApp,
59
+ canConnectGitLabPat,
60
+ soleConnectProvider,
61
+ surfaceProvider,
62
+ }
63
+ }
64
+
3
65
  /**
4
66
  * The provider-neutral half of the connection lifecycle: which connect surfaces the deployment
5
67
  * serves, and the per-workspace **PAT** connect (GitLab today). The GitHub-App installation
@@ -15,6 +15,7 @@ function connection(overrides: Partial<GitHubConnection> = {}): GitHubConnection
15
15
  targetType: 'User',
16
16
  connectedAt: 1,
17
17
  provider: 'github',
18
+ method: 'app',
18
19
  canCreateRepos: false,
19
20
  canManageWorkflows: true,
20
21
  ...overrides,
@@ -56,6 +57,33 @@ describe('github store — VCS connect capability', () => {
56
57
  expect(github.canConnectGitLabPat).toBe(true)
57
58
  // A single-provider deployment names its provider, so the connect copy never says "choose".
58
59
  expect(github.soleConnectProvider).toBe('gitlab')
60
+ // …and the repo-facing surfaces name it too, BEFORE anything is connected. `provider`
61
+ // answers "what is connected" and so defaults to github; a surface reading that one is how
62
+ // "Pick an existing GitHub repository" ends up on a GitLab-only deployment.
63
+ expect(github.provider).toBe('github')
64
+ expect(github.surfaceProvider).toBe('gitlab')
65
+ })
66
+
67
+ it('names the connected provider once bound, whatever the deployment could connect', async () => {
68
+ stubApi({
69
+ getGitHubConnection: vi
70
+ .fn()
71
+ .mockResolvedValue({ connection: connection({ provider: 'gitlab', method: 'pat' }) }),
72
+ listVcsConnectOptions: vi.fn().mockResolvedValue({
73
+ options: [
74
+ { provider: 'github', method: 'app' },
75
+ { provider: 'gitlab', method: 'pat' },
76
+ ] satisfies VcsConnectOption[],
77
+ }),
78
+ })
79
+ const github = storeWithWorkspace()
80
+
81
+ await github.probe()
82
+
83
+ // Several connectable, so there is no sole provider — but one IS connected, and that is
84
+ // what every repo-facing surface is about.
85
+ expect(github.soleConnectProvider).toBeNull()
86
+ expect(github.surfaceProvider).toBe('gitlab')
59
87
  })
60
88
 
61
89
  it('reports no connect surface when the capability read fails, without hiding the integration', async () => {
@@ -91,6 +119,9 @@ describe('github store — VCS connect capability', () => {
91
119
  expect(github.canConnectGitHubApp).toBe(true)
92
120
  expect(github.canConnectGitLabPat).toBe(true)
93
121
  expect(github.soleConnectProvider).toBeNull()
122
+ // Nothing connected and several on offer: naming one would be a guess, so the surfaces
123
+ // fall back to their neutral copy rather than picking a brand.
124
+ expect(github.surfaceProvider).toBeNull()
94
125
  })
95
126
 
96
127
  it('connects GitLab with a trimmed PAT and loads the projection', async () => {