@cat-factory/app 0.171.0 → 0.173.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 (35) hide show
  1. package/README.md +4 -0
  2. package/app/components/board/AddTaskModal.vue +10 -252
  3. package/app/components/board/CreateInitiativeModal.vue +27 -0
  4. package/app/components/board/nodes/InitiativeCard.vue +14 -0
  5. package/app/components/common/InterviewGateNotice.vue +39 -0
  6. package/app/components/context/ContextAttachmentFields.vue +289 -0
  7. package/app/components/docs/DocInterviewWindow.vue +77 -14
  8. package/app/components/fragments/FragmentLibraryManager.vue +91 -13
  9. package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
  10. package/app/components/github/RepoTreeBrowser.vue +65 -10
  11. package/app/components/initiative/InitiativePlanningWindow.vue +82 -13
  12. package/app/components/layout/DefaultTestEnvBanner.vue +117 -0
  13. package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
  14. package/app/components/settings/DefaultProvisionTypeSection.vue +176 -0
  15. package/app/components/settings/InfrastructureWindow.vue +8 -1
  16. package/app/composables/useContextLinking.ts +14 -2
  17. package/app/composables/useInitiativePlanning.ts +28 -6
  18. package/app/pages/index.vue +12 -1
  19. package/app/stores/ui/modals.ts +46 -0
  20. package/app/stores/workspaceSettings.ts +5 -0
  21. package/app/utils/defaultProvisioning.spec.ts +100 -0
  22. package/app/utils/defaultProvisioning.ts +99 -0
  23. package/app/utils/interviewGate.spec.ts +51 -0
  24. package/app/utils/interviewGate.ts +51 -0
  25. package/i18n/locales/de.json +70 -22
  26. package/i18n/locales/en.json +73 -22
  27. package/i18n/locales/es.json +70 -22
  28. package/i18n/locales/fr.json +70 -22
  29. package/i18n/locales/he.json +70 -22
  30. package/i18n/locales/it.json +70 -22
  31. package/i18n/locales/ja.json +70 -22
  32. package/i18n/locales/pl.json +70 -22
  33. package/i18n/locales/tr.json +70 -22
  34. package/i18n/locales/uk.json +70 -22
  35. package/package.json +2 -2
@@ -13,6 +13,7 @@ import { computed, ref, watch } from 'vue'
13
13
  import type { ProviderConnectionKind } from '~/types/providerConnections'
14
14
  import InfrastructureBackendPicker from '~/components/settings/InfrastructureBackendPicker.vue'
15
15
  import InfraHandlersConfigurator from '~/components/settings/InfraHandlersConfigurator.vue'
16
+ import DefaultProvisionTypeSection from '~/components/settings/DefaultProvisionTypeSection.vue'
16
17
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
17
18
  import SharedStacksPanel from '~/components/settings/SharedStacksPanel.vue'
18
19
 
