@cat-factory/app 0.277.1 → 0.277.2

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.
@@ -45,12 +45,17 @@ beforeEach(() => {
45
45
 
46
46
  // Regression for the live-push CLOBBER race: `board`-type stream events (and the on-connect
47
47
  // resync) each trigger a full-snapshot `refresh()`, and `hydrate` REPLACES the block list. Two
48
- // refreshes can be in flight at once (events >300ms apart, or a resync + a board event), and if a
49
- // slower/staler fetch resolves AFTER a newer one, its hydrate used to clobber the newer state
50
- // dropping a just-spawned block whose only live delivery was the coarse board event, so its card
51
- // never reappeared (no further event to restore it). This surfaced as an intermittent e2e timeout
52
- // where a spawned task/document card never rendered. `refresh()` now stamps each call so only the
53
- // latest-issued one commits; this test pins that a stale out-of-order refresh can't win.
48
+ // refreshes used to be in flight at once (events >300ms apart, or a resync + a board event), and a
49
+ // slower/staler fetch resolving AFTER a newer one clobbered the newer state, dropping a
50
+ // just-spawned block whose only live delivery was the coarse board event, so its card never
51
+ // reappeared (no further event to restore it). That surfaced as an intermittent e2e timeout where a
52
+ // spawned task/document card never rendered.
53
+ //
54
+ // `refresh()` now goes through the funnel, which SERIALIZES: a call arriving during a fetch waits
55
+ // for a follow-up fetch issued after it rather than racing beside it. So the out-of-order clobber
56
+ // is structurally unreachable rather than detected and discarded, and what this test pins is the
57
+ // property that replaced the sequence stamp: overlapping refreshes never interleave, and the board
58
+ // ends on the LAST snapshot fetched. `refreshFunnel.spec.ts` covers the coalescing rules directly.
54
59
 
55
60
  /** Minimal block — only the fields the board store's index getters read. */
56
61
  function block(id: string, over: Partial<Block> = {}): Block {
@@ -102,20 +107,19 @@ function snapshot(
102
107
  }
103
108
 
104
109
  describe('workspace store refresh ordering', () => {
105
- it('a stale refresh resolving out of order does not clobber a newer one', async () => {
110
+ it('serializes overlapping refreshes so the board ends on the last snapshot fetched', async () => {
106
111
  const frame = block('f1')
107
112
  const spawned = block('spawned', { level: 'task', parentId: 'f1' })
108
- let resolveStale!: (s: WorkspaceSnapshot) => void
109
- let resolveFresh!: (s: WorkspaceSnapshot) => void
113
+ let resolveFirst!: (s: WorkspaceSnapshot) => void
110
114
 
111
115
  const getWorkspace = vi
112
116
  .fn()
113
117
  // 1) switchTo establishes the active board (no spawned block yet).
114
118
  .mockResolvedValueOnce(snapshot('ws1', [frame]))
115
- // 2) the EARLIER-issued refresh (stale: still no spawned block) resolved LAST below.
116
- .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveStale = r)))
117
- // 3) the LATER-issued refresh (fresh: the spawned block landed) resolved FIRST below.
118
- .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFresh = r)))
119
+ // 2) the first refresh's fetch: still no spawned block, and held open below.
120
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFirst = r)))
121
+ // 3) the follow-up fetch, ISSUED ONLY AFTER (2) settles, which is why it sees the spawn.
122
+ .mockResolvedValueOnce(snapshot('ws1', [frame, spawned]))
119
123
  vi.stubGlobal('useApi', () => ({ getWorkspace }))
120
124
 
121
125
  const ws = useWorkspaceStore()
