@cat-factory/app 0.277.0 → 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.
- package/README.md +46 -10
- package/app/components/board/nodes/TaskCard.vue +5 -4
- package/app/components/board/nodes/TaskLane.vue +1 -2
- package/app/composables/api/workspaces.ts +4 -2
- package/app/composables/useFrameLanes.ts +35 -32
- package/app/composables/useWorkspaceStream.ts +15 -29
- package/app/composables/workspaceStream/coarseRefresh.spec.ts +146 -0
- package/app/composables/workspaceStream/coarseRefresh.ts +121 -0
- package/app/stores/environmentTest.spec.ts +33 -0
- package/app/stores/environmentTest.ts +24 -0
- package/app/stores/execution.spec.ts +50 -0
- package/app/stores/execution.ts +30 -7
- package/app/stores/notifications.ts +16 -0
- package/app/stores/pipelines.ts +9 -2
- package/app/stores/recurringPipelines.ts +15 -1
- package/app/stores/workspace/refreshFunnel.spec.ts +242 -0
- package/app/stores/workspace/refreshFunnel.ts +156 -0
- package/app/stores/workspace.spec.ts +30 -24
- package/app/stores/workspace.ts +17 -26
- package/app/utils/laneIdentity.spec.ts +119 -0
- package/app/utils/laneIdentity.ts +102 -0
- package/app/utils/laneSort.ts +35 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -248,6 +248,19 @@ works in (`not_started`, `in_progress`, `needs_you`) plus a collapsed **Done** s
|
|
|
248
248
|
The vocabulary, the classification and the Done caps are `app/utils/swimlanes.ts`; the ordering and
|
|
249
249
|
grouping are `app/utils/laneSort.ts`; `composables/useFrameLanes.ts` is the only store-facing half.
|
|
250
250
|
|
|
251
|
+
`useFrameLanes` runs ONE INSTANCE PER MOUNTED FRAME and any execution event invalidates all of them,
|
|
252
|
+
so what it derives is a performance decision as much as a modelling one. Two rules hold that line: a
|
|
253
|
+
derivation over WORKSPACE-wide state belongs on the store that owns its input, never here (the
|
|
254
|
+
review-debt map is `notifications.reviewDebtByBlock` for exactly this reason), and the assembled
|
|
255
|
+
output passes through `utils/laneIdentity.ts`, which hands back the previous lane / group / entry
|
|
256
|
+
objects wherever the fresh ones match, so the common event that moves no card leaves `TaskLane` and
|
|
257
|
+
`LaneGroup` diffing on `===`. That reuse is sound only while every DERIVED field is compared (those
|
|
258
|
+
exist nowhere but the entry, so a stale one is a lie nothing corrects), which is why a new
|
|
259
|
+
`LaneTaskEntry` field must be added to `sameEntry` in the same change. The `task` itself is compared
|
|
260
|
+
by reference and deliberately not field by field: a replaced block is a new reference, and an
|
|
261
|
+
in-place patch (`board/placement.ts`, the optimistic drag and field edits) is one object both entries
|
|
262
|
+
share, which the renderer reads through and deep reactivity invalidates on its own.
|
|
263
|
+
|
|
251
264
|
**A lane is a CLAIM, which is a higher bar than a badge.** A mislabelled badge sits beside the
|
|
252
265
|
truth; a card filed in the wrong lane states something false _and_ hides the card from the column
|
|
253
266
|
its reader was scanning. Three rules follow, and they are what the tests pin:
|
|
@@ -942,16 +955,30 @@ event left to restore it.
|
|
|
942
955
|
to the coarse signal, so nothing is lost but the refresh. A bootstrap's frame is always the first
|
|
943
956
|
case, which is why the `bootstrap` event's job rides live while the frame's own transitions
|
|
944
957
|
arrive as coarse `board` events beside it.
|
|
945
|
-
- **Full refreshes
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
958
|
+
- **Full refreshes go through the ONE funnel, which is what makes them monotonic.**
|
|
959
|
+
`workspace.refresh()` IS the funnel (`stores/workspace/refreshFunnel.ts`), so the ~35 direct
|
|
960
|
+
post-mutation call sites need no opt-in and a new one inherits it. It SERIALIZES: at most one
|
|
961
|
+
snapshot fetch is outstanding, so two cannot resolve out of order and no sequence stamp is
|
|
962
|
+
needed. It is deliberately not plain single-flight: a caller arriving mid-fetch joins a single
|
|
963
|
+
QUEUED follow-up rather than the in-flight request, because a caller that mutated and then
|
|
964
|
+
refreshed is entitled to a snapshot read AFTER its call. Do not reintroduce an unguarded
|
|
965
|
+
`hydrate(await fetch())`, and never add a second refresh path beside the funnel.
|
|
966
|
+
- **Serializing makes one stalled request everyone's problem, so the slot is BOUNDED.** The API
|
|
967
|
+
client sets no timeout, and a hung fetch behind a shared slot stops every later refresh, the
|
|
968
|
+
coarse-event resync and the retry chain at once. The funnel puts a deadline on its own read and
|
|
969
|
+
ABORTS it, so a dead connection surfaces as an ordinary failure rather than a wedge; a snapshot
|
|
970
|
+
arriving after the deadline is never applied. Anything else that serializes work behind one slot
|
|
971
|
+
owes the same bound. The same argument is why a dedupe must QUEUE the later ask rather than drop it
|
|
972
|
+
(`environmentTest.reconcileRun`): the outstanding read may predate the state the later ask exists
|
|
973
|
+
to observe.
|
|
949
974
|
- **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
|
|
950
|
-
`connected` only after it settles (which is why e2e gates on `data-connected`).
|
|
975
|
+
`connected` only after it settles (which is why e2e gates on `data-connected`). A resync that
|
|
976
|
+
stands down because a NEWER one started must hand its caller that newer one, not resolve: a
|
|
977
|
+
stand-down is not a reconcile, and `socket.onopen` cannot tell the difference.
|
|
951
978
|
- **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
|
|
952
|
-
into the snapshot or reconcile rather than replace. The
|
|
953
|
-
|
|
954
|
-
|
|
979
|
+
into the snapshot or reconcile rather than replace. The funnel above orders refreshes against
|
|
980
|
+
each OTHER and does nothing when ONE slow fetch straddles a live event, so every such store also
|
|
981
|
+
takes a WATERMARK: `refresh()` captures each one's `hydrateBaseline()`
|
|
955
982
|
before the fetch (`LiveWriteBaselines`) and its `hydrate` keeps whatever was written after it.
|
|
956
983
|
`board` and `notifications` are the two today; `execution` gets the same protection from the
|
|
957
984
|
server `rev` it carries. Whether the store can re-derive the dropped state is what decides how
|
|
@@ -969,9 +996,18 @@ event left to restore it.
|
|
|
969
996
|
"thinking…" bubble spinning). Every echo therefore goes through
|
|
970
997
|
`execution.echoAfter(executionId, send, apply)`, which captures the run's `rev` before the
|
|
971
998
|
request and drops the echo if anything advanced it. Never hand-roll the await-then-assign.
|
|
999
|
+
- **The coarse-event debounce is capped, and it checks coverage before firing.**
|
|
1000
|
+
(`composables/workspaceStream/coarseRefresh.ts`, which owns both ways a full resync is asked for:
|
|
1001
|
+
the on-connect reconcile and the `board`-event fan-out.) Trailing-only
|
|
1002
|
+
re-armed forever under a sustained sub-300ms event stream, so the board stopped resyncing exactly
|
|
1003
|
+
when the workspace was busiest; there is now a max-wait. Before fetching it asks the funnel
|
|
1004
|
+
whether a snapshot issued after the latest coarse event has already hydrated
|
|
1005
|
+
(`refreshMark()` / `hydratedSince()`) and stands down if so, which is what stops a mutation that
|
|
1006
|
+
refreshes directly AND raises a coarse event from paying for two snapshots. That skip rests on
|
|
1007
|
+
the server emitting a coarse `board` event only after committing what it announces.
|
|
972
1008
|
- **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
|
|
973
|
-
`stores/
|
|
974
|
-
wins.
|
|
1009
|
+
`stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
|
|
1010
|
+
for echoes): drive the two orderings and assert the fresher one wins.
|
|
975
1011
|
|
|
976
1012
|
## Internationalization (i18n) authoring
|
|
977
1013
|
|
|
@@ -232,9 +232,10 @@ async function merge() {
|
|
|
232
232
|
// separately by the AgentFailureCard above). The board previously only handled
|
|
233
233
|
// decisions, so an approval-gated task was a dead end: it read "Decision needed"
|
|
234
234
|
// (the old generic `blocked` label) with no badge and a click that did nothing.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
235
|
+
// Read off the per-block index rather than scanning the workspace-wide list: this computed is
|
|
236
|
+
// mounted once per card and invalidated by every execution event, so a `find` over `openDecisions`
|
|
237
|
+
// cost O(cards x open gates) per event. `decisionsByBlock` is the index built for exactly this.
|
|
238
|
+
const pendingDecision = computed(() => execution.decisionsByBlock.get(props.taskId)?.[0])
|
|
238
239
|
// The async stage an iterative reviewer gate (requirements-review / clarity-review) is
|
|
239
240
|
// mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate
|
|
240
241
|
// needs NO human action, so its approval is suppressed below and a working indicator
|
|
@@ -250,7 +251,7 @@ const reviewStageLabel = computed(() =>
|
|
|
250
251
|
: null,
|
|
251
252
|
)
|
|
252
253
|
const pendingApproval = computed(() => {
|
|
253
|
-
const a = execution.
|
|
254
|
+
const a = execution.approvalsByBlock.get(props.taskId)?.[0]
|
|
254
255
|
// A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
|
|
255
256
|
// background work, not awaiting a human — don't surface it as "Approval needed".
|
|
256
257
|
if (a && reviews.isBackground(a.agentKind, props.taskId)) return undefined
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import LaneGroup from './LaneGroup.vue'
|
|
3
|
-
import type { RenderedLane } from '~/composables/useFrameLanes'
|
|
4
3
|
import { LANE_GEOMETRY } from '~/utils/laneGeometry'
|
|
5
|
-
import type { LaneGroupKey } from '~/utils/laneSort'
|
|
4
|
+
import type { LaneGroupKey, RenderedLane } from '~/utils/laneSort'
|
|
6
5
|
import { LANE_META } from '~/utils/swimlanes'
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -29,8 +29,10 @@ export function workspacesApi({ send, ws }: ApiContext) {
|
|
|
29
29
|
body: { name?: string; description?: string; seed?: boolean; accountId?: string } = {},
|
|
30
30
|
) => send(createWorkspaceContract, { body }),
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// `signal` is what lets the refresh funnel put a deadline on the app's heaviest read: it holds
|
|
33
|
+
// one slot for every refresh in the SPA, and the client sets no timeout of its own.
|
|
34
|
+
getWorkspace: (workspaceId: string, signal?: AbortSignal) =>
|
|
35
|
+
send(getWorkspaceContract, { pathParams: { workspaceId }, signal }),
|
|
34
36
|
|
|
35
37
|
updateWorkspace: (workspaceId: string, body: { name?: string; description?: string | null }) =>
|
|
36
38
|
send(updateWorkspaceContract, { pathParams: { workspaceId }, body }),
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { computed, type Ref } from 'vue'
|
|
2
|
-
import { collectReviewDebt } from '@cat-factory/contracts'
|
|
3
2
|
import type { Block } from '~/types/domain'
|
|
3
|
+
import { createLaneMemo } from '~/utils/laneIdentity'
|
|
4
4
|
import {
|
|
5
5
|
groupLaneTasks,
|
|
6
6
|
runActivityAt,
|
|
7
7
|
runWaitingSince,
|
|
8
8
|
sortLaneTasks,
|
|
9
|
-
type LaneGroup,
|
|
10
9
|
type LaneTaskEntry,
|
|
10
|
+
type RenderedLane,
|
|
11
11
|
} from '~/utils/laneSort'
|
|
12
12
|
import {
|
|
13
13
|
classifyTask,
|
|
@@ -17,14 +17,6 @@ import {
|
|
|
17
17
|
type TaskLane,
|
|
18
18
|
} from '~/utils/swimlanes'
|
|
19
19
|
|
|
20
|
-
/** One rendered lane: its identity, its groups, and the count its header states. */
|
|
21
|
-
export interface RenderedLane {
|
|
22
|
-
readonly lane: TaskLane
|
|
23
|
-
readonly groups: LaneGroup[]
|
|
24
|
-
/** Every task classified into this lane, BEFORE the Done lane's caps. */
|
|
25
|
-
readonly total: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
20
|
/** A lane entry plus the lane it was classified into. */
|
|
29
21
|
interface ClassifiedEntry {
|
|
30
22
|
readonly entry: LaneTaskEntry
|
|
@@ -57,16 +49,6 @@ export function useFrameLanes(frameId: Ref<string>) {
|
|
|
57
49
|
() => new Map(board.modulesOf(frameId.value).map((m) => [m.title, m.id])),
|
|
58
50
|
)
|
|
59
51
|
|
|
60
|
-
/**
|
|
61
|
-
* Per-block "waiting since", derived once from the workspace's open review-wait cards by the
|
|
62
|
-
* same `collectReviewDebt` the backend's friction check uses. It is the fallback source for
|
|
63
|
-
* the park surfaces that stamp no `step.pausedAt`; deriving it once rather than per task is
|
|
64
|
-
* what keeps this assembly linear.
|
|
65
|
-
*/
|
|
66
|
-
const waitingSinceByBlock = computed(
|
|
67
|
-
() => new Map(collectReviewDebt(notifications.open).map((d) => [d.blockId, d.waitingSince])),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
52
|
/**
|
|
71
53
|
* The module a task belongs to: the module BLOCK's title when it already lives in one, else
|
|
72
54
|
* the module it DECLARES. The engine only materialises the block on merge
|
|
@@ -79,6 +61,17 @@ export function useFrameLanes(frameId: Ref<string>) {
|
|
|
79
61
|
return task.moduleName?.trim() || null
|
|
80
62
|
}
|
|
81
63
|
|
|
64
|
+
/**
|
|
65
|
+
* One task's lane, reason and rendered entry.
|
|
66
|
+
*
|
|
67
|
+
* Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
|
|
68
|
+
* of its own: this composable runs ONE INSTANCE PER MOUNTED FRAME, so deriving a workspace-wide
|
|
69
|
+
* fact here makes a board with n frames pay for it n times on every change. The review-debt map
|
|
70
|
+
* (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
|
|
71
|
+
* workspace's open notifications, and it belongs on the store that owns its input. The rule for
|
|
72
|
+
* anything else this assembly needs: a per-FRAME derivation belongs here, a workspace-wide one
|
|
73
|
+
* belongs on that store.
|
|
74
|
+
*/
|
|
82
75
|
function classify(task: Block, order: number): ClassifiedEntry {
|
|
83
76
|
const run = execution.getByBlock(task.id) ?? null
|
|
84
77
|
const decisions = execution.decisionsByBlock.get(task.id) ?? []
|
|
@@ -111,7 +104,7 @@ export function useFrameLanes(frameId: Ref<string>) {
|
|
|
111
104
|
reason,
|
|
112
105
|
order,
|
|
113
106
|
activityAt: runActivityAt(run),
|
|
114
|
-
waitingSince: runWaitingSince(run,
|
|
107
|
+
waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
|
|
115
108
|
moduleName: moduleNameOf(task),
|
|
116
109
|
initiativeName: task.initiativeId
|
|
117
110
|
? (board.getBlock(task.initiativeId)?.title ?? null)
|
|
@@ -153,18 +146,28 @@ export function useFrameLanes(frameId: Ref<string>) {
|
|
|
153
146
|
),
|
|
154
147
|
)
|
|
155
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Identity preservation for the assembled output, so an event that changed nothing in a lane
|
|
151
|
+
* hands `TaskLane`/`LaneGroup` the SAME objects it had and their diffs short-circuit on `===`.
|
|
152
|
+
* Every execution event invalidates this whole chain for every mounted frame, and most of them
|
|
153
|
+
* (a subtask tick, a progress fold) move no card at all. See `utils/laneIdentity.ts`.
|
|
154
|
+
*/
|
|
155
|
+
const shareLanes = createLaneMemo()
|
|
156
|
+
|
|
156
157
|
const lanes = computed<RenderedLane[]>(() =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
shareLanes(
|
|
159
|
+
TASK_LANES.map((lane) => {
|
|
160
|
+
const bucket = byLane.value.get(lane) ?? []
|
|
161
|
+
// Only the Done lane is capped; every other lane renders everything in it.
|
|
162
|
+
const visible = lane === 'done' ? admittedByCaps(bucket, doneSelection.value) : bucket
|
|
163
|
+
const ordered = sortLaneTasks(visible, laneView.sortKey, lane)
|
|
164
|
+
return {
|
|
165
|
+
lane,
|
|
166
|
+
groups: groupLaneTasks(ordered, laneView.groupKey, moduleBlockIdByName.value),
|
|
167
|
+
total: bucket.length,
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
168
171
|
)
|
|
169
172
|
|
|
170
173
|
return { lanes, doneSelection }
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
applyWorkspaceEvent,
|
|
6
6
|
type WorkspaceEventTargets,
|
|
7
7
|
} from '~/composables/workspaceStream/applyWorkspaceEvent'
|
|
8
|
+
import { createCoarseRefresh } from '~/composables/workspaceStream/coarseRefresh'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Subscribes to the backend's per-workspace WebSocket event stream and keeps the
|
|
@@ -53,37 +54,22 @@ export function useWorkspaceStream() {
|
|
|
53
54
|
let stopped = false
|
|
54
55
|
let attempt = 0
|
|
55
56
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
56
|
-
let boardDebounce: ReturnType<typeof setTimeout> | null = null
|
|
57
57
|
|
|
58
58
|
// http→ws, https→wss. `apiBase` is an absolute origin on a split-origin deployment (see
|
|
59
59
|
// nuxt.config.ts) and EMPTY on a same-origin one (one proxy in front of the SPA + the API —
|
|
60
60
|
// the compose preview stack), where the socket origin comes from the page instead.
|
|
61
61
|
const wsBase = wsOriginFor(String(apiBase), import.meta.client ? window.location.origin : '')
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await workspace.refresh()
|
|
74
|
-
return
|
|
75
|
-
} catch {
|
|
76
|
-
if (i < REFRESH_MAX_ATTEMPTS - 1) await sleep(Math.min(4_000, 400 * 2 ** i))
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function debouncedBoardRefresh() {
|
|
82
|
-
const workspaceId = workspace.workspaceId
|
|
83
|
-
if (!workspaceId) return
|
|
84
|
-
if (boardDebounce) clearTimeout(boardDebounce)
|
|
85
|
-
boardDebounce = setTimeout(() => void refreshWithRetry(workspaceId), 300)
|
|
86
|
-
}
|
|
63
|
+
// How a full resync is scheduled and driven (the retry chain, the capped debounce and its
|
|
64
|
+
// coverage check), extracted into a cohesive collaborator over bound callbacks so those rules are
|
|
65
|
+
// testable without a socket. This file keeps the socket lifecycle and the event routing.
|
|
66
|
+
const coarse = createCoarseRefresh({
|
|
67
|
+
stopped: () => stopped,
|
|
68
|
+
currentWorkspaceId: () => workspace.workspaceId,
|
|
69
|
+
refresh: () => workspace.refresh(),
|
|
70
|
+
refreshMark: () => workspace.refreshMark(),
|
|
71
|
+
hydratedSince: (mark) => workspace.hydratedSince(mark),
|
|
72
|
+
})
|
|
87
73
|
|
|
88
74
|
// The stores this stream feeds, bound once. Routing lives in `applyWorkspaceEvent` so the
|
|
89
75
|
// targeted-vs-coarse decision on a `board` event is unit-testable without a socket.
|
|
@@ -103,7 +89,7 @@ export function useWorkspaceStream() {
|
|
|
103
89
|
upsertKaizen: (g) => kaizen.upsert(g),
|
|
104
90
|
upsertInitiative: (i) => initiatives.upsert(i),
|
|
105
91
|
upsertDocInterview: (s) => docInterview.upsert(s),
|
|
106
|
-
refreshBoard: () =>
|
|
92
|
+
refreshBoard: () => coarse.schedule(),
|
|
107
93
|
}
|
|
108
94
|
|
|
109
95
|
function onMessage(raw: string) {
|
|
@@ -159,10 +145,10 @@ export function useWorkspaceStream() {
|
|
|
159
145
|
// it. Anything acting on a `connected` board (a user, or an e2e spec gating on
|
|
160
146
|
// `data-connected`) then does so only after this reconcile, so a lagging resync
|
|
161
147
|
// can't drop the state that action produces. The resync RETRIES on a transient
|
|
162
|
-
// failure (`
|
|
148
|
+
// failure (`coarse.withRetry`) so a reconnect no longer presents as fully live while
|
|
163
149
|
// silently missing everything from the outage; `connected` is still set even if every
|
|
164
150
|
// retry fails (we ARE connected; a refresh error must not wedge the indicator/tests).
|
|
165
|
-
void
|
|
151
|
+
void coarse.withRetry(workspaceId).finally(() => {
|
|
166
152
|
// A workspace switch (or stop()) may have happened while the refresh was in
|
|
167
153
|
// flight — don't announce a connection for a socket we've since abandoned.
|
|
168
154
|
if (!stopped && socket && workspace.workspaceId === workspaceId) {
|
|
@@ -206,7 +192,7 @@ export function useWorkspaceStream() {
|
|
|
206
192
|
function stop() {
|
|
207
193
|
stopped = true
|
|
208
194
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
209
|
-
|
|
195
|
+
coarse.cancel()
|
|
210
196
|
socket?.close()
|
|
211
197
|
socket = null
|
|
212
198
|
connected.value = false
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { createCoarseRefresh } from '~/composables/workspaceStream/coarseRefresh'
|
|
3
|
+
|
|
4
|
+
function harness() {
|
|
5
|
+
const pending: { resolve: () => void; reject: (e: Error) => void }[] = []
|
|
6
|
+
let workspaceId: string | null = 'ws1'
|
|
7
|
+
let stopped = false
|
|
8
|
+
let mark = 0
|
|
9
|
+
/** The mark a hydrated fetch was issued after, i.e. what `hydratedSince` answers against. */
|
|
10
|
+
let hydratedAfter: number | null = null
|
|
11
|
+
|
|
12
|
+
const coarse = createCoarseRefresh({
|
|
13
|
+
stopped: () => stopped,
|
|
14
|
+
currentWorkspaceId: () => workspaceId,
|
|
15
|
+
refresh: () => new Promise<void>((resolve, reject) => pending.push({ resolve, reject })),
|
|
16
|
+
refreshMark: () => mark,
|
|
17
|
+
hydratedSince: (m) => hydratedAfter !== null && hydratedAfter > m,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
coarse,
|
|
22
|
+
refreshes: () => pending.length,
|
|
23
|
+
settle: async (i = pending.length - 1) => {
|
|
24
|
+
pending[i]!.resolve()
|
|
25
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
26
|
+
},
|
|
27
|
+
fail: async (i = pending.length - 1) => {
|
|
28
|
+
pending[i]!.reject(new Error('offline'))
|
|
29
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
30
|
+
},
|
|
31
|
+
setWorkspaceId: (id: string | null) => {
|
|
32
|
+
workspaceId = id
|
|
33
|
+
},
|
|
34
|
+
stop: () => {
|
|
35
|
+
stopped = true
|
|
36
|
+
},
|
|
37
|
+
/** Pretend a direct `refresh()` call site already hydrated a snapshot issued just now. */
|
|
38
|
+
coverNow: () => {
|
|
39
|
+
mark += 1
|
|
40
|
+
hydratedAfter = mark
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('coarse refresh', () => {
|
|
46
|
+
beforeEach(() => vi.useFakeTimers())
|
|
47
|
+
afterEach(() => vi.useRealTimers())
|
|
48
|
+
|
|
49
|
+
it('retries a transient failure with backoff', async () => {
|
|
50
|
+
const h = harness()
|
|
51
|
+
const done = h.coarse.withRetry('ws1')
|
|
52
|
+
expect(h.refreshes()).toBe(1)
|
|
53
|
+
await h.fail()
|
|
54
|
+
expect(h.refreshes()).toBe(1) // still in backoff
|
|
55
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
56
|
+
expect(h.refreshes()).toBe(2)
|
|
57
|
+
await h.settle()
|
|
58
|
+
await done
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('stops retrying once the stream stopped or the board switched', async () => {
|
|
62
|
+
const h = harness()
|
|
63
|
+
const done = h.coarse.withRetry('ws1')
|
|
64
|
+
await h.fail()
|
|
65
|
+
h.setWorkspaceId('ws2')
|
|
66
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
67
|
+
await done
|
|
68
|
+
expect(h.refreshes()).toBe(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The readiness rule. `socket.onopen` announces `connected` off this promise, so a chain that
|
|
73
|
+
* stands down for a NEWER one must hand its caller that newer chain: resolving on supersession
|
|
74
|
+
* would announce a board whose reconcile has not run, which is exactly what gating the
|
|
75
|
+
* announcement on the resync exists to prevent.
|
|
76
|
+
*/
|
|
77
|
+
it('a superseded chain resolves only once the chain that replaced it has reconciled', async () => {
|
|
78
|
+
const h = harness()
|
|
79
|
+
let announced = false
|
|
80
|
+
const open = h.coarse.withRetry('ws1').then(() => {
|
|
81
|
+
announced = true
|
|
82
|
+
})
|
|
83
|
+
// The on-connect chain's first attempt fails, so it drops into backoff...
|
|
84
|
+
await h.fail()
|
|
85
|
+
// ...and a buffered coarse event starts a newer chain while it sleeps.
|
|
86
|
+
const newer = h.coarse.withRetry('ws1')
|
|
87
|
+
expect(h.refreshes()).toBe(2)
|
|
88
|
+
|
|
89
|
+
// The older chain wakes, sees it has been superseded and stops issuing fetches of its own.
|
|
90
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
91
|
+
expect(h.refreshes()).toBe(2)
|
|
92
|
+
expect(announced).toBe(false)
|
|
93
|
+
|
|
94
|
+
await h.settle()
|
|
95
|
+
await Promise.all([open, newer])
|
|
96
|
+
expect(announced).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('debounce', () => {
|
|
100
|
+
it('collapses a burst of coarse events into one refresh', async () => {
|
|
101
|
+
const h = harness()
|
|
102
|
+
h.coarse.schedule()
|
|
103
|
+
h.coarse.schedule()
|
|
104
|
+
h.coarse.schedule()
|
|
105
|
+
await vi.advanceTimersByTimeAsync(300)
|
|
106
|
+
expect(h.refreshes()).toBe(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `cancel()` must clear the window, not just the timer. The window start is read whenever a
|
|
111
|
+
* window is already open, so a handle left behind here lets the first event of the NEXT session
|
|
112
|
+
* inherit a start whose max-wait expired long ago and fire an undebounced full snapshot fetch.
|
|
113
|
+
*/
|
|
114
|
+
it('debounces the first event after a cancel instead of firing immediately', async () => {
|
|
115
|
+
const h = harness()
|
|
116
|
+
h.coarse.schedule()
|
|
117
|
+
h.coarse.cancel()
|
|
118
|
+
// Long enough that the cancelled window's max-wait would have elapsed.
|
|
119
|
+
await vi.advanceTimersByTimeAsync(3_000)
|
|
120
|
+
expect(h.refreshes()).toBe(0)
|
|
121
|
+
|
|
122
|
+
h.coarse.schedule()
|
|
123
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
124
|
+
expect(h.refreshes()).toBe(0)
|
|
125
|
+
await vi.advanceTimersByTimeAsync(300)
|
|
126
|
+
expect(h.refreshes()).toBe(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('fires within the max wait under a sustained event stream', async () => {
|
|
130
|
+
const h = harness()
|
|
131
|
+
for (let i = 0; i < 12; i++) {
|
|
132
|
+
h.coarse.schedule()
|
|
133
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
134
|
+
}
|
|
135
|
+
expect(h.refreshes()).toBeGreaterThan(0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('stands down when a snapshot issued after the event has already hydrated', async () => {
|
|
139
|
+
const h = harness()
|
|
140
|
+
h.coarse.schedule()
|
|
141
|
+
h.coverNow()
|
|
142
|
+
await vi.advanceTimersByTimeAsync(300)
|
|
143
|
+
expect(h.refreshes()).toBe(0)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the stream asks for a FULL resync: the on-(re)connect reconcile and the coarse `board`
|
|
3
|
+
* event's debounced fan-out, which are the same operation reached two ways.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `useWorkspaceStream` (which keeps the socket lifecycle and the event routing) so
|
|
6
|
+
* the scheduling rules below are unit-testable without a WebSocket. Every dependency is a bound
|
|
7
|
+
* callback, so nothing here reaches for a store.
|
|
8
|
+
*/
|
|
9
|
+
export interface CoarseRefreshDeps {
|
|
10
|
+
/** True once the stream has stopped: a scheduled pass stands down rather than firing. */
|
|
11
|
+
readonly stopped: () => boolean
|
|
12
|
+
/** The board the stream is bound to right now, or null before bootstrap. */
|
|
13
|
+
readonly currentWorkspaceId: () => string | null
|
|
14
|
+
/** `workspace.refresh()`: the one funnel every full-snapshot refresh goes through. */
|
|
15
|
+
readonly refresh: () => Promise<void>
|
|
16
|
+
/** The funnel's coverage mark, taken when a coarse event arrives. */
|
|
17
|
+
readonly refreshMark: () => number
|
|
18
|
+
/** Whether a snapshot fetch issued after `mark` has already hydrated. */
|
|
19
|
+
readonly hydratedSince: (mark: number) => boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CoarseRefresh {
|
|
23
|
+
/**
|
|
24
|
+
* Reconcile the board now, retrying a transient failure. Resolves once the board HAS been
|
|
25
|
+
* reconciled, or once the attempts are spent; a caller may treat that as "safe to announce".
|
|
26
|
+
*/
|
|
27
|
+
readonly withRetry: (workspaceId: string) => Promise<void>
|
|
28
|
+
/** Schedule a debounced coarse resync (the `board`-event fan-out). */
|
|
29
|
+
readonly schedule: () => void
|
|
30
|
+
/** Drop any pending pass. */
|
|
31
|
+
readonly cancel: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A coarse board refresh (the resync on reconnect, and the `board` event fan-out) must not be
|
|
35
|
+
// left silently stale by ONE transient failure: retry a few times with backoff so a blip
|
|
36
|
+
// self-heals. Bounded (the socket-level reconnect + the offline banner are the backstop for a
|
|
37
|
+
// genuine outage). Aborts between attempts if the stream stopped or the workspace switched.
|
|
38
|
+
const REFRESH_MAX_ATTEMPTS = 4
|
|
39
|
+
|
|
40
|
+
// The coarse-event debounce. Trailing, so a burst of `board` events costs one refresh, and
|
|
41
|
+
// CAPPED, because trailing alone re-armed the timer forever under a sustained sub-300ms stream:
|
|
42
|
+
// the board stopped resyncing exactly when the workspace was busiest. Past the cap the pending
|
|
43
|
+
// refresh fires on schedule and the next event starts a fresh window.
|
|
44
|
+
const BOARD_DEBOUNCE_MS = 300
|
|
45
|
+
const BOARD_DEBOUNCE_MAX_WAIT_MS = 2_000
|
|
46
|
+
|
|
47
|
+
export function createCoarseRefresh(deps: CoarseRefreshDeps): CoarseRefresh {
|
|
48
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
49
|
+
|
|
50
|
+
// A chain stands down when a NEWER one has started: the backoff sleeps run for seconds, so a
|
|
51
|
+
// sustained event stream used to leave several chains alive at once, each still issuing full
|
|
52
|
+
// snapshot fetches for a resync a later chain had already superseded.
|
|
53
|
+
//
|
|
54
|
+
// Standing down HANDS THE CALLER THE NEWER CHAIN rather than resolving, because the socket's
|
|
55
|
+
// `onopen` announces `connected` off this promise: resolving on supersession would announce a
|
|
56
|
+
// board whose reconcile is still in flight, which is the stale-readiness bug the
|
|
57
|
+
// resync-before-announce ordering exists to prevent. A stood-down chain always awaits a STRICTLY
|
|
58
|
+
// newer one, so the wait terminates at whichever chain is newest.
|
|
59
|
+
let chainCount = 0
|
|
60
|
+
let newest: Promise<void> = Promise.resolve()
|
|
61
|
+
|
|
62
|
+
function withRetry(workspaceId: string): Promise<void> {
|
|
63
|
+
const chain = ++chainCount
|
|
64
|
+
const run = drive(workspaceId, chain)
|
|
65
|
+
newest = run
|
|
66
|
+
return run
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function drive(workspaceId: string, chain: number): Promise<void> {
|
|
70
|
+
for (let i = 0; i < REFRESH_MAX_ATTEMPTS; i++) {
|
|
71
|
+
if (deps.stopped() || deps.currentWorkspaceId() !== workspaceId) return
|
|
72
|
+
if (chain !== chainCount) return newest
|
|
73
|
+
try {
|
|
74
|
+
await deps.refresh()
|
|
75
|
+
return
|
|
76
|
+
} catch {
|
|
77
|
+
if (i < REFRESH_MAX_ATTEMPTS - 1) await sleep(Math.min(4_000, 400 * 2 ** i))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
83
|
+
let windowStart = 0
|
|
84
|
+
// The funnel's coverage mark as of the LATEST coarse event in this window. A snapshot fetch
|
|
85
|
+
// issued after that event necessarily contains what the event announced (the server emits it
|
|
86
|
+
// after committing), so if one has already hydrated by the time the timer fires there is nothing
|
|
87
|
+
// left to resync. This is what stops a mutation that refreshes directly AND raises a coarse
|
|
88
|
+
// event from paying for two full snapshots.
|
|
89
|
+
let coverageMark = 0
|
|
90
|
+
|
|
91
|
+
function schedule() {
|
|
92
|
+
const workspaceId = deps.currentWorkspaceId()
|
|
93
|
+
if (!workspaceId) return
|
|
94
|
+
const now = Date.now()
|
|
95
|
+
coverageMark = deps.refreshMark()
|
|
96
|
+
if (timer) clearTimeout(timer)
|
|
97
|
+
else windowStart = now
|
|
98
|
+
const wait = Math.max(
|
|
99
|
+
0,
|
|
100
|
+
Math.min(BOARD_DEBOUNCE_MS, windowStart + BOARD_DEBOUNCE_MAX_WAIT_MS - now),
|
|
101
|
+
)
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
timer = null
|
|
104
|
+
if (deps.hydratedSince(coverageMark)) return
|
|
105
|
+
void withRetry(workspaceId)
|
|
106
|
+
}, wait)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `timer` is NULLED, not just cleared: it is what says "a window is open", so a stale handle left
|
|
111
|
+
* behind here would make the first event of the next session inherit the previous session's
|
|
112
|
+
* window start, whose max-wait has long since expired, and fire a full snapshot fetch immediately
|
|
113
|
+
* with no debounce at all.
|
|
114
|
+
*/
|
|
115
|
+
function cancel() {
|
|
116
|
+
if (timer) clearTimeout(timer)
|
|
117
|
+
timer = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { withRetry, schedule, cancel }
|
|
121
|
+
}
|
|
@@ -77,6 +77,39 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
77
77
|
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Overlapping refreshes preserve the same still-running run, so the point-read is deduped. It
|
|
82
|
+
* must be deduped with a QUEUED FOLLOW-UP rather than dropped: the outstanding read may have been
|
|
83
|
+
* issued before the run reached terminal, and it is the later ask that would observe the outcome.
|
|
84
|
+
* Nothing asks a third time (terminal runs emit no event and the snapshot omits them), so a
|
|
85
|
+
* dropped ask leaves the inspector on "testing" for the rest of the session.
|
|
86
|
+
*/
|
|
87
|
+
it('re-reads once when a hydrate asks again while a point-read is still out', async () => {
|
|
88
|
+
store.upsert(run('r1', { status: 'running', updatedAt: 5 }))
|
|
89
|
+
let releaseFirst!: () => void
|
|
90
|
+
const held = new Promise<void>((r) => (releaseFirst = r))
|
|
91
|
+
apiMock.getEnvironmentTest = vi
|
|
92
|
+
.fn()
|
|
93
|
+
// Issued before the run reached terminal, so it can only ever answer `running`.
|
|
94
|
+
.mockImplementationOnce(async () => {
|
|
95
|
+
await held
|
|
96
|
+
return run('r1', { status: 'running', updatedAt: 6 })
|
|
97
|
+
})
|
|
98
|
+
// The queued follow-up: the read that picks up the outcome.
|
|
99
|
+
.mockImplementationOnce(async () =>
|
|
100
|
+
run('r1', { status: 'succeeded', stage: 'done', updatedAt: 9 }),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
store.hydrate([], 'ws_test')
|
|
104
|
+
store.hydrate([], 'ws_test')
|
|
105
|
+
// Deduped while the first read is out: the second hydrate did not issue its own.
|
|
106
|
+
expect(apiMock.getEnvironmentTest).toHaveBeenCalledTimes(1)
|
|
107
|
+
|
|
108
|
+
releaseFirst()
|
|
109
|
+
await vi.waitFor(() => expect(store.runForBlock('blk_r1')!.status).toBe('succeeded'))
|
|
110
|
+
expect(apiMock.getEnvironmentTest).toHaveBeenCalledTimes(2)
|
|
111
|
+
})
|
|
112
|
+
|
|
80
113
|
it('hydrate DROPS a cached run from a different workspace (board switch starts clean)', () => {
|
|
81
114
|
store.upsert(run('r1', { status: 'failed', updatedAt: 5, workspaceId: 'ws_other' }))
|
|
82
115
|
store.hydrate([run('r2', { workspaceId: 'ws_test' })], 'ws_test')
|