code-coordinator 0.5.46__py3-none-any.whl
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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic SSE client: owns one `EventSource`-shaped connection,
|
|
3
|
+
* reconnects with exponential backoff + jitter, and reports an honest
|
|
4
|
+
* connection state so the UI never shows stale data as if it were live
|
|
5
|
+
* (#1549).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately decoupled from React and from `@tanstack/react-query` — this
|
|
8
|
+
* module only knows how to keep a stream open and tell you what it's typed
|
|
9
|
+
* events say and what state the stream is in. `RealtimeProvider.tsx` is the
|
|
10
|
+
* thin layer that wires those events to query-key invalidation.
|
|
11
|
+
*
|
|
12
|
+
* ## The StrictMode double-mount trap
|
|
13
|
+
*
|
|
14
|
+
* React 18 `<StrictMode>` mounts every component twice in dev: mount → effect
|
|
15
|
+
* → cleanup → mount → effect. A naive `useEffect(() => { const es = new
|
|
16
|
+
* EventSource(url); return () => es.close() }, [])` is fine on its own, but
|
|
17
|
+
* once reconnect/backoff state lives outside the effect (as it must, to
|
|
18
|
+
* survive a single real disconnect) two failure modes appear:
|
|
19
|
+
*
|
|
20
|
+
* 1. A "closed" flag set by the first cleanup is never cleared by the second
|
|
21
|
+
* mount's `start()`, so the second, real connection silently never
|
|
22
|
+
* retries after its first drop.
|
|
23
|
+
* 2. The first `EventSource`'s `onerror`/`onopen` fires *after* the second
|
|
24
|
+
* `start()` has already begun (both are async), stomping the real
|
|
25
|
+
* connection's state with a stale one.
|
|
26
|
+
*
|
|
27
|
+
* `start()` fixes (1) by unconditionally resetting all intent/backoff state
|
|
28
|
+
* — "intent flags reset in `connect()`", not read from whatever the last
|
|
29
|
+
* `stop()` left behind. `stop()`/`start()` fix (2) by minting a new `token`
|
|
30
|
+
* on every call and closing over it in each socket's callbacks; a callback
|
|
31
|
+
* whose captured token no longer matches the connection's current token
|
|
32
|
+
* belongs to a superseded socket and is dropped ("onclose guarded"). This is
|
|
33
|
+
* the same shape whether the second `start()` comes from StrictMode's replay
|
|
34
|
+
* or from a real remount, so there is exactly one code path to test.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ── Public types ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `disconnected` and `reconnecting` are deliberately distinct: the instant a
|
|
41
|
+
* live stream drops we know only that it's stale (`disconnected`); once a
|
|
42
|
+
* retry attempt is actually in flight the UI can say so (`reconnecting`).
|
|
43
|
+
* `connecting` is the pre-first-connect state, shown only until the very
|
|
44
|
+
* first `open` (or first failure, which moves to `disconnected` too).
|
|
45
|
+
*/
|
|
46
|
+
export type ConnectionState = 'connecting' | 'live' | 'disconnected' | 'reconnecting'
|
|
47
|
+
|
|
48
|
+
export interface ConnectionStatus {
|
|
49
|
+
state: ConnectionState
|
|
50
|
+
/** epoch ms the stream was last confirmed open, or null before the first successful connect. */
|
|
51
|
+
lastLiveAt: number | null
|
|
52
|
+
/** consecutive failed attempts since the last live connection (0 while live or before the first attempt has failed). */
|
|
53
|
+
attempt: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The slice of the browser `EventSource` API this module depends on. */
|
|
57
|
+
export interface SseHandle {
|
|
58
|
+
close(): void
|
|
59
|
+
addEventListener(type: string, listener: (ev: { data: string }) => void): void
|
|
60
|
+
onopen: ((ev: unknown) => void) | null
|
|
61
|
+
onerror: ((ev: unknown) => void) | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type EventSourceFactory = (url: string) => SseHandle
|
|
65
|
+
|
|
66
|
+
export interface SseConnectionOptions {
|
|
67
|
+
url: string
|
|
68
|
+
/** SSE `event:` names to subscribe to; anything else on the stream is ignored. */
|
|
69
|
+
eventTypes: readonly string[]
|
|
70
|
+
/** Called for each received event, already JSON-parsed (raw string if parsing fails). */
|
|
71
|
+
onEvent: (type: string, data: unknown) => void
|
|
72
|
+
/** Called whenever the connection state machine transitions. */
|
|
73
|
+
onStatusChange: (status: ConnectionStatus) => void
|
|
74
|
+
/** Defaults to the browser's native `EventSource`; overridable for tests. */
|
|
75
|
+
createEventSource?: EventSourceFactory
|
|
76
|
+
/** Backoff delays in ms, one per consecutive failed attempt; the last entry repeats. */
|
|
77
|
+
backoffScheduleMs?: readonly number[]
|
|
78
|
+
/** Fractional +/- jitter applied to each backoff delay. Default 0.2; tests pass 0 for exact timing. */
|
|
79
|
+
jitterFraction?: number
|
|
80
|
+
now?: () => number
|
|
81
|
+
setTimeoutFn?: typeof setTimeout
|
|
82
|
+
clearTimeoutFn?: typeof clearTimeout
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SseConnection {
|
|
86
|
+
/** (Re)start the connection, resetting all backoff/intent state. Safe to call while already running. */
|
|
87
|
+
start(): void
|
|
88
|
+
/** Stop for good: closes the socket, cancels any pending retry, no further callbacks fire. */
|
|
89
|
+
stop(): void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** 1s, 2s, 4s, 8s, 15s, then capped at 30s — gentle enough for a single tab, fast enough to feel alive. */
|
|
95
|
+
export const DEFAULT_BACKOFF_MS: readonly number[] = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000]
|
|
96
|
+
|
|
97
|
+
/** +/- 20% jitter so a daemon restart doesn't get every tab retrying in lockstep. */
|
|
98
|
+
const DEFAULT_JITTER_FRACTION = 0.2
|
|
99
|
+
|
|
100
|
+
function withJitter(baseMs: number, jitterFraction: number): number {
|
|
101
|
+
const jitter = baseMs * jitterFraction * (Math.random() * 2 - 1)
|
|
102
|
+
return Math.max(0, Math.round(baseMs + jitter))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function defaultEventSourceFactory(url: string): SseHandle {
|
|
106
|
+
return new EventSource(url) as unknown as SseHandle
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Implementation ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export function createSseConnection(options: SseConnectionOptions): SseConnection {
|
|
112
|
+
const {
|
|
113
|
+
url,
|
|
114
|
+
eventTypes,
|
|
115
|
+
onEvent,
|
|
116
|
+
onStatusChange,
|
|
117
|
+
createEventSource = defaultEventSourceFactory,
|
|
118
|
+
backoffScheduleMs = DEFAULT_BACKOFF_MS,
|
|
119
|
+
jitterFraction = DEFAULT_JITTER_FRACTION,
|
|
120
|
+
now = () => Date.now(),
|
|
121
|
+
setTimeoutFn = setTimeout,
|
|
122
|
+
clearTimeoutFn = clearTimeout,
|
|
123
|
+
} = options
|
|
124
|
+
|
|
125
|
+
// Bumped by every start()/stop(); a socket's callbacks close over the token
|
|
126
|
+
// in effect when they were wired up and no-op once it's stale. This is what
|
|
127
|
+
// makes a superseded (StrictMode-cleaned-up, or genuinely replaced) socket
|
|
128
|
+
// inert instead of racing the current one.
|
|
129
|
+
let token = 0
|
|
130
|
+
let socket: SseHandle | null = null
|
|
131
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
132
|
+
let attempt = 0
|
|
133
|
+
let lastLiveAt: number | null = null
|
|
134
|
+
|
|
135
|
+
function emitStatus(state: ConnectionState): void {
|
|
136
|
+
onStatusChange({ state, lastLiveAt, attempt })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function clearPendingRetry(): void {
|
|
140
|
+
if (timer !== null) {
|
|
141
|
+
clearTimeoutFn(timer)
|
|
142
|
+
timer = null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function closeSocket(): void {
|
|
147
|
+
if (socket !== null) {
|
|
148
|
+
socket.close()
|
|
149
|
+
socket = null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function scheduleRetry(myToken: number): void {
|
|
154
|
+
// `attempt` counts consecutive failures and was just incremented by
|
|
155
|
+
// handleFailure(), so attempt 1 (the first retry) indexes schedule[0].
|
|
156
|
+
const scheduleIndex = Math.min(attempt - 1, backoffScheduleMs.length - 1)
|
|
157
|
+
const delay = withJitter(backoffScheduleMs[scheduleIndex], jitterFraction)
|
|
158
|
+
timer = setTimeoutFn(() => {
|
|
159
|
+
timer = null
|
|
160
|
+
if (myToken !== token) return
|
|
161
|
+
openSocket(myToken)
|
|
162
|
+
}, delay)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleFailure(myToken: number): void {
|
|
166
|
+
if (myToken !== token) return
|
|
167
|
+
closeSocket()
|
|
168
|
+
attempt += 1
|
|
169
|
+
emitStatus('disconnected')
|
|
170
|
+
scheduleRetry(myToken)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function openSocket(myToken: number): void {
|
|
174
|
+
if (myToken !== token) return
|
|
175
|
+
emitStatus(attempt > 0 ? 'reconnecting' : 'connecting')
|
|
176
|
+
|
|
177
|
+
let handle: SseHandle
|
|
178
|
+
try {
|
|
179
|
+
handle = createEventSource(url)
|
|
180
|
+
} catch {
|
|
181
|
+
handleFailure(myToken)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
socket = handle
|
|
185
|
+
|
|
186
|
+
handle.onopen = () => {
|
|
187
|
+
if (myToken !== token) return
|
|
188
|
+
attempt = 0
|
|
189
|
+
lastLiveAt = now()
|
|
190
|
+
emitStatus('live')
|
|
191
|
+
}
|
|
192
|
+
handle.onerror = () => {
|
|
193
|
+
handleFailure(myToken)
|
|
194
|
+
}
|
|
195
|
+
for (const type of eventTypes) {
|
|
196
|
+
handle.addEventListener(type, (ev) => {
|
|
197
|
+
if (myToken !== token) return
|
|
198
|
+
onEvent(type, parsePayload(ev.data))
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
start(): void {
|
|
205
|
+
token += 1
|
|
206
|
+
const myToken = token
|
|
207
|
+
attempt = 0
|
|
208
|
+
clearPendingRetry()
|
|
209
|
+
closeSocket()
|
|
210
|
+
openSocket(myToken)
|
|
211
|
+
},
|
|
212
|
+
stop(): void {
|
|
213
|
+
token += 1
|
|
214
|
+
clearPendingRetry()
|
|
215
|
+
closeSocket()
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parsePayload(raw: string): unknown {
|
|
221
|
+
if (!raw) return null
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(raw)
|
|
224
|
+
} catch {
|
|
225
|
+
return raw
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSE wire vocabulary + which react-query keys each event type affects.
|
|
3
|
+
*
|
|
4
|
+
* NOT just a mirror of `coord/events.py`'s `KNOWN_EVENT_TYPES` — the
|
|
5
|
+
* dashboard server publishes three more event types of its own
|
|
6
|
+
* (`coord/dashboard/server.py`: `ASSIGNMENT_CANCELLED`, `ASSIGNMENT_ADVISORY`,
|
|
7
|
+
* `ASSIGNMENT_NEEDS_ATTENTION`, all pre-existing, not `coord.events`
|
|
8
|
+
* constants). `openSocket()` in `connection.ts` only registers a listener
|
|
9
|
+
* for the `event:` names in `EVENT_TYPES`, so any real server event type
|
|
10
|
+
* missing from this list is simply never delivered to a handler — it is
|
|
11
|
+
* *not* caught by `board_updated`'s catch-all invalidation, because that
|
|
12
|
+
* catch-all only fires for events the client actually subscribed to. This
|
|
13
|
+
* list must therefore cover every event type the dashboard server can
|
|
14
|
+
* publish, not just `coord.events.KNOWN_EVENT_TYPES`. Keep it in sync by
|
|
15
|
+
* hand — neither source is a `scripts/codegen.py` (#750) target (both are
|
|
16
|
+
* sets of string constants, not dataclasses).
|
|
17
|
+
*
|
|
18
|
+
* Query-key granularity here matches what the webapp already has: there is
|
|
19
|
+
* no per-assignment `['pipeline', id]` key (Detail.tsx reads the same
|
|
20
|
+
* `['pipeline']` list Home.tsx does), so "the precise query keys an event
|
|
21
|
+
* affects" bottoms out at `['pipeline']` and `['sessions']` — the two lists
|
|
22
|
+
* every event type below can change the shape of.
|
|
23
|
+
*
|
|
24
|
+
* TODO: once a per-assignment key (`['pipeline', id]`) or `['diff', id]`
|
|
25
|
+
* gets folded into SSE invalidation, revisit every entry below — today they
|
|
26
|
+
* all just invalidate the coarse lists.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const ASSIGNMENT_STARTED = 'assignment_started'
|
|
30
|
+
export const ASSIGNMENT_COMPLETED = 'assignment_completed'
|
|
31
|
+
export const ASSIGNMENT_FAILED = 'assignment_failed'
|
|
32
|
+
// The three below are dashboard-server-only (coord/dashboard/server.py),
|
|
33
|
+
// not coord.events.KNOWN_EVENT_TYPES -- see the module doc comment above.
|
|
34
|
+
export const ASSIGNMENT_CANCELLED = 'assignment_cancelled'
|
|
35
|
+
export const ASSIGNMENT_ADVISORY = 'assignment_advisory'
|
|
36
|
+
export const ASSIGNMENT_NEEDS_ATTENTION = 'assignment_needs_attention'
|
|
37
|
+
export const MACHINE_CONNECTED = 'machine_connected'
|
|
38
|
+
export const MACHINE_DISCONNECTED = 'machine_disconnected'
|
|
39
|
+
export const BOARD_UPDATED = 'board_updated'
|
|
40
|
+
|
|
41
|
+
/** Every event type the client subscribes to on `/events`. */
|
|
42
|
+
export const EVENT_TYPES = [
|
|
43
|
+
ASSIGNMENT_STARTED,
|
|
44
|
+
ASSIGNMENT_COMPLETED,
|
|
45
|
+
ASSIGNMENT_FAILED,
|
|
46
|
+
ASSIGNMENT_CANCELLED,
|
|
47
|
+
ASSIGNMENT_ADVISORY,
|
|
48
|
+
ASSIGNMENT_NEEDS_ATTENTION,
|
|
49
|
+
MACHINE_CONNECTED,
|
|
50
|
+
MACHINE_DISCONNECTED,
|
|
51
|
+
BOARD_UPDATED,
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
/** A react-query `queryKey`. */
|
|
55
|
+
export type QueryKey = readonly unknown[]
|
|
56
|
+
|
|
57
|
+
const PIPELINE: QueryKey = ['pipeline']
|
|
58
|
+
const SESSIONS: QueryKey = ['sessions']
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Event type -> the query keys it invalidates.
|
|
62
|
+
*
|
|
63
|
+
* - `assignment_*` (including the dashboard-server-only `_cancelled` /
|
|
64
|
+
* `_advisory` / `_needs_attention` variants) changes an item's
|
|
65
|
+
* stage/verdict/needs-attention flag (-> pipeline) and can start/stop a
|
|
66
|
+
* live `coord-*` tmux session (-> sessions).
|
|
67
|
+
* - `machine_*` only affects session reachability/attach state (-> sessions).
|
|
68
|
+
* - `board_updated` is the background poller's coarse "something may have
|
|
69
|
+
* changed" heartbeat (every 30s server-side, `coord/dashboard/server.py`
|
|
70
|
+
* `_POLL_INTERVAL`) — treated as a catch-all for both lists so a change
|
|
71
|
+
* this event vocabulary doesn't yet name specifically still surfaces
|
|
72
|
+
* within one heartbeat instead of never. It is a catch-all for *unnamed*
|
|
73
|
+
* changes only, not a substitute for subscribing to a named event type —
|
|
74
|
+
* a type missing from `EVENT_TYPES` above never reaches this map at all
|
|
75
|
+
* (see that constant's doc comment).
|
|
76
|
+
*/
|
|
77
|
+
export const EVENT_QUERY_KEYS: Readonly<Record<string, readonly QueryKey[]>> = {
|
|
78
|
+
[ASSIGNMENT_STARTED]: [PIPELINE, SESSIONS],
|
|
79
|
+
[ASSIGNMENT_COMPLETED]: [PIPELINE, SESSIONS],
|
|
80
|
+
[ASSIGNMENT_FAILED]: [PIPELINE, SESSIONS],
|
|
81
|
+
[ASSIGNMENT_CANCELLED]: [PIPELINE, SESSIONS],
|
|
82
|
+
[ASSIGNMENT_ADVISORY]: [PIPELINE, SESSIONS],
|
|
83
|
+
[ASSIGNMENT_NEEDS_ATTENTION]: [PIPELINE],
|
|
84
|
+
[MACHINE_CONNECTED]: [SESSIONS],
|
|
85
|
+
[MACHINE_DISCONNECTED]: [SESSIONS],
|
|
86
|
+
[BOARD_UPDATED]: [PIPELINE, SESSIONS],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Invalidated on every `disconnected`/`reconnecting` -> `live` recovery,
|
|
91
|
+
* regardless of which events fired.
|
|
92
|
+
*
|
|
93
|
+
* The server's event history ring buffer (`EventSource` in `coord/events.py`,
|
|
94
|
+
* `DEFAULT_HISTORY_SIZE = 256`) lets a reconnecting client backfill via
|
|
95
|
+
* `Last-Event-ID` — but a long enough drop can overflow it, and a daemon
|
|
96
|
+
* restart resets it (and the id sequence) entirely. Backfill is best-effort;
|
|
97
|
+
* an explicit resync on recovery is what makes "live" mean "correct", not
|
|
98
|
+
* just "the socket is open again".
|
|
99
|
+
*/
|
|
100
|
+
export const RESYNC_QUERY_KEYS: readonly QueryKey[] = [PIPELINE, SESSIONS]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the route tree's path builders (#1548).
|
|
3
|
+
*
|
|
4
|
+
* `paths` and `shellViewFromPath` are the trust boundary between "what the
|
|
5
|
+
* URL says" and "what the shell renders" — `ShellLayout` and every list
|
|
6
|
+
* panel import from here rather than templating strings themselves, so a bug
|
|
7
|
+
* here is a bug everywhere at once. Worth its own focused coverage.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { RAIL_VIEW_PATH, isDetailTab, paths, shellViewFromPath } from '../paths'
|
|
11
|
+
|
|
12
|
+
describe('paths.pipelineItem', () => {
|
|
13
|
+
it('builds the two-segment form with no tab', () => {
|
|
14
|
+
expect(paths.pipelineItem('myrepo', 42)).toBe('/pipeline/myrepo/42')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('collapses the default "overview" tab back to the two-segment form', () => {
|
|
18
|
+
expect(paths.pipelineItem('myrepo', 42, 'overview')).toBe('/pipeline/myrepo/42')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('appends a non-default tab as a third segment', () => {
|
|
22
|
+
expect(paths.pipelineItem('myrepo', 42, 'log')).toBe('/pipeline/myrepo/42/log')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('encodes a repo name that needs escaping (an owner/name slug)', () => {
|
|
26
|
+
expect(paths.pipelineItem('owner/name', 42)).toBe('/pipeline/owner%2Fname/42')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('accepts a string issue number unchanged', () => {
|
|
30
|
+
expect(paths.pipelineItem('myrepo', '42')).toBe('/pipeline/myrepo/42')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('paths.session / paths.terminal', () => {
|
|
35
|
+
it('encodes the session id', () => {
|
|
36
|
+
expect(paths.session('a b')).toBe('/sessions/a%20b')
|
|
37
|
+
expect(paths.terminal('a b')).toBe('/terminal/a%20b')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('isDetailTab', () => {
|
|
42
|
+
it('accepts every member of the detail tab set', () => {
|
|
43
|
+
for (const tab of ['overview', 'issue', 'log', 'findings', 'summary']) {
|
|
44
|
+
expect(isDetailTab(tab)).toBe(true)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects an unknown or missing value', () => {
|
|
49
|
+
expect(isDetailTab('bogus')).toBe(false)
|
|
50
|
+
expect(isDetailTab(undefined)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('shellViewFromPath', () => {
|
|
55
|
+
it('resolves the base path for every rail-selectable view', () => {
|
|
56
|
+
expect(shellViewFromPath(paths.pipeline())).toBe('pipeline')
|
|
57
|
+
expect(shellViewFromPath(paths.sessions())).toBe('sessions')
|
|
58
|
+
expect(shellViewFromPath(paths.board())).toBe('board')
|
|
59
|
+
expect(shellViewFromPath(paths.machines())).toBe('machines')
|
|
60
|
+
expect(shellViewFromPath(paths.mergeQueue())).toBe('merge-queue')
|
|
61
|
+
expect(shellViewFromPath(paths.milestones())).toBe('milestones')
|
|
62
|
+
expect(shellViewFromPath(paths.audit())).toBe('audit')
|
|
63
|
+
expect(shellViewFromPath(paths.spend())).toBe('spend')
|
|
64
|
+
expect(shellViewFromPath(paths.settings())).toBe('settings')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('resolves an item route under its view', () => {
|
|
68
|
+
expect(shellViewFromPath(paths.pipelineItem('myrepo', 42))).toBe('pipeline')
|
|
69
|
+
expect(shellViewFromPath(paths.pipelineItem('myrepo', 42, 'log'))).toBe('pipeline')
|
|
70
|
+
expect(shellViewFromPath(paths.session('sess-1'))).toBe('sessions')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not let one view path prefix-match another', () => {
|
|
74
|
+
// '/merge-queue' is not a sub-path of anything else in the map, but this
|
|
75
|
+
// guards the general case: a prefix match must respect the segment
|
|
76
|
+
// boundary (a trailing '/'), not just string startsWith.
|
|
77
|
+
expect(shellViewFromPath('/pipelines')).toBeNull()
|
|
78
|
+
expect(shellViewFromPath('/sessionsx')).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns null for root, terminal, and unknown paths', () => {
|
|
82
|
+
expect(shellViewFromPath('/')).toBeNull()
|
|
83
|
+
expect(shellViewFromPath('/terminal/sess-1')).toBeNull()
|
|
84
|
+
expect(shellViewFromPath('/nope')).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('every RAIL_VIEW_PATH entry round-trips through shellViewFromPath', () => {
|
|
88
|
+
for (const [view, base] of Object.entries(RAIL_VIEW_PATH)) {
|
|
89
|
+
expect(shellViewFromPath(base as string)).toBe(view)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route tree, in one place (#1548).
|
|
3
|
+
*
|
|
4
|
+
* Every screen the app can show has exactly one URL shape, built here rather
|
|
5
|
+
* than string-templated at each call site — a repo name with a `/` in it (a
|
|
6
|
+
* `owner/name` slug) or an id that needs escaping is a bug every hand-rolled
|
|
7
|
+
* template has to remember on its own; a path builder only has to get it
|
|
8
|
+
* right once. `Home`, `Detail`, `SessionsList`, `ShellLayout` and the e2e
|
|
9
|
+
* specs all import from here instead of writing template literals.
|
|
10
|
+
*
|
|
11
|
+
* `PipelineView` (see `src/api/generated.ts`) has no stable per-assignment
|
|
12
|
+
* URL-friendly id worth betting a bookmark on — `assignment_id` is one
|
|
13
|
+
* *attempt* at an issue (work, then a fix, then another fix carry different
|
|
14
|
+
* assignment ids for the same issue) — so the pipeline item route keys on
|
|
15
|
+
* `repo_name` + `issue_number`, which is what a human means by "that issue"
|
|
16
|
+
* and what a link pasted into a GitHub comment or Slack would naturally
|
|
17
|
+
* carry.
|
|
18
|
+
*/
|
|
19
|
+
import { type ShellView } from '@/shell/shellState'
|
|
20
|
+
|
|
21
|
+
/** The `PipelineDetailTab` set (`docs/mocks/web/issue-detail.html`), minus
|
|
22
|
+
* Terminal — the tab *content* is M-W2 scope; this route tree only needs to
|
|
23
|
+
* know the tab is addressable and round-trips. `overview` is the default and
|
|
24
|
+
* is never present in a generated URL (see `pipelineItem` below). */
|
|
25
|
+
export const DETAIL_TABS = ['overview', 'issue', 'log', 'findings', 'summary'] as const
|
|
26
|
+
export type DetailTab = (typeof DETAIL_TABS)[number]
|
|
27
|
+
|
|
28
|
+
export function isDetailTab(value: string | undefined): value is DetailTab {
|
|
29
|
+
return !!value && (DETAIL_TABS as readonly string[]).includes(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Path builders. Every one returns an absolute, already-encoded path. */
|
|
33
|
+
export const paths = {
|
|
34
|
+
pipeline: () => '/pipeline',
|
|
35
|
+
/** `tab` omitted (or `'overview'`) collapses to the two-segment form —
|
|
36
|
+
* `/pipeline/repo/42`, not `/pipeline/repo/42/overview` — so the common
|
|
37
|
+
* case stays the short, shareable URL. */
|
|
38
|
+
pipelineItem: (repo: string, issue: number | string, tab?: DetailTab): string => {
|
|
39
|
+
const base = `/pipeline/${encodeURIComponent(repo)}/${encodeURIComponent(String(issue))}`
|
|
40
|
+
return tab && tab !== 'overview' ? `${base}/${tab}` : base
|
|
41
|
+
},
|
|
42
|
+
sessions: () => '/sessions',
|
|
43
|
+
session: (id: string) => `/sessions/${encodeURIComponent(id)}`,
|
|
44
|
+
terminal: (sessionId: string) => `/terminal/${encodeURIComponent(sessionId)}`,
|
|
45
|
+
board: () => '/board',
|
|
46
|
+
machines: () => '/machines',
|
|
47
|
+
mergeQueue: () => '/merge-queue',
|
|
48
|
+
milestones: () => '/milestones',
|
|
49
|
+
audit: () => '/audit',
|
|
50
|
+
spend: () => '/spend',
|
|
51
|
+
settings: () => '/settings',
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Base path (no `:id`/`:tab`) for every rail-selectable view — what
|
|
56
|
+
* `ActivityRail`'s `onSelect` navigates to. Views with no route of their own
|
|
57
|
+
* (`terminal` is opened *from* a session, never navigated to directly) are
|
|
58
|
+
* omitted; `ActivityRail` never offers them as clickable (see
|
|
59
|
+
* `railItems.ts`'s `'soon'`/no-nav entries) so there is nothing to look up
|
|
60
|
+
* for those, but `Partial` keeps that honest here too rather than papering
|
|
61
|
+
* over it with a route that doesn't exist.
|
|
62
|
+
*/
|
|
63
|
+
export const RAIL_VIEW_PATH: Partial<Record<ShellView, string>> = {
|
|
64
|
+
pipeline: paths.pipeline(),
|
|
65
|
+
board: paths.board(),
|
|
66
|
+
sessions: paths.sessions(),
|
|
67
|
+
machines: paths.machines(),
|
|
68
|
+
'merge-queue': paths.mergeQueue(),
|
|
69
|
+
milestones: paths.milestones(),
|
|
70
|
+
audit: paths.audit(),
|
|
71
|
+
spend: paths.spend(),
|
|
72
|
+
settings: paths.settings(),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The inverse of `RAIL_VIEW_PATH` — which rail view a URL belongs to, for
|
|
77
|
+
* highlighting the right rail entry and picking the right list panel on cold
|
|
78
|
+
* load. `null` means the path doesn't belong to any known view (root `/`,
|
|
79
|
+
* `/terminal/...`, or a genuinely unknown route) — callers decide what that
|
|
80
|
+
* means for them rather than this function guessing a fallback.
|
|
81
|
+
*/
|
|
82
|
+
export function shellViewFromPath(pathname: string): ShellView | null {
|
|
83
|
+
const entries = Object.entries(RAIL_VIEW_PATH) as Array<[ShellView, string]>
|
|
84
|
+
// Longest prefix first: without this, '/merge-queue' would never win
|
|
85
|
+
// because nothing here is a prefix of it *except* itself, but this guards
|
|
86
|
+
// future entries where one path is a literal prefix of another.
|
|
87
|
+
const sorted = entries.sort((a, b) => b[1].length - a[1].length)
|
|
88
|
+
for (const [view, base] of sorted) {
|
|
89
|
+
if (pathname === base || pathname.startsWith(`${base}/`)) return view
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|