weir-harness 1.1.6 → 1.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/lib/client.blackboard-model.js +35 -2
- package/lib/client.blackboard-panel.js +61 -9
- package/lib/client.js +254 -223
- package/lib/client.settings-page.js +132 -46
- package/package.json +1 -1
- package/src/blackboard/durable.js +13 -0
- package/src/blackboard/index.js +45 -1
- package/src/blackboard/remote.js +69 -16
- package/src/blackboard/storage.js +56 -0
- package/src/edit-lock/binding-guidance.js +15 -4
- package/src/edit-lock/domain.js +12 -0
- package/src/edit-lock/domains.js +67 -0
- package/src/edit-lock/index.js +209 -45
- package/src/edit-lock/lifecycle.js +70 -4
- package/src/edit-lock/remote.js +23 -4
- package/src/settings/sections.js +25 -16
|
@@ -213,8 +213,64 @@ export function createBlackboardStorage({ home, hook = async (_op, _path) => {},
|
|
|
213
213
|
return result.publish()
|
|
214
214
|
} finally { await release() }
|
|
215
215
|
}
|
|
216
|
+
// One-time fork seeding: a forked Session is its own board root, but its
|
|
217
|
+
// board starts as a snapshot of the fork source's durable root board. Both
|
|
218
|
+
// directories stay under the writer lock in deterministic path order, and
|
|
219
|
+
// the fork is only ever seeded while it has no sidecar of its own — a
|
|
220
|
+
// re-seed can never overwrite or merge an initialized board.
|
|
221
|
+
async function seedFork(id, parentId, cwd) {
|
|
222
|
+
sessionId(id)
|
|
223
|
+
let hopId = sessionId(parentId)
|
|
224
|
+
// The source's durable root is resolved on disk: a delegated source has
|
|
225
|
+
// no sidecar of its own, so ascend its parent chain to the root board it
|
|
226
|
+
// actually used.
|
|
227
|
+
const seen = new Set()
|
|
228
|
+
let source
|
|
229
|
+
for (let hop = 0; hop < 64; hop++) {
|
|
230
|
+
if (seen.has(hopId)) throw new Error('blackboard: cyclic Session parent chain')
|
|
231
|
+
seen.add(hopId)
|
|
232
|
+
const located = await locateSession(home, hopId)
|
|
233
|
+
const depth = located.header.delegationDepth ?? 0
|
|
234
|
+
if (!Number.isSafeInteger(depth) || depth < 0) throw new Error('blackboard: invalid Session delegation depth')
|
|
235
|
+
if (depth === 0) { source = located; break }
|
|
236
|
+
hopId = sessionId(located.header.parentSession)
|
|
237
|
+
}
|
|
238
|
+
if (!source) throw new Error('blackboard: Session ancestry exceeds safety limit')
|
|
239
|
+
const child = await locateSession(home, id, cwd)
|
|
240
|
+
// Two directories need two writer locks; taking them in deterministic
|
|
241
|
+
// path-string order means single-lock users (transact) can never
|
|
242
|
+
// deadlock with this pair. Distinct Session ids never share a directory.
|
|
243
|
+
const [first, second] = source.dir < child.dir ? [source.dir, child.dir] : [child.dir, source.dir]
|
|
244
|
+
const releaseFirst = await lock(first)
|
|
245
|
+
let releaseSecond
|
|
246
|
+
try { releaseSecond = await lock(second) } catch (error) { await releaseFirst(); throw error }
|
|
247
|
+
try {
|
|
248
|
+
const records = []
|
|
249
|
+
for (const name of (await fs.readdir(source.dir)).filter(name => SIDE.test(name))) {
|
|
250
|
+
records.push(decodeRecord(await safeRead(join(source.dir, name)), source.id))
|
|
251
|
+
}
|
|
252
|
+
if (!records.length) return false
|
|
253
|
+
if (records.some(record => JSON.stringify(record) !== JSON.stringify(records[0]))) throw new Error('blackboard: conflicting sidecars; originals preserved')
|
|
254
|
+
// Idempotence: any existing fork sidecar means the board is already
|
|
255
|
+
// initialized — never overwrite or merge.
|
|
256
|
+
if ((await fs.readdir(child.dir)).some(name => SIDE.test(name))) return false
|
|
257
|
+
// Volatile state (tokens/subscriptions/timers) is never persisted, so
|
|
258
|
+
// the snapshot carries durable entry fields only; the fork starts at
|
|
259
|
+
// revision 0 under its own root identity.
|
|
260
|
+
const seeded = { schemaVersion: 1, rootSessionId: id, revision: 0, entries: structuredClone(records[0].entries) }
|
|
261
|
+
const encoded = JSON.stringify(decodeRecord(JSON.stringify(seeded), id)) + '\n'
|
|
262
|
+
await publish(child.file, encoded)
|
|
263
|
+
return true
|
|
264
|
+
} finally {
|
|
265
|
+
let failure
|
|
266
|
+
try { await releaseSecond() } catch (error) { failure ??= error }
|
|
267
|
+
try { await releaseFirst() } catch (error) { failure ??= error }
|
|
268
|
+
if (failure) throw failure
|
|
269
|
+
}
|
|
270
|
+
}
|
|
216
271
|
return {
|
|
217
272
|
locate: (id, cwd) => locateSession(home, id, cwd),
|
|
273
|
+
seedFork,
|
|
218
274
|
async releaseWriter(id, cwd) {
|
|
219
275
|
let where
|
|
220
276
|
try { where = await locateSession(home, id, cwd) } catch { return }
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* @param {number | undefined} pid @param {string} attestation */
|
|
3
3
|
export function bindingRefusalReason(pid, attestation) {
|
|
4
4
|
const holder = attestation === 'serving' ? 'serving'
|
|
5
|
+
: attestation === 'serving-idle' ? 'serving-idle'
|
|
5
6
|
: pid === process.pid ? 'same-process-unattestable'
|
|
6
7
|
: pid !== undefined ? 'cross-process-pid' : 'unknown'
|
|
7
8
|
return `session already bound: holder=${holder}${pid !== undefined ? `; pid=${pid}` : ''}`
|
|
@@ -10,12 +11,20 @@ export function bindingRefusalReason(pid, attestation) {
|
|
|
10
11
|
/** Render only publisher-classified facts. Unrecognized/legacy reasons are unknown.
|
|
11
12
|
* @param {string} sessionId @param {string} reason */
|
|
12
13
|
export function bindingGuidance(sessionId, reason) {
|
|
13
|
-
const facts = /^session already bound: holder=(serving|same-process-unattestable|cross-process-pid|unknown)(?:; pid=([1-9]\d*))?$/.exec(reason)
|
|
14
|
+
const facts = /^session already bound: holder=(serving-idle|serving|same-process-unattestable|cross-process-pid|unknown)(?:; pid=([1-9]\d*))?$/.exec(reason)
|
|
14
15
|
const classification = facts?.[1]
|
|
15
16
|
const pid = facts?.[2]
|
|
16
17
|
let holder
|
|
17
|
-
if (classification === 'serving') {
|
|
18
|
-
|
|
18
|
+
if (classification === 'serving-idle') {
|
|
19
|
+
// Transferable (change edit-lock-binding-transfer): an idle same-process
|
|
20
|
+
// holder is taken over by the next driving ingress — calm copy, no pid,
|
|
21
|
+
// no window hunt, and NEVER the publisher-reservation warning.
|
|
22
|
+
holder = `session ${sessionId} is held by an inactive attachment of this same session. Editing attaches here automatically when you next send a message; /edit-lock retry also takes it over now. Retry does not Continue editing.`
|
|
23
|
+
} else if (classification === 'serving') {
|
|
24
|
+
// The holder is provably live and not transferable (mid-turn or a live
|
|
25
|
+
// channel proxy): name what is happening and when to retry. No window
|
|
26
|
+
// hunt (single-instance hosts exist) and no process management.
|
|
27
|
+
holder = `session ${sessionId} is being edited in another attachment right now. Wait for that turn to finish, then use /edit-lock retry in this session; retry does not Continue editing.`
|
|
19
28
|
} else if (classification === 'same-process-unattestable' && pid !== undefined) {
|
|
20
29
|
holder = `session ${sessionId} has a binding in this same process (pid ${pid}) that cannot be safely reclaimed. A stale runtime incarnation may still hold its channel. Wait for the original holder to disconnect or for confirmed process shutdown.`
|
|
21
30
|
} else if (classification === 'cross-process-pid' && pid !== undefined) {
|
|
@@ -23,6 +32,7 @@ export function bindingGuidance(sessionId, reason) {
|
|
|
23
32
|
} else {
|
|
24
33
|
holder = `session ${sessionId} has a binding whose holder cannot be attested. Wait for the original holder to disconnect or for confirmed process shutdown.`
|
|
25
34
|
}
|
|
35
|
+
if (classification === 'serving-idle' || classification === 'serving') return holder
|
|
26
36
|
return `${holder} Then use /edit-lock retry in the original session when available; retry does not Continue editing. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while its process is alive — removing it would split the edit authority.`
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -32,7 +42,8 @@ export function bindingGuidance(sessionId, reason) {
|
|
|
32
42
|
export function bindingFailure(pid, attestation, revocationFailed = false) {
|
|
33
43
|
return { code: 'binding-duplicate', retryable: true,
|
|
34
44
|
holderKind: revocationFailed ? 'revocation-failed' : attestation === 'serving' ? 'serving'
|
|
35
|
-
:
|
|
45
|
+
: attestation === 'serving-idle' ? 'transferable'
|
|
46
|
+
: pid !== undefined && pid !== process.pid ? 'cross-process' : 'unknown',
|
|
36
47
|
holderPid: pid ?? null }
|
|
37
48
|
}
|
|
38
49
|
/** @param {number | undefined} pid @param {string} attestation @param {boolean} [revocationFailed] */
|
package/src/edit-lock/domain.js
CHANGED
|
@@ -15,6 +15,14 @@ export function localDomain(lifecycle, endpoint) {
|
|
|
15
15
|
lifecycle.assertStartable(agent)
|
|
16
16
|
return endpoint?.startLocal ? endpoint.startLocal(agent, true) : lifecycle.start(agent)
|
|
17
17
|
},
|
|
18
|
+
/** Trusted binding transfer (change edit-lock-binding-transfer): the
|
|
19
|
+
* explicit driving-ingress counterpart of retryStart — the holder-side
|
|
20
|
+
* admission (local, registry-named, provably idle holder) is re-adjudicated
|
|
21
|
+
* at execution and refuses exactly like start() otherwise. @param {object} agent */
|
|
22
|
+
transferStart: async agent => {
|
|
23
|
+
lifecycle.assertStartable(agent)
|
|
24
|
+
return endpoint?.startLocal ? endpoint.startLocal(agent, true, true) : lifecycle.transferStart(agent)
|
|
25
|
+
},
|
|
18
26
|
/** Synchronous admission fence for an aborted turn. @param {object} agent */
|
|
19
27
|
latch: agent => { lifecycle.stop(agent).catch(() => {}) },
|
|
20
28
|
/** @param {object} agent */
|
|
@@ -86,6 +94,10 @@ export function remoteDomain(remote) {
|
|
|
86
94
|
start: async agent => (await remote.channelFor(agent)).state,
|
|
87
95
|
/** @param {object} agent */
|
|
88
96
|
retryStart: async agent => (await remote.channelFor(agent, true)).state,
|
|
97
|
+
/** The wire retry flag carries the transfer admission to the publisher
|
|
98
|
+
* (its lifecycle re-adjudicates holder idleness); the client adds nothing. */
|
|
99
|
+
/** @param {object} agent */
|
|
100
|
+
transferStart: async agent => (await remote.channelFor(agent, true)).state,
|
|
89
101
|
/** @param {object} agent */
|
|
90
102
|
forget: async agent => { try { await remote.call(agent, 'stop') } finally { remote.drop(agent) } },
|
|
91
103
|
/** Closing the channel makes the publisher revoke on EOF. @param {object} agent */
|
package/src/edit-lock/domains.js
CHANGED
|
@@ -97,6 +97,73 @@ export function createDomainRegistry(open, resolveRoot = managementRootFor) {
|
|
|
97
97
|
})
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** @type {WeakMap<object, Map<string, {registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}>>} */
|
|
101
|
+
const sharedRegistriesByHost = new WeakMap()
|
|
102
|
+
|
|
103
|
+
/** Process-shared domain registry container (change
|
|
104
|
+
* edit-lock-shared-domain-mounts, design D1): several preset mounts of this
|
|
105
|
+
* bundle coexist for the whole process, and every mount of one management
|
|
106
|
+
* root must resolve the SAME domain — one runtime, one publisher reservation,
|
|
107
|
+
* one lifecycle and one endpoint — instead of the second mount degrading to a
|
|
108
|
+
* same-process remote client of the first.
|
|
109
|
+
*
|
|
110
|
+
* The container is keyed by host (`ctx.root ?? ctx`, the same key expression
|
|
111
|
+
* the write-scope store uses — proven to be shared across preset mounts) plus
|
|
112
|
+
* a caller-supplied configuration discriminator: mounts whose effective
|
|
113
|
+
* domain configuration differs (a fixed root/authority row) NEVER share; they
|
|
114
|
+
* keep today's independent-registry semantics exactly.
|
|
115
|
+
*
|
|
116
|
+
* Reference-counted by mount: each apply takes one count, each disposer
|
|
117
|
+
* releases exactly one (idempotently); a factory that throws enters no count.
|
|
118
|
+
* Only the LAST release closes every domain through the established drain
|
|
119
|
+
* discipline (`registry.all() → domain.close()`), revoking sessions and
|
|
120
|
+
* releasing reservations; the entry is then dropped, so a later generation
|
|
121
|
+
* reopens fresh. The per-entry `settled` store and the per-root
|
|
122
|
+
* automatic-recovery notice queue share the entry's lifetime: they outlive
|
|
123
|
+
* any single mount (a preset switch lets a sibling mount's listeners read
|
|
124
|
+
* the binding the first mount settled, and the mount that hears the first
|
|
125
|
+
* post-recovery turn drains the summary), and they die with the last
|
|
126
|
+
* release so a closed domain is never mistaken for a live binding.
|
|
127
|
+
*
|
|
128
|
+
* @template T
|
|
129
|
+
* @param {object} host
|
|
130
|
+
* @param {string} discriminator
|
|
131
|
+
* @param {() => ReturnType<typeof createDomainRegistry<T>>} makeRegistry
|
|
132
|
+
* @returns {{registry: ReturnType<typeof createDomainRegistry<T>>, settled: WeakMap<object, T>, recoveryNotices: Map<string, string>, release: () => Promise<void>}} */
|
|
133
|
+
export function sharedDomainRegistryFor(host, discriminator, makeRegistry) {
|
|
134
|
+
let entries = sharedRegistriesByHost.get(host)
|
|
135
|
+
if (!entries?.has(discriminator)) {
|
|
136
|
+
// A throwing factory mutates nothing and enters no count.
|
|
137
|
+
const created = makeRegistry()
|
|
138
|
+
if (!entries) {
|
|
139
|
+
entries = new Map()
|
|
140
|
+
sharedRegistriesByHost.set(host, entries)
|
|
141
|
+
}
|
|
142
|
+
entries.set(discriminator, { registry: created, settled: new WeakMap(), recoveryNotices: new Map(), mounts: 0 })
|
|
143
|
+
}
|
|
144
|
+
const entry = /** @type {{registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}} */ (entries.get(discriminator))
|
|
145
|
+
entry.mounts += 1
|
|
146
|
+
let released = false
|
|
147
|
+
return Object.freeze({
|
|
148
|
+
registry: entry.registry,
|
|
149
|
+
settled: entry.settled,
|
|
150
|
+
/** One queued automatic-recovery summary per root, shared so the mount
|
|
151
|
+
* that hears the first post-recovery turn drains it (design D3). */
|
|
152
|
+
recoveryNotices: entry.recoveryNotices,
|
|
153
|
+
/** Idempotent: only the first call counts; only the last call closes. */
|
|
154
|
+
async release() {
|
|
155
|
+
if (released) return
|
|
156
|
+
released = true
|
|
157
|
+
entry.mounts -= 1
|
|
158
|
+
if (entry.mounts > 0 || entries.get(discriminator) !== entry) return
|
|
159
|
+
entries.delete(discriminator)
|
|
160
|
+
const results = await Promise.allSettled(entry.registry.all().map(async domain => (await domain).close()))
|
|
161
|
+
const failure = results.find(result => result.status === 'rejected')
|
|
162
|
+
if (failure?.status === 'rejected') throw failure.reason
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
100
167
|
/** @param {string} path */
|
|
101
168
|
export function isDirectory(path) {
|
|
102
169
|
try { return lstatSync(path).isDirectory() } catch { return false }
|