@cat-factory/app 0.311.3 → 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.
Files changed (46) hide show
  1. package/README.md +32 -0
  2. package/app/components/board/nodes/TaskCard.vue +45 -212
  3. package/app/components/layout/AiProvidersBanner.vue +3 -2
  4. package/app/components/layout/BoardToolbar.vue +24 -26
  5. package/app/components/layout/BoardTopOverlays.vue +8 -2
  6. package/app/components/layout/GitHubPatBanner.vue +2 -2
  7. package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
  8. package/app/components/layout/InfraSetupBanner.vue +6 -7
  9. package/app/components/layout/SideBar.vue +5 -0
  10. package/app/components/layout/WorkViewSwitcher.vue +64 -0
  11. package/app/components/queue/QueueCard.vue +269 -0
  12. package/app/components/queue/QueueView.vue +192 -0
  13. package/app/components/setup/SetupChecklistPanel.vue +340 -0
  14. package/app/composables/useFrameLanes.ts +6 -82
  15. package/app/composables/useNavContributions.ts +3 -0
  16. package/app/composables/useSetupChecklist.ts +122 -0
  17. package/app/composables/useSetupRemedies.ts +72 -0
  18. package/app/composables/useTaskActions.ts +254 -0
  19. package/app/composables/useTaskLaneClassifier.ts +88 -0
  20. package/app/modular/nav-contributions.spec.ts +6 -0
  21. package/app/modular/nav-contributions.ts +56 -0
  22. package/app/modular/tutorial-tours.ts +10 -0
  23. package/app/pages/index.vue +43 -6
  24. package/app/stores/github/context.ts +1 -0
  25. package/app/stores/github/probe.ts +7 -3
  26. package/app/stores/github.ts +9 -0
  27. package/app/stores/ui/modals.ts +14 -0
  28. package/app/stores/workView.ts +51 -0
  29. package/app/stores/workspaceQueue.ts +148 -0
  30. package/app/utils/queueSections.spec.ts +111 -0
  31. package/app/utils/queueSections.ts +117 -0
  32. package/app/utils/setupChecklist.spec.ts +184 -0
  33. package/app/utils/setupChecklist.ts +229 -0
  34. package/app/utils/workView.spec.ts +16 -0
  35. package/app/utils/workView.ts +25 -0
  36. package/i18n/locales/de.json +132 -3
  37. package/i18n/locales/en.json +132 -3
  38. package/i18n/locales/es.json +132 -3
  39. package/i18n/locales/fr.json +132 -3
  40. package/i18n/locales/he.json +132 -3
  41. package/i18n/locales/it.json +132 -3
  42. package/i18n/locales/ja.json +132 -3
  43. package/i18n/locales/pl.json +132 -3
  44. package/i18n/locales/tr.json +132 -3
  45. package/i18n/locales/uk.json +132 -3
  46. package/package.json +1 -1
