@cat-factory/app 0.312.0 → 0.313.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 +5 -0
- package/app/components/layout/AiProvidersBanner.vue +3 -2
- package/app/components/layout/GitHubPatBanner.vue +2 -2
- package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
- package/app/components/layout/InfraSetupBanner.vue +6 -7
- package/app/components/setup/SetupChecklistPanel.vue +340 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/useSetupChecklist.ts +122 -0
- package/app/composables/useSetupRemedies.ts +72 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +25 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/github/context.ts +1 -0
- package/app/stores/github/probe.ts +7 -3
- package/app/stores/github.ts +9 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/utils/setupChecklist.spec.ts +184 -0
- package/app/utils/setupChecklist.ts +229 -0
- package/i18n/locales/de.json +71 -1
- package/i18n/locales/en.json +71 -1
- package/i18n/locales/es.json +71 -1
- package/i18n/locales/fr.json +71 -1
- package/i18n/locales/he.json +71 -1
- package/i18n/locales/it.json +71 -1
- package/i18n/locales/ja.json +71 -1
- package/i18n/locales/pl.json +71 -1
- package/i18n/locales/tr.json +71 -1
- package/i18n/locales/uk.json +71 -1
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { githubPatCheckSource } from '@cat-factory/contracts'
|
|
3
|
+
import type { GitHubPatKind, InfraSetupArea } from '~/types/domain'
|
|
4
|
+
import { githubPatRemintUrl } from '~/utils/vcs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where each setup gap is fixed: the ONE map the setup banners and the setup checklist both open
|
|
8
|
+
* through, so "the row opens what its banner opens" holds because there is one place that says
|
|
9
|
+
* it, never two copies kept in step by hand.
|
|
10
|
+
*
|
|
11
|
+
* Keyed by `InfraSetupArea` for the infrastructure, the vocabulary the server reports in, plus the
|
|
12
|
+
* model and source-control remedies, which have no area of their own.
|
|
13
|
+
*/
|
|
14
|
+
export function useSetupRemedies() {
|
|
15
|
+
const ui = useUiStore()
|
|
16
|
+
const auth = useAuthStore()
|
|
17
|
+
const github = useGitHubStore()
|
|
18
|
+
|
|
19
|
+
const INFRA: Record<InfraSetupArea, () => void> = {
|
|
20
|
+
agentExecutor: () => ui.openProviderConnection('runner-pool'),
|
|
21
|
+
ephemeralEnvironments: () => ui.openProviderConnection('environment'),
|
|
22
|
+
binaryStorage: () => ui.openContentStorageSettings(),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Open the screen that configures an infrastructure area. */
|
|
26
|
+
function openInfraArea(area: InfraSetupArea) {
|
|
27
|
+
INFRA[area]()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** No usable model: the AI onboarding dialog. */
|
|
31
|
+
function openModelSetup() {
|
|
32
|
+
ui.openAiProviderSetup()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Usable models, but the default preset names one that is not: the preset-mismatch dialog. */
|
|
36
|
+
function openPresetMismatch() {
|
|
37
|
+
ui.openAiPresetMismatch()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Local mode with no token: GitHub's token page, scopes preselected. Null outside that case. */
|
|
41
|
+
const patSetupUrl = computed(() => auth.localMode?.githubPatSetupUrl ?? null)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The token page for a token GitHub rejected or under-scoped. A rejected token was never
|
|
45
|
+
* classified, so it falls back to `unknown`, which lands on the classic form, the only one
|
|
46
|
+
* GitHub lets us pre-fill.
|
|
47
|
+
*/
|
|
48
|
+
const patReport = computed(() =>
|
|
49
|
+
github.patCheck?.state === 'checked' ? github.patCheck.report : undefined,
|
|
50
|
+
)
|
|
51
|
+
/** The token's kind, which picks the form. `unknown` for a token GitHub rejected outright. */
|
|
52
|
+
const patKind = computed<GitHubPatKind>(() => patReport.value?.kind ?? 'unknown')
|
|
53
|
+
const patRemintUrl = computed(() =>
|
|
54
|
+
githubPatRemintUrl(patKind.value, patReport.value?.webUrl ?? github.connection?.webUrl),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whose token that is: the deployment's (replaced by whoever runs it) or the person's own
|
|
59
|
+
* (replaced in their settings). Read off the check, which carries it in every state it reports.
|
|
60
|
+
*/
|
|
61
|
+
const patSource = computed(() => (github.patCheck ? githubPatCheckSource(github.patCheck) : null))
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
openInfraArea,
|
|
65
|
+
openModelSetup,
|
|
66
|
+
openPresetMismatch,
|
|
67
|
+
patSetupUrl,
|
|
68
|
+
patKind,
|
|
69
|
+
patRemintUrl,
|
|
70
|
+
patSource,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -491,6 +491,7 @@ describe('nav grouping helpers', () => {
|
|
|
491
491
|
expect(integrations?.items.map((i) => i.id)).toEqual(['integrations-hub'])
|
|
492
492
|
const configuration = groups.find((g) => g.group === 'configuration')
|
|
493
493
|
expect(configuration?.items.map((i) => i.id)).toEqual([
|
|
494
|
+
'setup-checklist',
|
|
494
495
|
'workspace-settings',
|
|
495
496
|
'account-settings',
|
|
496
497
|
'operator-dashboard',
|
|
@@ -524,6 +525,7 @@ describe('nav grouping helpers', () => {
|
|
|
524
525
|
'ui-role',
|
|
525
526
|
'tutorial',
|
|
526
527
|
'foundational-services',
|
|
528
|
+
'setup-checklist',
|
|
527
529
|
'queue-view',
|
|
528
530
|
'board-view',
|
|
529
531
|
])
|
|
@@ -210,6 +210,7 @@ export const NAV_ACTIONS = [
|
|
|
210
210
|
'foundationalServices',
|
|
211
211
|
'mergeThresholds',
|
|
212
212
|
'workspaceSettings',
|
|
213
|
+
'setupChecklist',
|
|
213
214
|
'modelConfiguration',
|
|
214
215
|
'serviceFragmentDefaults',
|
|
215
216
|
'localModels',
|
|
@@ -538,6 +539,30 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
538
539
|
keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
|
|
539
540
|
},
|
|
540
541
|
},
|
|
542
|
+
{
|
|
543
|
+
// Every prerequisite for "a task can run and merge", composed from the probes the setup
|
|
544
|
+
// banners already read (issue #2258). First in Configuration, because a workspace that cannot
|
|
545
|
+
// run a task yet needs it before anything else there. Both tiers: getting to green is not an
|
|
546
|
+
// advanced job. Gated on either permission, since its rows open both kinds of surface. Not
|
|
547
|
+
// `intake`: the designer surface never configures the platform, and the setup banners give it
|
|
548
|
+
// no actionable card either. Each row still applies the banners' per-owner rule
|
|
549
|
+
// (`utils/setupAudience.ts`), because holding one of these permissions does not make a caller
|
|
550
|
+
// the owner of every gap.
|
|
551
|
+
id: 'setup-checklist',
|
|
552
|
+
labelKey: 'nav.setupChecklist',
|
|
553
|
+
icon: 'i-lucide-list-checks',
|
|
554
|
+
surfaces: S('sidebar', 'command'),
|
|
555
|
+
gate: (g) => g.canManageIntegrations || g.canManageSettings,
|
|
556
|
+
action: 'setupChecklist',
|
|
557
|
+
testId: 'nav-setup-checklist',
|
|
558
|
+
sidebar: { group: 'configuration', order: 5 },
|
|
559
|
+
command: {
|
|
560
|
+
group: 'workspace',
|
|
561
|
+
order: 120,
|
|
562
|
+
labelKey: 'layout.commandBar.cmd.setupChecklist',
|
|
563
|
+
keywordsKey: 'layout.commandBar.keywords.setupChecklist',
|
|
564
|
+
},
|
|
565
|
+
},
|
|
541
566
|
{
|
|
542
567
|
id: 'model-config',
|
|
543
568
|
labelKey: 'nav.modelConfiguration',
|
package/app/pages/index.vue
CHANGED
|
@@ -104,6 +104,9 @@ const ModelProvidersHub = defineAsyncView(() => import('~/components/layout/Mode
|
|
|
104
104
|
const PersonalSetupModal = defineAsyncView(
|
|
105
105
|
() => import('~/components/layout/PersonalSetupModal.vue'),
|
|
106
106
|
)
|
|
107
|
+
const SetupChecklistPanel = defineAsyncView(
|
|
108
|
+
() => import('~/components/setup/SetupChecklistPanel.vue'),
|
|
109
|
+
)
|
|
107
110
|
const WorkspaceSettingsPanel = defineAsyncView(
|
|
108
111
|
() => import('~/components/settings/WorkspaceSettingsPanel.vue'),
|
|
109
112
|
)
|
|
@@ -564,6 +567,7 @@ watch(
|
|
|
564
567
|
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
565
568
|
<ModelProvidersHub v-if="ui.modelProvidersOpen" />
|
|
566
569
|
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
570
|
+
<SetupChecklistPanel v-if="ui.setupChecklistOpen" />
|
|
567
571
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
568
572
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
569
573
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
@@ -26,6 +26,7 @@ export interface GitHubStoreContext {
|
|
|
26
26
|
api: ReturnType<typeof useApi>
|
|
27
27
|
workspace: ReturnType<typeof useWorkspaceStore>
|
|
28
28
|
available: Ref<boolean | null>
|
|
29
|
+
unavailableCause: Ref<'disabled' | 'failed' | null>
|
|
29
30
|
connection: Ref<GitHubConnection | null>
|
|
30
31
|
/** The connect surfaces this deployment can serve (GitHub App / GitLab PAT / …). */
|
|
31
32
|
connectOptions: Ref<VcsConnectOption[]>
|
|
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
|
|
|
2
2
|
import type { GitHubPatCheck } from '~/types/domain'
|
|
3
3
|
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
4
4
|
import type { GitHubStoreContext } from '~/stores/github/context'
|
|
5
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The board-load probe: everything the store learns about the deployment's VCS setup in one round
|
|
@@ -37,7 +38,7 @@ export function createGitHubProbe(
|
|
|
37
38
|
ctx: GitHubStoreContext,
|
|
38
39
|
patCheck: Ref<GitHubPatCheck | null>,
|
|
39
40
|
): { probe: () => Promise<void>; ensureProbed: () => Promise<void> } {
|
|
40
|
-
const { api, workspace, available, connection, connectOptions } = ctx
|
|
41
|
+
const { api, workspace, available, unavailableCause, connection, connectOptions } = ctx
|
|
41
42
|
|
|
42
43
|
async function runPatCheck(): Promise<void> {
|
|
43
44
|
// Which board asked, captured BEFORE the await: a workspace switch mid-flight must not land
|
|
@@ -65,11 +66,14 @@ export function createGitHubProbe(
|
|
|
65
66
|
.catch(() => []),
|
|
66
67
|
])
|
|
67
68
|
available.value = true
|
|
69
|
+
unavailableCause.value = null
|
|
68
70
|
connection.value = conn
|
|
69
71
|
connectOptions.value = options
|
|
70
|
-
} catch {
|
|
71
|
-
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// 503 (integration disabled) or any error → hide the UI entry points. The cause is kept, so
|
|
74
|
+
// a surface that states the setup can tell "switched off" from "could not ask".
|
|
72
75
|
available.value = false
|
|
76
|
+
unavailableCause.value = apiErrorStatus(err) === 503 ? 'disabled' : 'failed'
|
|
73
77
|
connection.value = null
|
|
74
78
|
connectOptions.value = []
|
|
75
79
|
}
|
package/app/stores/github.ts
CHANGED
|
@@ -38,6 +38,12 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
38
38
|
|
|
39
39
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
40
40
|
const available = ref<boolean | null>(null)
|
|
41
|
+
/**
|
|
42
|
+
* Why `available` is false: the deployment switched the integration off (a 503), or the probe
|
|
43
|
+
* itself failed. Both hide the GitHub UI, but only the first is a fact about the deployment;
|
|
44
|
+
* the second is an unanswered question. Null while `available` is not false.
|
|
45
|
+
*/
|
|
46
|
+
const unavailableCause = ref<'disabled' | 'failed' | null>(null)
|
|
41
47
|
/** The workspace's VCS connection (App installation or PAT), or null when not connected. */
|
|
42
48
|
const connection = ref<GitHubConnection | null>(null)
|
|
43
49
|
/** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
|
|
@@ -160,6 +166,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
160
166
|
api,
|
|
161
167
|
workspace,
|
|
162
168
|
available,
|
|
169
|
+
unavailableCause,
|
|
163
170
|
connection,
|
|
164
171
|
connectOptions,
|
|
165
172
|
installations,
|
|
@@ -207,6 +214,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
207
214
|
*/
|
|
208
215
|
function reset() {
|
|
209
216
|
available.value = null
|
|
217
|
+
unavailableCause.value = null
|
|
210
218
|
connection.value = null
|
|
211
219
|
connectOptions.value = []
|
|
212
220
|
patCheck.value = null
|
|
@@ -221,6 +229,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
221
229
|
|
|
222
230
|
return {
|
|
223
231
|
available,
|
|
232
|
+
unavailableCause,
|
|
224
233
|
connection,
|
|
225
234
|
connectOptions,
|
|
226
235
|
patCheck,
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -728,6 +728,17 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
|
|
|
728
728
|
// so a later plain open doesn't re-scroll. Null when no section was requested.
|
|
729
729
|
const accountSettingsScrollTarget = ref<string | null>(null)
|
|
730
730
|
|
|
731
|
+
// Setup checklist: every prerequisite for "a task can run and merge" in one list, composed
|
|
732
|
+
// from the probes the setup banners already read (issue #2258).
|
|
733
|
+
const setupChecklistOpen = ref(false)
|
|
734
|
+
function openSetupChecklist() {
|
|
735
|
+
resetHubReturn()
|
|
736
|
+
setupChecklistOpen.value = true
|
|
737
|
+
}
|
|
738
|
+
function closeSetupChecklist() {
|
|
739
|
+
setupChecklistOpen.value = false
|
|
740
|
+
}
|
|
741
|
+
|
|
731
742
|
function openWorkspaceSettings(tab = 'workspace') {
|
|
732
743
|
resetHubReturn()
|
|
733
744
|
workspaceSettingsTab.value = tab
|
|
@@ -765,6 +776,9 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
|
|
|
765
776
|
}
|
|
766
777
|
|
|
767
778
|
return {
|
|
779
|
+
setupChecklistOpen,
|
|
780
|
+
openSetupChecklist,
|
|
781
|
+
closeSetupChecklist,
|
|
768
782
|
workspaceSettingsOpen,
|
|
769
783
|
workspaceSettingsTab,
|
|
770
784
|
accountSettingsOpen,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { InfraSetup } from '@cat-factory/contracts'
|
|
3
|
+
import { deriveSetupChecklist, type SetupProbeInput } from '~/utils/setupChecklist'
|
|
4
|
+
import type { SetupActor } from '~/utils/setupAudience'
|
|
5
|
+
|
|
6
|
+
// The checklist adds no probe of its own; what it owes is to state each probe's answer without
|
|
7
|
+
// changing its meaning. Three rules matter most: an unanswered probe is never "missing" or
|
|
8
|
+
// "done", a broken setup is never "missing" (that would send the reader to create a second one),
|
|
9
|
+
// and a row only offers its action to someone whose save would land.
|
|
10
|
+
|
|
11
|
+
const ALL_CONFIGURED: InfraSetup = {
|
|
12
|
+
agentExecutor: 'configured',
|
|
13
|
+
ephemeralEnvironments: 'configured',
|
|
14
|
+
binaryStorage: 'configured',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ADMIN: SetupActor = {
|
|
18
|
+
fullSurface: true,
|
|
19
|
+
canManageIntegrations: true,
|
|
20
|
+
canExecuteRuns: true,
|
|
21
|
+
isAccountAdmin: true,
|
|
22
|
+
accountsEnabled: true,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SOURCE_OK: SetupProbeInput['sourceControl'] = {
|
|
26
|
+
available: true,
|
|
27
|
+
unavailableCause: null,
|
|
28
|
+
connected: true,
|
|
29
|
+
patMissing: false,
|
|
30
|
+
patProblem: null,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function input(overrides: Partial<SetupProbeInput> = {}): SetupProbeInput {
|
|
34
|
+
return {
|
|
35
|
+
model: { loaded: true, loadFailed: false, usable: true, presetBroken: false },
|
|
36
|
+
sourceControl: SOURCE_OK,
|
|
37
|
+
infra: ALL_CONFIGURED,
|
|
38
|
+
workspaceLoaded: true,
|
|
39
|
+
infraOwners: null,
|
|
40
|
+
providers: { loaded: true, needingConfig: [] },
|
|
41
|
+
actor: ADMIN,
|
|
42
|
+
...overrides,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function row(i: SetupProbeInput, id: string) {
|
|
47
|
+
const found = deriveSetupChecklist(i).rows.find((r) => r.id === id)
|
|
48
|
+
if (!found) throw new Error(`no row ${id}`)
|
|
49
|
+
return found
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('deriveSetupChecklist', () => {
|
|
53
|
+
it('reports a fully set-up workspace as done, with nothing left checking', () => {
|
|
54
|
+
const c = deriveSetupChecklist(input())
|
|
55
|
+
expect(c.done).toBe(c.total)
|
|
56
|
+
expect(c.checking).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reports an unanswered probe as checking, never as missing or done', () => {
|
|
60
|
+
const c = deriveSetupChecklist(
|
|
61
|
+
input({
|
|
62
|
+
model: { loaded: false, loadFailed: false, usable: false, presetBroken: false },
|
|
63
|
+
sourceControl: { ...SOURCE_OK, available: null, connected: false },
|
|
64
|
+
infra: null,
|
|
65
|
+
workspaceLoaded: false,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
expect(c.rows.every((r) => r.status === 'checking')).toBe(true)
|
|
69
|
+
expect(c.checking).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps a configured runner checking until the providers say whether it is complete', () => {
|
|
73
|
+
const waiting = input({ providers: { loaded: false, needingConfig: [] } })
|
|
74
|
+
expect(row(waiting, 'agentExecutor').status).toBe('checking')
|
|
75
|
+
expect(deriveSetupChecklist(waiting).checking).toBe(true)
|
|
76
|
+
// Storage has no provider connection, so it does not wait on them.
|
|
77
|
+
expect(row(waiting, 'binaryStorage').status).toBe('done')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('names the cause of each broken setup, and tells it from an absent one', () => {
|
|
81
|
+
expect(
|
|
82
|
+
row(input({ infra: { ...ALL_CONFIGURED, agentExecutor: 'unreachable' } }), 'agentExecutor'),
|
|
83
|
+
).toMatchObject({ status: 'attention', cause: 'unreachable' })
|
|
84
|
+
expect(
|
|
85
|
+
row(input({ providers: { loaded: true, needingConfig: ['runner-pool'] } }), 'agentExecutor'),
|
|
86
|
+
).toMatchObject({ status: 'attention', cause: 'incomplete' })
|
|
87
|
+
expect(
|
|
88
|
+
row(input({ infra: { ...ALL_CONFIGURED, agentExecutor: 'not_defined' } }), 'agentExecutor'),
|
|
89
|
+
).toMatchObject({ status: 'missing', cause: null })
|
|
90
|
+
expect(
|
|
91
|
+
row(
|
|
92
|
+
input({ model: { loaded: true, loadFailed: false, usable: true, presetBroken: true } }),
|
|
93
|
+
'model',
|
|
94
|
+
),
|
|
95
|
+
).toMatchObject({ status: 'attention', cause: 'presetBroken' })
|
|
96
|
+
expect(
|
|
97
|
+
row(input({ sourceControl: { ...SOURCE_OK, patProblem: 'rejected' } }), 'sourceControl'),
|
|
98
|
+
).toMatchObject({ status: 'attention', cause: 'rejected' })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('states a failed probe as unanswered, never as an absent capability', () => {
|
|
102
|
+
expect(
|
|
103
|
+
row(
|
|
104
|
+
input({ model: { loaded: false, loadFailed: true, usable: false, presetBroken: false } }),
|
|
105
|
+
'model',
|
|
106
|
+
),
|
|
107
|
+
).toMatchObject({ status: 'attention', cause: 'probeFailed' })
|
|
108
|
+
const failed = input({
|
|
109
|
+
sourceControl: {
|
|
110
|
+
...SOURCE_OK,
|
|
111
|
+
available: false,
|
|
112
|
+
unavailableCause: 'failed',
|
|
113
|
+
connected: false,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
expect(row(failed, 'sourceControl')).toMatchObject({
|
|
117
|
+
status: 'attention',
|
|
118
|
+
cause: 'probeFailed',
|
|
119
|
+
})
|
|
120
|
+
// A deployment that switched the integration off does not use it: no gap to close.
|
|
121
|
+
const disabled = input({
|
|
122
|
+
sourceControl: {
|
|
123
|
+
...SOURCE_OK,
|
|
124
|
+
available: false,
|
|
125
|
+
unavailableCause: 'disabled',
|
|
126
|
+
connected: false,
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
expect(row(disabled, 'sourceControl').status).toBe('not_needed')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('says so when the loaded workspace does not report its infrastructure, never spins', () => {
|
|
133
|
+
const i = input({ infra: null, workspaceLoaded: true })
|
|
134
|
+
expect(row(i, 'agentExecutor')).toMatchObject({ status: 'attention', cause: 'notReported' })
|
|
135
|
+
expect(deriveSetupChecklist(i).checking).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('leaves an area the deployment does not use out of the count', () => {
|
|
139
|
+
const c = deriveSetupChecklist(
|
|
140
|
+
input({ infra: { ...ALL_CONFIGURED, ephemeralEnvironments: 'not_applicable' } }),
|
|
141
|
+
)
|
|
142
|
+
expect(c.total).toBe(c.rows.length - 1)
|
|
143
|
+
expect(c.done).toBe(c.total)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('who can act on a row (the banners rule, `utils/setupAudience.ts`)', () => {
|
|
147
|
+
const unconfigured: InfraSetup = {
|
|
148
|
+
agentExecutor: 'not_defined',
|
|
149
|
+
ephemeralEnvironments: 'not_defined',
|
|
150
|
+
binaryStorage: 'not_defined',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it('offers a board admin who is not an account admin only the gaps they own', () => {
|
|
154
|
+
const boardAdmin = { ...ADMIN, isAccountAdmin: false }
|
|
155
|
+
const i = input({
|
|
156
|
+
infra: unconfigured,
|
|
157
|
+
infraOwners: {
|
|
158
|
+
agentExecutor: 'workspace_admin',
|
|
159
|
+
ephemeralEnvironments: 'operator',
|
|
160
|
+
binaryStorage: 'account_admin',
|
|
161
|
+
},
|
|
162
|
+
actor: boardAdmin,
|
|
163
|
+
})
|
|
164
|
+
expect(row(i, 'agentExecutor').remedy).toBeNull()
|
|
165
|
+
expect(row(i, 'ephemeralEnvironments').remedy).toBe('operator')
|
|
166
|
+
expect(row(i, 'binaryStorage').remedy).toBe('account_admin')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('tells someone on the designer surface they can fix it themselves', () => {
|
|
170
|
+
const i = input({ infra: unconfigured, actor: { ...ADMIN, fullSurface: false } })
|
|
171
|
+
expect(row(i, 'agentExecutor').remedy).toBe('full_surface')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('gives every caller the token page for a personal access token', () => {
|
|
175
|
+
const member = { ...ADMIN, canManageIntegrations: false, isAccountAdmin: false }
|
|
176
|
+
const i = input({
|
|
177
|
+
sourceControl: { ...SOURCE_OK, patMissing: true, connected: false },
|
|
178
|
+
actor: member,
|
|
179
|
+
})
|
|
180
|
+
expect(row(i, 'sourceControl').remedy).toBeNull()
|
|
181
|
+
expect(row(input({ actor: member }), 'model').remedy).toBe('workspace_admin')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { InfraSetup, InfraSetupOwners, InfraSetupStatus } from '@cat-factory/contracts'
|
|
2
|
+
import { isInfraSetupHealthStatus } from '@cat-factory/contracts'
|
|
3
|
+
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
4
|
+
import {
|
|
5
|
+
canActOnSetup,
|
|
6
|
+
effectiveOwner,
|
|
7
|
+
type SetupActor,
|
|
8
|
+
type SetupRemedy,
|
|
9
|
+
} from '~/utils/setupAudience'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// The setup checklist: what this workspace needs before a task can run and merge, as ONE list.
|
|
13
|
+
//
|
|
14
|
+
// Every row is composed from a probe that already exists and already drives a banner or a
|
|
15
|
+
// startup dialog (`useAiReadiness`, the GitHub probe, `workspace.infraSetup`, the provider
|
|
16
|
+
// connections' missing-required fields). The checklist adds no probe of its own: it states the
|
|
17
|
+
// same facts in one place, with a finish line, instead of one interruption per missing piece.
|
|
18
|
+
// Who may act on a row is the banners' rule too (`utils/setupAudience.ts`).
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** The rows. The infrastructure rows are named by their `InfraSetupArea`, the server's vocabulary. */
|
|
22
|
+
export const SETUP_ROWS = [
|
|
23
|
+
'model',
|
|
24
|
+
'sourceControl',
|
|
25
|
+
'agentExecutor',
|
|
26
|
+
'ephemeralEnvironments',
|
|
27
|
+
'binaryStorage',
|
|
28
|
+
] as const
|
|
29
|
+
export type SetupRowId = (typeof SETUP_ROWS)[number]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A row's state. Five, because they need five different reactions:
|
|
33
|
+
*
|
|
34
|
+
* - `done`: nothing to do.
|
|
35
|
+
* - `missing`: never set up. The fix is to connect something.
|
|
36
|
+
* - `attention`: set up, but broken, unreachable or incomplete. The fix is to repair it, and
|
|
37
|
+
* saying "connect" would send the reader to create a second one. Its {@link SetupRowCause} says
|
|
38
|
+
* which, because each needs a different fix.
|
|
39
|
+
* - `checking`: the probe has not answered. Never rendered as missing: an unanswered probe and
|
|
40
|
+
* an absent capability are opposite facts.
|
|
41
|
+
* - `not_needed`: this deployment does not use the area at all. Left out of the count.
|
|
42
|
+
*/
|
|
43
|
+
export type SetupRowStatus = 'done' | 'missing' | 'attention' | 'checking' | 'not_needed'
|
|
44
|
+
|
|
45
|
+
/** Why a row needs attention. Each one names a different fix. */
|
|
46
|
+
export type SetupRowCause =
|
|
47
|
+
/** Configured, and its live probe says it cannot be reached. */
|
|
48
|
+
| 'unreachable'
|
|
49
|
+
/** Connected, but a mandatory provider field is unset. */
|
|
50
|
+
| 'incomplete'
|
|
51
|
+
/** GitHub refused the access token outright. */
|
|
52
|
+
| 'rejected'
|
|
53
|
+
/** The token authenticates but cannot push or open pull requests. */
|
|
54
|
+
| 'underscoped'
|
|
55
|
+
/** Usable models exist, but the default preset names one that is not. */
|
|
56
|
+
| 'presetBroken'
|
|
57
|
+
/** The probe itself failed, so the answer is unknown. The fix is to ask again. */
|
|
58
|
+
| 'probeFailed'
|
|
59
|
+
/** The workspace loaded, and this backend does not report the area at all. */
|
|
60
|
+
| 'notReported'
|
|
61
|
+
|
|
62
|
+
/** Whether a task can run without the row. An optional row only switches off the agents that use it. */
|
|
63
|
+
export const SETUP_ROW_REQUIRED: Record<SetupRowId, boolean> = {
|
|
64
|
+
model: true,
|
|
65
|
+
sourceControl: true,
|
|
66
|
+
agentExecutor: true,
|
|
67
|
+
ephemeralEnvironments: false,
|
|
68
|
+
binaryStorage: false,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Everything the checklist reads, resolved by the caller so this stays pure. */
|
|
72
|
+
export interface SetupProbeInput {
|
|
73
|
+
readonly model: {
|
|
74
|
+
/** The per-workspace model catalog has loaded for this workspace. */
|
|
75
|
+
readonly loaded: boolean
|
|
76
|
+
/** The last attempt to load it failed. */
|
|
77
|
+
readonly loadFailed: boolean
|
|
78
|
+
readonly usable: boolean
|
|
79
|
+
/** Usable models exist, but the default preset names one that is not. */
|
|
80
|
+
readonly presetBroken: boolean
|
|
81
|
+
}
|
|
82
|
+
readonly sourceControl: {
|
|
83
|
+
/** Null while the GitHub probe is in flight. */
|
|
84
|
+
readonly available: boolean | null
|
|
85
|
+
/** Why `available` is false: switched off by the deployment, or the probe failed. */
|
|
86
|
+
readonly unavailableCause: 'disabled' | 'failed' | null
|
|
87
|
+
readonly connected: boolean
|
|
88
|
+
/** Local mode with no personal access token set. */
|
|
89
|
+
readonly patMissing: boolean
|
|
90
|
+
/** The token check's verdict, when it needs attention. */
|
|
91
|
+
readonly patProblem: 'rejected' | 'underscoped' | null
|
|
92
|
+
}
|
|
93
|
+
/** Null until the workspace snapshot has delivered it, or from a backend that omits it. */
|
|
94
|
+
readonly infra: InfraSetup | null
|
|
95
|
+
/** The workspace snapshot has loaded, so a null `infra` is an answer, not a wait. */
|
|
96
|
+
readonly workspaceLoaded: boolean
|
|
97
|
+
/** Who can close each infrastructure gap; null from a backend that does not report it. */
|
|
98
|
+
readonly infraOwners: InfraSetupOwners | null
|
|
99
|
+
readonly providers: {
|
|
100
|
+
/** The provider connections have loaded, so `needingConfig` is an answer. */
|
|
101
|
+
readonly loaded: boolean
|
|
102
|
+
/** Provider connections that are wired but miss a mandatory field. */
|
|
103
|
+
readonly needingConfig: readonly ProviderConnectionKind[]
|
|
104
|
+
}
|
|
105
|
+
readonly actor: SetupActor
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A row's state and, exactly when it needs attention, why. */
|
|
109
|
+
export type SetupRowState =
|
|
110
|
+
| { readonly status: Exclude<SetupRowStatus, 'attention'>; readonly cause: null }
|
|
111
|
+
| { readonly status: 'attention'; readonly cause: SetupRowCause }
|
|
112
|
+
|
|
113
|
+
export type SetupRow = SetupRowState & {
|
|
114
|
+
readonly id: SetupRowId
|
|
115
|
+
readonly required: boolean
|
|
116
|
+
/** Null when this caller can act on the row; otherwise how the gap gets fixed. */
|
|
117
|
+
readonly remedy: SetupRemedy | null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SetupChecklist {
|
|
121
|
+
readonly rows: SetupRow[]
|
|
122
|
+
/** Rows that count toward the finish line (everything except `not_needed`). */
|
|
123
|
+
readonly total: number
|
|
124
|
+
readonly done: number
|
|
125
|
+
/** A counted row has not answered yet, so `done` is not a count of the finished rows yet. */
|
|
126
|
+
readonly checking: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type RowState = SetupRowState
|
|
130
|
+
const state = (status: Exclude<SetupRowStatus, 'attention'>): RowState => ({ status, cause: null })
|
|
131
|
+
const attention = (cause: SetupRowCause): RowState => ({ status: 'attention', cause })
|
|
132
|
+
|
|
133
|
+
function infraRow(
|
|
134
|
+
status: InfraSetupStatus | undefined,
|
|
135
|
+
workspaceLoaded: boolean,
|
|
136
|
+
provider: { loaded: boolean; incomplete: boolean } | null,
|
|
137
|
+
): RowState {
|
|
138
|
+
if (status === undefined) return workspaceLoaded ? attention('notReported') : state('checking')
|
|
139
|
+
if (status === 'not_applicable') return state('not_needed')
|
|
140
|
+
if (status === 'not_defined') return state('missing')
|
|
141
|
+
if (isInfraSetupHealthStatus(status)) return attention('unreachable')
|
|
142
|
+
// Configured. Whether a mandatory field is unset is known only once the providers load; until
|
|
143
|
+
// then the row has not answered, rather than claiming done.
|
|
144
|
+
if (provider && !provider.loaded) return state('checking')
|
|
145
|
+
return provider?.incomplete ? attention('incomplete') : state('done')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function modelRow(m: SetupProbeInput['model']): RowState {
|
|
149
|
+
if (!m.loaded) return m.loadFailed ? attention('probeFailed') : state('checking')
|
|
150
|
+
if (!m.usable) return state('missing')
|
|
151
|
+
return m.presetBroken ? attention('presetBroken') : state('done')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sourceControlRow(s: SetupProbeInput['sourceControl']): RowState {
|
|
155
|
+
if (s.patMissing) return state('missing')
|
|
156
|
+
if (s.patProblem) return attention(s.patProblem)
|
|
157
|
+
if (s.connected) return state('done')
|
|
158
|
+
if (s.available === null) return state('checking')
|
|
159
|
+
if (s.available === false) {
|
|
160
|
+
if (s.unavailableCause === 'disabled') return state('not_needed')
|
|
161
|
+
if (s.unavailableCause === 'failed') return attention('probeFailed')
|
|
162
|
+
}
|
|
163
|
+
return state('missing')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Who closes a row's gap. Infrastructure follows its reported owner. A personal access token is
|
|
168
|
+
* fixed by whoever holds it: the remedy is a token page, not a workspace setting, so every caller
|
|
169
|
+
* gets the link. A model or a GitHub connection is a workspace setting.
|
|
170
|
+
*/
|
|
171
|
+
function rowOwner(id: SetupRowId, input: SetupProbeInput): unknown {
|
|
172
|
+
switch (id) {
|
|
173
|
+
case 'agentExecutor':
|
|
174
|
+
return input.infraOwners?.agentExecutor
|
|
175
|
+
case 'ephemeralEnvironments':
|
|
176
|
+
return input.infraOwners?.ephemeralEnvironments
|
|
177
|
+
case 'binaryStorage':
|
|
178
|
+
return input.infraOwners?.binaryStorage
|
|
179
|
+
case 'model':
|
|
180
|
+
return 'workspace_admin'
|
|
181
|
+
case 'sourceControl':
|
|
182
|
+
return input.sourceControl.patMissing || input.sourceControl.patProblem
|
|
183
|
+
? null
|
|
184
|
+
: 'workspace_admin'
|
|
185
|
+
default: {
|
|
186
|
+
const unreachable: never = id
|
|
187
|
+
return unreachable
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function remedyFor(owner: unknown, actor: SetupActor): SetupRemedy | null {
|
|
193
|
+
if (owner === null) return null
|
|
194
|
+
if (canActOnSetup(owner, actor)) return null
|
|
195
|
+
return canActOnSetup(owner, { ...actor, fullSurface: true })
|
|
196
|
+
? 'full_surface'
|
|
197
|
+
: effectiveOwner(owner)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function deriveSetupChecklist(input: SetupProbeInput): SetupChecklist {
|
|
201
|
+
const { loaded, needingConfig } = input.providers
|
|
202
|
+
const states: Record<SetupRowId, RowState> = {
|
|
203
|
+
model: modelRow(input.model),
|
|
204
|
+
sourceControl: sourceControlRow(input.sourceControl),
|
|
205
|
+
agentExecutor: infraRow(input.infra?.agentExecutor, input.workspaceLoaded, {
|
|
206
|
+
loaded,
|
|
207
|
+
incomplete: needingConfig.includes('runner-pool'),
|
|
208
|
+
}),
|
|
209
|
+
ephemeralEnvironments: infraRow(input.infra?.ephemeralEnvironments, input.workspaceLoaded, {
|
|
210
|
+
loaded,
|
|
211
|
+
incomplete: needingConfig.includes('environment'),
|
|
212
|
+
}),
|
|
213
|
+
// Storage has no provider connection, so nothing about it waits on the providers.
|
|
214
|
+
binaryStorage: infraRow(input.infra?.binaryStorage, input.workspaceLoaded, null),
|
|
215
|
+
}
|
|
216
|
+
const rows = SETUP_ROWS.map((id) => ({
|
|
217
|
+
id,
|
|
218
|
+
...states[id],
|
|
219
|
+
required: SETUP_ROW_REQUIRED[id],
|
|
220
|
+
remedy: remedyFor(rowOwner(id, input), input.actor),
|
|
221
|
+
}))
|
|
222
|
+
const counted = rows.filter((r) => r.status !== 'not_needed')
|
|
223
|
+
return {
|
|
224
|
+
rows,
|
|
225
|
+
total: counted.length,
|
|
226
|
+
done: counted.filter((r) => r.status === 'done').length,
|
|
227
|
+
checking: counted.some((r) => r.status === 'checking'),
|
|
228
|
+
}
|
|
229
|
+
}
|