@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.
- package/README.md +4 -0
- package/app/components/board/AddTaskModal.vue +10 -252
- package/app/components/board/CreateInitiativeModal.vue +27 -0
- package/app/components/board/nodes/InitiativeCard.vue +14 -0
- package/app/components/common/InterviewGateNotice.vue +39 -0
- package/app/components/context/ContextAttachmentFields.vue +289 -0
- package/app/components/docs/DocInterviewWindow.vue +77 -14
- package/app/components/fragments/FragmentLibraryManager.vue +91 -13
- package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
- package/app/components/github/RepoTreeBrowser.vue +65 -10
- package/app/components/initiative/InitiativePlanningWindow.vue +82 -13
- package/app/components/layout/DefaultTestEnvBanner.vue +117 -0
- package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
- package/app/components/settings/DefaultProvisionTypeSection.vue +176 -0
- package/app/components/settings/InfrastructureWindow.vue +8 -1
- package/app/composables/useContextLinking.ts +14 -2
- package/app/composables/useInitiativePlanning.ts +28 -6
- package/app/pages/index.vue +12 -1
- package/app/stores/ui/modals.ts +46 -0
- package/app/stores/workspaceSettings.ts +5 -0
- package/app/utils/defaultProvisioning.spec.ts +100 -0
- package/app/utils/defaultProvisioning.ts +99 -0
- package/app/utils/interviewGate.spec.ts +51 -0
- package/app/utils/interviewGate.ts +51 -0
- package/i18n/locales/de.json +70 -22
- package/i18n/locales/en.json +73 -22
- package/i18n/locales/es.json +70 -22
- package/i18n/locales/fr.json +70 -22
- package/i18n/locales/he.json +70 -22
- package/i18n/locales/it.json +70 -22
- package/i18n/locales/ja.json +70 -22
- package/i18n/locales/pl.json +70 -22
- package/i18n/locales/tr.json +70 -22
- package/i18n/locales/uk.json +70 -22
- 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
|
-
<
|
|
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(
|
|
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:
|
|
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
|
|
41
|
-
*
|
|
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
|
|
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,
|
package/app/pages/index.vue
CHANGED
|
@@ -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. -->
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -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
|
+
}
|