@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.
@@ -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',
@@ -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
  }
@@ -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,
@@ -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
+ }