@@ -131,10 +132,16 @@ watch([tabs, () => store.loaded], () => {
131
132
  </template>
132
133
  <template #environment>
133
134
  <div class="space-y-4">
135
+ <!-- What this board's services PRODUCE by default. Comes first because it is the
136
+ question the per-type handlers below answer "how" for, and because a board that
137
+ has never chosen is the one the setup banner deep-links straight to here. -->
138
+ <DefaultProvisionTypeSection />
134
139
  <!-- The Tester's environment is driven by each SERVICE's declared provision type
135
140
  (the "what/where"); the workspace configures HOW each type is handled here —
136
141
  the engine + connection per provision type, plus the custom-type catalog. -->
137
- <InfraHandlersConfigurator />
142
+ <div class="border-t border-slate-800 pt-4">
143
+ <InfraHandlersConfigurator />
144
+ </div>
138
145
  </div>
139
146
  </template>
140
147
  <template #shared-stacks>
@@ -161,8 +161,18 @@ export function useContextLinking() {
161
161
  * reasons as the body, and a "Copy details" action that puts the full diagnostic
162
162
  * report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
163
163
  * so the cause stays readable long enough to act on. No-op when nothing failed.
164
+ *
165
+ * `opts.title` names what WAS created, which differs per host ("Task added, but …" vs
166
+ * "Initiative created, but …"). It is a resolver over the count rather than a message key, so
167
+ * each caller keeps a literal key at its own translation call site — passing the key through
168
+ * would make it a variable, which defeats both the typed-message-key check and the extractor's
169
+ * static scan — and the plural choice has to be made against the same count.
164
170
  */
165
- function presentLinkFailures(failures: LinkFailure[], blockId?: string): void {
171
+ function presentLinkFailures(
172
+ failures: LinkFailure[],
173
+ blockId?: string,
174
+ opts: { title?: (count: number) => string } = {},
175
+ ): void {
166
176
  if (failures.length === 0) return
167
177
  const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
168
178
  const report = buildLinkFailureReport(failures, {
@@ -171,7 +181,9 @@ export function useContextLinking() {
171
181
  when: new Date().toISOString(),
172
182
  })
173
183
  toast.add({
174
- title: t('board.addTask.linkFailed', { count: failures.length }, failures.length),
184
+ title:
185
+ opts.title?.(failures.length) ??
186
+ t('board.addTask.linkFailed', { count: failures.length }, failures.length),
175
187
  description,
176
188
  icon: 'i-lucide-triangle-alert',
177
189
  color: 'warning',
@@ -4,6 +4,7 @@ import { useExecutionStore } from '~/stores/execution'
4
4
  import { useInitiativesStore } from '~/stores/initiative'
5
5
  import { usePipelinesStore } from '~/stores/pipelines'
6
6
  import { useUiStore } from '~/stores/ui'
7
+ import { interviewGatePhase } from '~/utils/interviewGate'
7
8
 
8
9
  /**
9
10
  * Shared planning affordances for an `initiative`-level block, used by BOTH the board card
@@ -37,13 +38,32 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
37
38
  const running = computed(() => !!block.value?.executionId)
38
39
 
39
40
  /**
40
- * The interviewer has PARKED the planning run for the human. Keyed purely on the interview's
41
- * parked `status` (`awaiting`) NOT on whether individual questions are still blank — so the
42
- * "Answer planning questions" affordance stays available even after every question is filled but
43
- * before the human resumes. Gating on unanswered questions would hide the only path back to the
44
- * interview window once all are answered, stranding the still-parked run.
41
+ * The live interview phase, derived from the entity AND the planning run (see
42
+ * {@link interviewGatePhase} for why the run status is load-bearing).
45
43
  */
46
- const awaitingAnswers = computed(() => initiative.value?.interview?.status === 'awaiting')
44
+ const interviewPhase = computed(() =>
45
+ interviewGatePhase(
46
+ initiative.value?.interview?.status,
47
+ execution.getByBlock(toValue(blockId))?.status,
48
+ ),
49
+ )
50
+
51
+ /**
52
+ * The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
53
+ * questions are still blank — the "Answer planning questions" affordance must stay available
54
+ * after every question is filled but before the human resumes, or the only path back to the
55
+ * interview window disappears and the still-parked run is stranded.
56
+ *
57
+ * It IS keyed on the run not being mid-pass: after a continue/proceed the entity still reads
58
+ * `awaiting` for the whole (slow) interviewer pass, so an entity-only reading keeps the card
59
+ * pulsing and offering "Answer planning questions" over a question set that is already
60
+ * submitted and about to be replaced. {@link interviewing} covers that window instead, and a
61
+ * pass that fails takes the run out of `running`, so this comes back rather than stranding.
62
+ */
63
+ const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
64
+
65
+ /** An interviewer pass is running — the human is waiting on the planner, not the reverse. */
66
+ const interviewing = computed(() => interviewPhase.value === 'working')
47
67
 
48
68
  /**
49
69
  * Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
@@ -82,7 +102,9 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
82
102
  return {
83
103
  planningPipeline,
84
104
  running,
105
+ interviewPhase,
85
106
  awaitingAnswers,
107
+ interviewing,
86
108
  starting,
87
109
  runPlanning,
88
110
  openPlanning,
@@ -9,6 +9,7 @@ import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
9
9
  import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
10
10
  import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
11
11
  import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
12
+ import DefaultTestEnvBanner from '~/components/layout/DefaultTestEnvBanner.vue'
12
13
  // Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
13
14
  // store-driven so they must react from anywhere — kept eager for snappy open/close).
14
15
  import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
@@ -155,6 +156,9 @@ onMounted(() => {
155
156
  // Honour a `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`): open the Infrastructure
156
157
  // window pre-seeded with the provisioned connection so the user only pastes the token + saves.
157
158
  ui.consumeK3sSetupDeepLink()
159
+ // Honour the setup banner's shareable link (`?settings=default-test-env`): open the
160
+ // Infrastructure window on the default test-environment provisioning section.
161
+ ui.consumeDefaultProvisionDeepLink()
158
162
  })
159
163
 
160
164
  // Per-session guards so each AI-onboarding dialog auto-opens at most once (later opens are
@@ -179,6 +183,9 @@ watch(
179
183
  ui.resetAiOnboarding()
180
184
  // Infra-setup banner session dismissals are per-workspace too — clear them on switch.
181
185
  ui.resetInfraSetupDismissals()
186
+ // Same for the default test-environment prompt: each board records its own choice, so a
187
+ // dismissal on one must not hide the (independent) prompt for another.
188
+ ui.resetDefaultProvisionDismissal()
182
189
  // A different board has its own pipeline library, so re-arm the once-per-session advisory.
183
190
  ui.pipelineHealthSeen = false
184
191
  }
@@ -312,7 +319,10 @@ watch(
312
319
  - AI-readiness (no usable model source, or default preset uses unavailable models).
313
320
  - Infrastructure provider (env/runner-pool wired but missing mandatory config).
314
321
  - Infra-setup (this deployment needs an executor / test env / storage the operator hasn't
315
- defined yet, so a class of agents can't run). -->
322
+ defined yet, so a class of agents can't run).
323
+ - Default test environment (this BOARD has never chosen the provisioning mechanism its
324
+ new services should default to). Last in the column: it asks for a convenience default,
325
+ so it yields to the prompts about things that are outright broken. -->
316
326
  <div
317
327
  v-if="workspace.ready && !needsGitHubInstall && !githubProbePending"
318
328
  class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-4"
@@ -320,6 +330,7 @@ watch(
320
330
  <AiProvidersBanner />
321
331
  <ProviderConfigBanner />
322
332
  <InfraSetupBanner />
333
+ <DefaultTestEnvBanner />
323
334
  </div>
324
335
 
325
336
  <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
@@ -1,6 +1,10 @@
1
1
  import { ref } from 'vue'
2
2
  import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
3
3
  import type { PendingContext } from '~/composables/useContextLinking'
4
+ import {
5
+ DEFAULT_PROVISION_DEEP_LINK_PARAM,
6
+ DEFAULT_PROVISION_DEEP_LINK_VALUE,
7
+ } from '~/utils/defaultProvisioning'
4
8
 
5
9
  /** Values used to seed the add-task form when it is opened from another surface. */
6
10
  export interface AddTaskPrefill {
@@ -761,6 +765,29 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
761
765
  const qs = params.toString()
762
766
  history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
763
767
  }
768
+ // Open the Infrastructure window straight at the default test-environment provisioning
769
+ // mechanism (the section at the top of the Test-environments tab). Distinct from
770
+ // `openProviderConnection('environment')` only in intent today, but it is the target of a
771
+ // SHAREABLE deep link (see `consumeDefaultProvisionDeepLink`), so it gets its own entry point
772
+ // rather than leaving the banner and the URL to duplicate the tab choice independently.
773
+ function openDefaultProvisionSettings() {
774
+ resetHubReturn()
775
+ infrastructureTab.value = 'environment'
776
+ infrastructureOpen.value = true
777
+ }
778
+ // Capture the `?settings=default-test-env` deep link on app load — the URL the setup banner
779
+ // shows, so an operator can send "go configure this" to a teammate rather than describing
780
+ // where the screen lives. Strips the param afterwards (mirroring the k3s hand-off above) so a
781
+ // reload doesn't re-open the window and the link isn't left in history. No-op when absent.
782
+ function consumeDefaultProvisionDeepLink() {
783
+ if (typeof window === 'undefined') return
784
+ const params = new URLSearchParams(window.location.search)
785
+ if (params.get(DEFAULT_PROVISION_DEEP_LINK_PARAM) !== DEFAULT_PROVISION_DEEP_LINK_VALUE) return
786
+ openDefaultProvisionSettings()
787
+ params.delete(DEFAULT_PROVISION_DEEP_LINK_PARAM)
788
+ const qs = params.toString()
789
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
790
+ }
764
791
  // Launch the environment setup wizard, optionally preselecting the service frame it targets
765
792
  // (the inspector nudge passes the frame; the navbar entry opens it with the pick step active).
766
793
  function openEnvironmentSetup(frameId: string | null = null) {
@@ -781,6 +808,8 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
781
808
  consumeK3sSetupDeepLink,
782
809
  environmentWizardOpen,
783
810
  environmentWizardFrameId,
811
+ openDefaultProvisionSettings,
812
+ consumeDefaultProvisionDeepLink,
784
813
  openProviderConnection,
785
814
  closeProviderConnection,
786
815
  openEnvironmentSetup,
@@ -815,6 +844,20 @@ function createAiOnboardingModals() {
815
844
  infraSetupSessionDismissed.value = []
816
845
  }
817
846
 
847
+ // Default-test-environment banner: a single per-SESSION dismissal, cleared on workspace switch
848
+ // like the flags above. There is deliberately no PERMANENT dismissal here (unlike the
849
+ // infra-setup areas): the prompt asks for a DECISION, and every answer — including `infraless`
850
+ // ("services stand up no environment") — is recordable in one click, so "silence this forever
851
+ // without answering" would only ever produce a board nobody can tell apart from an unconfigured
852
+ // one. Dismissing hides it until the next load.
853
+ const defaultProvisionDismissed = ref(false)
854
+ function dismissDefaultProvision() {
855
+ defaultProvisionDismissed.value = true
856
+ }
857
+ function resetDefaultProvisionDismissal() {
858
+ defaultProvisionDismissed.value = false
859
+ }
860
+
818
861
  function openAiProviderSetup() {
819
862
  aiProviderSetupOpen.value = true
820
863
  }
@@ -857,6 +900,9 @@ function createAiOnboardingModals() {
857
900
  infraSetupSessionDismissed,
858
901
  dismissInfraSetupForSession,
859
902
  resetInfraSetupDismissals,
903
+ defaultProvisionDismissed,
904
+ dismissDefaultProvision,
905
+ resetDefaultProvisionDismissal,
860
906
  openAiProviderSetup,
861
907
  closeAiProviderSetup,
862
908
  openAiPresetMismatch,
@@ -20,6 +20,11 @@ const DEFAULTS: WorkspaceSettings = {
20
20
  reviewFrictionBlockStuckMinutes: null,
21
21
  spendCurrency: null,
22
22
  spendMonthlyLimit: null,
23
+ // Null means the operator has never chosen a default test-environment provisioning mechanism,
24
+ // which is what the setup banner nags about. Defaulting it to a type here would silence that
25
+ // banner on every board before the snapshot even lands.
26
+ defaultProvisionType: null,
27
+ defaultProvisionManifestId: null,
23
28
  }
24
29
 
25
30
  /**
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CustomManifestType } from '@cat-factory/contracts'
3
+ import {
4
+ canSaveDefaultProvisioning,
5
+ needsDefaultProvisioningChoice,
6
+ suggestDefaultProvisioning,
7
+ } from './defaultProvisioning'
8
+
9
+ function customType(
10
+ manifestId: string,
11
+ source: CustomManifestType['source'] = 'registered',
12
+ ): CustomManifestType {
13
+ return { manifestId, label: manifestId, source }
14
+ }
15
+
16
+ describe('needsDefaultProvisioningChoice', () => {
17
+ it('an unset default still owes a decision', () => {
18
+ expect(needsDefaultProvisioningChoice({ defaultProvisionType: null })).toBe(true)
19
+ })
20
+
21
+ it('an explicit infraless is a decision, not an absence', () => {
22
+ // The case that separates "nobody chose" from "we chose no environments" — get this wrong
23
+ // and a workspace that deliberately runs infraless is nagged forever.
24
+ expect(needsDefaultProvisioningChoice({ defaultProvisionType: 'infraless' })).toBe(false)
25
+ })
26
+
27
+ it('any recorded type settles it', () => {
28
+ expect(needsDefaultProvisioningChoice({ defaultProvisionType: 'kubernetes' })).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('suggestDefaultProvisioning', () => {
33
+ const unset = { defaultProvisionType: null, defaultProvisionManifestId: null }
34
+
35
+ it('opens on the recorded choice rather than re-suggesting over it', () => {
36
+ expect(
37
+ suggestDefaultProvisioning(
38
+ { defaultProvisionType: 'kubernetes', defaultProvisionManifestId: null },
39
+ [customType('acme-preview')],
40
+ ),
41
+ ).toEqual({ type: 'kubernetes', manifestId: null })
42
+ })
43
+
44
+ it('round-trips a recorded custom choice with its manifest id', () => {
45
+ expect(
46
+ suggestDefaultProvisioning(
47
+ { defaultProvisionType: 'custom', defaultProvisionManifestId: 'acme-preview' },
48
+ [customType('acme-preview'), customType('other')],
49
+ ),
50
+ ).toEqual({ type: 'custom', manifestId: 'acme-preview' })
51
+ })
52
+
53
+ it('suggests the first registered custom provider when nothing is selected', () => {
54
+ expect(
55
+ suggestDefaultProvisioning(unset, [customType('acme-preview'), customType('acme-legacy')]),
56
+ ).toEqual({ type: 'custom', manifestId: 'acme-preview' })
57
+ })
58
+
59
+ it('prefers a registered provider over a hand-authored workspace type', () => {
60
+ // A registered type is a deployment-level integration; a workspace row is one someone typed
61
+ // into the UI. Order in the catalog must not decide which one we recommend.
62
+ expect(
63
+ suggestDefaultProvisioning(unset, [
64
+ customType('typed-by-hand', 'workspace'),
65
+ customType('acme-preview', 'registered'),
66
+ ]),
67
+ ).toEqual({ type: 'custom', manifestId: 'acme-preview' })
68
+ })
69
+
70
+ it('falls back to a workspace-defined type when no provider is registered', () => {
71
+ expect(suggestDefaultProvisioning(unset, [customType('typed-by-hand', 'workspace')])).toEqual({
72
+ type: 'custom',
73
+ manifestId: 'typed-by-hand',
74
+ })
75
+ })
76
+
77
+ it('suggests nothing when there are no custom providers at all', () => {
78
+ // Deliberately does NOT guess a built-in: the picker offering `kubernetes` says nothing
79
+ // about whether this workspace's services actually use it.
80
+ expect(suggestDefaultProvisioning(unset, [])).toEqual({ type: null, manifestId: null })
81
+ })
82
+ })
83
+
84
+ describe('canSaveDefaultProvisioning', () => {
85
+ it('refuses an empty selection', () => {
86
+ expect(canSaveDefaultProvisioning({ type: null, manifestId: null })).toBe(false)
87
+ })
88
+
89
+ it('refuses custom with no manifest id, mirroring the server rule', () => {
90
+ expect(canSaveDefaultProvisioning({ type: 'custom', manifestId: null })).toBe(false)
91
+ })
92
+
93
+ it('accepts custom once a manifest type is named', () => {
94
+ expect(canSaveDefaultProvisioning({ type: 'custom', manifestId: 'acme-preview' })).toBe(true)
95
+ })
96
+
97
+ it('accepts a built-in type, including infraless', () => {
98
+ expect(canSaveDefaultProvisioning({ type: 'infraless', manifestId: null })).toBe(true)
99
+ })
100
+ })
@@ -0,0 +1,99 @@
1
+ import type { CustomManifestType, ProvisionType, WorkspaceSettings } from '@cat-factory/contracts'
2
+
3
+ /**
4
+ * The workspace-level default test-environment provisioning mechanism: what the SPA nags about
5
+ * when it is unset, and what the config section preselects when the operator first opens it.
6
+ *
7
+ * Pure so both consumers — the banner's visibility and the section's initial selection — agree
8
+ * on the same notion of "nothing chosen yet" without either re-deriving it.
9
+ */
10
+
11
+ /**
12
+ * The query param that deep-links straight to the default-provisioning config section
13
+ * (`?settings=default-test-env`). Defined here — beside the logic both ends share — so the
14
+ * banner that BUILDS the URL and the ui store that CONSUMES it can never drift; a mismatch
15
+ * would produce a link that silently opens nothing.
16
+ */
17
+ export const DEFAULT_PROVISION_DEEP_LINK_PARAM = 'settings'
18
+ export const DEFAULT_PROVISION_DEEP_LINK_VALUE = 'default-test-env'
19
+
20
+ /**
21
+ * The shareable absolute URL for the config screen. Built from the CURRENT location (rather
22
+ * than a configured base) so it is correct for whatever origin/path this SPA is actually served
23
+ * from — a deployment mounted under a sub-path included. Returns just the query string when
24
+ * there is no `window` (SSR is off, but the util stays callable in a unit test).
25
+ */
26
+ export function defaultProvisioningConfigUrl(location?: {
27
+ origin: string
28
+ pathname: string
29
+ }): string {
30
+ const query = `?${DEFAULT_PROVISION_DEEP_LINK_PARAM}=${DEFAULT_PROVISION_DEEP_LINK_VALUE}`
31
+ const loc = location ?? (typeof window === 'undefined' ? undefined : window.location)
32
+ return loc ? `${loc.origin}${loc.pathname}${query}` : query
33
+ }
34
+
35
+ /** The section's editable selection: a provision type plus, for `custom`, the pinned manifest id. */
36
+ export interface DefaultProvisioningSelection {
37
+ type: ProvisionType | null
38
+ manifestId: string | null
39
+ }
40
+
41
+ /**
42
+ * Whether the workspace still owes a decision. `null` means the operator has never chosen —
43
+ * distinct from an explicit `infraless` ("services stand up no environment"), which is a real
44
+ * choice and silences the prompt. See the contracts block comment on `defaultProvisionType`.
45
+ */
46
+ export function needsDefaultProvisioningChoice(
47
+ settings: Pick<WorkspaceSettings, 'defaultProvisionType'>,
48
+ ): boolean {
49
+ return settings.defaultProvisionType == null
50
+ }
51
+
52
+ /**
53
+ * The selection the config section opens on.
54
+ *
55
+ * A recorded choice always wins — the section is an editor for it, not a recommender that
56
+ * overrides what the workspace already decided.
57
+ *
58
+ * With nothing recorded, a deployment that has REGISTERED custom providers is telling us it
59
+ * brought its own environment tooling, and that tooling is almost always what its services
60
+ * should use — so the first one is preselected rather than making the operator discover the
61
+ * `custom` tab and then pick from a list only they can interpret. `registered` (code-defined,
62
+ * shipped by the deployment) is preferred over `workspace` (a row somebody typed into the UI):
63
+ * only the former is evidence of a deliberate platform-level integration. Falling back to the
64
+ * first workspace-defined type keeps the suggestion useful on a board whose only custom type
65
+ * was authored by hand.
66
+ *
67
+ * With no custom providers at all there is nothing to suggest, so the section opens UNSET and
68
+ * the operator picks from the built-in types. It deliberately does not guess a built-in: unlike
69
+ * a registered custom provider, the presence of `kubernetes` in the picker says nothing about
70
+ * whether this workspace's services use it.
71
+ *
72
+ * Nothing here is persisted — this is the form's initial value, and the operator still saves.
73
+ */
74
+ export function suggestDefaultProvisioning(
75
+ settings: Pick<WorkspaceSettings, 'defaultProvisionType' | 'defaultProvisionManifestId'>,
76
+ customTypes: readonly CustomManifestType[],
77
+ ): DefaultProvisioningSelection {
78
+ if (settings.defaultProvisionType != null) {
79
+ return {
80
+ type: settings.defaultProvisionType,
81
+ manifestId: settings.defaultProvisionManifestId,
82
+ }
83
+ }
84
+ const suggested =
85
+ customTypes.find((t) => t.source === 'registered') ?? customTypes[0] ?? undefined
86
+ return suggested
87
+ ? { type: 'custom', manifestId: suggested.manifestId }
88
+ : { type: null, manifestId: null }
89
+ }
90
+
91
+ /**
92
+ * Whether a selection can be saved. Mirrors the server's cross-field rule so the button
93
+ * disables instead of round-tripping to a 422: `custom` must name the manifest type it uses,
94
+ * and there is nothing to save until a type is picked.
95
+ */
96
+ export function canSaveDefaultProvisioning(selection: DefaultProvisioningSelection): boolean {
97
+ if (selection.type == null) return false
98
+ return selection.type !== 'custom' || !!selection.manifestId
99
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { interviewGatePhase } from './interviewGate'
3
+
4
+ // `interviewGatePhase` is what stops continue/proceed reading as no-ops in BOTH interview windows
5
+ // (initiative planning, document interview): the resume is asynchronous — the HTTP call only wakes
6
+ // the durable driver, so it returns the PRE-resume entity — and these pin that the RUN status is
7
+ // what distinguishes "parked, waiting on you" from "a pass is running", a distinction the entity
8
+ // alone cannot make.
9
+
10
+ describe('interviewGatePhase', () => {
11
+ it('is awaiting while the run is parked on the human', () => {
12
+ expect(interviewGatePhase('awaiting', 'blocked')).toBe('awaiting')
13
+ })
14
+
15
+ it('is working once the resumed run is running again, even though the entity still says awaiting', () => {
16
+ // The exact regression: continue/proceed leave the entity's status untouched until the pass
17
+ // finishes, so an entity-only reading renders the same questions and looks like a dead button.
18
+ expect(interviewGatePhase('awaiting', 'running')).toBe('working')
19
+ })
20
+
21
+ it('is working for the FIRST pass, before any question exists', () => {
22
+ expect(interviewGatePhase(undefined, 'running')).toBe('working')
23
+ })
24
+
25
+ it('is failed when the run stopped before the interview settled', () => {
26
+ // Must not stay `working`: a pass that dies would otherwise spin forever.
27
+ expect(interviewGatePhase('awaiting', 'failed')).toBe('failed')
28
+ expect(interviewGatePhase(undefined, 'failed')).toBe('failed')
29
+ })
30
+
31
+ it('is converged once the interview settled, whatever the run went on to do', () => {
32
+ // `converged` outranks `failed`: a later step's failure belongs to that step, not the
33
+ // interview, and the block's own failure surface reports it.
34
+ expect(interviewGatePhase('done', 'running')).toBe('converged')
35
+ expect(interviewGatePhase('done', 'failed')).toBe('converged')
36
+ expect(interviewGatePhase('done', undefined)).toBe('converged')
37
+ })
38
+
39
+ it('is idle when the interview never ran', () => {
40
+ expect(interviewGatePhase(undefined, undefined)).toBe('idle')
41
+ })
42
+
43
+ it('degrades to the entity-only reading when the run is not cached', () => {
44
+ // A window opened before the execution snapshot lands must show the questions, never a spinner.
45
+ expect(interviewGatePhase('awaiting', undefined)).toBe('awaiting')
46
+ })
47
+
48
+ it('keeps a paused run answerable', () => {
49
+ expect(interviewGatePhase('awaiting', 'paused')).toBe('awaiting')
50
+ })
51
+ })
@@ -0,0 +1,51 @@
1
+ import type { ExecutionInstance } from '~/types/domain'
2
+
3
+ // The frontend dual of the backend's shared `InterviewGateController` spine. Both interview gates
4
+ // — the initiative-planning interviewer and the document interviewer — park their run on a
5
+ // decision-wait, expose the SAME `awaiting | done` status on their entity, and resume the same
6
+ // way, so how their windows read "what is happening right now" is shared vocabulary rather than
7
+ // two copies. See `docs/initiatives/clarification-items.md`.
8
+
9
+ /**
10
+ * What an interview gate is doing right now, from the human's point of view. Drives the interview
11
+ * window's body AND (for the initiative) the card/inspector affordances, so the surfaces can't
12
+ * disagree about whether there is anything to answer.
13
+ *
14
+ * - `idle` — the interview has not run yet (nothing to answer, nothing in flight).
15
+ * - `working` — an interviewer pass is running; the human waits.
16
+ * - `awaiting` — the run is parked on the human's answers.
17
+ * - `converged` — the interview settled; the run moved on.
18
+ * - `failed` — the run stopped before the interview settled.
19
+ */
20
+ export type InterviewGatePhase = 'idle' | 'working' | 'awaiting' | 'converged' | 'failed'
21
+
22
+ /**
23
+ * Resolve the phase from the interview entity's status AND its run's status.
24
+ *
25
+ * The RUN status is load-bearing, not redundant. Continue/proceed are ASYNC by design: the HTTP
26
+ * call only records the intent on the parked step and wakes the durable driver, which then runs
27
+ * the (slow) interviewer LLM — so the response carries the PRE-resume entity, with the same
28
+ * questions and the same `awaiting` status. Keyed on the entity alone a window is therefore
29
+ * byte-identical before and after the click, which is indistinguishable from the button doing
30
+ * nothing for however long the pass takes. A resumed run flips `blocked` → `running` and emits,
31
+ * so `running` while the interview is unsettled is exactly "a pass is in flight".
32
+ *
33
+ * Deriving this from the run rather than a local in-flight flag also survives a reload and cannot
34
+ * wedge: a pass that FAILS takes the run to `failed`, so the window drops out of `working` and
35
+ * says so instead of spinning forever. An unknown run (no instance cached yet) degrades to the
36
+ * entity-only reading, never to a spinner.
37
+ *
38
+ * `converged` wins over `failed` on purpose: once the interview settled, a later failure belongs
39
+ * to the step that failed (the analyst/planner, the writer), and the block's own failure surface
40
+ * reports it — the interview window claiming the interview broke would be wrong.
41
+ */
42
+ export function interviewGatePhase(
43
+ status: 'awaiting' | 'done' | undefined,
44
+ runStatus: ExecutionInstance['status'] | undefined,
45
+ ): InterviewGatePhase {
46
+ if (status === 'done') return 'converged'
47
+ if (runStatus === 'failed') return 'failed'
48
+ if (runStatus === 'running') return 'working'
49
+ if (status === 'awaiting') return 'awaiting'
50
+ return 'idle'
51
+ }