@@ -0,0 +1,51 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import { DEFAULT_WORK_VIEW, isWorkView, WORK_VIEW_PARAM, type WorkView } from '~/utils/workView'
4
+
5
+ /**
6
+ * Which view of the board this person is looking at: the queue or the canvas.
7
+ *
8
+ * Persisted per BROWSER in a cookie, like the interface tier and the lane order: it is the view
9
+ * used LAST in this browser, so a person who prefers the canvas stays on it, and nobody on another
10
+ * browser is moved by their choice. See `utils/workView.ts` for why the queue is the default.
11
+ */
12
+ export const useWorkViewStore = defineStore(
13
+ 'workView',
14
+ () => {
15
+ const tutorial = useTutorialStore()
16
+ // Stored raw and narrowed on read, as `uiMode` does: the persisted value is untrusted input.
17
+ const storedView = ref<string>(DEFAULT_WORK_VIEW)
18
+ /**
19
+ * The view on screen. A running tour holds the CANVAS for its whole length: every tour anchors
20
+ * to canvas nodes and the canvas toolbar, which the queue covers or unmounts, so a switch mid-
21
+ * tour would leave its later steps with nothing to point at. A choice made meanwhile is kept
22
+ * and applies when the tour ends.
23
+ */
24
+ const view = computed<WorkView>(() => {
25
+ if (tutorial.touring) return 'board'
26
+ return isWorkView(storedView.value) ? storedView.value : DEFAULT_WORK_VIEW
27
+ })
28
+
29
+ function show(next: WorkView) {
30
+ storedView.value = next
31
+ }
32
+
33
+ /**
34
+ * Honour a `?view=queue` / `?view=board` link, then strip the parameter so a reload or a
35
+ * copied URL does not re-apply it over a later choice. Same shape as the run deep link. A
36
+ * `view` value that names no view is dropped without effect, rather than guessed onto one.
37
+ */
38
+ function consumeDeepLink() {
39
+ if (!import.meta.client) return
40
+ const url = new URL(window.location.href)
41
+ if (!url.searchParams.has(WORK_VIEW_PARAM)) return
42
+ const asked = url.searchParams.get(WORK_VIEW_PARAM)
43
+ url.searchParams.delete(WORK_VIEW_PARAM)
44
+ window.history.replaceState(window.history.state, '', url.toString())
45
+ if (isWorkView(asked)) show(asked)
46
+ }
47
+
48
+ return { storedView, view, show, consumeDeepLink }
49
+ },
50
+ { persist: { pick: ['storedView'] } },
51
+ )
@@ -0,0 +1,148 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import { useTaskLaneClassifier, type ClassifiedEntry } from '~/composables/useTaskLaneClassifier'
5
+ import {
6
+ assembleQueue,
7
+ QUEUE_SECTION_BY_REASON,
8
+ type QueueAssembly,
9
+ type QueueEntry,
10
+ } from '~/utils/queueSections'
11
+
12
+ /**
13
+ * Every task on the board, across every service, classified ONCE and filed into queue sections.
14
+ *
15
+ * The store-facing half of `utils/queueSections.ts`, and a STORE rather than a composable for the
16
+ * reason the swimlane README gives: a workspace-wide derivation belongs on one owner, never on each
17
+ * consumer. The queue view, the sidebar's attention counter AND every frame's swimlanes
18
+ * (`useFrameLanes`) read the one classification here, so a board pays for each task's verdict once
19
+ * per change, however many surfaces show it.
20
+ *
21
+ * It walks the service frames rather than `board.allTasks` so it holds exactly the tasks the
22
+ * frames' own swimlanes hold (direct tasks plus those inside modules): a task the canvas shows
23
+ * nowhere is not one the queue may claim needs you.
24
+ */
25
+ export const useWorkspaceQueueStore = defineStore('workspaceQueue', () => {
26
+ const board = useBoardStore()
27
+ const execution = useExecutionStore()
28
+ const workspace = useWorkspaceStore()
29
+ const settings = useWorkspaceSettingsStore()
30
+ const reviews = useReviewStage()
31
+ const { classify } = useTaskLaneClassifier()
32
+
33
+ /**
34
+ * Each task's lane verdict, keyed by id. `order` is the task's position in its OWN frame, which
35
+ * is the tiebreak the frame's lanes sort by.
36
+ */
37
+ const classified = computed(() => {
38
+ const byId = new Map<string, ClassifiedEntry>()
39
+ for (const frame of board.frames) {
40
+ board
41
+ .allTasksUnder(frame.id)
42
+ .forEach((task, order) => byId.set(task.id, classify(task, order)))
43
+ }
44
+ return byId
45
+ })
46
+
47
+ /** Narrow the queue to one service; null means all of them. Session-only, like a search box. */
48
+ const serviceFilter = ref<string | null>(null)
49
+ // A filter belongs to the board it was set on: another board's service id matches nothing there.
50
+ watch(
51
+ () => workspace.workspaceId,
52
+ () => (serviceFilter.value = null),
53
+ )
54
+
55
+ /** The service frames, for the filter. Board order, which is the order the canvas lays out. */
56
+ const services = computed(() => board.frames.map((f) => ({ id: f.id, title: f.title })))
57
+
58
+ /** The filter in force: a service deleted since it was picked narrows to nothing, so it lapses. */
59
+ const activeFilter = computed(() =>
60
+ serviceFilter.value && services.value.some((s) => s.id === serviceFilter.value)
61
+ ? serviceFilter.value
62
+ : null,
63
+ )
64
+
65
+ /** Show every service. The surfaces that send a person to the queue call it, so a filter set
66
+ * earlier cannot hide the work they were sent to see. */
67
+ function clearFilter() {
68
+ serviceFilter.value = null
69
+ }
70
+
71
+ /**
72
+ * A decision or approval on a block that is not a task in a service: an initiative's planning
73
+ * run, or a frame-level run. The swimlanes have no lane for it, but it waits on a person all the
74
+ * same, and the toolbar's decision count includes it, so the queue lists it under Needs you.
75
+ */
76
+ const parkedElsewhere = computed<QueueEntry[]>(() => {
77
+ const ids = new Set<string>()
78
+ for (const d of execution.openDecisions) ids.add(d.blockId)
79
+ for (const a of execution.openApprovals) {
80
+ if (!reviews.isBackground(a.agentKind, a.blockId)) ids.add(a.blockId)
81
+ }
82
+ const out: QueueEntry[] = []
83
+ for (const id of ids) {
84
+ if (classified.value.has(id)) continue
85
+ const block = board.getBlock(id)
86
+ if (!block) continue
87
+ out.push(parkEntry(block, out.length))
88
+ }
89
+ return out
90
+ })
91
+
92
+ function parkEntry(block: Block, order: number): QueueEntry {
93
+ const { entry } = classify(block, order)
94
+ const service = board.serviceOf(block)
95
+ return {
96
+ ...entry,
97
+ reason: execution.decisionsByBlock.has(block.id) ? 'decision' : 'approval',
98
+ service: service ? { id: service.id, title: service.title } : null,
99
+ }
100
+ }
101
+
102
+ /** Everything the queue lists, before the filter. The attention counter reads this, unfiltered. */
103
+ const entries = computed<QueueEntry[]>(() => {
104
+ const out: QueueEntry[] = []
105
+ for (const frame of board.frames) {
106
+ const service = { id: frame.id, title: frame.title }
107
+ // `classified` walks these same frames and tasks, so every one of them has a verdict.
108
+ for (const task of board.allTasksUnder(frame.id)) {
109
+ out.push({ ...classified.value.get(task.id)!.entry, service })
110
+ }
111
+ }
112
+ return out.concat(parkedElsewhere.value)
113
+ })
114
+
115
+ // `Date.now()` is read non-reactively, as the frame's Done lane does: the window is
116
+ // re-evaluated on every board or run change, which on a live board is constantly.
117
+ const queue = computed<QueueAssembly>(() =>
118
+ assembleQueue(
119
+ activeFilter.value
120
+ ? entries.value.filter((e) => e.service?.id === activeFilter.value)
121
+ : entries.value,
122
+ Date.now(),
123
+ {
124
+ maxItems: settings.settings.doneLaneMaxItems,
125
+ retentionDays: settings.settings.doneLaneRetentionDays,
126
+ },
127
+ ),
128
+ )
129
+
130
+ /**
131
+ * The ONE attention count the queue view shows: what a human must act on before an agent can
132
+ * continue, across every service and regardless of the filter. A pull request waiting to merge
133
+ * is not in it, because nothing is stalled behind it (see `utils/queueSections.ts`).
134
+ */
135
+ const needsYouCount = computed(
136
+ () => entries.value.filter((e) => QUEUE_SECTION_BY_REASON[e.reason] === 'needs_you').length,
137
+ )
138
+
139
+ return {
140
+ classified,
141
+ serviceFilter,
142
+ activeFilter,
143
+ clearFilter,
144
+ services,
145
+ queue,
146
+ needsYouCount,
147
+ }
148
+ })
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Block } from '~/types/domain'
3
+ import { LANE_BY_REASON, LANE_REASONS, type LaneReason } from '~/utils/swimlanes'
4
+ import { assembleQueue, QUEUE_SECTION_BY_REASON, type QueueEntry } from '~/utils/queueSections'
5
+ import type { DoneLaneCaps } from '~/utils/swimlanes'
6
+
7
+ // The queue reads the frame lanes' verdict and must never contradict it. The one place it
8
+ // splits differently (a PR waiting to merge gets its own column) is a regrouping INSIDE the
9
+ // frame's `needs_you` lane, never a move across lanes.
10
+
11
+ const NOW = 1_800_000_000_000
12
+ const DAY = 86_400_000
13
+ /** The caps a workspace sets for its frames' Done lane, which the queue applies too. */
14
+ const CAPS: DoneLaneCaps = { maxItems: 20, retentionDays: 14 }
15
+
16
+ function entry(id: string, reason: LaneReason, overrides: Partial<QueueEntry> = {}): QueueEntry {
17
+ return {
18
+ task: { id, title: id, status: 'ready', level: 'task' } as Block,
19
+ reason,
20
+ order: 0,
21
+ activityAt: null,
22
+ waitingSince: null,
23
+ moduleName: null,
24
+ initiativeName: null,
25
+ epicName: null,
26
+ service: { id: 'svc', title: 'Service' },
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ describe('QUEUE_SECTION_BY_REASON', () => {
32
+ it('files every reason, and never across the lane the frame put it in', () => {
33
+ const laneOfSection = {
34
+ needs_you: 'needs_you',
35
+ ready_to_merge: 'needs_you',
36
+ in_flight: 'in_progress',
37
+ waiting: 'not_started',
38
+ done: 'done',
39
+ } as const
40
+ for (const reason of LANE_REASONS) {
41
+ expect(laneOfSection[QUEUE_SECTION_BY_REASON[reason]], reason).toBe(LANE_BY_REASON[reason])
42
+ }
43
+ })
44
+ })
45
+
46
+ describe('assembleQueue', () => {
47
+ it('puts a decision, a failure and a parked run in Needs you, and a waiting PR beside it', () => {
48
+ const { sections } = assembleQueue(
49
+ [
50
+ entry('decide', 'decision'),
51
+ entry('broken', 'failed'),
52
+ entry('parked', 'parked'),
53
+ entry('pr', 'pr_awaiting_merge'),
54
+ ],
55
+ NOW,
56
+ CAPS,
57
+ )
58
+ expect(sections.needs_you.map((e) => e.task.id).sort()).toEqual(['broken', 'decide', 'parked'])
59
+ expect(sections.ready_to_merge.map((e) => e.task.id)).toEqual(['pr'])
60
+ })
61
+
62
+ it('orders Needs you as the frame lane does: broken first, then the longest wait', () => {
63
+ const { sections } = assembleQueue(
64
+ [
65
+ entry('recent', 'decision', { waitingSince: NOW - 1000, order: 0 }),
66
+ entry('old', 'decision', { waitingSince: NOW - 5000, order: 1 }),
67
+ entry('broken', 'failed', { order: 2 }),
68
+ ],
69
+ NOW,
70
+ CAPS,
71
+ )
72
+ expect(sections.needs_you.map((e) => e.task.id)).toEqual(['broken', 'old', 'recent'])
73
+ })
74
+
75
+ it("applies the frames' Done-lane window: newest first, undated last, older ones left out", () => {
76
+ const merged = (id: string, completedAt: number | undefined) =>
77
+ entry(id, 'merged', {
78
+ task: { id, title: id, status: 'done', level: 'task', completedAt } as Block,
79
+ })
80
+ const queue = assembleQueue(
81
+ [
82
+ merged('yesterday', NOW - DAY),
83
+ merged('today', NOW - 1000),
84
+ merged('ancient', NOW - (CAPS.retentionDays! + 1) * DAY),
85
+ merged('undated', undefined),
86
+ ],
87
+ NOW,
88
+ CAPS,
89
+ )
90
+ expect(queue.sections.done.map((e) => e.task.id)).toEqual(['today', 'yesterday', 'undated'])
91
+ expect(queue.doneInWindow).toBe(3)
92
+ })
93
+
94
+ it('caps the Done list but never the count above it', () => {
95
+ const many = Array.from({ length: CAPS.maxItems + 3 }, (_, i) =>
96
+ entry(`m${i}`, 'merged', {
97
+ order: i,
98
+ task: {
99
+ id: `m${i}`,
100
+ title: '',
101
+ status: 'done',
102
+ level: 'task',
103
+ completedAt: NOW - i,
104
+ } as Block,
105
+ }),
106
+ )
107
+ const queue = assembleQueue(many, NOW, CAPS)
108
+ expect(queue.sections.done).toHaveLength(CAPS.maxItems)
109
+ expect(queue.doneInWindow).toBe(many.length)
110
+ })
111
+ })
@@ -0,0 +1,117 @@
1
+ import type { Block } from '~/types/domain'
2
+ import { sortLaneTasks, type LaneTaskEntry } from '~/utils/laneSort'
3
+ import {
4
+ selectDoneLaneTasks,
5
+ type DoneLaneCaps,
6
+ type LaneReason,
7
+ type TaskLane,
8
+ } from '~/utils/swimlanes'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // The workspace queue: the swimlane model lifted out of the service frames.
12
+ //
13
+ // A frame's lanes answer "what is the state of THIS service". The queue answers "what needs
14
+ // me, across every service", which is the question a person asks first when they open the
15
+ // app. It reads the SAME `classifyTask` verdict the frame does (see
16
+ // `useTaskLaneClassifier`), so a task never sits in one column here and another lane there.
17
+ //
18
+ // It splits the verdict differently in one place, on purpose: a pull request waiting to be
19
+ // merged is in the frame's `needs_you` lane, and here it has its own column. Answering a
20
+ // decision and landing a finished change are different jobs done at different moments (one
21
+ // unblocks an agent within minutes, the other is a review session), and a single column that
22
+ // mixed them would put "merge this" between two questions an agent is parked on.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** The queue's sections, in reading order. */
26
+ export const QUEUE_SECTIONS = [
27
+ 'needs_you',
28
+ 'in_flight',
29
+ 'waiting',
30
+ 'ready_to_merge',
31
+ 'done',
32
+ ] as const
33
+ export type QueueSection = (typeof QUEUE_SECTIONS)[number]
34
+
35
+ /**
36
+ * Each lane reason's queue section. Exhaustive, so a new reason cannot ship without deciding
37
+ * where the queue files it.
38
+ */
39
+ export const QUEUE_SECTION_BY_REASON: Record<LaneReason, QueueSection> = {
40
+ unstarted: 'waiting',
41
+ dependencies: 'waiting',
42
+ running: 'in_flight',
43
+ background_review: 'in_flight',
44
+ decision: 'needs_you',
45
+ approval: 'needs_you',
46
+ failed: 'needs_you',
47
+ budget_paused: 'needs_you',
48
+ parked: 'needs_you',
49
+ unclassified: 'needs_you',
50
+ pr_awaiting_merge: 'ready_to_merge',
51
+ merged: 'done',
52
+ }
53
+
54
+ /**
55
+ * The frame lane whose `smart` order each section borrows. The queue adds no comparator of its
56
+ * own: an order that differed from the frame's would make the two surfaces disagree about what
57
+ * to look at first. Done takes the Done lane's own selection and order (below).
58
+ */
59
+ const SORT_LANE_BY_SECTION: Record<Exclude<QueueSection, 'done'>, TaskLane> = {
60
+ needs_you: 'needs_you',
61
+ in_flight: 'in_progress',
62
+ waiting: 'not_started',
63
+ ready_to_merge: 'needs_you',
64
+ }
65
+
66
+ /** One queue entry: the lane entry plus the service the task belongs to. */
67
+ export interface QueueEntry extends LaneTaskEntry {
68
+ /** The enclosing service frame, or null for a task the board holds outside any service. */
69
+ readonly service: Pick<Block, 'id' | 'title'> | null
70
+ }
71
+
72
+ /** The assembled queue. */
73
+ export interface QueueAssembly {
74
+ readonly sections: Record<QueueSection, QueueEntry[]>
75
+ /** Every merged task inside the retention window, before the item cap. */
76
+ readonly doneInWindow: number
77
+ }
78
+
79
+ /**
80
+ * Bucket and order classified entries into queue sections. Pure, so the ordering rules are
81
+ * testable without a Pinia instance.
82
+ *
83
+ * The Done section is the frames' Done lane over every service: the same selection
84
+ * (`selectDoneLaneTasks`) under the same workspace caps, so the queue and a frame never disagree
85
+ * about which merges are recent.
86
+ */
87
+ export function assembleQueue(
88
+ entries: readonly QueueEntry[],
89
+ now: number,
90
+ doneCaps: DoneLaneCaps,
91
+ ): QueueAssembly {
92
+ const sections = Object.fromEntries(QUEUE_SECTIONS.map((s) => [s, [] as QueueEntry[]])) as Record<
93
+ QueueSection,
94
+ QueueEntry[]
95
+ >
96
+ for (const entry of entries) sections[QUEUE_SECTION_BY_REASON[entry.reason]].push(entry)
97
+
98
+ const byId = new Map(sections.done.map((e) => [e.task.id, e]))
99
+ const done = selectDoneLaneTasks(
100
+ sections.done.map((e) => e.task),
101
+ doneCaps,
102
+ now,
103
+ )
104
+
105
+ for (const section of QUEUE_SECTIONS) {
106
+ if (section === 'done') continue
107
+ sections[section] = sortLaneTasks(
108
+ sections[section],
109
+ 'smart',
110
+ SORT_LANE_BY_SECTION[section],
111
+ ) as QueueEntry[]
112
+ }
113
+ // Newest completion first, as the frame's Done lane orders it.
114
+ sections.done = done.shown.map((task) => byId.get(task.id)!)
115
+
116
+ return { sections, doneInWindow: done.shown.length + done.hiddenByCap }
117
+ }
@@ -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
+ })