@cat-factory/app 0.274.0 → 0.276.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 +158 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +31 -11
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/layout/CommandBar.vue +8 -1
- package/app/components/layout/RolePrompt.vue +75 -0
- package/app/components/layout/SideBar.vue +22 -13
- package/app/components/layout/UiRoleSwitcher.vue +73 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/docs/consumer-extensions.md +20 -6
- package/app/modular/external-tools.spec.ts +0 -45
- package/app/modular/external-tools.ts +12 -23
- package/app/modular/nav-contributions.spec.ts +176 -17
- package/app/modular/nav-contributions.ts +106 -23
- package/app/modular/nav-gates.ts +11 -2
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -3
- package/app/modular/tutorial-tours.ts +53 -8
- package/app/pages/index.vue +36 -7
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/launchPrompt.ts +63 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/tutorial.ts +4 -4
- package/app/stores/uiMode.spec.ts +11 -0
- package/app/stores/uiMode.ts +14 -2
- package/app/stores/uiRole.spec.ts +185 -0
- package/app/stores/uiRole.ts +86 -0
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/app/utils/uiMode.spec.ts +12 -0
- package/app/utils/uiMode.ts +24 -6
- package/app/utils/uiRole.ts +123 -0
- package/i18n/locales/de.json +104 -3
- package/i18n/locales/en.json +110 -3
- package/i18n/locales/es.json +104 -3
- package/i18n/locales/fr.json +104 -3
- package/i18n/locales/he.json +104 -3
- package/i18n/locales/it.json +104 -3
- package/i18n/locales/ja.json +104 -3
- package/i18n/locales/pl.json +104 -3
- package/i18n/locales/tr.json +104 -3
- package/i18n/locales/uk.json +104 -3
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
- package/app/stores/tutorial.prompt.ts +0 -59
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
|
+
import {
|
|
4
|
+
classifyTask,
|
|
5
|
+
LANE_BY_REASON,
|
|
6
|
+
LANE_REASONS,
|
|
7
|
+
selectDoneLaneTasks,
|
|
8
|
+
TASK_LANES,
|
|
9
|
+
type TaskLaneInput,
|
|
10
|
+
} from '~/utils/swimlanes'
|
|
11
|
+
|
|
12
|
+
// The lane a card sits in is a CLAIM, not a decoration: file a parked run under "In
|
|
13
|
+
// progress" and the human it is waiting on never scans the column it is in. So the cases
|
|
14
|
+
// worth pinning are the ones where two states would otherwise collapse into one lane — a
|
|
15
|
+
// failure vs a gate, a background reviewer vs a real question, a finished pipeline vs a
|
|
16
|
+
// live one — plus the two totality properties that stop a card vanishing entirely.
|
|
17
|
+
|
|
18
|
+
function input(overrides: Partial<TaskLaneInput> = {}): TaskLaneInput {
|
|
19
|
+
return {
|
|
20
|
+
status: 'ready',
|
|
21
|
+
run: null,
|
|
22
|
+
runFailed: false,
|
|
23
|
+
parkIsBackground: false,
|
|
24
|
+
pendingDecision: false,
|
|
25
|
+
pendingApproval: false,
|
|
26
|
+
hasUnmetDeps: false,
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function doneTask(id: string, completedAt: number | undefined): Block {
|
|
32
|
+
return { id, title: id, status: 'done', level: 'task', completedAt } as Block
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('classifyTask — totality', () => {
|
|
36
|
+
it('gives every BlockStatus a lane, so a card can never fall out of the board', () => {
|
|
37
|
+
// Derived from the status vocabulary rather than a hand-listed count: adding a status
|
|
38
|
+
// must fail HERE (and in the Record the function reads) rather than silently produce a
|
|
39
|
+
// task that renders in no column at all.
|
|
40
|
+
const statuses: BlockStatus[] = [
|
|
41
|
+
'planned',
|
|
42
|
+
'ready',
|
|
43
|
+
'in_progress',
|
|
44
|
+
'blocked',
|
|
45
|
+
'pr_ready',
|
|
46
|
+
'done',
|
|
47
|
+
]
|
|
48
|
+
for (const status of statuses) {
|
|
49
|
+
const { lane } = classifyTask(input({ status }))
|
|
50
|
+
expect(TASK_LANES, `status ${status}`).toContain(lane)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('reports a status this build does not know instead of dropping the card', () => {
|
|
55
|
+
// The Record is total against the TYPE and partial against the DATABASE: a status
|
|
56
|
+
// retired by a later build still sits on old rows. Reading one back must not hand
|
|
57
|
+
// `undefined` to the lane lookup.
|
|
58
|
+
const { lane, reason } = classifyTask(input({ status: 'archived_forever' as BlockStatus }))
|
|
59
|
+
expect(reason).toBe('unclassified')
|
|
60
|
+
expect(lane).toBe('needs_you')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('assigns every reason a lane, and leaves no lane without a reason', () => {
|
|
64
|
+
for (const reason of LANE_REASONS) {
|
|
65
|
+
expect(TASK_LANES, `reason ${reason}`).toContain(LANE_BY_REASON[reason])
|
|
66
|
+
}
|
|
67
|
+
// The other direction: a lane no reason maps to would render as a permanently empty
|
|
68
|
+
// column, which reads as "nothing is in this state" rather than "this is unreachable".
|
|
69
|
+
const reached = new Set(LANE_REASONS.map((r) => LANE_BY_REASON[r]))
|
|
70
|
+
expect([...TASK_LANES].filter((lane) => !reached.has(lane))).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('classifyTask — precedence', () => {
|
|
75
|
+
it('treats merged as terminal even when the run that merged it is gone', () => {
|
|
76
|
+
// A retry prunes terminal runs, so a merged task routinely has no run to consult.
|
|
77
|
+
expect(classifyTask(input({ status: 'done', run: null }))).toEqual({
|
|
78
|
+
lane: 'done',
|
|
79
|
+
reason: 'merged',
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('ranks a failure above a park, because the two need unrelated actions', () => {
|
|
84
|
+
// A failed run leaves the BLOCK on `blocked` exactly as a park does, so keying off the
|
|
85
|
+
// block alone would show "Approval needed" on a run that crashed.
|
|
86
|
+
const verdict = classifyTask(
|
|
87
|
+
input({
|
|
88
|
+
status: 'blocked',
|
|
89
|
+
run: { status: 'blocked' },
|
|
90
|
+
runFailed: true,
|
|
91
|
+
pendingApproval: true,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
expect(verdict).toEqual({ lane: 'needs_you', reason: 'failed' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('separates a spend pause from a failure', () => {
|
|
98
|
+
// Nothing on the board renders this state today: a paused run reads as still working.
|
|
99
|
+
// It needs a human (raise the budget) but it has not broken.
|
|
100
|
+
expect(classifyTask(input({ status: 'in_progress', run: { status: 'paused' } }))).toEqual({
|
|
101
|
+
lane: 'needs_you',
|
|
102
|
+
reason: 'budget_paused',
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('keeps a background reviewer IN FLIGHT rather than asking for an answer', () => {
|
|
107
|
+
// An iterative reviewer mid-cycle parks the run with a pending approval while the
|
|
108
|
+
// driver folds answers in. Nobody is waiting on a human, so `needs_you` would be a
|
|
109
|
+
// request for input that does not exist.
|
|
110
|
+
expect(
|
|
111
|
+
classifyTask(
|
|
112
|
+
input({
|
|
113
|
+
status: 'blocked',
|
|
114
|
+
run: { status: 'blocked' },
|
|
115
|
+
parkIsBackground: true,
|
|
116
|
+
pendingApproval: true,
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
).toEqual({ lane: 'in_progress', reason: 'background_review' })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('names a decision before an approval when both are open', () => {
|
|
123
|
+
const verdict = classifyTask(
|
|
124
|
+
input({
|
|
125
|
+
status: 'blocked',
|
|
126
|
+
run: { status: 'blocked' },
|
|
127
|
+
pendingDecision: true,
|
|
128
|
+
pendingApproval: true,
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
expect(verdict.reason).toBe('decision')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('files an unnameable park as a human wait, never as work in flight', () => {
|
|
135
|
+
// The SPA models only decisions and approvals globally; a judge / human-test / fork /
|
|
136
|
+
// follow-up / input-gate park is reachable only by drilling in. `run.status === 'blocked'`
|
|
137
|
+
// is the canonical marker for all of them, so the lane is still right even though the
|
|
138
|
+
// reason cannot be narrowed.
|
|
139
|
+
expect(classifyTask(input({ status: 'blocked', run: { status: 'blocked' } }))).toEqual({
|
|
140
|
+
lane: 'needs_you',
|
|
141
|
+
reason: 'parked',
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('distinguishes a finished pipeline with an open PR from a live one', () => {
|
|
146
|
+
const finished = classifyTask(input({ status: 'pr_ready', run: { status: 'done' } }))
|
|
147
|
+
expect(finished).toEqual({ lane: 'needs_you', reason: 'pr_awaiting_merge' })
|
|
148
|
+
|
|
149
|
+
// Mid-run, `pr_ready` means the PR is open and CI + the merger are still to come.
|
|
150
|
+
const live = classifyTask(input({ status: 'pr_ready', run: { status: 'running' } }))
|
|
151
|
+
expect(live).toEqual({ lane: 'in_progress', reason: 'running' })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('prefers a live run to a stale block status', () => {
|
|
155
|
+
// The engine writes the block after the run advances, so the two disagree for one
|
|
156
|
+
// round trip. The run is the fresher fact.
|
|
157
|
+
expect(classifyTask(input({ status: 'blocked', run: { status: 'running' } }))).toEqual({
|
|
158
|
+
lane: 'in_progress',
|
|
159
|
+
reason: 'running',
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('separates a runnable backlog task from one waiting on a dependency', () => {
|
|
164
|
+
expect(classifyTask(input({ status: 'ready' })).reason).toBe('unstarted')
|
|
165
|
+
const blocked = classifyTask(input({ status: 'ready', hasUnmetDeps: true }))
|
|
166
|
+
// Same lane — it has not started either way — but a different reason, because "start
|
|
167
|
+
// this" is offered for one and refused for the other.
|
|
168
|
+
expect(blocked).toEqual({ lane: 'not_started', reason: 'dependencies' })
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('selectDoneLaneTasks', () => {
|
|
173
|
+
const NOW = 1_000 * 86_400_000
|
|
174
|
+
|
|
175
|
+
it('drops tasks completed outside the retention window and says how many', () => {
|
|
176
|
+
const result = selectDoneLaneTasks(
|
|
177
|
+
[
|
|
178
|
+
doneTask('fresh', NOW - 86_400_000),
|
|
179
|
+
doneTask('stale', NOW - 30 * 86_400_000),
|
|
180
|
+
doneTask('ancient', NOW - 400 * 86_400_000),
|
|
181
|
+
],
|
|
182
|
+
{ maxItems: 50, retentionDays: 14 },
|
|
183
|
+
NOW,
|
|
184
|
+
)
|
|
185
|
+
expect(result.shown.map((b) => b.id)).toEqual(['fresh'])
|
|
186
|
+
expect(result.hiddenByAge).toBe(2)
|
|
187
|
+
expect(result.hiddenByCap).toBe(0)
|
|
188
|
+
expect(result.total).toBe(3)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('accounts for the two caps separately', () => {
|
|
192
|
+
// They mean different things to a reader: "there is older history" vs "there is more
|
|
193
|
+
// from this same period". One merged count would answer neither.
|
|
194
|
+
// `t<i>` completed i days ago, so a 3-day window keeps t0..t3 (the boundary is inclusive)
|
|
195
|
+
// and drops t4/t5; the count cap of 2 then takes t2/t3 off the visible end.
|
|
196
|
+
const tasks = Array.from({ length: 6 }, (_, i) => doneTask(`t${i}`, NOW - i * 86_400_000))
|
|
197
|
+
const result = selectDoneLaneTasks(tasks, { maxItems: 2, retentionDays: 3 }, NOW)
|
|
198
|
+
expect(result.shown.map((b) => b.id)).toEqual(['t0', 't1'])
|
|
199
|
+
expect(result.hiddenByAge).toBe(2)
|
|
200
|
+
expect(result.hiddenByCap).toBe(2)
|
|
201
|
+
// The two accounts plus what is shown must cover every completed task exactly once, or
|
|
202
|
+
// the lane's "N hidden" lines would quietly disagree with its total.
|
|
203
|
+
expect(result.shown.length + result.hiddenByAge + result.hiddenByCap).toBe(result.total)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('exempts an undated task from the age cap and reports that it did', () => {
|
|
207
|
+
// Blocks written before `completedAt` existed have no honest age. Treating absent as
|
|
208
|
+
// ancient would hide history on the strength of a timestamp nobody recorded.
|
|
209
|
+
const result = selectDoneLaneTasks(
|
|
210
|
+
[doneTask('dated', NOW - 400 * 86_400_000), doneTask('undated', undefined)],
|
|
211
|
+
{ maxItems: 50, retentionDays: 14 },
|
|
212
|
+
NOW,
|
|
213
|
+
)
|
|
214
|
+
expect(result.shown.map((b) => b.id)).toEqual(['undated'])
|
|
215
|
+
expect(result.hiddenByAge).toBe(1)
|
|
216
|
+
expect(result.undatedShown).toBe(1)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('still bounds undated tasks by the count cap, so the exemption cannot unbound the lane', () => {
|
|
220
|
+
const tasks = Array.from({ length: 5 }, (_, i) => doneTask(`u${i}`, undefined))
|
|
221
|
+
const result = selectDoneLaneTasks(tasks, { maxItems: 2, retentionDays: 14 }, NOW)
|
|
222
|
+
expect(result.shown).toHaveLength(2)
|
|
223
|
+
expect(result.hiddenByCap).toBe(3)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('sorts newest completion first and puts undated last', () => {
|
|
227
|
+
const result = selectDoneLaneTasks(
|
|
228
|
+
[
|
|
229
|
+
doneTask('old', NOW - 3 * 86_400_000),
|
|
230
|
+
doneTask('undated', undefined),
|
|
231
|
+
doneTask('new', NOW - 86_400_000),
|
|
232
|
+
],
|
|
233
|
+
{ maxItems: 50, retentionDays: null },
|
|
234
|
+
NOW,
|
|
235
|
+
)
|
|
236
|
+
expect(result.shown.map((b) => b.id)).toEqual(['new', 'old', 'undated'])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('counts without rendering when the cap is zero', () => {
|
|
240
|
+
const result = selectDoneLaneTasks(
|
|
241
|
+
[doneTask('a', NOW)],
|
|
242
|
+
{ maxItems: 0, retentionDays: null },
|
|
243
|
+
NOW,
|
|
244
|
+
)
|
|
245
|
+
expect(result.shown).toEqual([])
|
|
246
|
+
expect(result.hiddenByCap).toBe(1)
|
|
247
|
+
expect(result.total).toBe(1)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('applies no age filter when retention is null', () => {
|
|
251
|
+
const result = selectDoneLaneTasks(
|
|
252
|
+
[doneTask('ancient', NOW - 5_000 * 86_400_000)],
|
|
253
|
+
{ maxItems: 50, retentionDays: null },
|
|
254
|
+
NOW,
|
|
255
|
+
)
|
|
256
|
+
expect(result.shown.map((b) => b.id)).toEqual(['ancient'])
|
|
257
|
+
expect(result.hiddenByAge).toBe(0)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { Block, BlockStatus, ExecutionInstance } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The swimlane model: which lane a task belongs in, and WHY.
|
|
5
|
+
//
|
|
6
|
+
// A service frame lays its tasks out in lanes rather than at hand-placed
|
|
7
|
+
// coordinates, so the lane a card sits in is a CLAIM the board makes about that
|
|
8
|
+
// task. That raises the bar over a badge: a mislabelled badge is noise beside
|
|
9
|
+
// the truth, while a card filed in the wrong lane states something false and
|
|
10
|
+
// hides the card from the column its reader was scanning. Two consequences run
|
|
11
|
+
// through everything below.
|
|
12
|
+
//
|
|
13
|
+
// First, the classification is TOTAL. `BASE_REASON_BY_STATUS` is a
|
|
14
|
+
// `Record<BlockStatus, …>` so a new block status fails the build until it is
|
|
15
|
+
// given a lane, and a status the type says is impossible but the DATABASE still
|
|
16
|
+
// holds (a retired picklist member on an old row) resolves to `unclassified`
|
|
17
|
+
// rather than to `undefined`, which would drop the card out of every lane with
|
|
18
|
+
// nothing left to say it was ever there.
|
|
19
|
+
//
|
|
20
|
+
// Second, an imprecise reason beats a wrong lane. The SPA models only DECISIONS
|
|
21
|
+
// and APPROVALS as global per-block selectors; the other park surfaces (a judge,
|
|
22
|
+
// a human-test, a visual confirmation, a fork choice, follow-up triage, the
|
|
23
|
+
// pre-dispatch input gate) are reachable only by drilling into a step or
|
|
24
|
+
// following a notification. But every one of them parks the RUN at
|
|
25
|
+
// `status: 'blocked'`, and that coarse marker is enough to place the card in
|
|
26
|
+
// `needs_you` even when this module cannot name which surface is asking. So a
|
|
27
|
+
// park it cannot classify is reported as `parked`, never demoted to work in
|
|
28
|
+
// flight.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The lanes a frame's tasks are laid out in, in display order.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately four, not the three states of work: `done` exists because a board whose
|
|
35
|
+
* terminal column is missing reads as if finished work evaporated. It is capped (by count
|
|
36
|
+
* and by age, both per-workspace) and collapsed by default, which is what keeps a service
|
|
37
|
+
* that has merged hundreds of tasks cheap to render. See {@link selectDoneLaneTasks}.
|
|
38
|
+
*/
|
|
39
|
+
export const TASK_LANES = ['not_started', 'in_progress', 'needs_you', 'done'] as const
|
|
40
|
+
export type TaskLane = (typeof TASK_LANES)[number]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Why a task is in its lane.
|
|
44
|
+
*
|
|
45
|
+
* The lane answers "should I be looking at this"; the reason answers "what would I do".
|
|
46
|
+
* Those are different questions and collapsing them loses the second: `failed`,
|
|
47
|
+
* `budget_paused` and `approval` all stop work dead and need three unrelated actions. So
|
|
48
|
+
* the reason is what the `blocking_reason` grouping splits a lane by, and what a lane
|
|
49
|
+
* header or card states in words.
|
|
50
|
+
*/
|
|
51
|
+
export const LANE_REASONS = [
|
|
52
|
+
// -- not_started ---------------------------------------------------------
|
|
53
|
+
/** Authored, runnable, nothing has been dispatched against it yet. */
|
|
54
|
+
'unstarted',
|
|
55
|
+
/** Not runnable: at least one dependency has not merged. Starting it is not offered. */
|
|
56
|
+
'dependencies',
|
|
57
|
+
// -- in_progress ---------------------------------------------------------
|
|
58
|
+
/** A run is live. */
|
|
59
|
+
'running',
|
|
60
|
+
/**
|
|
61
|
+
* The run is parked, but on an iterative reviewer mid-cycle (folding answers in,
|
|
62
|
+
* re-reviewing). That is background work needing no human, so it is IN FLIGHT: the
|
|
63
|
+
* board must not ask for an answer nobody is waiting on.
|
|
64
|
+
*/
|
|
65
|
+
'background_review',
|
|
66
|
+
// -- needs_you -----------------------------------------------------------
|
|
67
|
+
/** An agent raised a question with options and nobody has chosen. */
|
|
68
|
+
'decision',
|
|
69
|
+
/** A human approval gate is pending. */
|
|
70
|
+
'approval',
|
|
71
|
+
/** The run failed. Nothing advances until someone retries it or fixes the cause. */
|
|
72
|
+
'failed',
|
|
73
|
+
/** The spend budget paused the run. It resumes when the budget is raised. */
|
|
74
|
+
'budget_paused',
|
|
75
|
+
/**
|
|
76
|
+
* The pipeline finished with a pull request still open: the work is done and a human
|
|
77
|
+
* has to land it. Distinct from `pr_ready` mid-run, where CI and the merger are still
|
|
78
|
+
* to come.
|
|
79
|
+
*/
|
|
80
|
+
'pr_awaiting_merge',
|
|
81
|
+
/**
|
|
82
|
+
* The run is parked on a human, and which surface is asking is not derivable here (see
|
|
83
|
+
* the module header). A named-but-imprecise wait, never a silent omission.
|
|
84
|
+
*/
|
|
85
|
+
'parked',
|
|
86
|
+
/**
|
|
87
|
+
* The task's stored status is not a member of the status vocabulary this build knows.
|
|
88
|
+
* Only reachable from data written by another version, and reported rather than
|
|
89
|
+
* guessed onto a current member: nothing here knows which one was meant.
|
|
90
|
+
*/
|
|
91
|
+
'unclassified',
|
|
92
|
+
// -- done ----------------------------------------------------------------
|
|
93
|
+
/** Merged. Terminal. */
|
|
94
|
+
'merged',
|
|
95
|
+
] as const
|
|
96
|
+
export type LaneReason = (typeof LANE_REASONS)[number]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Each reason's lane. Exhaustive, so a new reason cannot be added without deciding which
|
|
100
|
+
* column it is scanned in.
|
|
101
|
+
*/
|
|
102
|
+
export const LANE_BY_REASON: Record<LaneReason, TaskLane> = {
|
|
103
|
+
unstarted: 'not_started',
|
|
104
|
+
dependencies: 'not_started',
|
|
105
|
+
running: 'in_progress',
|
|
106
|
+
background_review: 'in_progress',
|
|
107
|
+
decision: 'needs_you',
|
|
108
|
+
approval: 'needs_you',
|
|
109
|
+
failed: 'needs_you',
|
|
110
|
+
budget_paused: 'needs_you',
|
|
111
|
+
pr_awaiting_merge: 'needs_you',
|
|
112
|
+
parked: 'needs_you',
|
|
113
|
+
unclassified: 'needs_you',
|
|
114
|
+
merged: 'done',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The reason a task's OWN status implies, before any run signal refines it.
|
|
119
|
+
*
|
|
120
|
+
* Exhaustive over `BlockStatus`: adding a status fails the typecheck here first. Note
|
|
121
|
+
* `blocked` maps to `parked` rather than to a failure — the status is overloaded across
|
|
122
|
+
* park, failure and terminal-failure (see `STATUS_META` in `utils/catalog.ts`), so the run
|
|
123
|
+
* is what tells those apart and {@link classifyTask} consults it before falling back here.
|
|
124
|
+
*/
|
|
125
|
+
const BASE_REASON_BY_STATUS: Record<BlockStatus, LaneReason> = {
|
|
126
|
+
planned: 'unstarted',
|
|
127
|
+
ready: 'unstarted',
|
|
128
|
+
in_progress: 'running',
|
|
129
|
+
blocked: 'parked',
|
|
130
|
+
pr_ready: 'running',
|
|
131
|
+
done: 'merged',
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Per-lane presentation. Copy lives in the i18n catalog, so these are KEYS.
|
|
136
|
+
*
|
|
137
|
+
* The colours deliberately reuse the status palette's meanings (`STATUS_META` in
|
|
138
|
+
* `utils/catalog.ts`): slate for not-started, indigo for in-flight, amber for "needs
|
|
139
|
+
* attention", green for done. A lane that coloured itself independently would put an amber
|
|
140
|
+
* card in a lane with a different accent and make the reader arbitrate between two claims.
|
|
141
|
+
*/
|
|
142
|
+
export const LANE_META: Record<
|
|
143
|
+
TaskLane,
|
|
144
|
+
{ labelKey: string; emptyKey: string; icon: string; color: string }
|
|
145
|
+
> = {
|
|
146
|
+
not_started: {
|
|
147
|
+
labelKey: 'board.lanes.notStarted.label',
|
|
148
|
+
emptyKey: 'board.lanes.notStarted.empty',
|
|
149
|
+
icon: 'i-lucide-circle-dashed',
|
|
150
|
+
color: '#64748b',
|
|
151
|
+
},
|
|
152
|
+
in_progress: {
|
|
153
|
+
labelKey: 'board.lanes.inProgress.label',
|
|
154
|
+
emptyKey: 'board.lanes.inProgress.empty',
|
|
155
|
+
icon: 'i-lucide-loader',
|
|
156
|
+
color: '#6366f1',
|
|
157
|
+
},
|
|
158
|
+
needs_you: {
|
|
159
|
+
labelKey: 'board.lanes.needsYou.label',
|
|
160
|
+
emptyKey: 'board.lanes.needsYou.empty',
|
|
161
|
+
icon: 'i-lucide-hand',
|
|
162
|
+
color: '#f59e0b',
|
|
163
|
+
},
|
|
164
|
+
done: {
|
|
165
|
+
labelKey: 'board.lanes.done.label',
|
|
166
|
+
emptyKey: 'board.lanes.done.empty',
|
|
167
|
+
icon: 'i-lucide-circle-check',
|
|
168
|
+
color: '#16a34a',
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A short phrase naming each reason, for the card's lane hint and for the header of a
|
|
174
|
+
* `blocking_reason` group. Exhaustive, so a new reason cannot ship with a raw enum member
|
|
175
|
+
* leaking into the UI as its own label.
|
|
176
|
+
*/
|
|
177
|
+
export const LANE_REASON_LABEL_KEYS: Record<LaneReason, string> = {
|
|
178
|
+
unstarted: 'board.lanes.reason.unstarted',
|
|
179
|
+
dependencies: 'board.lanes.reason.dependencies',
|
|
180
|
+
running: 'board.lanes.reason.running',
|
|
181
|
+
background_review: 'board.lanes.reason.backgroundReview',
|
|
182
|
+
decision: 'board.lanes.reason.decision',
|
|
183
|
+
approval: 'board.lanes.reason.approval',
|
|
184
|
+
failed: 'board.lanes.reason.failed',
|
|
185
|
+
budget_paused: 'board.lanes.reason.budgetPaused',
|
|
186
|
+
pr_awaiting_merge: 'board.lanes.reason.prAwaitingMerge',
|
|
187
|
+
parked: 'board.lanes.reason.parked',
|
|
188
|
+
unclassified: 'board.lanes.reason.unclassified',
|
|
189
|
+
merged: 'board.lanes.reason.merged',
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** What {@link classifyTask} needs to know about a task and its run. */
|
|
193
|
+
export interface TaskLaneInput {
|
|
194
|
+
/** The task block. */
|
|
195
|
+
readonly status: BlockStatus
|
|
196
|
+
/**
|
|
197
|
+
* The task's current run, or null when there is none (never started) or the store has
|
|
198
|
+
* not hydrated it. Both are legitimately "no live run": a block's `executionId` is
|
|
199
|
+
* never cleared when a run finishes, so its presence proves nothing.
|
|
200
|
+
*/
|
|
201
|
+
readonly run: Pick<ExecutionInstance, 'status'> | null
|
|
202
|
+
/**
|
|
203
|
+
* Whether the run FAILED. Read from the `agentRuns` per-block summary rather than from
|
|
204
|
+
* the instance, because that summary also covers a bootstrap run, so a frame-level
|
|
205
|
+
* failure classifies through the same path.
|
|
206
|
+
*/
|
|
207
|
+
readonly runFailed: boolean
|
|
208
|
+
/**
|
|
209
|
+
* Whether every park on this run is an iterative reviewer mid-cycle. Computed by the
|
|
210
|
+
* caller through `useReviewStage().isBackground`, which keys off the parked approval's
|
|
211
|
+
* own `agentKind` so an unrelated approval on the same block is never suppressed.
|
|
212
|
+
*/
|
|
213
|
+
readonly parkIsBackground: boolean
|
|
214
|
+
/** Whether an agent decision is open on this task. */
|
|
215
|
+
readonly pendingDecision: boolean
|
|
216
|
+
/** Whether a (non-background) human approval gate is pending on this task. */
|
|
217
|
+
readonly pendingApproval: boolean
|
|
218
|
+
/** Whether at least one of the task's dependencies has not merged. */
|
|
219
|
+
readonly hasUnmetDeps: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface TaskLaneVerdict {
|
|
223
|
+
readonly lane: TaskLane
|
|
224
|
+
readonly reason: LaneReason
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function verdict(reason: LaneReason): TaskLaneVerdict {
|
|
228
|
+
return { lane: LANE_BY_REASON[reason], reason }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Place a task in a lane and say why.
|
|
233
|
+
*
|
|
234
|
+
* Precedence, and the reason each step comes where it does:
|
|
235
|
+
*
|
|
236
|
+
* 1. **Merged** wins outright. The block status is the authority because the run that
|
|
237
|
+
* merged it may already have been pruned, so no run signal can contradict it.
|
|
238
|
+
* 2. **Failure** outranks every park: a failed run has nothing to answer, and what a
|
|
239
|
+
* human does about it is unrelated to answering a gate.
|
|
240
|
+
* 3. **Budget pause** is a human's call but not a failure — the run is intact and resumes
|
|
241
|
+
* when the budget is raised. Nothing on the board renders this state today, which is
|
|
242
|
+
* why a spend-paused task currently reads as if it were still working.
|
|
243
|
+
* 4. **A parked run** is a human wait, whichever surface is asking (module header).
|
|
244
|
+
* 5. **A finished pipeline with an open PR** is a human wait too, and the only one whose
|
|
245
|
+
* run is already terminal.
|
|
246
|
+
* 6. **A live run** outranks a stale block status: the engine writes the block after the
|
|
247
|
+
* run advances, so the two disagree for one round trip mid-transition.
|
|
248
|
+
* 7. Otherwise the task's own status decides, refined by whether it can actually start.
|
|
249
|
+
*/
|
|
250
|
+
export function classifyTask(input: TaskLaneInput): TaskLaneVerdict {
|
|
251
|
+
const { run } = input
|
|
252
|
+
|
|
253
|
+
if (input.status === 'done') return verdict('merged')
|
|
254
|
+
if (input.runFailed) return verdict('failed')
|
|
255
|
+
if (run?.status === 'paused') return verdict('budget_paused')
|
|
256
|
+
|
|
257
|
+
if (run?.status === 'blocked') {
|
|
258
|
+
if (input.parkIsBackground) return verdict('background_review')
|
|
259
|
+
if (input.pendingDecision) return verdict('decision')
|
|
260
|
+
if (input.pendingApproval) return verdict('approval')
|
|
261
|
+
return verdict('parked')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (input.status === 'pr_ready' && (run == null || run.status === 'done')) {
|
|
265
|
+
return verdict('pr_awaiting_merge')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (run?.status === 'running') return verdict('running')
|
|
269
|
+
|
|
270
|
+
// The cast is deliberate. The Record above is TOTAL against `BlockStatus` and PARTIAL
|
|
271
|
+
// against the rows in the database: a status this build has retired still sits on old
|
|
272
|
+
// blocks, and reading one back would otherwise hand `undefined` to `LANE_BY_REASON` and
|
|
273
|
+
// drop the card out of every lane, with no card left to say a task went missing.
|
|
274
|
+
const base = (BASE_REASON_BY_STATUS as Partial<Record<string, LaneReason>>)[input.status]
|
|
275
|
+
if (base === undefined) return verdict('unclassified')
|
|
276
|
+
if (base === 'unstarted' && input.hasUnmetDeps) return verdict('dependencies')
|
|
277
|
+
return verdict(base)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// The Done lane's two caps
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/** The per-workspace caps bounding what the Done lane renders. */
|
|
285
|
+
export interface DoneLaneCaps {
|
|
286
|
+
/** Most cards the lane will render. `0` ⇒ the lane counts its tasks and shows none. */
|
|
287
|
+
readonly maxItems: number
|
|
288
|
+
/**
|
|
289
|
+
* Hide a task that completed more than this many days ago. `null` ⇒ no age cap, so the
|
|
290
|
+
* count cap alone bounds the lane.
|
|
291
|
+
*/
|
|
292
|
+
readonly retentionDays: number | null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* What the Done lane renders, plus a full account of what it withheld.
|
|
297
|
+
*
|
|
298
|
+
* Both drop counts are reported separately because the two caps mean different things to
|
|
299
|
+
* a reader: `hiddenByAge` says "this service has older history, look further back if you
|
|
300
|
+
* need it", while `hiddenByCap` says "there is more from this same period". A single
|
|
301
|
+
* "N hidden" would answer neither, and reporting nothing would make a truncated lane read
|
|
302
|
+
* exactly like a complete one.
|
|
303
|
+
*/
|
|
304
|
+
export interface DoneLaneSelection {
|
|
305
|
+
/** The tasks to render, newest completion first; undated tasks last. */
|
|
306
|
+
readonly shown: Block[]
|
|
307
|
+
/** Completed tasks older than the retention window. */
|
|
308
|
+
readonly hiddenByAge: number
|
|
309
|
+
/** Completed tasks dropped by the count cap after the age filter ran. */
|
|
310
|
+
readonly hiddenByCap: number
|
|
311
|
+
/**
|
|
312
|
+
* How many of {@link shown} carry no completion timestamp. Blocks written before
|
|
313
|
+
* `completedAt` existed have no honest age, and this states how much of the lane is
|
|
314
|
+
* therefore exempt from the age cap rather than letting the cap look total.
|
|
315
|
+
*/
|
|
316
|
+
readonly undatedShown: number
|
|
317
|
+
/** Every completed task, before either cap. */
|
|
318
|
+
readonly total: number
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Apply the Done lane's caps.
|
|
323
|
+
*
|
|
324
|
+
* A task with no `completedAt` is EXEMPT from the age cap and sorted last. It is not
|
|
325
|
+
* treated as ancient (which would hide history on the strength of a timestamp nobody
|
|
326
|
+
* recorded) nor as just-completed (which would pin stale rows to the top of the lane
|
|
327
|
+
* forever). It is still subject to the count cap, so the exemption cannot make the lane
|
|
328
|
+
* unbounded, and {@link DoneLaneSelection.undatedShown} states how many there are.
|
|
329
|
+
*/
|
|
330
|
+
export function selectDoneLaneTasks(
|
|
331
|
+
done: readonly Block[],
|
|
332
|
+
caps: DoneLaneCaps,
|
|
333
|
+
now: number,
|
|
334
|
+
): DoneLaneSelection {
|
|
335
|
+
const cutoff = caps.retentionDays == null ? null : now - caps.retentionDays * 86_400_000
|
|
336
|
+
|
|
337
|
+
const withinWindow =
|
|
338
|
+
cutoff == null
|
|
339
|
+
? [...done]
|
|
340
|
+
: done.filter((b) => b.completedAt == null || b.completedAt >= cutoff)
|
|
341
|
+
const hiddenByAge = done.length - withinWindow.length
|
|
342
|
+
|
|
343
|
+
// Newest completion first; undated last. `Number.NEGATIVE_INFINITY` is a SORT position,
|
|
344
|
+
// not an age: the age filter above has already run and skipped these rows entirely.
|
|
345
|
+
withinWindow.sort((a, b) => (b.completedAt ?? -Infinity) - (a.completedAt ?? -Infinity))
|
|
346
|
+
|
|
347
|
+
const shown = withinWindow.slice(0, Math.max(0, caps.maxItems))
|
|
348
|
+
return {
|
|
349
|
+
shown,
|
|
350
|
+
hiddenByAge,
|
|
351
|
+
hiddenByCap: withinWindow.length - shown.length,
|
|
352
|
+
undatedShown: shown.reduce((n, b) => n + (b.completedAt == null ? 1 : 0), 0),
|
|
353
|
+
total: done.length,
|
|
354
|
+
}
|
|
355
|
+
}
|
package/app/utils/uiMode.spec.ts
CHANGED
|
@@ -29,6 +29,18 @@ describe('resolveUiMode', () => {
|
|
|
29
29
|
expect(resolveUiMode(null, null)).toBe(DEFAULT_UI_MODE)
|
|
30
30
|
expect(DEFAULT_UI_MODE).toBe('basic')
|
|
31
31
|
})
|
|
32
|
+
|
|
33
|
+
it('caps an intake role at basic, above BOTH the env pin and the stored choice', () => {
|
|
34
|
+
// The role is a ceiling rather than another preference: an `intake` surface is offered the
|
|
35
|
+
// delivery loop and none of the platform configuration behind it, which is what the advanced
|
|
36
|
+
// tier is made of. So it wins over the env pin too, the one layer nothing else overrides.
|
|
37
|
+
expect(resolveUiMode('advanced', 'advanced', 'intake')).toBe('basic')
|
|
38
|
+
expect(resolveUiMode(null, 'advanced', 'intake')).toBe('basic')
|
|
39
|
+
// A full-surface role changes nothing, which is what the default argument encodes for every
|
|
40
|
+
// caller that has no role to hand (the pure-logic callers and the specs above).
|
|
41
|
+
expect(resolveUiMode('advanced', null, 'full')).toBe('advanced')
|
|
42
|
+
expect(resolveUiMode(null, 'advanced', 'full')).toBe('advanced')
|
|
43
|
+
})
|
|
32
44
|
})
|
|
33
45
|
|
|
34
46
|
describe('showOverrideField', () => {
|
package/app/utils/uiMode.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import type { RoleSurface } from '~/utils/uiRole'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The interface tier the SPA renders at: `basic` shows the everyday surface, `advanced`
|
|
3
5
|
* shows every destination and every run/pipeline option. Pure resolution logic, kept out
|
|
4
6
|
* of the store so it is testable without Pinia or a Nuxt runtime.
|
|
5
7
|
*
|
|
6
|
-
* Precedence is fixed and NOT negotiable per surface: the
|
|
7
|
-
* wins over the browser-stored user choice, which wins over the
|
|
8
|
-
* ordering is what lets an operator pin a fleet of kiosk-ish deployments
|
|
9
|
-
* without a per-browser reset, so `setMode` is a no-op while the env pin is present
|
|
8
|
+
* Precedence is fixed and NOT negotiable per surface: the ROLE's surface caps the tier, then
|
|
9
|
+
* the deployment's env value wins over the browser-stored user choice, which wins over the
|
|
10
|
+
* `basic` default. That ordering is what lets an operator pin a fleet of kiosk-ish deployments
|
|
11
|
+
* to one tier without a per-browser reset, so `setMode` is a no-op while the env pin is present
|
|
10
12
|
* rather than writing a preference the resolver would then ignore.
|
|
11
13
|
*/
|
|
14
|
+
|
|
12
15
|
export const UI_MODES = ['basic', 'advanced'] as const
|
|
13
16
|
|
|
14
17
|
export type UiMode = (typeof UI_MODES)[number]
|
|
@@ -37,8 +40,23 @@ export function parseUiMode(raw: unknown): UiMode | null {
|
|
|
37
40
|
return (UI_MODES as readonly string[]).includes(value) ? (value as UiMode) : null
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Apply the precedence: the ROLE's surface as a ceiling, then env pin → browser-stored user
|
|
45
|
+
* choice → {@link DEFAULT_UI_MODE}.
|
|
46
|
+
*
|
|
47
|
+
* The role (`utils/uiRole.ts`) sits ABOVE the env pin rather than beside it, because it is a
|
|
48
|
+
* ceiling and not a preference: an `intake` role is offered the delivery surface and none of the
|
|
49
|
+
* platform configuration behind it, and the advanced tier's whole content is that configuration.
|
|
50
|
+
* Resolved here rather than by hiding the tier switcher alone, so every `isAdvanced` reader
|
|
51
|
+
* inside a surface (the override fields, the authoring affordances) agrees with the nav
|
|
52
|
+
* without each one restating the role.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveUiMode(
|
|
55
|
+
env: UiMode | null,
|
|
56
|
+
stored: UiMode | null,
|
|
57
|
+
surface: RoleSurface = 'full',
|
|
58
|
+
): UiMode {
|
|
59
|
+
if (surface === 'intake') return 'basic'
|
|
42
60
|
return env ?? stored ?? DEFAULT_UI_MODE
|
|
43
61
|
}
|
|
44
62
|
|