@cat-factory/app 0.154.0 → 0.155.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.
@@ -20,10 +20,11 @@
20
20
  //
21
21
  // It also owns the one trailing section every step-backed window shows: the agent's effort
22
22
  // self-assessment (see the footer block below), so a window never renders it itself.
23
- import { computed, ref } from 'vue'
23
+ import { computed, ref, watch } from 'vue'
24
24
  import { useModalBehavior } from '@modular-vue/core'
25
25
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
26
26
  import StepEffortReport from '~/components/panels/StepEffortReport.vue'
27
+ import StepValidationReport from '~/components/panels/StepValidationReport.vue'
27
28
  import { effortBand, effortHint } from '~/utils/effort'
28
29
 
29
30
  /** A pipeline step reference — passed by step-result windows to surface the shared
@@ -85,11 +86,26 @@ const { dialogRef } = useModalBehavior({
85
86
  // step whose agent wrote no report both resolve to null, and the footer disappears.
86
87
  const ui = useUiStore()
87
88
  const execution = useExecutionStore()
88
- const effortReport = computed(() => {
89
+ const activeStep = computed(() => {
89
90
  const view = ui.resultView
90
91
  if (!view || view.instanceId === null || view.stepIndex === null) return null
91
- return execution.getInstance(view.instanceId)?.steps[view.stepIndex]?.effortReport ?? null
92
+ return execution.getInstance(view.instanceId)?.steps[view.stepIndex] ?? null
92
93
  })
94
+ const effortReport = computed(() => activeStep.value?.effortReport ?? null)
95
+ // The step's PRE-PR VALIDATION report — the second universal trailing section, resolved the same
96
+ // way and for the same reason: every window whose step ran a coding job can show whether the
97
+ // checkout actually passed the service's checks before the PR opened (and, on a red run, exactly
98
+ // what failed). Absent for a service that configured no checks, and the footer disappears.
99
+ const validationReport = computed(() => activeStep.value?.validation ?? null)
100
+ const validationOpen = ref(false)
101
+ // A failing report is the interesting one, so it opens expanded; a green one stays a one-line row.
102
+ watch(
103
+ validationReport,
104
+ (report) => {
105
+ if (report && !report.passed) validationOpen.value = true
106
+ },
107
+ { immediate: true },
108
+ )
93
109
  // Collapsed by default — the windows own the vertical space, and the row already carries the
94
110
  // difficulty plus the gist of what held the agent back.
95
111
  const effortOpen = ref(false)
@@ -204,6 +220,61 @@ const panelClass = computed(() => [
204
220
  <StepEffortReport :report="effortReport" variant="flat" />
205
221
  </div>
206
222
  </section>
223
+
224
+ <!-- Shared trailing section: the pre-PR validation report (the service's check commands
225
+ run against the checkout BEFORE the PR opened). Collapsed when green, expanded when
226
+ red — a failed checkout is the one an operator opened the window to read. -->
227
+ <section
228
+ v-if="validationReport"
229
+ class="shrink-0 border-t border-slate-800 bg-slate-900/60"
230
+ data-testid="result-window-validation"
231
+ >
232
+ <button
233
+ type="button"
234
+ class="flex w-full items-center gap-2 px-5 py-2 text-start hover:bg-slate-800/40"
235
+ :aria-expanded="validationOpen"
236
+ data-testid="result-window-validation-toggle"
237
+ @click="validationOpen = !validationOpen"
238
+ >
239
+ <UIcon
240
+ :name="validationReport.passed ? 'i-lucide-shield-check' : 'i-lucide-shield-alert'"
241
+ class="h-3.5 w-3.5 shrink-0"
242
+ :class="validationReport.passed ? 'text-emerald-400' : 'text-rose-400'"
243
+ />
244
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
245
+ {{ t('panels.stepDetail.validation.heading') }}
246
+ </span>
247
+ <span
248
+ class="shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium tabular-nums"
249
+ :class="
250
+ validationReport.passed
251
+ ? 'bg-emerald-500/15 text-emerald-300'
252
+ : 'bg-rose-500/15 text-rose-300'
253
+ "
254
+ >
255
+ {{
256
+ validationReport.passed
257
+ ? t('panels.stepDetail.validation.passed')
258
+ : t('panels.stepDetail.validation.failed')
259
+ }}
260
+ </span>
261
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-400">
262
+ {{
263
+ t('panels.stepDetail.validation.attempts', {
264
+ attempts: validationReport.attempts,
265
+ maxAttempts: validationReport.maxAttempts,
266
+ })
267
+ }}
268
+ </span>
269
+ <UIcon
270
+ :name="validationOpen ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
271
+ class="ms-auto h-3.5 w-3.5 shrink-0 text-slate-500"
272
+ />
273
+ </button>
274
+ <div v-if="validationOpen" class="max-h-72 overflow-y-auto px-5 pb-3">
275
+ <StepValidationReport :report="validationReport" />
276
+ </div>
277
+ </section>
207
278
  </div>
208
279
  </div>
209
280
  </Teleport>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ValidationReport } from '~/types/validationChecks'
4
+
5
+ // The PRE-PR VALIDATION report for a step: which of the service's configured check commands
6
+ // passed against the checkout before the PR was allowed to open, their exit codes, and a
7
+ // bounded, secret-scrubbed tail of each command's output. Produced by the executor-harness
8
+ // running the commands — never a model self-report — so this is the run's captured PROOF on a
9
+ // green run and its EVIDENCE on a failed one. See docs/initiatives/pre-pr-validation.md.
10
+ const props = defineProps<{ report: ValidationReport }>()
11
+ const { t } = useI18n()
12
+
13
+ const failed = computed(() => props.report.outcomes.filter((o) => !o.passed))
14
+ </script>
15
+
16
+ <template>
17
+ <div class="space-y-2" data-testid="step-validation-report">
18
+ <p class="text-[11px] text-slate-400">
19
+ {{
20
+ report.passed
21
+ ? t('panels.stepDetail.validation.passedSummary', {
22
+ total: report.outcomes.length,
23
+ attempts: report.attempts,
24
+ })
25
+ : t('panels.stepDetail.validation.failedSummary', {
26
+ failed: failed.length,
27
+ total: report.outcomes.length,
28
+ attempts: report.attempts,
29
+ maxAttempts: report.maxAttempts,
30
+ })
31
+ }}
32
+ </p>
33
+
34
+ <div
35
+ v-for="outcome in report.outcomes"
36
+ :key="outcome.label"
37
+ class="rounded-md border border-slate-800 bg-slate-950/40 p-2"
38
+ data-testid="validation-outcome"
39
+ >
40
+ <div class="flex items-center gap-2">
41
+ <UIcon
42
+ :name="outcome.passed ? 'i-lucide-check' : 'i-lucide-x'"
43
+ class="h-3.5 w-3.5 shrink-0"
44
+ :class="outcome.passed ? 'text-emerald-400' : 'text-rose-400'"
45
+ />
46
+ <span class="text-[12px] font-medium text-slate-200">{{ outcome.label }}</span>
47
+ <span class="truncate font-mono text-[11px] text-slate-500">{{ outcome.command }}</span>
48
+ <span
49
+ v-if="!outcome.passed"
50
+ class="ms-auto shrink-0 rounded bg-rose-500/15 px-1.5 py-0.5 text-[11px] tabular-nums text-rose-300"
51
+ >
52
+ {{
53
+ outcome.timedOut
54
+ ? t('panels.stepDetail.validation.timedOut')
55
+ : t('panels.stepDetail.validation.exitCode', { code: outcome.exitCode })
56
+ }}
57
+ </span>
58
+ </div>
59
+ <pre
60
+ v-if="outcome.outputTail"
61
+ class="mt-1.5 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-slate-950 p-2 font-mono text-[11px] text-slate-400"
62
+ data-testid="validation-output"
63
+ >{{ outcome.outputTail }}</pre>
64
+ </div>
65
+ </div>
66
+ </template>
@@ -255,7 +255,15 @@ async function generateOrFixManifest() {
255
255
  // it failed at. The returned run is tracked live (by frame id) via the workspace stream store.
256
256
  const envTest = useEnvironmentTestStore()
257
257
  const envTestStarting = ref(false)
258
- const envTestError = ref<string | null>(null)
258
+ // The self-test's start/stop error. Structured (not a bare string) so the not-provisionable case
259
+ // can render its remedy prose PLUS a one-click jump to the environment-handler config; every other
260
+ // case is plain text.
261
+ interface EnvTestError {
262
+ text: string
263
+ /** Show the "Configure infrastructure" deep-link — only the not-provisionable handler case. */
264
+ configurable?: boolean
265
+ }
266
+ const envTestError = ref<EnvTestError | null>(null)
259
267
  // The newest self-test run for this frame — re-attaches after a reconnect (the run is carried in