@@ -123,21 +127,23 @@ describe('workspace store refresh ordering', () => {
123
127
  await ws.switchTo('ws1')
124
128
  expect(board.getBlock('spawned')).toBeUndefined()
125
129
 
126
- // Two overlapping refreshes: the later-issued one carries the fresher snapshot, but the
127
- // earlier-issued (stale) one resolves last — the exact out-of-order clobber the guard blocks.
128
- const stalePass = ws.refresh()
129
- const freshPass = ws.refresh()
130
- resolveFresh(snapshot('ws1', [frame, spawned]))
131
- resolveStale(snapshot('ws1', [frame]))
132
- await Promise.all([stalePass, freshPass])
130
+ // A second refresh arriving mid-fetch does NOT start a racing fetch: one is outstanding.
131
+ const first = ws.refresh()
132
+ const second = ws.refresh()
133
+ expect(getWorkspace).toHaveBeenCalledTimes(2)
133
134
 
134
- // The fresh snapshot won and the stale one was discarded: the spawned card survives.
135
+ resolveFirst(snapshot('ws1', [frame]))
136
+ await Promise.all([first, second])
137
+
138
+ // The follow-up ran after the first settled and its snapshot is what the board holds, so a
139
+ // caller that refreshed after spawning the block sees it.
140
+ expect(getWorkspace).toHaveBeenCalledTimes(3)
135
141
  expect(board.getBlock('spawned')).toBeDefined()
136
142
  })
137
143
 
138
- // Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`. The
139
- // `refreshSeq` guard above only orders refreshes against each OTHER it does nothing when a
140
- // single refresh's (slow) fetch overlaps a targeted live event. A run's status transitions
144
+ // Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`. Serializing
145
+ // refreshes orders them against each OTHER and does nothing when a single refresh's (slow) fetch
146
+ // overlaps a targeted live event. A run's status transitions
141
147
  // (…→ in_progress → pr_ready/done) arrive as `execution`-event `board.upsert`s; a refresh whose
142
148
  // snapshot was FETCHED while the block was still `in_progress` must not, on resolving later,
143
149
  // replace that block back to the stale status. This was the reliable-under-CI-latency e2e
@@ -14,6 +14,7 @@ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
14
14
  import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
15
15
  import { createWorkspaceCommands } from '~/stores/workspace/commands'
16
16
  import { createInfraSetupState } from '~/stores/workspace/infraSetup'
17
+ import { createRefreshFunnel } from '~/stores/workspace/refreshFunnel'
17
18
  import { markBoot } from '~/utils/bootMarks'
18
19
  import { retryWhileBackendUnreachable } from '~/utils/backendReady'
19
20
 
@@ -198,34 +199,22 @@ export const useWorkspaceStore = defineStore(
198
199
  resolveActiveBoard,
199
200
  })
200
201
 
201
- // Monotonic guard for {@link refresh}: `board`-type stream events (and the on-connect resync)
202
- // each fire a full-snapshot refresh, and {@link hydrate} REPLACES the block list. Without
203
- // ordering, two in-flight fetches can resolve out of order, so a slower/staler snapshot's
204
- // hydrate clobbers a newer one dropping a just-spawned block whose ONLY live delivery was
205
- // the coarse `board` event (there is no per-block push), so its card never reappears (no
206
- // further event to restore it). Stamping each call lets only the latest-issued refresh commit.
207
- let refreshSeq = 0
208
-
209
- /** Re-fetch the snapshot and re-hydrate (after mutations and on stream (re)connect). */
210
- async function refresh() {
211
- const targetId = workspaceId.value
212
- if (!targetId) return
213
- const seq = ++refreshSeq
214
- // Capture the live-write baselines BEFORE the fetch: anything a live event writes while
215
- // this (potentially slow) snapshot is in flight is newer than the snapshot, so `hydrate`
216
- // must NOT clobber it back. The `refreshSeq` guard below only orders refreshes against each
217
- // OTHER — this guards a refresh against an interleaved live write (a run's terminal status
218
- // landing mid-fetch, or the inbox card it raises), the coherence hazard under CI latency.
219
- const baselines: LiveWriteBaselines = {
202
+ // The one door every full-snapshot refresh goes through: it coalesces the ~35 direct
203
+ // post-mutation call sites and the stream's coarse-event resync into at most one in-flight
204
+ // fetch plus one queued follow-up, and exposes the coverage mark the stream's debounce uses to
205
+ // drop a resync a mutation's own refresh already served. Ordering (a stale snapshot's hydrate
206
+ // clobbering a newer one, which would drop a just-spawned block whose ONLY live delivery was
207
+ // the coarse `board` event) falls out of there being one fetch at a time. Rules and the
208
+ // reasoning: `stores/workspace/refreshFunnel.ts`.
209
+ const { refresh, refreshMark, hydratedSince } = createRefreshFunnel({
210
+ currentWorkspaceId: () => workspaceId.value,
211
+ fetchSnapshot: (id, signal) => api.getWorkspace(id, signal),
212
+ captureBaselines: (): LiveWriteBaselines => ({
220
213
  board: useBoardStore().hydrateBaseline(),
221
214
  notifications: useNotificationsStore().hydrateBaseline(),
222
- }
223
- const snapshot = await api.getWorkspace(targetId)
224
- // A newer refresh was issued (or the active board switched) while this fetch was in flight —
225
- // discard this older/staler result so it can't clobber the newer hydrate.
226
- if (seq !== refreshSeq || workspaceId.value !== targetId) return
227
- hydrate(snapshot, baselines)
228
- }
215
+ }),
216
+ apply: hydrate,
217
+ })
229
218
 
