@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.
Files changed (69) hide show
  1. package/README.md +158 -4
  2. package/app/components/board/LaneViewControl.vue +87 -0
  3. package/app/components/board/nodes/BlockNode.vue +31 -11
  4. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  5. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  6. package/app/components/board/nodes/LaneGroup.vue +93 -0
  7. package/app/components/board/nodes/LaneTask.vue +66 -0
  8. package/app/components/board/nodes/TaskCard.vue +16 -2
  9. package/app/components/board/nodes/TaskLane.vue +82 -0
  10. package/app/components/layout/BoardToolbar.vue +4 -0
  11. package/app/components/layout/CommandBar.vue +8 -1
  12. package/app/components/layout/RolePrompt.vue +75 -0
  13. package/app/components/layout/SideBar.vue +22 -13
  14. package/app/components/layout/UiRoleSwitcher.vue +73 -0
  15. package/app/components/panels/InspectorPanel.vue +15 -2
  16. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  17. package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
  18. package/app/composables/useBlockDrag.ts +47 -17
  19. package/app/composables/useBlockQueries.ts +27 -24
  20. package/app/composables/useFrameLanes.ts +177 -0
  21. package/app/composables/useNavContributions.ts +3 -0
  22. package/app/composables/useTaskExpansion.ts +1 -1
  23. package/app/docs/consumer-extensions.md +20 -6
  24. package/app/modular/external-tools.spec.ts +0 -45
  25. package/app/modular/external-tools.ts +12 -23
  26. package/app/modular/nav-contributions.spec.ts +176 -17
  27. package/app/modular/nav-contributions.ts +106 -23
  28. package/app/modular/nav-gates.ts +11 -2
  29. package/app/modular/registry.spec.ts +1 -0
  30. package/app/modular/tutorial-tours.spec.ts +5 -3
  31. package/app/modular/tutorial-tours.ts +53 -8
  32. package/app/pages/index.vue +36 -7
  33. package/app/stores/board/placement.ts +7 -0
  34. package/app/stores/board.spec.ts +119 -14
  35. package/app/stores/laneView.spec.ts +61 -0
  36. package/app/stores/laneView.ts +85 -0
  37. package/app/stores/launchPrompt.ts +63 -0
  38. package/app/stores/taskExpansion.spec.ts +1 -1
  39. package/app/stores/taskExpansion.ts +1 -1
  40. package/app/stores/tutorial.ts +4 -4
  41. package/app/stores/uiMode.spec.ts +11 -0
  42. package/app/stores/uiMode.ts +14 -2
  43. package/app/stores/uiRole.spec.ts +185 -0
  44. package/app/stores/uiRole.ts +86 -0
  45. package/app/stores/workspaceSettings.ts +4 -0
  46. package/app/utils/framePlacement.ts +9 -4
  47. package/app/utils/laneGeometry.spec.ts +69 -0
  48. package/app/utils/laneGeometry.ts +104 -0
  49. package/app/utils/laneSort.spec.ts +236 -0
  50. package/app/utils/laneSort.ts +306 -0
  51. package/app/utils/swimlanes.spec.ts +259 -0
  52. package/app/utils/swimlanes.ts +355 -0
  53. package/app/utils/uiMode.spec.ts +12 -0
  54. package/app/utils/uiMode.ts +24 -6
  55. package/app/utils/uiRole.ts +123 -0
  56. package/i18n/locales/de.json +104 -3
  57. package/i18n/locales/en.json +110 -3
  58. package/i18n/locales/es.json +104 -3
  59. package/i18n/locales/fr.json +104 -3
  60. package/i18n/locales/he.json +104 -3
  61. package/i18n/locales/it.json +104 -3
  62. package/i18n/locales/ja.json +104 -3
  63. package/i18n/locales/pl.json +104 -3
  64. package/i18n/locales/tr.json +104 -3
  65. package/i18n/locales/uk.json +104 -3
  66. package/package.json +2 -2
  67. package/app/components/board/nodes/DraggableTask.vue +0 -58
  68. package/app/components/board/nodes/ModuleFrame.vue +0 -73
  69. 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
+ }
@@ -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', () => {
@@ -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 deployment's env value always
7
- * wins over the browser-stored user choice, which wins over the `basic` default. That
8
- * ordering is what lets an operator pin a fleet of kiosk-ish deployments to one tier
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
- /** Apply the precedence: env pin → browser-stored user choice → {@link DEFAULT_UI_MODE}. */
41
- export function resolveUiMode(env: UiMode | null, stored: UiMode | null): UiMode {
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