weir-harness 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +0 -2
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +5 -2
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +23 -6
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +12 -0
- package/src/shared/child-scope.js +3 -1
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
|
@@ -6,9 +6,48 @@
|
|
|
6
6
|
// a synchronous followup, and the advisory text promises neither history
|
|
7
7
|
// erasure nor call retraction. A failed injection is audit/warn only and
|
|
8
8
|
// never affects the accepted commit.
|
|
9
|
+
//
|
|
10
|
+
// durable-pending-notice (D1/D2/D4/D5/D6): the queued obligation is a durable
|
|
11
|
+
// capability-store unit ({kind:'notice', sessionId} → sessions/<id>/notice.json),
|
|
12
|
+
// not process memory — an Apply that was accepted is owed a notice even if the
|
|
13
|
+
// host exits before the session's next request. The queue write happens inside
|
|
14
|
+
// the engine's one non-yielding publication segment (commitSync), coalescing
|
|
15
|
+
// with whatever is already pending through the unchanged mergeNetChanges; the
|
|
16
|
+
// obligation is cleared only AFTER the text has actually been injected, by
|
|
17
|
+
// subtracting exactly the delivered names. Failing to read, write or clear
|
|
18
|
+
// never fabricates a notice, never deletes a damaged unit and never touches the
|
|
19
|
+
// accepted selection record — it is reported on the cold-safe audit channel
|
|
20
|
+
// (AUDIT_TYPES.capabilityNotice; counts, never names).
|
|
21
|
+
import { AUDIT_TYPES } from '../shared/audit.js'
|
|
22
|
+
import { userTextMessage } from '../shared/user-message.js'
|
|
9
23
|
|
|
10
24
|
export const NOTIFY_SOURCE = 'weir-selection-notify'
|
|
11
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The accepted-set delta for the model-facing notice (apply-delta-durable-basis
|
|
28
|
+
* D1): two `{ skills: identities[], mcpServers: string[] }` set objects in —
|
|
29
|
+
* the durable pre-write accepted record and the just-committed payload — one
|
|
30
|
+
* sorted `{ added, removed }` name delta out. Skill identities map to display
|
|
31
|
+
* names (`identity?.name`), MCP server names pass through unchanged; nameless
|
|
32
|
+
* entries never leak. Pure: the same inputs always yield the same delta, so
|
|
33
|
+
* the committed branch and the recovery-'published' branch share it.
|
|
34
|
+
* @param {{ skills?: unknown[], mcpServers?: string[] } | null | undefined} previousSets
|
|
35
|
+
* @param {{ skills?: unknown[], mcpServers?: string[] } | null | undefined} acceptedSets
|
|
36
|
+
* @returns {{ added: string[], removed: string[] }}
|
|
37
|
+
*/
|
|
38
|
+
export function selectionDelta(previousSets, acceptedSets) {
|
|
39
|
+
const names = identities => new Set((Array.isArray(identities) ? identities : []).map(identity => String(identity?.name ?? '')).filter(Boolean))
|
|
40
|
+
const servers = value => new Set(Array.isArray(value) ? value : [])
|
|
41
|
+
const before = names(previousSets?.skills)
|
|
42
|
+
const after = names(acceptedSets?.skills)
|
|
43
|
+
const beforeMcp = servers(previousSets?.mcpServers)
|
|
44
|
+
const afterMcp = servers(acceptedSets?.mcpServers)
|
|
45
|
+
return {
|
|
46
|
+
added: [...after].filter(name => !before.has(name)).concat([...afterMcp].filter(name => !beforeMcp.has(name))).sort(),
|
|
47
|
+
removed: [...before].filter(name => !after.has(name)).concat([...beforeMcp].filter(name => !afterMcp.has(name))).sort(),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
12
51
|
/**
|
|
13
52
|
* The merged net change set for one session (12.4): a name queued as added
|
|
14
53
|
* then removed nets out of BOTH sets (and vice versa).
|
|
@@ -48,28 +87,292 @@ export function notificationText(net) {
|
|
|
48
87
|
].join('\n')
|
|
49
88
|
}
|
|
50
89
|
|
|
90
|
+
/** The empty net: nothing is owed to the model. */
|
|
91
|
+
const NO_CHANGES = Object.freeze({ added: [], removed: [] })
|
|
92
|
+
|
|
51
93
|
/**
|
|
52
|
-
*
|
|
94
|
+
* Normalize a stored notice payload into a net change set. Only non-empty
|
|
95
|
+
* strings are names, both lists dedupe and sort, and a payload that is not an
|
|
96
|
+
* object reads as an empty net: a damaged payload is NEVER turned into a
|
|
97
|
+
* fabricated notice (the notice is advisory — nothing is owed when nothing
|
|
98
|
+
* readable names a change).
|
|
99
|
+
* @param {unknown} payload
|
|
100
|
+
* @returns {{ added: string[], removed: string[] }}
|
|
53
101
|
*/
|
|
54
|
-
export function
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
export function noticeNet(payload) {
|
|
103
|
+
const value = /** @type {Record<string, unknown>} */ (payload !== null && typeof payload === 'object' ? payload : {})
|
|
104
|
+
const names = list => Array.isArray(list)
|
|
105
|
+
? [...new Set(list.filter(name => typeof name === 'string' && name.length > 0))].sort()
|
|
106
|
+
: []
|
|
107
|
+
return { added: names(value.added), removed: names(value.removed) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {{ added: string[], removed: string[] }} net */
|
|
111
|
+
const isEmptyNet = net => net.added.length === 0 && net.removed.length === 0
|
|
112
|
+
|
|
113
|
+
/** @param {{ added: string[], removed: string[] }} a @param {{ added: string[], removed: string[] }} b */
|
|
114
|
+
const sameNet = (a, b) => a.added.length === b.added.length && a.removed.length === b.removed.length
|
|
115
|
+
&& a.added.every((name, index) => name === b.added[index])
|
|
116
|
+
&& a.removed.every((name, index) => name === b.removed[index])
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Subtract exactly the delivered names (D4): an added name leaves the added
|
|
120
|
+
* set, a removed name leaves the removed set. A concurrent Apply's delta merged
|
|
121
|
+
* under the CAS therefore survives a clear.
|
|
122
|
+
* @param {{ added: string[], removed: string[] }} current
|
|
123
|
+
* @param {{ added: string[], removed: string[] }} delivered
|
|
124
|
+
*/
|
|
125
|
+
function subtractDelivered(current, delivered) {
|
|
126
|
+
const added = new Set(current.added)
|
|
127
|
+
const removed = new Set(current.removed)
|
|
128
|
+
for (const name of delivered.added) added.delete(name)
|
|
129
|
+
for (const name of delivered.removed) removed.delete(name)
|
|
130
|
+
return { added: [...added].sort(), removed: [...removed].sort() }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {{ added: string[], removed: string[] }} NetChange
|
|
135
|
+
* @typedef {{ added: string[], removed: string[], revision: number }} PendingNotice
|
|
136
|
+
* @typedef {{ read(unit: unknown): Promise<any>, readSync(unit: unknown): any,
|
|
137
|
+
* commit(unit: unknown, expectedRevision: number, mutate: (payload: unknown, info: { revision: number }) => unknown): Promise<any>,
|
|
138
|
+
* commitSync(unit: unknown, expectedRevision: number, mutate: (payload: unknown, info: { revision: number }) => unknown): any }} NoticeStore
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The durable pending-notice queue + injection seam (12.4, made durable by
|
|
143
|
+
* durable-pending-notice). One instance per host process:
|
|
144
|
+
* - `queueSync` runs inside the Apply publication segment (no await allowed) and
|
|
145
|
+
* THROWS on any non-committed outcome, so the engine's existing catch degrades
|
|
146
|
+
* the response instead of silently dropping the obligation;
|
|
147
|
+
* - `pending`/`deliver`/`clearDelivered` are the request-boundary side: read,
|
|
148
|
+
* inject, then clear;
|
|
149
|
+
* - `has` is the synchronous diagnostic (`hasPendingNotification`).
|
|
150
|
+
* A per-session mirror (D6) keeps the steady state free of extra disk reads: a
|
|
151
|
+
* session is read on its first boundary of this process and after any local
|
|
152
|
+
* write. A notice written by ANOTHER process is therefore not observed until the
|
|
153
|
+
* next cold load — a stated limit, strictly better than the process Map it
|
|
154
|
+
* replaces.
|
|
155
|
+
* @param {{ store?: NoticeStore, audit?: (session: { id?: string } | null, type: string, data?: unknown) => void }} [options]
|
|
156
|
+
*/
|
|
157
|
+
export function createSelectionNotifier({ store, audit: emitAudit = () => {} } = {}) {
|
|
158
|
+
/** @type {Map<string, { net: NetChange, revision: number, present: boolean }>} */
|
|
159
|
+
const mirror = new Map()
|
|
160
|
+
|
|
161
|
+
/** @param {string} sessionId */
|
|
162
|
+
const unitOf = sessionId => ({ kind: 'notice', sessionId })
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One audit record on the cold-safe channel (D7): counts, never names.
|
|
166
|
+
* @param {string} sessionId @param {string} outcome @param {Record<string, unknown>} [data]
|
|
167
|
+
*/
|
|
168
|
+
function note(sessionId, outcome, data = {}) {
|
|
169
|
+
try { emitAudit({ id: sessionId }, AUDIT_TYPES.capabilityNotice, { outcome, ...data }) } catch { /* audit never breaks a turn */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {string} sessionId @param {{ net: NetChange, revision: number, present: boolean }} entry */
|
|
173
|
+
const remember = (sessionId, entry) => { mirror.set(sessionId, entry) }
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Queue a net delta for a session, merged with whatever is already durable.
|
|
177
|
+
* Runs in the publication segment: no await, and a failed write THROWS so the
|
|
178
|
+
* engine can degrade the response (D2). Merging the same delta twice is a
|
|
179
|
+
* no-op — the merge absorbs it, so a double settle can never double-announce.
|
|
180
|
+
* @param {string} sessionId
|
|
181
|
+
* @param {{ added?: string[], removed?: string[] }} delta
|
|
182
|
+
* @param {{ revision?: number }} [info] the accepted revision (diagnosis/audit)
|
|
183
|
+
* @returns {NetChange}
|
|
184
|
+
*/
|
|
185
|
+
function queueSync(sessionId, delta, info = {}) {
|
|
186
|
+
const acceptedRevision = typeof info.revision === 'number' ? info.revision : undefined
|
|
187
|
+
if (!store) {
|
|
188
|
+
note(sessionId, 'queue-failed', { status: 'unavailable', revision: acceptedRevision })
|
|
189
|
+
throw new Error('capability notice queue failed: unavailable')
|
|
190
|
+
}
|
|
191
|
+
// One retry on a lost CAS race (a foreign writer landed between read and
|
|
192
|
+
// write); every other outcome fails loudly on the first attempt.
|
|
193
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
194
|
+
let record
|
|
195
|
+
try {
|
|
196
|
+
record = store.readSync(unitOf(sessionId))
|
|
197
|
+
} catch (error) {
|
|
198
|
+
note(sessionId, 'queue-failed', { status: 'read-failed', revision: acceptedRevision })
|
|
199
|
+
throw new Error(`capability notice queue failed: read-failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
200
|
+
}
|
|
201
|
+
if (record.kind !== 'ok' && record.kind !== 'absent') {
|
|
202
|
+
// Never repaired by a rewrite: a damaged unit is reported and left alone.
|
|
203
|
+
note(sessionId, 'queue-failed', { status: record.kind, revision: acceptedRevision })
|
|
204
|
+
throw new Error(`capability notice queue failed: ${record.kind}`)
|
|
205
|
+
}
|
|
206
|
+
const previous = record.kind === 'ok' ? noticeNet(record.payload) : NO_CHANGES
|
|
207
|
+
const merged = mergeNetChanges(previous, delta)
|
|
208
|
+
if (sameNet(merged, previous)) {
|
|
209
|
+
// Nothing changed (an absorbed re-queue): no write, no revision bump.
|
|
210
|
+
remember(sessionId, { net: merged, revision: record.kind === 'ok' ? record.revision : 0, present: record.kind === 'ok' })
|
|
211
|
+
note(sessionId, 'queued', { added: merged.added.length, removed: merged.removed.length, revision: acceptedRevision })
|
|
212
|
+
return merged
|
|
213
|
+
}
|
|
214
|
+
const result = store.commitSync(unitOf(sessionId), record.kind === 'ok' ? record.revision : 0, () => merged)
|
|
215
|
+
if (result.status === 'committed') {
|
|
216
|
+
remember(sessionId, { net: merged, revision: result.revision, present: true })
|
|
217
|
+
note(sessionId, 'queued', { added: merged.added.length, removed: merged.removed.length, revision: acceptedRevision })
|
|
218
|
+
return merged
|
|
219
|
+
}
|
|
220
|
+
if (result.status === 'revision-conflict' && attempt === 0) continue
|
|
221
|
+
note(sessionId, 'queue-failed', { added: merged.added.length, removed: merged.removed.length, status: result.status, revision: acceptedRevision })
|
|
222
|
+
throw new Error(`capability notice queue failed: ${result.status}`)
|
|
223
|
+
}
|
|
224
|
+
/* c8 ignore next */
|
|
225
|
+
throw new Error('capability notice queue failed: unsettled')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Load what the durable unit says about this session, distinguishing the three
|
|
230
|
+
* outcomes the boundary must not conflate (D5): nothing owed, an obligation,
|
|
231
|
+
* or a unit that cannot be read. Never throws — a read failure is reported and
|
|
232
|
+
* delivers nothing, because the notice is advisory and must not block a
|
|
233
|
+
* request. One cold read per session per process, then the mirror.
|
|
234
|
+
* @param {string} sessionId
|
|
235
|
+
* @returns {Promise<{ kind: 'owed', net: NetChange, revision: number } | { kind: 'none' } | { kind: 'unreadable', status: string }>}
|
|
236
|
+
*/
|
|
237
|
+
async function loadPending(sessionId) {
|
|
238
|
+
const cached = mirror.get(sessionId)
|
|
239
|
+
if (cached) return cached.present && !isEmptyNet(cached.net)
|
|
240
|
+
? { kind: 'owed', net: cached.net, revision: cached.revision }
|
|
241
|
+
: { kind: 'none' }
|
|
242
|
+
if (!store) return { kind: 'none' }
|
|
243
|
+
let record
|
|
244
|
+
try {
|
|
245
|
+
record = await store.read(unitOf(sessionId))
|
|
246
|
+
} catch {
|
|
247
|
+
note(sessionId, 'unreadable', { status: 'read-failed' })
|
|
248
|
+
return { kind: 'unreadable', status: 'read-failed' }
|
|
249
|
+
}
|
|
250
|
+
// The platform gate is not an error: no durable queue exists, and no
|
|
251
|
+
// accepted selection can owe a notice here (the same store backs Apply).
|
|
252
|
+
if (record.kind === 'unsupported') return { kind: 'none' }
|
|
253
|
+
if (record.kind !== 'ok' && record.kind !== 'absent') {
|
|
254
|
+
// Delivered nothing, deleted nothing: reported, never repaired.
|
|
255
|
+
note(sessionId, 'unreadable', { status: record.kind })
|
|
256
|
+
return { kind: 'unreadable', status: record.kind }
|
|
257
|
+
}
|
|
258
|
+
const present = record.kind === 'ok'
|
|
259
|
+
const net = present ? noticeNet(record.payload) : NO_CHANGES
|
|
260
|
+
remember(sessionId, { net, revision: present ? record.revision : 0, present })
|
|
261
|
+
return present && !isEmptyNet(net) ? { kind: 'owed', net, revision: record.revision } : { kind: 'none' }
|
|
74
262
|
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The obligation owed to this session right now, or null when nothing is owed
|
|
266
|
+
* (absent unit, an empty unit, an unsupported store). A unit that cannot be
|
|
267
|
+
* read also answers null — `deliver` reports that case separately.
|
|
268
|
+
* @param {string} sessionId
|
|
269
|
+
* @returns {Promise<PendingNotice | null>}
|
|
270
|
+
*/
|
|
271
|
+
async function pending(sessionId) {
|
|
272
|
+
const state = await loadPending(sessionId)
|
|
273
|
+
return state.kind === 'owed' ? { ...state.net, revision: state.revision } : null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Subtract exactly the delivered names (D4). A lost CAS race is retried once
|
|
278
|
+
* against a fresh read — that retry is what preserves a concurrent Apply's
|
|
279
|
+
* delta; a second conflict (or any other failure) leaves the pending intact,
|
|
280
|
+
* which repeats a notice rather than losing one.
|
|
281
|
+
* @param {string} sessionId
|
|
282
|
+
* @param {{ added?: string[], removed?: string[] }} delivered
|
|
283
|
+
* @param {{ revision?: number }} [info] the revision observed when delivering
|
|
284
|
+
* @returns {Promise<{ status: string, revision?: number, kind?: string }>}
|
|
285
|
+
*/
|
|
286
|
+
async function clearDelivered(sessionId, delivered, info = {}) {
|
|
287
|
+
if (!store) return { status: 'unsupported' }
|
|
288
|
+
const owed = noticeNet(delivered)
|
|
289
|
+
/** @type {number | null} */
|
|
290
|
+
let expected = typeof info.revision === 'number' ? info.revision : null
|
|
291
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
292
|
+
if (expected === null) {
|
|
293
|
+
let record
|
|
294
|
+
try {
|
|
295
|
+
record = await store.read(unitOf(sessionId))
|
|
296
|
+
} catch {
|
|
297
|
+
note(sessionId, 'clear-failed', { status: 'read-failed' })
|
|
298
|
+
return { status: 'read-failed' }
|
|
299
|
+
}
|
|
300
|
+
if (record.kind === 'unsupported') return { status: 'unsupported' }
|
|
301
|
+
if (record.kind === 'absent') {
|
|
302
|
+
remember(sessionId, { net: NO_CHANGES, revision: 0, present: false })
|
|
303
|
+
return { status: 'absent' }
|
|
304
|
+
}
|
|
305
|
+
if (record.kind !== 'ok') {
|
|
306
|
+
note(sessionId, 'clear-failed', { status: record.kind })
|
|
307
|
+
return { status: 'unreadable', kind: record.kind }
|
|
308
|
+
}
|
|
309
|
+
// An empty obligation needs no write at all.
|
|
310
|
+
if (isEmptyNet(noticeNet(record.payload))) {
|
|
311
|
+
remember(sessionId, { net: NO_CHANGES, revision: record.revision, present: true })
|
|
312
|
+
return { status: 'empty', revision: record.revision }
|
|
313
|
+
}
|
|
314
|
+
expected = record.revision
|
|
315
|
+
}
|
|
316
|
+
const result = await store.commit(unitOf(sessionId), expected, previous => subtractDelivered(noticeNet(previous), owed))
|
|
317
|
+
if (result.status === 'committed') {
|
|
318
|
+
remember(sessionId, { net: noticeNet(result.payload), revision: result.revision, present: true })
|
|
319
|
+
note(sessionId, 'cleared', { added: owed.added.length, removed: owed.removed.length, revision: result.revision })
|
|
320
|
+
return { status: 'cleared', revision: result.revision }
|
|
321
|
+
}
|
|
322
|
+
if (result.status === 'revision-conflict' && attempt === 0) {
|
|
323
|
+
expected = null
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
note(sessionId, 'clear-failed', { added: owed.added.length, removed: owed.removed.length, status: result.status })
|
|
327
|
+
return { status: result.status }
|
|
328
|
+
}
|
|
329
|
+
/* c8 ignore next */
|
|
330
|
+
return { status: 'unsettled' }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Deliver the pending notice into the session's next request boundary:
|
|
335
|
+
* read → build → inject → clear (D4). The obligation is cleared only after the
|
|
336
|
+
* message landed, so a failed injection leaves it for the next boundary — the
|
|
337
|
+
* old consume-before-push lost it silently.
|
|
338
|
+
* @param {string} sessionId @param {unknown[] | unknown} messages
|
|
339
|
+
* @returns {Promise<'absent'|'delivered'|'injection-failed'|'clear-failed'|'unreadable'>}
|
|
340
|
+
*/
|
|
341
|
+
async function deliver(sessionId, messages) {
|
|
342
|
+
const state = await loadPending(sessionId)
|
|
343
|
+
// A unit that cannot be read delivers nothing and deletes nothing (D5).
|
|
344
|
+
if (state.kind === 'unreadable') return 'unreadable'
|
|
345
|
+
// Nothing owed — including an empty unit, which holds no names to clear.
|
|
346
|
+
if (state.kind !== 'owed') return 'absent'
|
|
347
|
+
const owed = state.net
|
|
348
|
+
const text = notificationText(owed)
|
|
349
|
+
/* c8 ignore next */
|
|
350
|
+
if (text === null) return 'absent'
|
|
351
|
+
try {
|
|
352
|
+
if (!Array.isArray(messages)) throw new TypeError('messages is not an array')
|
|
353
|
+
messages.push(userTextMessage(text, NOTIFY_SOURCE))
|
|
354
|
+
} catch (error) {
|
|
355
|
+
note(sessionId, 'injection-failed', { added: owed.added.length, removed: owed.removed.length, status: error instanceof Error ? error.message : String(error) })
|
|
356
|
+
return 'injection-failed'
|
|
357
|
+
}
|
|
358
|
+
note(sessionId, 'delivered', { added: owed.added.length, removed: owed.removed.length })
|
|
359
|
+
const cleared = await clearDelivered(sessionId, owed, { revision: owed.revision })
|
|
360
|
+
return cleared.status === 'cleared' || cleared.status === 'absent' || cleared.status === 'empty' ? 'delivered' : 'clear-failed'
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Synchronous diagnostic: is a notice queued for this session? Answers from
|
|
365
|
+
* the mirror, else from one synchronous read — never a stale cache guess.
|
|
366
|
+
* @param {string} sessionId
|
|
367
|
+
*/
|
|
368
|
+
function has(sessionId) {
|
|
369
|
+
const cached = mirror.get(sessionId)
|
|
370
|
+
if (cached) return cached.present && !isEmptyNet(cached.net)
|
|
371
|
+
if (!store) return false
|
|
372
|
+
let record
|
|
373
|
+
try { record = store.readSync(unitOf(sessionId)) } catch { return false }
|
|
374
|
+
return record.kind === 'ok' && !isEmptyNet(noticeNet(record.payload))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return { queueSync, pending, clearDelivered, deliver, has }
|
|
75
378
|
}
|
|
@@ -30,7 +30,13 @@ export function createOfficeAdapter(skills, machineId) {
|
|
|
30
30
|
const observation = Array.isArray(output) ? { candidates: output, complete: true } : output
|
|
31
31
|
const candidates = []
|
|
32
32
|
for (const raw of observation.candidates) {
|
|
33
|
-
|
|
33
|
+
// DSH 0.2.1's office provider lists MORE bundled siblings than the three
|
|
34
|
+
// Weir composes (`office`, `office-render`, `office-recalculate`,
|
|
35
|
+
// `office-export-pdf`). This adapter only owns OFFICE_NAMES: a foreign
|
|
36
|
+
// sibling is skipped, never an error — while a candidate that CLAIMS one
|
|
37
|
+
// of our names with an unexpected shape still fails closed below.
|
|
38
|
+
if (!OFFICE_NAMES.includes(raw.name)) continue
|
|
39
|
+
if (raw.provider !== 'dsh-office' || raw.source !== 'bundled' || typeof raw.locator !== 'string') {
|
|
34
40
|
throw new Error('Unsupported Office provider candidate')
|
|
35
41
|
}
|
|
36
42
|
const path = await realpath(raw.locator)
|