230
219
  /** The active workspace id, or throw if the app isn't bootstrapped yet. */
231
220
  function requireId(): string {
@@ -262,6 +251,8 @@ export const useWorkspaceStore = defineStore(
262
251
  rename,
263
252
  remove,
264
253
  refresh,
254
+ refreshMark,
255
+ hydratedSince,
265
256
  requireId,
266
257
  resumeSpend,
267
258
  }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createLaneMemo } from '~/utils/laneIdentity'
3
+ import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
4
+ import type { Block } from '~/types/domain'
5
+
6
+ const taskA = { id: 'a', title: 'A' } as unknown as Block
7
+ const taskB = { id: 'b', title: 'B' } as unknown as Block
8
+
9
+ function entry(task: Block, over: Partial<LaneTaskEntry> = {}): LaneTaskEntry {
10
+ return {
11
+ task,
12
+ reason: 'running',
13
+ order: 0,
14
+ activityAt: null,
15
+ waitingSince: null,
16
+ moduleName: null,
17
+ initiativeName: null,
18
+ epicName: null,
19
+ ...over,
20
+ } as LaneTaskEntry
21
+ }
22
+
23
+ function group(id: string | null, entries: LaneTaskEntry[]): LaneGroup {
24
+ return { id, label: id, entries }
25
+ }
26
+
27
+ function lanes(...groups: LaneGroup[][]): RenderedLane[] {
28
+ const names = ['not_started', 'in_progress', 'needs_you', 'done'] as const
29
+ return groups.map((g, i) => ({
30
+ lane: names[i]!,
31
+ groups: g,
32
+ total: g.reduce((n, x) => n + x.entries.length, 0),
33
+ }))
34
+ }
35
+
36
+ describe('lane structural sharing', () => {
37
+ it('returns the previous array when nothing changed', () => {
38
+ const share = createLaneMemo()
39
+ const first = share(lanes([group('g1', [entry(taskA)])]))
40
+ const second = share(lanes([group('g1', [entry(taskA)])]))
41
+ expect(second).toBe(first)
42
+ expect(second[0]).toBe(first[0])
43
+ expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
44
+ expect(second[0]!.groups[0]!.entries[0]).toBe(first[0]!.groups[0]!.entries[0])
45
+ })
46
+
47
+ it('keeps the untouched lane identical while replacing the one that changed', () => {
48
+ const share = createLaneMemo()
49
+ const first = share(lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB)])]))
50
+ const second = share(
51
+ lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB, { activityAt: 5 })])]),
52
+ )
53
+ expect(second).not.toBe(first)
54
+ expect(second[0]).toBe(first[0])
55
+ expect(second[1]).not.toBe(first[1])
56
+ })
57
+
58
+ // Half of the reference rule: a REPLACED block (what every `board.upsert` does) is a new object,
59
+ // so reuse cannot hand a renderer the pre-write one.
60
+ it('does not reuse an entry whose task object was replaced', () => {
61
+ const share = createLaneMemo()
62
+ const first = share(lanes([group('g1', [entry(taskA)])]))
63
+ const renamed = { id: 'a', title: 'A renamed' } as unknown as Block
64
+ const second = share(lanes([group('g1', [entry(renamed)])]))
65
+ expect(second).not.toBe(first)
66
+ expect(second[0]!.groups[0]!.entries[0]!.task).toBe(renamed)
67
+ })
68
+
69
+ /**
70
+ * The other half, and the case the comparison deliberately does NOT try to detect:
71
+ * `stores/board/placement.ts` patches a block IN PLACE for its optimistic writes, so both entries
72
+ * hold the same object and no comparison over it could see the difference. Reuse is still
73
+ * observationally identical, because the object a renderer reads through IS the patched one (and
74
+ * `board.blocks` is deeply reactive, so the patch invalidates whatever read it). What must not be
75
+ * reused is an entry whose DERIVED fields moved with the patch, which is the assertion below.
76
+ */
77
+ it('reuses an entry whose task was patched in place, but not its derived fields', () => {
78
+ const share = createLaneMemo()
79
+ const task = { id: 'a', title: 'A', moduleName: null } as unknown as Block
80
+ const first = share(lanes([group('g1', [entry(task)])]))
81
+ Object.assign(task, { title: 'A patched', moduleName: 'billing' })
82
+ // Same derived fields: the entry is reused, and it carries the patched object.
83
+ const unchanged = share(lanes([group('g1', [entry(task)])]))
84
+ expect(unchanged).toBe(first)
85
+ expect(unchanged[0]!.groups[0]!.entries[0]!.task.title).toBe('A patched')
86
+ // The assembly re-derived `moduleName` from the patch: that is a fresh entry.
87
+ const rederived = share(lanes([group('g1', [entry(task, { moduleName: 'billing' })])]))
88
+ expect(rederived).not.toBe(first)
89
+ expect(rederived[0]!.groups[0]!.entries[0]!.moduleName).toBe('billing')
90
+ })
91
+
92
+ it('treats reordering, additions and removals as changes', () => {
93
+ const share = createLaneMemo()
94
+ const first = share(lanes([group('g1', [entry(taskA), entry(taskB)])]))
95
+ expect(share(lanes([group('g1', [entry(taskB), entry(taskA)])]))).not.toBe(first)
96
+
97
+ const share2 = createLaneMemo()
98
+ const base = share2(lanes([group('g1', [entry(taskA)])]))
99
+ expect(share2(lanes([group('g1', [entry(taskA), entry(taskB)])]))).not.toBe(base)
100
+ })
101
+
102
+ it('reuses an unchanged group while the sibling group in the same lane changes', () => {
103
+ const share = createLaneMemo()
104
+ const first = share(lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB)])]))
105
+ const second = share(
106
+ lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB, { order: 3 })])]),
107
+ )
108
+ expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
109
+ expect(second[0]!.groups[1]).not.toBe(first[0]!.groups[1])
110
+ })
111
+
112
+ it('does not reuse a lane whose total changed even when its rendered groups match', () => {
113
+ const share = createLaneMemo()
114
+ const first = share(lanes([group('g1', [entry(taskA)])]))
115
+ const capped = lanes([group('g1', [entry(taskA)])])
116
+ capped[0] = { ...capped[0]!, total: 99 }
117
+ expect(share(capped)).not.toBe(first)
118
+ })
119
+ })
@@ -0,0 +1,102 @@
1
+ import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Structural sharing for the swimlane output.
5
+ //
6
+ // WHY. The lane assembly (`useFrameLanes`) is a chain of computeds over the board blocks, both
7
+ // pending-gate indexes, the coarse agent-run summary and the open notifications. Any ONE execution
8
+ // event invalidates the whole chain for EVERY mounted frame, and the chain rebuilds every entry,
9
+ // every group and every lane as fresh objects. Vue then sees new identities all the way down, so
10
+ // `TaskLane` / `LaneGroup` re-render and every card diff re-runs, even for the (common) case where
11
+ // the event changed one run's progress and moved nothing.
12
+ //
13
+ // WHAT THIS DOES. Compare the freshly built value against the previous one field by field and hand
14
+ // BACK the previous object wherever nothing changed, so `===` holds for the parts that did not
15
+ // move. Reuse cascades: unchanged entries let a group be reused, unchanged groups let a lane be
16
+ // reused, unchanged lanes let the whole array be reused.
17
+ //
18
+ // THE CORRECTNESS RULE. Reuse is only sound when the reused object is observationally identical to
19
+ // the fresh one, so the comparison must cover every DERIVED field: a value this assembly computed
20
+ // (`moduleName`, `initiativeName`, `epicName`, the activity/wait stamps) exists only on the entry,
21
+ // so a reused entry carrying a stale one is a lie nothing else can correct. That is why
22
+ // {@link sameEntry} destructures rather than looping over keys, and why a field added to
23
+ // `LaneTaskEntry` must be added to it in the same change.
24
+ //
25
+ // `task` is compared by REFERENCE, and that is NOT because the board store always replaces the
26
+ // object: `stores/board/placement.ts` patches a block IN PLACE for its optimistic drag, reparent and
27
+ // field edits, so the previous entry and the fresh one hold the very same object. Reference equality
28
+ // is still the right comparison, for a different reason on each side. A REPLACED block (every
29
+ // `board.upsert`, i.e. every server-sourced write) is a new reference, so it is caught here. A block
30
+ // patched IN PLACE is one object that both entries share, so a renderer reading through the reused
31
+ // entry reads the new value, and `board.blocks` is deeply reactive, so the mutation itself
32
+ // invalidates whatever read it. What the mutation cannot reach is a value DERIVED off it, which is
33
+ // exactly what the field comparison above covers.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function sameEntry(a: LaneTaskEntry, b: LaneTaskEntry): boolean {
37
+ return (
38
+ a.task === b.task &&
39
+ a.reason === b.reason &&
40
+ a.order === b.order &&
41
+ a.activityAt === b.activityAt &&
42
+ a.waitingSince === b.waitingSince &&
43
+ a.moduleName === b.moduleName &&
44
+ a.initiativeName === b.initiativeName &&
45
+ a.epicName === b.epicName
46
+ )
47
+ }
48
+
49
+ /** Element-wise reuse: the previous array when every member matched, else the fresh one. */
50
+ function shareList<T>(
51
+ previous: readonly T[] | undefined,
52
+ next: T[],
53
+ same: (a: T, b: T) => boolean,
54
+ reuse?: (previous: T, next: T) => T,
55
+ ): T[] {
56
+ if (!previous || previous.length !== next.length) return next
57
+ let changed = false
58
+ const shared = next.map((item, i) => {
59
+ const prior = previous[i]!
60
+ if (same(prior, item)) return prior
61
+ const merged = reuse ? reuse(prior, item) : item
62
+ if (merged !== prior) changed = true
63
+ return merged
64
+ })
65
+ return changed ? shared : (previous as T[])
66
+ }
67
+
68
+ function sameGroup(a: LaneGroup, b: LaneGroup): boolean {
69
+ return a.id === b.id && a.label === b.label && a.entries === b.entries
70
+ }
71
+
72
+ function reuseGroup(previous: LaneGroup, next: LaneGroup): LaneGroup {
73
+ if (previous.id !== next.id || previous.label !== next.label) return next
74
+ const entries = shareList(previous.entries, next.entries, sameEntry)
75
+ return entries === previous.entries ? previous : { ...next, entries }
76
+ }
77
+
78
+ function sameLane(a: RenderedLane, b: RenderedLane): boolean {
79
+ return a.lane === b.lane && a.total === b.total && a.groups === b.groups
80
+ }
81
+
82
+ function reuseLane(previous: RenderedLane, next: RenderedLane): RenderedLane {
83
+ if (previous.lane !== next.lane || previous.total !== next.total) return next
84
+ const groups = shareList(previous.groups, next.groups, sameGroup, reuseGroup)
85
+ return groups === previous.groups ? previous : { ...next, groups }
86
+ }
87
+
88
+ /**
89
+ * A per-frame memo that hands back the previously rendered lanes wherever the freshly assembled
90
+ * ones are identical.
91
+ *
92
+ * Holds ONE generation, not a cache: the previous result is the only thing a recompute can share
93
+ * with, so there is nothing to evict and nothing to bound. Created per `useFrameLanes` instance, so
94
+ * it lives and dies with the frame that owns it.
95
+ */
96
+ export function createLaneMemo(): (next: RenderedLane[]) => RenderedLane[] {
97
+ let previous: RenderedLane[] | undefined
98
+ return (next) => {
99
+ previous = shareList(previous, next, sameLane, reuseLane)
100
+ return previous
101
+ }
102
+ }
@@ -179,8 +179,26 @@ const NEEDS_YOU_TIER: Partial<Record<LaneReason, number>> = {
179
179
 
180
180
  type Comparator = (a: LaneTaskEntry, b: LaneTaskEntry) => number
181
181
 
182
+ /**
183
+ * One collator for every text comparison in this module, built lazily and reused.
184
+ *
185
+ * `String.prototype.localeCompare` constructs a fresh collator per CALL, and these comparators run
186
+ * O(n log n) times per lane per frame on every board event: the `task_type` comparator alone made
187
+ * two of those calls per comparison. Constructing the collator once turns the dominant cost of a
188
+ * text sort back into the comparison itself.
189
+ *
190
+ * Built with NO options, so it is the same collation `a.localeCompare(b)` performed: this is a
191
+ * caching change, and a lane whose order shifted under it would be a behaviour change nobody asked
192
+ * for wearing a performance change's clothes.
193
+ */
194
+ let collator: Intl.Collator | undefined
195
+ function compareText(a: string, b: string): number {
196
+ collator ??= new Intl.Collator()
197
+ return collator.compare(a, b)
198
+ }
199
+
182
200
  const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> = {
183
- title: (a, b) => a.task.title.localeCompare(b.task.title),
201
+ title: (a, b) => compareText(a.task.title, b.task.title),
184
202
  oldest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, ascending),
185
203
  newest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, descending),
