spexcode 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +60 -19
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// @@@ board-delta — the pure core of the board's incremental push: decompose a board snapshot into a keyed
|
|
4
|
+
// UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[board-stream]])
|
|
5
|
+
// chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
|
|
6
|
+
// instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
|
|
7
|
+
// dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the
|
|
8
|
+
// equivalence argument (see the spec node's equivalence.md) is checkable by the property tests alone.
|
|
9
|
+
//
|
|
10
|
+
// Unit keys: `node:<id>` (one spec node), `sess:<id>` (one session row), `nodes#order` / `sess#order`
|
|
11
|
+
// (id sequences, preserving array order), `meta` (every other top-level field as one small object).
|
|
12
|
+
// Precondition P: node ids and session ids are collision-free. unitize REPORTS P (`ok`) rather than
|
|
13
|
+
// assuming it — on a violation the transport falls back to full-snapshot sends, so a delta is only ever
|
|
14
|
+
// chained between snapshots where the decomposition is a real bijection.
|
|
15
|
+
|
|
16
|
+
export type Units = Map<string, { j: string; v: unknown }>
|
|
17
|
+
export type Delta = { from: string; to: string; set: Record<string, unknown>; del: string[] }
|
|
18
|
+
|
|
19
|
+
type Boardish = { nodes?: unknown; sessions?: unknown; [k: string]: unknown }
|
|
20
|
+
|
|
21
|
+
// decompose a board into units. `ok` = the bijection precondition held (arrays are arrays, ids unique &
|
|
22
|
+
// non-empty); when false the map is still returned (usable for tagging) but must not seed a delta chain.
|
|
23
|
+
export function unitize(board: Boardish): { units: Units; ok: boolean } {
|
|
24
|
+
const units: Units = new Map()
|
|
25
|
+
let ok = true
|
|
26
|
+
const keyed = (arr: unknown, prefix: string, orderKey: string): void => {
|
|
27
|
+
const list = Array.isArray(arr) ? arr : (ok = false, [])
|
|
28
|
+
const order: string[] = []
|
|
29
|
+
for (const item of list) {
|
|
30
|
+
const id = (item as { id?: unknown })?.id
|
|
31
|
+
if (typeof id !== 'string' || !id || units.has(`${prefix}${id}`)) { ok = false; continue }
|
|
32
|
+
units.set(`${prefix}${id}`, { j: JSON.stringify(item), v: item })
|
|
33
|
+
order.push(id)
|
|
34
|
+
}
|
|
35
|
+
units.set(orderKey, { j: JSON.stringify(order), v: order })
|
|
36
|
+
}
|
|
37
|
+
const { nodes, sessions, ...meta } = board
|
|
38
|
+
keyed(nodes, 'node:', 'nodes#order')
|
|
39
|
+
keyed(sessions, 'sess:', 'sess#order')
|
|
40
|
+
units.set('meta', { j: JSON.stringify(meta), v: meta })
|
|
41
|
+
return { units, ok }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// the snapshot tag: a digest over every unit's key + content hash, order-independent (keys sorted). Two
|
|
45
|
+
// builds serializing equal content get equal tags; JSON.stringify equality is conservative (equal strings ⇒
|
|
46
|
+
// equal values; a key-order difference at worst re-sends an unchanged unit, never misses a changed one).
|
|
47
|
+
export function tagOf(units: Units): string {
|
|
48
|
+
const h = createHash('sha1')
|
|
49
|
+
for (const key of [...units.keys()].sort()) {
|
|
50
|
+
const u = units.get(key)!
|
|
51
|
+
h.update(key).update('\0').update(u.j).update('\0')
|
|
52
|
+
}
|
|
53
|
+
return h.digest('hex')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// diff two unit maps into the minimal patch: units whose serialized content moved land in `set` (with the
|
|
57
|
+
// NEW value), units that vanished land in `del`. apply(prev, diff(prev, next)) = next — the round-trip
|
|
58
|
+
// lemma the property tests pin down.
|
|
59
|
+
export function diffUnits(prev: Units, next: Units): { set: Record<string, unknown>; del: string[] } {
|
|
60
|
+
const set: Record<string, unknown> = {}
|
|
61
|
+
const del: string[] = []
|
|
62
|
+
for (const [key, u] of next) {
|
|
63
|
+
const p = prev.get(key)
|
|
64
|
+
if (!p || p.j !== u.j) set[key] = u.v
|
|
65
|
+
}
|
|
66
|
+
for (const key of prev.keys()) if (!next.has(key)) del.push(key)
|
|
67
|
+
return { set, del }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// apply a patch to a unit-value map — the exact algorithm the dashboard mirrors in data.js, kept here so
|
|
71
|
+
// the round-trip property is provable against the real shape, not a paraphrase of it.
|
|
72
|
+
export function applyDelta(values: Map<string, unknown>, d: Pick<Delta, 'set' | 'del'>): Map<string, unknown> {
|
|
73
|
+
const out = new Map(values)
|
|
74
|
+
for (const key of d.del) out.delete(key)
|
|
75
|
+
for (const [key, v] of Object.entries(d.set)) out.set(key, v)
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// reconstruct the board from unit values — R(U(B)) = B on the P-satisfying subspace (the client's render
|
|
80
|
+
// input after every applied patch). Order rides the #order units, so array order survives the round trip.
|
|
81
|
+
export function boardFromUnits(values: Map<string, unknown>): Boardish {
|
|
82
|
+
const pick = (prefix: string, orderKey: string): unknown[] => {
|
|
83
|
+
const order = (values.get(orderKey) as string[] | undefined) || []
|
|
84
|
+
return order.map((id) => values.get(`${prefix}${id}`))
|
|
85
|
+
}
|
|
86
|
+
const meta = (values.get('meta') as Record<string, unknown> | undefined) || {}
|
|
87
|
+
return { ...meta, nodes: pick('node:', 'nodes#order'), sessions: pick('sess:', 'sess#order') }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const unitValues = (units: Units): Map<string, unknown> => new Map([...units].map(([k, u]) => [k, u.v]))
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { streamSSE } from 'hono/streaming'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { watch, mkdirSync, type FSWatcher } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
|
+
import { sessionSignature } from './sessions.js'
|
|
7
|
+
import { getBoard, invalidateBoard } from './boardCache.js'
|
|
8
|
+
import { unitize, tagOf, diffUnits, type Units } from './boardDelta.js'
|
|
9
|
+
|
|
10
|
+
// @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
|
|
11
|
+
// plain mode it gets a bare `board-changed` and refetches /api/board (the legacy protocol, kept verbatim
|
|
12
|
+
// for old clients); in DELTA mode (`?mode=delta`) the server itself rebuilds on change and streams the
|
|
13
|
+
// hash-chained patch ([[board-delta]]): a `board-full {to, board}` on connect, then `board-delta
|
|
14
|
+
// {from, to, set, del}` per change — a few KB against the ~600KB snapshot, with a full-snapshot send
|
|
15
|
+
// whenever the patch wouldn't win (bigger than the board, or the unit decomposition's id-uniqueness
|
|
16
|
+
// precondition failed), so a delta subscriber is NEVER worse off than a full refetch.
|
|
17
|
+
//
|
|
18
|
+
// Event sources, ALL funneled into one debounced pipeline: (1) fs.watch on the per-user session store —
|
|
19
|
+
// every lifecycle transition lands as a sessions/<id>/session.json write; (2) fs.watch on the shared git
|
|
20
|
+
// dir's refs (+ packed-refs/HEAD) — a commit or merge moves a ref, so tree reshapes push instead of
|
|
21
|
+
// waiting out a poll; (3) a subscriber-gated ~2s poll of the CHEAP tmux session signature ([[sessions]])
|
|
22
|
+
// for liveness/activity, which never touch a file; (4) a delta-gated ~15s cold tick that rebuilds and
|
|
23
|
+
// diffs server-side, catching what no watcher sees (uncommitted worktree spec edits, forge issues) — ONE
|
|
24
|
+
// rebuild per tick total, replacing every open dashboard's own 15s full refetch. Plain mode without delta
|
|
25
|
+
// subscribers keeps its zero-build behavior: sources just fan out `board-changed`.
|
|
26
|
+
|
|
27
|
+
type Notify = () => void
|
|
28
|
+
type Frame = { event: string; data: string }
|
|
29
|
+
type DeltaSend = (frame: Frame) => void
|
|
30
|
+
const plainSubs = new Set<Notify>()
|
|
31
|
+
const deltaSubs = new Set<DeltaSend>()
|
|
32
|
+
let debounce: ReturnType<typeof setTimeout> | null = null
|
|
33
|
+
|
|
34
|
+
// ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
|
|
35
|
+
// last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
|
|
36
|
+
// subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
|
|
37
|
+
// failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
|
|
38
|
+
let lastUnits: Units | null = null
|
|
39
|
+
let lastTag = ''
|
|
40
|
+
let lastFullFrame: Frame | null = null
|
|
41
|
+
let building = false
|
|
42
|
+
let dirty = false
|
|
43
|
+
|
|
44
|
+
async function rebuildAndBroadcast(): Promise<void> {
|
|
45
|
+
if (building) { dirty = true; return }
|
|
46
|
+
building = true
|
|
47
|
+
try {
|
|
48
|
+
do {
|
|
49
|
+
dirty = false
|
|
50
|
+
let board: unknown
|
|
51
|
+
// share the route's single-flight build ([[board-cache]]); fireChanged() already invalidated the
|
|
52
|
+
// cache, so this gets a fresh build (or joins one a concurrent poll already started).
|
|
53
|
+
try { board = await getBoard() } catch { for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }; continue }
|
|
54
|
+
const boardJson = JSON.stringify(board)
|
|
55
|
+
const { units, ok } = unitize(board as Record<string, unknown>)
|
|
56
|
+
const tag = tagOf(units)
|
|
57
|
+
if (tag === lastTag) continue
|
|
58
|
+
const fullFrame: Frame = { event: 'board-full', data: `{"to":"${tag}","board":${boardJson}}` }
|
|
59
|
+
let frame = fullFrame
|
|
60
|
+
if (lastUnits && ok) {
|
|
61
|
+
const { set, del } = diffUnits(lastUnits, units)
|
|
62
|
+
const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del })
|
|
63
|
+
// guaranteed win: ship the patch only when it actually beats the snapshot
|
|
64
|
+
if (deltaData.length < fullFrame.data.length) frame = { event: 'board-delta', data: deltaData }
|
|
65
|
+
}
|
|
66
|
+
lastUnits = ok ? units : null
|
|
67
|
+
lastTag = tag
|
|
68
|
+
lastFullFrame = fullFrame
|
|
69
|
+
for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
|
|
70
|
+
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
71
|
+
} while (dirty)
|
|
72
|
+
} finally { building = false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// a merge/launch/close touches several record files at once; collapse the burst into ONE signal. With
|
|
76
|
+
// delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same tag-moved
|
|
77
|
+
// gate — no spurious refetches); without them it stays the zero-build legacy notify.
|
|
78
|
+
function fireChanged(): void {
|
|
79
|
+
// invalidate the route's board cache ([[board-cache]]) on EVERY change signal, before the debounce guard
|
|
80
|
+
// — a plain-mode client that polls /api/board (no delta rebuild here) must still see fresh data on its
|
|
81
|
+
// next poll, and a delta rebuild below re-reads the same now-stale cache.
|
|
82
|
+
invalidateBoard()
|
|
83
|
+
if (debounce) return
|
|
84
|
+
debounce = setTimeout(() => {
|
|
85
|
+
debounce = null
|
|
86
|
+
if (deltaSubs.size) void rebuildAndBroadcast()
|
|
87
|
+
else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
|
|
88
|
+
}, 150)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- event source 0: an EXPLICIT server-side nudge ----
|
|
92
|
+
// for the one mutation the watchers structurally cannot see: /rename writes the worktree's `.session`
|
|
93
|
+
// (the name's ONE home, outside the watched store — [[session-rename]]). The route calls this instead of
|
|
94
|
+
// double-writing the name into the store: a notification problem gets a notification solution, never a
|
|
95
|
+
// second data home. Same debounced funnel as every other source.
|
|
96
|
+
export const notifyBoardChanged = (): void => fireChanged()
|
|
97
|
+
|
|
98
|
+
// ---- event source 1: the session store (lifecycle status writes) ----
|
|
99
|
+
let watcher: FSWatcher | null = null
|
|
100
|
+
function ensureWatcher(): void {
|
|
101
|
+
if (watcher) return
|
|
102
|
+
const root = sessionsRoot()
|
|
103
|
+
try { mkdirSync(root, { recursive: true }) } catch { /* best-effort; the watch below still tries */ }
|
|
104
|
+
try { watcher = watch(root, { recursive: true }, () => fireChanged()) } catch { watcher = null }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) ----
|
|
108
|
+
// refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
|
|
109
|
+
// for packed-refs rewrites and HEAD flips. Best-effort like every source: no watch → the cold tick covers.
|
|
110
|
+
let refsWatchers: FSWatcher[] | null = null
|
|
111
|
+
function ensureRefsWatcher(): void {
|
|
112
|
+
if (refsWatchers) return
|
|
113
|
+
refsWatchers = []
|
|
114
|
+
try {
|
|
115
|
+
const common = gitCommonDir()
|
|
116
|
+
try { refsWatchers.push(watch(join(common, 'refs'), { recursive: true }, () => fireChanged())) } catch { /* loose refs unwatched */ }
|
|
117
|
+
try { refsWatchers.push(watch(common, (_e, f) => { if (f === 'packed-refs' || f === 'HEAD') fireChanged() })) } catch { /* packed refs unwatched */ }
|
|
118
|
+
} catch { /* not a repo? the cold tick still covers */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- event source 3: the tmux-derived signature (liveness + activity — never a file write) ----
|
|
122
|
+
let poller: ReturnType<typeof setInterval> | null = null
|
|
123
|
+
let lastSig = ''
|
|
124
|
+
function ensureLivePoll(): void {
|
|
125
|
+
if (poller) return
|
|
126
|
+
poller = setInterval(() => {
|
|
127
|
+
void sessionSignature().then((sig) => { if (sig !== lastSig) { lastSig = sig; fireChanged() } }).catch(() => {})
|
|
128
|
+
}, 2000)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- event source 4: the cold tick — the server-side replacement for every client's slow fallback poll.
|
|
132
|
+
// Rebuild+diff on a relaxed timer so what NO watcher sees (an uncommitted worktree spec edit, a forge
|
|
133
|
+
// issue refresh) still lands; an unchanged tag broadcasts nothing. Delta-gated: plain-only clients keep
|
|
134
|
+
// their own client-side fallback, so without delta subscribers this must not burn builds.
|
|
135
|
+
let coldTick: ReturnType<typeof setInterval> | null = null
|
|
136
|
+
function ensureColdTick(): void {
|
|
137
|
+
if (coldTick) return
|
|
138
|
+
coldTick = setInterval(() => { if (deltaSubs.size) void rebuildAndBroadcast() }, 15000)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stopSourcesIfIdle(): void {
|
|
142
|
+
if (plainSubs.size + deltaSubs.size > 0) return
|
|
143
|
+
if (poller) { clearInterval(poller); poller = null; lastSig = '' }
|
|
144
|
+
if (coldTick) { clearInterval(coldTick); coldTick = null }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// GET /api/board/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
|
|
148
|
+
// idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
|
|
149
|
+
// auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `board-full`, so the
|
|
150
|
+
// chain re-anchors with no client-side repair logic.
|
|
151
|
+
export function boardStream(c: Context) {
|
|
152
|
+
const delta = c.req.query('mode') === 'delta'
|
|
153
|
+
ensureWatcher()
|
|
154
|
+
ensureRefsWatcher()
|
|
155
|
+
return streamSSE(c, async (stream) => {
|
|
156
|
+
let aborted = false
|
|
157
|
+
const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
|
|
158
|
+
const notify: Notify = () => { void stream.writeSSE({ event: 'board-changed', data: 'x' }).catch(() => {}) }
|
|
159
|
+
if (delta) { deltaSubs.add(send); ensureColdTick() } else { plainSubs.add(notify) }
|
|
160
|
+
ensureLivePoll()
|
|
161
|
+
const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
|
|
162
|
+
stream.onAbort(() => { aborted = true; unsub() })
|
|
163
|
+
try {
|
|
164
|
+
await stream.writeSSE({ event: 'ready', data: 'x' })
|
|
165
|
+
if (delta) {
|
|
166
|
+
// seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
|
|
167
|
+
// then a fire so a connect during a quiet stretch converges to truly-current within one build.
|
|
168
|
+
if (lastFullFrame) { await stream.writeSSE(lastFullFrame).catch(() => {}) ; fireChanged() }
|
|
169
|
+
else void rebuildAndBroadcast()
|
|
170
|
+
}
|
|
171
|
+
while (!aborted) {
|
|
172
|
+
await stream.sleep(25000)
|
|
173
|
+
if (aborted) break
|
|
174
|
+
await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
175
|
+
}
|
|
176
|
+
} finally { unsub() }
|
|
177
|
+
})
|
|
178
|
+
}
|