260
268
  // the snapshot while running), so the live stage keeps showing without a locally-held id.
261
269
  const envTestRun = computed(() => envTest.runForBlock(props.block.id))
@@ -291,14 +299,30 @@ const ENV_TEST_CONFLICT_KEYS: Record<Extract<ConflictReason, `env_test_${string}
291
299
  env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
292
300
  }
293
301
 
294
- function envTestErrorText(e: unknown): string {
295
- const reason = parseConflict(e)?.reason
302
+ function buildEnvTestError(e: unknown): EnvTestError {
303
+ const parsed = parseConflict(e)
304
+ const reason = parsed?.reason
305
+ // No workspace handler resolves for the service's provision type. Word the SPECIFIC case —
306
+ // nothing configured vs. an ambiguous match (carried on `details.handlerIssue`, distinct from the
307
+ // `env_test_not_provisionable` code) — and offer the one-click jump to the Infrastructure →
308
+ // Test-environments handler config.
309
+ if (reason === 'env_test_not_provisionable') {
310
+ const ambiguous = parsed?.details.handlerIssue === 'type-mismatch'
311
+ return {
312
+ text: t(
313
+ ambiguous
314
+ ? 'errors.conflict.description.env_test_not_provisionable_type_mismatch'
315
+ : 'errors.conflict.description.env_test_not_provisionable_no_handler',
316
+ ),
317
+ configurable: true,
318
+ }
319
+ }
296
320
  const key =
297
321
  reason && reason in ENV_TEST_CONFLICT_KEYS
298
322
  ? ENV_TEST_CONFLICT_KEYS[reason as keyof typeof ENV_TEST_CONFLICT_KEYS]
299
323
  : undefined
300
- if (key && te(key)) return t(key)
301
- return apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e))
324
+ if (key && te(key)) return { text: t(key) }
325
+ return { text: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)) }
302
326
  }
303
327
 
304
328
  async function startEnvTest() {
@@ -308,7 +332,7 @@ async function startEnvTest() {
308
332
  try {
309
333
  await envTest.start(props.block.id)
310
334
  } catch (e) {
311
- envTestError.value = envTestErrorText(e)
335
+ envTestError.value = buildEnvTestError(e)
312
336
  } finally {
313
337
  envTestStarting.value = false
314
338
  }
@@ -320,7 +344,7 @@ async function stopEnvTest() {
320
344
  try {
321
345
  await envTest.stop(run.id)
322
346
  } catch (e) {
323
- envTestError.value = envTestErrorText(e)
347
+ envTestError.value = buildEnvTestError(e)
324
348
  }
325
349
  }
326
350
 
@@ -1072,9 +1096,23 @@ function setSize(value: InstanceSize) {
1072
1096
  </template>
1073
1097
  </p>
1074
1098
 
1075
- <p v-if="envTestError" class="text-[11px] text-rose-400" data-testid="env-test-error">
1076
- {{ envTestError }}
1077
- </p>
1099
+ <div v-if="envTestError" class="text-[11px] text-rose-400" data-testid="env-test-error">
1100
+ <p>{{ envTestError.text }}</p>
1101
+ <!-- Only the not-provisionable handler case is one-click fixable: jump to Infrastructure →
1102
+ Test environments, where the workspace's per-type environment handler is registered. -->
1103
+ <UButton
1104
+ v-if="envTestError.configurable"
1105
+ class="mt-1.5"
1106
+ icon="i-lucide-settings"
1107
+ size="xs"
1108
+ color="neutral"
1109
+ variant="soft"
1110
+ data-testid="env-test-configure-handler"
1111
+ @click="ui.openProviderConnection('environment')"
1112
+ >
1113
+ {{ t('errors.conflict.action.configureInfrastructure') }}
1114
+ </UButton>
1115
+ </div>
1078
1116
  </div>
1079
1117
  </InspectorSection>
1080
1118
  </template>
@@ -0,0 +1,208 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import {
6
+ VALIDATION_DEFAULT_MAX_ATTEMPTS,
7
+ VALIDATION_MAX_ATTEMPTS_CEILING,
8
+ VALIDATION_MAX_CHECKS,
9
+ type ValidationCheck,
10
+ } from '~/types/validationChecks'
11
+
12
+ // Per-service (frame) PRE-PR VALIDATION CHECKS: the shell commands the executor-harness runs
13
+ // against the checkout after the coder settles and BEFORE the PR opens. A failing command's
14
+ // output is handed back to the agent, which retries under the attempt budget; only a green
15
+ // checkout opens a PR, and an exhausted budget fails the step with the captured output.
16
+ // Keyed by THIS block's id (service frames only — a task resolves its checks up the frame chain).
17
+ const props = defineProps<{ block: Block }>()
18
+
19
+ const store = useValidationChecksStore()
20
+ const toast = useToast()
21
+ const { t } = useI18n()
22
+ const { confirmAction, toastDone } = useConfirmAction()
23
+
24
+ const busy = ref(false)
25
+ const rows = ref<ValidationCheck[]>([])
26
+ const maxAttempts = ref(VALIDATION_DEFAULT_MAX_ATTEMPTS)
27
+
28
+ const saved = computed(() => store.forBlock(props.block.id))
29
+ const configured = computed(() => saved.value.checks.length > 0)
30
+ const canAdd = computed(() => rows.value.length < VALIDATION_MAX_CHECKS)
31
+ /** A row is only submittable once it has a command; the label falls back to the command. */
32
+ const submittable = computed(() =>
33
+ rows.value
34
+ .map((r) => ({ label: r.label.trim() || r.command.trim(), command: r.command.trim() }))
35
+ .filter((r) => r.command.length > 0),
36
+ )
37
+
38
+ onMounted(() => {
39
+ store.ensureLoaded().catch(() => {})
40
+ })
41
+ watch(
42
+ saved,
43
+ (config) => {
44
+ rows.value = config.checks.map((c) => ({ ...c }))
45
+ maxAttempts.value = config.maxAttempts
46
+ },
47
+ { immediate: true },
48
+ )
49
+
50
+ function addRow() {
51
+ if (canAdd.value) rows.value.push({ label: '', command: '' })
52
+ }
53
+
54
+ function removeRow(index: number) {
55
+ rows.value.splice(index, 1)
56
+ }
57
+
58
+ function notifyError(title: string, e: unknown) {
59
+ toast.add({
60
+ title,
61
+ description: e instanceof Error ? e.message : String(e),
62
+ icon: 'i-lucide-triangle-alert',
63
+ color: 'error',
64
+ })
65
+ }
66
+
67
+ async function save() {
68
+ busy.value = true
69
+ try {
70
+ await store.save(props.block.id, submittable.value, maxAttempts.value)
71
+ toast.add({
72
+ title: t('inspector.validationChecks.savedToast'),
73
+ icon: 'i-lucide-check',
74
+ color: 'success',
75
+ })
76
+ } catch (e) {
77
+ notifyError(t('inspector.validationChecks.saveFailed'), e)
78
+ } finally {
79
+ busy.value = false
80
+ }
81
+ }
82
+
83
+ async function clear() {
84
+ const noun = t('inspector.validationChecks.configNoun')
85
+ if (!(await confirmAction('clear', noun))) return
86
+ busy.value = true
87
+ try {
88
+ await store.remove(props.block.id)
89
+ rows.value = []
90
+ toastDone('clear', noun)
91
+ } catch (e) {
92
+ notifyError(t('inspector.validationChecks.clearFailed'), e)
93
+ } finally {
94
+ busy.value = false
95
+ }
96
+ }
97
+ </script>
98
+
99
+ <template>
100
+ <InspectorSection
101
+ v-if="store.available !== false"
102
+ :title="t('inspector.validationChecks.title')"
103
+ :hint="t('inspector.validationChecks.sectionHint')"
104
+ data-testid="service-validation-config"
105
+ >
106
+ <template #actions>
107
+ <UButton
108
+ v-if="configured"
109
+ color="error"
110
+ variant="ghost"
111
+ size="xs"
112
+ icon="i-lucide-trash-2"
113
+ :loading="busy"
114
+ data-testid="validation-clear"
115
+ @click="clear"
116
+ >
117
+ {{ t('inspector.validationChecks.clear') }}
118
+ </UButton>
119
+ </template>
120
+
121
+ <div class="space-y-2">
122
+ <p class="text-[11px] text-slate-500">
123
+ {{ t('inspector.validationChecks.hint') }}
124
+ </p>
125
+
126
+ <div
127
+ v-for="(row, index) in rows"
128
+ :key="index"
129
+ class="flex items-end gap-2"
130
+ data-testid="validation-check-row"
131
+ >
132
+ <UFormField :label="t('inspector.validationChecks.label')" class="w-1/3">
133
+ <UInput
134
+ v-model="row.label"
135
+ :placeholder="t('inspector.validationChecks.labelPlaceholder')"
136
+ size="sm"
137
+ class="w-full"
138
+ data-testid="validation-check-label"
139
+ />
140
+ </UFormField>
141
+ <UFormField :label="t('inspector.validationChecks.command')" class="flex-1">
142
+ <UInput
143
+ v-model="row.command"
144
+ placeholder="pnpm lint"
145
+ size="sm"
146
+ class="w-full"
147
+ data-testid="validation-check-command"
148
+ />
149
+ </UFormField>
150
+ <UButton
151
+ color="neutral"
152
+ variant="ghost"
153
+ size="xs"
154
+ icon="i-lucide-x"
155
+ :aria-label="t('inspector.validationChecks.removeCheck')"
156
+ data-testid="validation-check-remove"
157
+ @click="removeRow(index)"
158
+ />
159
+ </div>
160
+
161
+ <p v-if="rows.length === 0" class="text-[11px] text-slate-500" data-testid="validation-empty">
162
+ {{ t('inspector.validationChecks.empty') }}
163
+ </p>
164
+
165
+ <div class="flex items-end justify-between gap-2">
166
+ <UFormField
167
+ :label="t('inspector.validationChecks.maxAttempts')"
168
+ :hint="t('inspector.validationChecks.maxAttemptsHint')"
169
+ class="w-40"
170
+ >
171
+ <UInput
172
+ v-model.number="maxAttempts"
173
+ type="number"
174
+ :min="1"
175
+ :max="VALIDATION_MAX_ATTEMPTS_CEILING"
176
+ size="sm"
177
+ class="w-full"
178
+ data-testid="validation-max-attempts"
179
+ />
180
+ </UFormField>
181
+ <div class="flex gap-2">
182
+ <UButton
183
+ color="neutral"
184
+ variant="soft"
185
+ size="xs"
186
+ icon="i-lucide-plus"
187
+ :disabled="!canAdd"
188
+ data-testid="validation-add-check"
189
+ @click="addRow"
190
+ >
191
+ {{ t('inspector.validationChecks.addCheck') }}
192
+ </UButton>
193
+ <UButton
194
+ color="primary"
195
+ variant="soft"
196
+ size="xs"
197
+ icon="i-lucide-save"
198
+ :loading="busy"
199
+ data-testid="validation-save"
200
+ @click="save"
201
+ >
202
+ {{ t('inspector.validationChecks.save') }}
203
+ </UButton>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </InspectorSection>
208
+ </template>
@@ -11,10 +11,12 @@ import CopyButton from '~/components/common/CopyButton.vue'
11
11
 
12
12
  const { t, d } = useI18n()
13
13
 
14
- // The permission ladder a minted key can carry (read ⊂ write ⊂ admin), mirroring the backend
15
- // contract. A `read` key can only observe; `write` adds create/start/manage; `admin` adds the
16
- // destructive/merge-adjacent operations (e.g. deleting a task).
17
- const SCOPES: PublicApiScope[] = ['read', 'write', 'admin']
14
+ // The permission ladder a minted key can carry (read ⊂ write ⊂ decide ⊂ admin), mirroring the
15
+ // backend contract. A `read` key can only observe; `write` adds create/start/manage; `decide` adds
16
+ // answering a run's PARKED human decisions (and, because of that, starting a headless run on a
17
+ // pipeline that can park at all); `admin` adds the destructive/merge-adjacent operations (e.g.
18
+ // deleting a task).
19
+ const SCOPES: PublicApiScope[] = ['read', 'write', 'decide', 'admin']
18
20
 
19
21
  /** Localized label for a scope — an exhaustive switch, so a new scope is a compile error here. */
20
22
  function scopeLabel(scope: PublicApiScope): string {
@@ -23,6 +25,8 @@ function scopeLabel(scope: PublicApiScope): string {
23
25
  return t('settings.apiTokens.scopes.read')
24
26
  case 'write':
25
27
  return t('settings.apiTokens.scopes.write')
28
+ case 'decide':
29
+ return t('settings.apiTokens.scopes.decide')
26
30
  case 'admin':
27
31
  return t('settings.apiTokens.scopes.admin')
28
32
  }
@@ -0,0 +1,39 @@
1
+ import {
2
+ deleteServiceValidationConfigContract,
3
+ getServiceValidationConfigContract,
4
+ listServiceValidationConfigsContract,
5
+ setServiceValidationConfigContract,
6
+ } from '@cat-factory/contracts'
7
+ import type { UpsertServiceValidationConfigInput } from '~/types/validationChecks'
8
+ import type { ApiContext } from './context'
9
+
10
+ /** Pre-PR validation checks: the per-service-frame commands the harness runs before opening a PR. */
11
+ export function validationChecksApi({ send, ws }: ApiContext) {
12
+ return {
13
+ listServiceValidationConfigs: (workspaceId: string) =>
14
+ send(listServiceValidationConfigsContract, { pathPrefix: ws(workspaceId) }),
15
+
16
+ getServiceValidationConfig: (workspaceId: string, blockId: string) =>
17
+ send(getServiceValidationConfigContract, {
18
+ pathPrefix: ws(workspaceId),
19
+ pathParams: { blockId },
20
+ }),
21
+
22
+ setServiceValidationConfig: (
23
+ workspaceId: string,
24
+ blockId: string,
25
+ body: UpsertServiceValidationConfigInput,
26
+ ) =>
27
+ send(setServiceValidationConfigContract, {
28
+ pathPrefix: ws(workspaceId),
29
+ pathParams: { blockId },
30
+ body,
31
+ }),
32
+
33
+ deleteServiceValidationConfig: (workspaceId: string, blockId: string) =>
34
+ send(deleteServiceValidationConfigContract, {
35
+ pathPrefix: ws(workspaceId),
36
+ pathParams: { blockId },
37
+ }),
38
+ }
39
+ }
@@ -35,6 +35,7 @@ import { recurringApi } from './api/recurring'
35
35
  import { previewApi } from './api/preview'
36
36
  import { environmentsApi } from './api/environments'
37
37
  import { releaseHealthApi } from './api/releaseHealth'
38
+ import { validationChecksApi } from './api/validationChecks'
38
39
  import { sandboxApi } from './api/sandbox'
39
40
  import { reviewsApi } from './api/reviews'
40
41
  import { slackApi } from './api/slack'
@@ -135,6 +136,7 @@ export function useApi() {
135
136
  ...docInterviewApi(ctx),
136
137
  ...provisioningLogsApi(ctx),
137
138
  ...releaseHealthApi(ctx),
139
+ ...validationChecksApi(ctx),
138
140
  ...testSecretsApi(ctx),
139
141
  ...packageRegistriesApi(ctx),
140
142
  ...previewApi(ctx),
@@ -41,6 +41,7 @@ describe('inspector panel group', () => {
41
41
  'service-test-secrets',
42
42
  'service-fragments',
43
43
  'service-release-health',
44
+ 'service-validation-checks',
44
45
  ])
45
46
  })
46
47
 
@@ -59,6 +60,7 @@ describe('inspector panel group', () => {
59
60
  'service-test-secrets',
60
61
  'service-fragments',
61
62
  'service-release-health',
63
+ 'service-validation-checks',
62
64
  ])
63
65
  })
64
66
 
@@ -70,6 +72,7 @@ describe('inspector panel group', () => {
70
72
  'service-test-secrets',
71
73
  'service-fragments',
72
74
  'service-release-health',
75
+ 'service-validation-checks',
73
76
  ])
74
77
  })
75
78
 
@@ -53,6 +53,7 @@ export const INSPECTOR_PANEL_IDS = [
53
53
  'service-test-secrets',
54
54
  'service-fragments',
55
55
  'service-release-health',
56
+ 'service-validation-checks',
56
57
  // epic / initiative body
57
58
  'epic-children',
58
59
  'initiative-inspector',
@@ -118,6 +119,10 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
118
119
  { id: 'service-test-secrets', order: 150, when: isDeployableFrame },
119
120
  { id: 'service-fragments', order: 160, when: isFrame },
120
121
  { id: 'service-release-health', order: 170, when: isDeployableFrame },
122
+ // Pre-PR validation checks: the commands the harness runs before opening this service's PRs.
123
+ // Deployable frames only — a doc repo ships no build to install/lint/test, exactly like the
124
+ // test-infra and release-health panels above it.
125
+ { id: 'service-validation-checks', order: 180, when: isDeployableFrame },
121
126
  { id: 'epic-children', order: 200, when: (b) => b.level === 'epic' },
122
127
  { id: 'initiative-inspector', order: 210, when: (b) => b.level === 'initiative' },
123
128
  ]
@@ -28,6 +28,7 @@ import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.v
28
28
  import ServiceTestSecrets from '~/components/panels/inspector/ServiceTestSecrets.vue'
29
29
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
30
30
  import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
31
+ import ServiceValidationConfig from '~/components/panels/inspector/ServiceValidationConfig.vue'
31
32
  import EpicChildren from '~/components/panels/inspector/EpicChildren.vue'
32
33
  import InitiativeInspector from '~/components/panels/inspector/InitiativeInspector.vue'
33
34
 
@@ -80,6 +81,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
80
81
  'service-test-secrets': ServiceTestSecrets,
81
82
  'service-fragments': ServiceFragments,
82
83
  'service-release-health': ServiceReleaseHealthConfig,
84
+ 'service-validation-checks': ServiceValidationConfig,
83
85
  'epic-children': EpicChildren,
84
86
  'initiative-inspector': InitiativeInspector,
85
87
  }