weir-harness 1.2.3 → 2.1.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/cordis.patch.yml +7 -18
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The read-only ownership view of the status surface (design D13).
|
|
2
|
+
//
|
|
3
|
+
// This module is the ONE place that decides what a user may see about
|
|
4
|
+
// ownership, and it is deliberately the narrowest possible projection:
|
|
5
|
+
//
|
|
6
|
+
// - three facts and nothing else — who holds which short path, when the hold
|
|
7
|
+
// started, and how long it can last. No epoch, no generation, no
|
|
8
|
+
// reservation, no recovery record, no operation history, no fence. Those
|
|
9
|
+
// concepts were deleted with the durable authority (D12); a view that still
|
|
10
|
+
// carried them would let the panel grow a story that no longer exists.
|
|
11
|
+
// - short names only. An absolute path in `path` would leak the user's home
|
|
12
|
+
// directory into a UI string and, worse, invite someone to "fix" a row by
|
|
13
|
+
// path. `pathFull` is the separate, mechanically faithful field the
|
|
14
|
+
// technical layer may use; the panel does not render it by default.
|
|
15
|
+
// - purity. No IO, no clock, no arbiter, no cordis: `now` arrives as an
|
|
16
|
+
// argument so the expiry comparison is the caller's clock, exactly the
|
|
17
|
+
// "read-time comparison" the lease protocol specifies (D2). A lapsed row is
|
|
18
|
+
// therefore not ownership any more — showing it would invent a state whose
|
|
19
|
+
// only exit is somebody cleaning it up, which the rebuild forbids.
|
|
20
|
+
//
|
|
21
|
+
// The arbiter's own health is a closed three-value vocabulary (D7): a healthy
|
|
22
|
+
// arbiter arbitrates, a degraded one lost the connection it once had, and an
|
|
23
|
+
// unavailable one was never reached in this process. Anything else is reported
|
|
24
|
+
// as `unavailable` — an unknown state must never be presented as healthy.
|
|
25
|
+
|
|
26
|
+
import { shortName as shortPathName } from './tools.js'
|
|
27
|
+
|
|
28
|
+
/** The only shape of this view the panel understands. */
|
|
29
|
+
export const VIEW_SCHEMA_VERSION = 1
|
|
30
|
+
|
|
31
|
+
/** Closed arbiter vocabulary (design D7); anything else renders as unavailable. */
|
|
32
|
+
export const ARBITER_STATES = Object.freeze(['ok', 'degraded', 'unavailable'])
|
|
33
|
+
|
|
34
|
+
/** @returns {string | undefined} the value when it is a non-empty string */
|
|
35
|
+
function text(value) {
|
|
36
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A millisecond instant, or `null` when the field is absent. `null` (not 0) is
|
|
41
|
+
* the view's "no such deadline" — the panel distinguishes "not retained" from
|
|
42
|
+
* "retained until the epoch".
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @returns {number | null}
|
|
45
|
+
*/
|
|
46
|
+
function instant(value) {
|
|
47
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* When a lease stops being ownership: `max(expiresAt, holdUntil ?? 0)`, the
|
|
52
|
+
* protocol's one validity rule (D2). Not a second authority — the arbiter
|
|
53
|
+
* already applied it; this is what makes the view agree with the arbiter
|
|
54
|
+
* without asking it again.
|
|
55
|
+
* @param {any} lease
|
|
56
|
+
* @returns {number}
|
|
57
|
+
*/
|
|
58
|
+
function validUntil(lease) {
|
|
59
|
+
const expiresAt = instant(lease?.expiresAt) ?? 0
|
|
60
|
+
const holdUntil = instant(lease?.holdUntil) ?? 0
|
|
61
|
+
return Math.max(expiresAt, holdUntil)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The short name of one lease path: relative inside the session's cwd, the
|
|
66
|
+
* basename outside it. Never absolute (the rule itself lives in `tools.js`, so
|
|
67
|
+
* the model-facing status text and this view cannot disagree).
|
|
68
|
+
* @param {string} path
|
|
69
|
+
* @param {string | undefined} cwd
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function shortName(path, cwd) {
|
|
73
|
+
try {
|
|
74
|
+
return shortPathName(path, cwd)
|
|
75
|
+
} catch {
|
|
76
|
+
// A malformed path must degrade to something renderable, never throw in
|
|
77
|
+
// the endpoint: the panel is an observability surface, not a validator.
|
|
78
|
+
return path
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the read-only ownership view.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} input
|
|
86
|
+
* @param {any[]} [input.leases] the arbiter's `status` snapshot (`LeaseView[]`)
|
|
87
|
+
* @param {string | undefined} [input.mineLineage] the calling session's lineage id (design D9)
|
|
88
|
+
* @param {string | undefined} [input.cwd] the session's working directory (short-name base and scope)
|
|
89
|
+
* @param {'ok' | 'degraded' | 'unavailable' | string} [input.arbiter] the client's read-only connection state
|
|
90
|
+
* @param {number} [input.now] read-time instant (epoch ms) used for the expiry comparison
|
|
91
|
+
* @returns {{
|
|
92
|
+
* schemaVersion: number,
|
|
93
|
+
* arbiter: 'ok' | 'degraded' | 'unavailable',
|
|
94
|
+
* mine: { path: string, pathFull: string, label: string, heldSince: number | null, holdUntil: number | null }[],
|
|
95
|
+
* others: { path: string, pathFull: string, holder: { label: string, session: string }, expiresAt: number | null, holdUntil: number | null }[],
|
|
96
|
+
* }}
|
|
97
|
+
*/
|
|
98
|
+
export function buildLeaseView({ leases, mineLineage, cwd, arbiter, now } = {}) {
|
|
99
|
+
const at = instant(now) ?? 0
|
|
100
|
+
const base = text(cwd)
|
|
101
|
+
const mineId = text(mineLineage)
|
|
102
|
+
/** @type {any[]} */
|
|
103
|
+
const mine = []
|
|
104
|
+
/** @type {any[]} */
|
|
105
|
+
const others = []
|
|
106
|
+
for (const lease of Array.isArray(leases) ? leases : []) {
|
|
107
|
+
if (lease === null || typeof lease !== 'object') continue
|
|
108
|
+
const path = text(lease.path)
|
|
109
|
+
if (path === undefined) continue
|
|
110
|
+
// Lapsed rows are not ownership: ownership ends by itself (D2/D10), and the
|
|
111
|
+
// view says so instead of presenting a row somebody would have to clear.
|
|
112
|
+
if (validUntil(lease) <= at) continue
|
|
113
|
+
const owner = lease.owner !== null && typeof lease.owner === 'object' ? lease.owner : {}
|
|
114
|
+
const label = text(owner.label) ?? text(owner.session) ?? ''
|
|
115
|
+
const short = shortName(path, base)
|
|
116
|
+
const holdUntil = instant(lease.holdUntil)
|
|
117
|
+
if (mineId !== undefined && text(owner.id) === mineId) {
|
|
118
|
+
mine.push({ path: short, pathFull: path, label, heldSince: instant(lease.acquiredAt), holdUntil })
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
others.push({
|
|
122
|
+
path: short,
|
|
123
|
+
pathFull: path,
|
|
124
|
+
holder: { label, session: text(owner.session) ?? '' },
|
|
125
|
+
expiresAt: instant(lease.expiresAt),
|
|
126
|
+
holdUntil,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
// Deterministic order, independent of the snapshot's own ordering: by the
|
|
130
|
+
// short name the user actually reads (two basenames may collide), then by the
|
|
131
|
+
// full path, which is unique per lease and therefore a total order.
|
|
132
|
+
const byPath = (/** @type {any} */ left, /** @type {any} */ right) => {
|
|
133
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1
|
|
134
|
+
return left.pathFull < right.pathFull ? -1 : left.pathFull > right.pathFull ? 1 : 0
|
|
135
|
+
}
|
|
136
|
+
mine.sort(byPath)
|
|
137
|
+
others.sort(byPath)
|
|
138
|
+
return {
|
|
139
|
+
schemaVersion: VIEW_SCHEMA_VERSION,
|
|
140
|
+
arbiter: ARBITER_STATES.includes(/** @type {any} */ (arbiter)) ? /** @type {any} */ (arbiter) : 'unavailable',
|
|
141
|
+
mine,
|
|
142
|
+
others,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// The in-memory release-subscription coordinator behind `edit_lock_watch`.
|
|
2
|
+
//
|
|
3
|
+
// There is ONE coordinator per lease client (design D2/D4), not one per preset
|
|
4
|
+
// mount. Every mount of this bundle shares one client, so it must also share one
|
|
5
|
+
// view of "which session is waiting for which path": a per-mount registry would
|
|
6
|
+
// let two mounts subscribe the same `(session, path)` twice — two entries, two
|
|
7
|
+
// notices for one release — and would let one mount's teardown cancel a wait
|
|
8
|
+
// another mount still owns.
|
|
9
|
+
//
|
|
10
|
+
// The coordinator holds three collections and nothing else:
|
|
11
|
+
//
|
|
12
|
+
// active one entry per `(session, normalized path)`, in `watching`
|
|
13
|
+
// pendingDelivery consumed entries (`hit` / `reset`) whose notice has not
|
|
14
|
+
// been delivered yet, so disposal can still cancel them
|
|
15
|
+
// mounts the mount references that may deliver a notice
|
|
16
|
+
//
|
|
17
|
+
// The five states are a closed set:
|
|
18
|
+
//
|
|
19
|
+
// watching — registered, waiting for a release fact
|
|
20
|
+
// hit — a release fact consumed this subscription; it is over
|
|
21
|
+
// reset — the connection that could have delivered the fact is gone; it
|
|
22
|
+
// is over, and NOTHING is claimed about ownership
|
|
23
|
+
// self — this session already owns the path (an authoritative read said
|
|
24
|
+
// so, or a transfer settled); it is over, and there is nothing
|
|
25
|
+
// left to wait for
|
|
26
|
+
// cancelled — the subscriber went away (agent disposal, unmount, or a read
|
|
27
|
+
// that found the path free); it is over
|
|
28
|
+
//
|
|
29
|
+
// Consumption is one-way and happens in the event callback, BEFORE any notice is
|
|
30
|
+
// scheduled, so a duplicate or repeated release observation can never deliver a
|
|
31
|
+
// subscription twice. The notice itself is deferred by one turn of the event
|
|
32
|
+
// loop, because its caller is a socket callback and a message must never be
|
|
33
|
+
// injected from inside the transport's publish stack.
|
|
34
|
+
//
|
|
35
|
+
// Nothing here talks to a socket or a cordis context. A mount handle supplies
|
|
36
|
+
// the two things only the plugin knows: how to resolve the live agent for a
|
|
37
|
+
// session, and how to deliver a notice to it. Everything else — entries, the
|
|
38
|
+
// one shared status flight per entry, the pending-delivery window, the deferred
|
|
39
|
+
// timer — is decided here.
|
|
40
|
+
|
|
41
|
+
/** The closed subscription-state vocabulary. */
|
|
42
|
+
export const WATCH_STATES = Object.freeze(['watching', 'hit', 'reset', 'self', 'cancelled'])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {'watching' | 'hit' | 'reset' | 'self' | 'cancelled'} WatchState
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} WatchEntry
|
|
50
|
+
* @property {string} sessionId the session that subscribed
|
|
51
|
+
* @property {string} path normalized absolute path
|
|
52
|
+
* @property {WatchState} state
|
|
53
|
+
* @property {boolean} settledByTool true once a running tool already reported
|
|
54
|
+
* this episode to the model, so a deferred notice would only repeat it
|
|
55
|
+
* @property {any} agent the agent that subscribed: delivery re-resolves the
|
|
56
|
+
* session and refuses to land on a replacement agent
|
|
57
|
+
* @property {Promise<any> | null} statusFlight the ONE authoritative read this
|
|
58
|
+
* entry is currently sharing, so overlapping calls cannot disagree
|
|
59
|
+
* @property {Set<WatchMount>} mounts the mount references that still want it
|
|
60
|
+
* @property {ReturnType<typeof setTimeout> | null} timer the deferred notice
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} WatchMount
|
|
65
|
+
* @property {boolean} disposed set by the coordinator when the mount leaves
|
|
66
|
+
* @property {(sessionId: string) => any} agentFor the live agent for a session
|
|
67
|
+
* @property {(entry: WatchEntry, agent: any) => void} notify deliver one notice
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} WatchProjection
|
|
72
|
+
* @property {string} sessionId
|
|
73
|
+
* @property {string} path
|
|
74
|
+
* @property {WatchState} state
|
|
75
|
+
* @property {boolean} settledByTool
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Do not keep a finished process alive for a notice (design D4).
|
|
80
|
+
* @param {ReturnType<typeof setTimeout>} timer
|
|
81
|
+
*/
|
|
82
|
+
function unrefTimer(timer) {
|
|
83
|
+
const handle = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (timer))
|
|
84
|
+
if (typeof handle.unref === 'function') handle.unref()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The coordinator surface: one factory is the single definition of what a
|
|
89
|
+
* consumer holds, so the tool surface and the plugin row never re-describe it.
|
|
90
|
+
* @typedef {ReturnType<typeof createWatchCoordinator>} WatchCoordinator
|
|
91
|
+
*/
|
|
92
|
+
export function createWatchCoordinator() {
|
|
93
|
+
/** @type {Map<string, WatchEntry>} */
|
|
94
|
+
const active = new Map()
|
|
95
|
+
/** @type {Set<WatchEntry>} */
|
|
96
|
+
const pendingDelivery = new Set()
|
|
97
|
+
/** @type {Set<WatchMount>} */
|
|
98
|
+
const mounts = new Set()
|
|
99
|
+
|
|
100
|
+
/** @param {string} sessionId @param {string} path @returns {string} */
|
|
101
|
+
function keyOf(sessionId, path) {
|
|
102
|
+
return `${sessionId}\u0000${path}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The public shape of an entry. Internals (mounts, flight, timer, agent
|
|
107
|
+
* identity) stay inside the coordinator.
|
|
108
|
+
* @param {WatchEntry} entry
|
|
109
|
+
* @returns {WatchProjection}
|
|
110
|
+
*/
|
|
111
|
+
function project(entry) {
|
|
112
|
+
return { sessionId: entry.sessionId, path: entry.path, state: entry.state, settledByTool: entry.settledByTool }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The mount that may deliver one notice: the entry's own references first, so
|
|
117
|
+
* the mount that registered the wait speaks for it, then any live mount of the
|
|
118
|
+
* same client — a notice survives the unmount that created it (design D4).
|
|
119
|
+
* @param {WatchEntry} entry
|
|
120
|
+
* @returns {WatchMount | undefined}
|
|
121
|
+
*/
|
|
122
|
+
function liveMount(entry) {
|
|
123
|
+
for (const mount of entry.mounts) if (!mount.disposed) return mount
|
|
124
|
+
for (const mount of mounts) if (!mount.disposed) return mount
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {WatchEntry} entry */
|
|
129
|
+
function cancelTimer(entry) {
|
|
130
|
+
if (entry.timer === null) return
|
|
131
|
+
clearTimeout(entry.timer)
|
|
132
|
+
entry.timer = null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Consume one entry and schedule its notice: it joins `pendingDelivery` first,
|
|
137
|
+
* so a disposal between now and the timer still cancels it.
|
|
138
|
+
*
|
|
139
|
+
* A notice consumed while an authoritative read is still outstanding waits for
|
|
140
|
+
* that read to settle. The read's awaiting callers resume as microtasks and can
|
|
141
|
+
* then report the same episode as a tool result (`released` / `reset`), while
|
|
142
|
+
* this notice needs a macrotask to be delivered; arming after the flight is
|
|
143
|
+
* therefore enough for the tool's own answer to win, and a zero-delay timer
|
|
144
|
+
* can never duplicate it.
|
|
145
|
+
* @param {WatchEntry} entry
|
|
146
|
+
*/
|
|
147
|
+
function scheduleNotice(entry) {
|
|
148
|
+
pendingDelivery.add(entry)
|
|
149
|
+
const flight = entry.statusFlight
|
|
150
|
+
if (flight === null) {
|
|
151
|
+
armNotice(entry)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
// Both outcomes arm: a rejected read is caught by the tool as well, and its
|
|
155
|
+
// terminal rules run before this reaction's timer can fire.
|
|
156
|
+
void flight.then(
|
|
157
|
+
() => armNotice(entry),
|
|
158
|
+
() => armNotice(entry),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start the one deferred timer for a consumed entry. The pending set is the
|
|
164
|
+
* source of truth: a disposal, unmount or transfer may have ended the entry
|
|
165
|
+
* while the read was still outstanding, and that must win over the notice.
|
|
166
|
+
*
|
|
167
|
+
* The timer re-checks everything that could have ended the subscription in the
|
|
168
|
+
* meantime — the terminal state, the mount references, and the live agent
|
|
169
|
+
* identity — so a late notice can never revive one or land on a replacement
|
|
170
|
+
* agent (design D4/D5).
|
|
171
|
+
* @param {WatchEntry} entry
|
|
172
|
+
*/
|
|
173
|
+
function armNotice(entry) {
|
|
174
|
+
if (!pendingDelivery.has(entry)) return
|
|
175
|
+
if (entry.state !== 'hit' && entry.state !== 'reset') {
|
|
176
|
+
pendingDelivery.delete(entry)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (entry.timer !== null) return
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
entry.timer = null
|
|
182
|
+
pendingDelivery.delete(entry)
|
|
183
|
+
if (entry.state !== 'hit' && entry.state !== 'reset') return
|
|
184
|
+
if (entry.settledByTool === true) return
|
|
185
|
+
const mount = liveMount(entry)
|
|
186
|
+
if (mount === undefined) return
|
|
187
|
+
const agent = mount.agentFor(entry.sessionId)
|
|
188
|
+
if (agent === undefined || agent === null) return
|
|
189
|
+
if (entry.agent !== undefined && agent !== entry.agent) return
|
|
190
|
+
try {
|
|
191
|
+
mount.notify(entry, agent)
|
|
192
|
+
} catch {
|
|
193
|
+
// A notice is best effort: delivery must never break the transport.
|
|
194
|
+
}
|
|
195
|
+
}, 0)
|
|
196
|
+
entry.timer = timer
|
|
197
|
+
unrefTimer(timer)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* End a PENDING entry: its notice is over and its timer must not fire.
|
|
202
|
+
* @param {WatchEntry} entry
|
|
203
|
+
* @param {WatchState} state
|
|
204
|
+
*/
|
|
205
|
+
function endPending(entry, state) {
|
|
206
|
+
if (pendingDelivery.delete(entry)) cancelTimer(entry)
|
|
207
|
+
entry.state = state
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Attach one mount reference. The first reference starts the subscription
|
|
212
|
+
* lifetime of this coordinator; a later mount of the same client reuses the
|
|
213
|
+
* same entries, flights and notices.
|
|
214
|
+
* @param {WatchMount} mount
|
|
215
|
+
*/
|
|
216
|
+
function join(mount) {
|
|
217
|
+
mounts.add(mount)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Drop one mount reference (unmount). A single mount leaving removes only its
|
|
222
|
+
* own references: entries another mount still wants stay active, and a pending
|
|
223
|
+
* notice transfers to a surviving reference. When the LAST mount leaves, the
|
|
224
|
+
* whole coordinator — entries, pending notices, timers — is cleared so a later
|
|
225
|
+
* mount starts with no inherited wait.
|
|
226
|
+
* @param {WatchMount} mount
|
|
227
|
+
*/
|
|
228
|
+
function leave(mount) {
|
|
229
|
+
if (!mounts.delete(mount)) return
|
|
230
|
+
mount.disposed = true
|
|
231
|
+
for (const [key, entry] of [...active]) {
|
|
232
|
+
if (!entry.mounts.delete(mount)) continue
|
|
233
|
+
if (entry.mounts.size > 0) continue
|
|
234
|
+
active.delete(key)
|
|
235
|
+
entry.state = 'cancelled'
|
|
236
|
+
}
|
|
237
|
+
for (const entry of [...pendingDelivery]) entry.mounts.delete(mount)
|
|
238
|
+
if (mounts.size === 0) clear()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Find or create the single entry for one session and path. Repeating a
|
|
243
|
+
* subscription is idempotent: the same entry comes back, so one release can
|
|
244
|
+
* produce at most one notice however many times or from however many mounts
|
|
245
|
+
* the subscription was requested.
|
|
246
|
+
* @param {string} sessionId
|
|
247
|
+
* @param {string} path
|
|
248
|
+
* @param {WatchMount} [mount]
|
|
249
|
+
* @param {any} [agent]
|
|
250
|
+
* @returns {WatchEntry}
|
|
251
|
+
*/
|
|
252
|
+
function register(sessionId, path, mount, agent) {
|
|
253
|
+
const key = keyOf(sessionId, path)
|
|
254
|
+
const known = active.get(key)
|
|
255
|
+
if (known !== undefined) {
|
|
256
|
+
if (mount !== undefined) known.mounts.add(mount)
|
|
257
|
+
if (agent !== undefined && agent !== null) known.agent = agent
|
|
258
|
+
return known
|
|
259
|
+
}
|
|
260
|
+
/** @type {WatchEntry} */
|
|
261
|
+
const entry = {
|
|
262
|
+
sessionId,
|
|
263
|
+
path,
|
|
264
|
+
state: 'watching',
|
|
265
|
+
settledByTool: false,
|
|
266
|
+
agent,
|
|
267
|
+
statusFlight: null,
|
|
268
|
+
mounts: new Set(),
|
|
269
|
+
timer: null,
|
|
270
|
+
}
|
|
271
|
+
if (mount !== undefined) entry.mounts.add(mount)
|
|
272
|
+
active.set(key, entry)
|
|
273
|
+
return entry
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {string} sessionId
|
|
278
|
+
* @param {string} path
|
|
279
|
+
* @returns {WatchEntry | undefined}
|
|
280
|
+
*/
|
|
281
|
+
function peek(sessionId, path) {
|
|
282
|
+
return active.get(keyOf(sessionId, path))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* One authoritative read per entry, shared by every overlapping caller
|
|
287
|
+
* (design D2). Two calls for the same `(session, path)` must not run two
|
|
288
|
+
* reads and then disagree about whether the entry is still active: they await
|
|
289
|
+
* the same answer and apply the same terminal rules to the same object.
|
|
290
|
+
* @param {WatchEntry} entry
|
|
291
|
+
* @param {() => any} run the arbitration read
|
|
292
|
+
* @returns {Promise<any>}
|
|
293
|
+
*/
|
|
294
|
+
function shareStatus(entry, run) {
|
|
295
|
+
if (entry.statusFlight !== null) return entry.statusFlight
|
|
296
|
+
/** @type {Promise<any>} */
|
|
297
|
+
const flight = Promise.resolve()
|
|
298
|
+
.then(run)
|
|
299
|
+
.finally(() => {
|
|
300
|
+
if (entry.statusFlight === flight) entry.statusFlight = null
|
|
301
|
+
})
|
|
302
|
+
entry.statusFlight = flight
|
|
303
|
+
return flight
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* End one subscription because the caller no longer needs it. `expected`
|
|
308
|
+
* makes the removal conditional on the SAME entry object still being active:
|
|
309
|
+
* a late caller can then never cancel a newer episode, and can never report
|
|
310
|
+
* `watching` for an entry it no longer owns.
|
|
311
|
+
* @param {string} sessionId
|
|
312
|
+
* @param {string} path
|
|
313
|
+
* @param {WatchEntry} [expected]
|
|
314
|
+
* @param {WatchState} [state]
|
|
315
|
+
* @returns {WatchEntry | undefined}
|
|
316
|
+
*/
|
|
317
|
+
function cancel(sessionId, path, expected, state = 'cancelled') {
|
|
318
|
+
const key = keyOf(sessionId, path)
|
|
319
|
+
const entry = active.get(key)
|
|
320
|
+
if (entry === undefined) return undefined
|
|
321
|
+
if (expected !== undefined && entry !== expected) return undefined
|
|
322
|
+
active.delete(key)
|
|
323
|
+
if (pendingDelivery.delete(entry)) cancelTimer(entry)
|
|
324
|
+
entry.state = state
|
|
325
|
+
return entry
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* This session acquired the path by another route (a transfer settled as
|
|
330
|
+
* `transferred`). The wait is over because its goal was reached, and any
|
|
331
|
+
* notice already consumed for that path is moot: waking the session would
|
|
332
|
+
* only make it compete with itself (design D4).
|
|
333
|
+
* @param {string} sessionId
|
|
334
|
+
* @param {string} path
|
|
335
|
+
* @returns {WatchEntry | undefined}
|
|
336
|
+
*/
|
|
337
|
+
function settleSelf(sessionId, path) {
|
|
338
|
+
/** @type {WatchEntry | undefined} */
|
|
339
|
+
let ended
|
|
340
|
+
for (const entry of [...pendingDelivery]) {
|
|
341
|
+
if (entry.sessionId !== sessionId || entry.path !== path) continue
|
|
342
|
+
endPending(entry, 'self')
|
|
343
|
+
ended = ended ?? entry
|
|
344
|
+
}
|
|
345
|
+
const key = keyOf(sessionId, path)
|
|
346
|
+
const activeEntry = active.get(key)
|
|
347
|
+
if (activeEntry !== undefined) {
|
|
348
|
+
active.delete(key)
|
|
349
|
+
activeEntry.state = 'self'
|
|
350
|
+
ended = ended ?? activeEntry
|
|
351
|
+
}
|
|
352
|
+
return ended
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Consume every subscription for one path (the release fact carries no
|
|
357
|
+
* session, and one path may be watched by several sessions of this client).
|
|
358
|
+
* Each returned entry is already in `hit` and out of the active map, so a
|
|
359
|
+
* second observation of the same release finds nothing.
|
|
360
|
+
* @param {string} path
|
|
361
|
+
* @returns {WatchEntry[]}
|
|
362
|
+
*/
|
|
363
|
+
function consumePath(path) {
|
|
364
|
+
/** @type {WatchEntry[]} */
|
|
365
|
+
const consumed = []
|
|
366
|
+
for (const [key, entry] of [...active]) {
|
|
367
|
+
if (entry.path !== path) continue
|
|
368
|
+
active.delete(key)
|
|
369
|
+
entry.state = 'hit'
|
|
370
|
+
scheduleNotice(entry)
|
|
371
|
+
consumed.push(entry)
|
|
372
|
+
}
|
|
373
|
+
return consumed
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The connection that could have delivered a release is gone. Every active
|
|
378
|
+
* subscription becomes `reset` — a terminal state that says nothing about
|
|
379
|
+
* ownership — and is scheduled for the re-check notice.
|
|
380
|
+
* @returns {WatchEntry[]}
|
|
381
|
+
*/
|
|
382
|
+
function resetAll() {
|
|
383
|
+
const entries = [...active.values()]
|
|
384
|
+
active.clear()
|
|
385
|
+
for (const entry of entries) {
|
|
386
|
+
entry.state = 'reset'
|
|
387
|
+
scheduleNotice(entry)
|
|
388
|
+
}
|
|
389
|
+
return entries
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* End every subscription of one session — active waits AND consumed entries
|
|
394
|
+
* whose notice has not been delivered yet (agent disposal, design D4).
|
|
395
|
+
* @param {string} sessionId
|
|
396
|
+
* @returns {WatchEntry[]}
|
|
397
|
+
*/
|
|
398
|
+
function cancelSession(sessionId) {
|
|
399
|
+
/** @type {WatchEntry[]} */
|
|
400
|
+
const cancelled = []
|
|
401
|
+
for (const [key, entry] of [...active]) {
|
|
402
|
+
if (entry.sessionId !== sessionId) continue
|
|
403
|
+
active.delete(key)
|
|
404
|
+
entry.state = 'cancelled'
|
|
405
|
+
cancelled.push(entry)
|
|
406
|
+
}
|
|
407
|
+
for (const entry of [...pendingDelivery]) {
|
|
408
|
+
if (entry.sessionId !== sessionId) continue
|
|
409
|
+
endPending(entry, 'cancelled')
|
|
410
|
+
cancelled.push(entry)
|
|
411
|
+
}
|
|
412
|
+
return cancelled
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* End everything: no entry can be revived afterwards, and no scheduled notice
|
|
417
|
+
* survives. Called when the last mount of the client leaves.
|
|
418
|
+
* @returns {WatchEntry[]}
|
|
419
|
+
*/
|
|
420
|
+
function clear() {
|
|
421
|
+
/** @type {WatchEntry[]} */
|
|
422
|
+
const entries = [...active.values(), ...pendingDelivery]
|
|
423
|
+
active.clear()
|
|
424
|
+
for (const entry of [...pendingDelivery]) cancelTimer(entry)
|
|
425
|
+
pendingDelivery.clear()
|
|
426
|
+
for (const entry of entries) entry.state = 'cancelled'
|
|
427
|
+
return entries
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** @returns {number} how many subscriptions are still active */
|
|
431
|
+
function size() {
|
|
432
|
+
return active.size
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @returns {number} how many consumed notices are still undelivered */
|
|
436
|
+
function pendingSize() {
|
|
437
|
+
return pendingDelivery.size
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @returns {number} how many mount references are attached */
|
|
441
|
+
function mountCount() {
|
|
442
|
+
return mounts.size
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** @returns {WatchProjection[]} the active entries, as copies */
|
|
446
|
+
function entries() {
|
|
447
|
+
return [...active.values()].map(project)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** @returns {WatchProjection[]} the pending notices, as copies */
|
|
451
|
+
function pendingEntries() {
|
|
452
|
+
return [...pendingDelivery].map(project)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return Object.freeze({
|
|
456
|
+
join,
|
|
457
|
+
leave,
|
|
458
|
+
register,
|
|
459
|
+
peek,
|
|
460
|
+
shareStatus,
|
|
461
|
+
cancel,
|
|
462
|
+
settleSelf,
|
|
463
|
+
consumePath,
|
|
464
|
+
resetAll,
|
|
465
|
+
cancelSession,
|
|
466
|
+
clear,
|
|
467
|
+
size,
|
|
468
|
+
pendingSize,
|
|
469
|
+
mountCount,
|
|
470
|
+
entries,
|
|
471
|
+
pendingEntries,
|
|
472
|
+
})
|
|
473
|
+
}
|