186
204
  longest_wait: (a, b) => nullsLast(a.waitingSince, b.waitingSince, ascending),
@@ -188,8 +206,8 @@ const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> =
188
206
  impact_desc: (a, b) =>
189
207
  nullsLast(a.task.estimate?.impact ?? null, b.task.estimate?.impact ?? null, descending),
190
208
  task_type: (a, b) =>
191
- (a.task.taskType ?? '').localeCompare(b.task.taskType ?? '') ||
192
- a.task.title.localeCompare(b.task.title),
209
+ compareText(a.task.taskType ?? '', b.task.taskType ?? '') ||
210
+ compareText(a.task.title, b.task.title),
193
211
  }
194
212
 
195
213
  /**
@@ -242,6 +260,20 @@ export interface LaneGroup {
242
260
  readonly entries: LaneTaskEntry[]
243
261
  }
244
262
 
263
+ /**
264
+ * One rendered lane: its identity, its groups, and the count its header states.
265
+ *
266
+ * Lives beside the shapes it composes rather than in `composables/useFrameLanes`, which assembles
267
+ * it: `utils/laneIdentity.ts` compares these, and a util reaching into a composable for its central
268
+ * type points the dependency the wrong way round.
269
+ */
270
+ export interface RenderedLane {
271
+ readonly lane: TaskLane
272
+ readonly groups: LaneGroup[]
273
+ /** Every task classified into this lane, BEFORE the Done lane's caps. */
274
+ readonly total: number
275
+ }
276
+
245
277
  /**
246
278
  * The value a task is grouped under, or null for the catch-all group.
247
279
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.277.1",
3
+ "version": "0.277.2",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",