@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.
- package/README.md +32 -0
- package/app/components/board/nodes/TaskCard.vue +45 -212
- package/app/components/layout/AiProvidersBanner.vue +3 -2
- package/app/components/layout/BoardToolbar.vue +24 -26
- package/app/components/layout/BoardTopOverlays.vue +8 -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/layout/SideBar.vue +5 -0
- package/app/components/layout/WorkViewSwitcher.vue +64 -0
- package/app/components/queue/QueueCard.vue +269 -0
- package/app/components/queue/QueueView.vue +192 -0
- package/app/components/setup/SetupChecklistPanel.vue +340 -0
- package/app/composables/useFrameLanes.ts +6 -82
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useSetupChecklist.ts +122 -0
- package/app/composables/useSetupRemedies.ts +72 -0
- package/app/composables/useTaskActions.ts +254 -0
- package/app/composables/useTaskLaneClassifier.ts +88 -0
- package/app/modular/nav-contributions.spec.ts +6 -0
- package/app/modular/nav-contributions.ts +56 -0
- package/app/modular/tutorial-tours.ts +10 -0
- package/app/pages/index.vue +43 -6
- 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/stores/workView.ts +51 -0
- package/app/stores/workspaceQueue.ts +148 -0
- package/app/utils/queueSections.spec.ts +111 -0
- package/app/utils/queueSections.ts +117 -0
- package/app/utils/setupChecklist.spec.ts +184 -0
- package/app/utils/setupChecklist.ts +229 -0
- package/app/utils/workView.spec.ts +16 -0
- package/app/utils/workView.ts +25 -0
- package/i18n/locales/de.json +132 -3
- package/i18n/locales/en.json +132 -3
- package/i18n/locales/es.json +132 -3
- package/i18n/locales/fr.json +132 -3
- package/i18n/locales/he.json +132 -3
- package/i18n/locales/it.json +132 -3
- package/i18n/locales/ja.json +132 -3
- package/i18n/locales/pl.json +132 -3
- package/i18n/locales/tr.json +132 -3
- package/i18n/locales/uk.json +132 -3
- 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
|
+
})
|