weir-harness 1.2.2 → 2.0.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.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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 +1066 -0
- package/src/file-lease/index.js +30 -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 +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/matcher.js +27 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/lsp/tools.js +137 -27
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +11 -0
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +29 -28
- package/src/shared/audit.js +6 -1
- 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/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
package/src/lsp/tools.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// The LSP semantic tools: four read-only queries plus `lsp_rename`, the one
|
|
2
2
|
// rewriting tool (cross-file WorkspaceEdit application through the fs version
|
|
3
|
-
// guard). Pure-object definitions with
|
|
4
|
-
// positions in, structured text out; every LSP
|
|
5
|
-
// tool error result, never a broken turn.
|
|
3
|
+
// guard, under ONE batch file lease). Pure-object definitions with
|
|
4
|
+
// object-rooted schemas; 1-based positions in, structured text out; every LSP
|
|
5
|
+
// failure becomes an ordinary tool error result, never a broken turn.
|
|
6
6
|
import { familyForLanguageId, languageIdForFile } from './registry.js'
|
|
7
7
|
import { uriToPath } from './uri.js'
|
|
8
8
|
import { applyTextEdits, detectLineEndings, extractChanges, normalizeLineEndings, restoreLineEndings } from './rename.js'
|
|
9
9
|
import { unifiedDiff } from '../hashline-edit/diff.js'
|
|
10
|
+
import { createEditorLease } from '../file-lease/singleton.js'
|
|
11
|
+
import { isLeaseConflict } from '../file-lease/client.js'
|
|
10
12
|
|
|
11
13
|
const SEVERITY = { 1: 'error', 2: 'warning', 3: 'information', 4: 'hint' }
|
|
12
14
|
|
|
@@ -34,9 +36,105 @@ function asLocationArray(result) {
|
|
|
34
36
|
.filter(Boolean)
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The sandbox denial marker the host backend raises, by prefix only: every
|
|
41
|
+
* effective mode ends with its own mode name, and a denial never moved bytes.
|
|
42
|
+
*/
|
|
43
|
+
const SANDBOX_DENIAL_MARKER = '[sandbox: file access denied under'
|
|
44
|
+
|
|
45
|
+
/** @param {unknown} error @returns {boolean} */
|
|
46
|
+
function isSandboxDenial(error) {
|
|
47
|
+
return String(/** @type {any} */ (error)?.message ?? '').includes(SANDBOX_DENIAL_MARKER)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {unknown} error @returns {string} */
|
|
51
|
+
function messageOf(error) {
|
|
52
|
+
return String(/** @type {any} */ (error)?.message ?? error)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The three-way truth of a failed rename (design D12): which targets are on
|
|
57
|
+
* disk, which PROVABLY are not, and which cannot be proven either way.
|
|
58
|
+
*
|
|
59
|
+
* Only these three failures prove that no byte of a target landed: a refused
|
|
60
|
+
* claim (nothing ran), the filesystem version guard (the write was rejected
|
|
61
|
+
* before it moved bytes) and a sandbox denial (the backend refused the
|
|
62
|
+
* operation). Anything else — an I/O error, an abort, a backend that threw
|
|
63
|
+
* after a partial write — is UNPROVEN, and claiming it was not written would be
|
|
64
|
+
* a lie the model would act on.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} error
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function isProvablyUnwritten(error) {
|
|
70
|
+
if (isLeaseConflict(error)) return true
|
|
71
|
+
const value = /** @type {any} */ (error)
|
|
72
|
+
if (String(value?.code ?? '').includes('FS_STALE_VERSION')) return true
|
|
73
|
+
return String(value?.message ?? '').includes('FS_STALE_VERSION') || isSandboxDenial(error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The honest failure report: three lists and no rollback claim. The identity of
|
|
78
|
+
* the caller's claim is stated truthfully too — a batch refused before the
|
|
79
|
+
* claim was granted holds nothing, while a batch that failed mid-write keeps its
|
|
80
|
+
* leases until the turn ends (design D2/D10).
|
|
81
|
+
*
|
|
82
|
+
* @param {{ failure: unknown, remaining: any[], written: any[], entered: boolean }} detail
|
|
83
|
+
* @returns {Error}
|
|
84
|
+
*/
|
|
85
|
+
function renameFailure({ failure, remaining, written, entered }) {
|
|
86
|
+
const list = (plans) => `[${plans.map((plan) => JSON.stringify(plan.target.displayPath)).join(', ')}]`
|
|
87
|
+
const proven = !entered || isProvablyUnwritten(failure)
|
|
88
|
+
const notWritten = proven ? remaining : []
|
|
89
|
+
const uncertain = proven ? [] : remaining
|
|
90
|
+
const failedPath = entered ? JSON.stringify(remaining[0]?.target.displayPath ?? '') : undefined
|
|
91
|
+
/** @type {Error & { cause?: unknown }} */
|
|
92
|
+
const error = new Error([
|
|
93
|
+
failedPath === undefined
|
|
94
|
+
? `lsp_rename: the rename was refused before any write: ${messageOf(failure)}`
|
|
95
|
+
: `lsp_rename: write failed for ${failedPath}: ${messageOf(failure)}`,
|
|
96
|
+
`filesAlreadyWritten: ${list(written)};`,
|
|
97
|
+
`filesNotWritten: ${list(notWritten)};`,
|
|
98
|
+
`filesUncertain: ${list(uncertain)};`,
|
|
99
|
+
entered
|
|
100
|
+
? 'ownership retained (no rollback)'
|
|
101
|
+
: 'no claim was taken for this batch (no rollback)',
|
|
102
|
+
].join('\n'))
|
|
103
|
+
error.cause = failure
|
|
104
|
+
return error
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The optional `leaseClient` seam on a scope that may not declare it.
|
|
109
|
+
*
|
|
110
|
+
* A cordis context THROWS on reading a property it does not inject
|
|
111
|
+
* ("cannot get property \"leaseClient\" without inject"), so probing it directly
|
|
112
|
+
* makes the seam a trap: `lsp` injects `tools`/`subprocess`/`fs`, never a lease
|
|
113
|
+
* client, and every `lsp_rename` would fail at tool construction. An explicit
|
|
114
|
+
* argument (unit tests, an embedder) always wins; the probe only ever answers
|
|
115
|
+
* `undefined` when the composition does not carry one.
|
|
116
|
+
* @param {any} scope
|
|
117
|
+
* @returns {any}
|
|
118
|
+
*/
|
|
119
|
+
function optionalLeaseClient(scope) {
|
|
120
|
+
try {
|
|
121
|
+
return scope?.leaseClient
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the five tool definitions for one calling agent's enable.
|
|
129
|
+
*
|
|
130
|
+
* `leaseClient` is the injection seam (explicit argument or a carrying ctx),
|
|
131
|
+
* because this module's caller forwards only `{manager, ctx, agent}`. Nothing
|
|
132
|
+
* else in the row reads it: no service is published or consumed for leases
|
|
133
|
+
* (design D11) — the default is the process-wide client, resolved on the first
|
|
134
|
+
* managed write.
|
|
135
|
+
*/
|
|
136
|
+
export function createLspTools({ manager, ctx, agent, leaseClient }) {
|
|
137
|
+
const lease = createEditorLease({ leaseClient: leaseClient ?? optionalLeaseClient(ctx), ctx })
|
|
40
138
|
// Optional sandbox policy capture (S23, mirrors hashline-edit): the
|
|
41
139
|
// sandboxed fs backend enforces the session policy only when the caller
|
|
42
140
|
// passes it per call. Absent service (headless test compositions, other
|
|
@@ -179,7 +277,7 @@ export function createLspTools({ manager, ctx, agent }) {
|
|
|
179
277
|
},
|
|
180
278
|
{
|
|
181
279
|
name: 'lsp_rename',
|
|
182
|
-
description: `Rename the symbol at a 1-based position across the workspace: the language server computes every edit location and the resulting WorkspaceEdit is applied to disk through the filesystem version guard. Every target file is preflighted before anything is written (a bad edit range aborts the call with zero writes); then files are written one by one and a mid-write failure (file changed on disk, sandbox denial, I/O) stops immediately, reporting exactly which files were already written and which
|
|
280
|
+
description: `Rename the symbol at a 1-based position across the workspace: the language server computes every edit location and the resulting WorkspaceEdit is applied to disk through the filesystem version guard, under one file-lease claim covering every preflighted target. Every target file is preflighted before anything is written (a bad edit range aborts the call with zero writes); then files are written one by one and a mid-write failure (file changed on disk, sandbox denial, I/O) stops immediately, reporting exactly which files were already written, which were not written, and which could not be proven either way. Nothing is rolled back. The original line-ending style (LF/CRLF) of each file is preserved.`,
|
|
183
281
|
parameters: {
|
|
184
282
|
type: 'object',
|
|
185
283
|
properties: {
|
|
@@ -254,25 +352,39 @@ export function createLspTools({ manager, ctx, agent }) {
|
|
|
254
352
|
return { text: `lsp_rename: no-op — the server's edits leave every file unchanged; nothing was written.` }
|
|
255
353
|
}
|
|
256
354
|
|
|
257
|
-
// Phase 2 — write pass
|
|
258
|
-
//
|
|
355
|
+
// Phase 2 — write pass under ONE batch claim (design D2): the complete
|
|
356
|
+
// preflighted target set is acquired atomically before the first byte, so
|
|
357
|
+
// a conflict anywhere refuses the whole call with nothing written and no
|
|
358
|
+
// implicit takeover. The claim is NOT released here: it lives until the
|
|
359
|
+
// turn ends, like every other managed edit claim (D10), so the session's
|
|
360
|
+
// own later edits of these files cannot conflict with itself.
|
|
259
361
|
const written = []
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
362
|
+
let entered = false
|
|
363
|
+
/** @type {unknown} */
|
|
364
|
+
let failure = null
|
|
365
|
+
try {
|
|
366
|
+
await lease.client().withLeases(
|
|
367
|
+
lease.ownerOf(exec.agent),
|
|
368
|
+
plans.map((plan) => plan.target.displayPath),
|
|
369
|
+
lease.scopeOf(exec.agent, resolveCwd),
|
|
370
|
+
async () => {
|
|
371
|
+
entered = true
|
|
372
|
+
for (const plan of writePlans) {
|
|
373
|
+
await ctx.fs.writeText(plan.target, plan.after, { kind: 'replaceIfVersion', version: plan.version }, exec.signal, policy)
|
|
374
|
+
written.push(plan)
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
} catch (error) {
|
|
379
|
+
failure = error
|
|
380
|
+
}
|
|
381
|
+
if (failure !== null) {
|
|
382
|
+
// The failure report is the whole contract here (design D12): which
|
|
383
|
+
// files are provably on disk, which provably are not, which cannot be
|
|
384
|
+
// proven, and never a rollback claim. `written` holds exactly the
|
|
385
|
+
// files that returned from writeText, so the failing target is the
|
|
386
|
+
// head of the remaining slice.
|
|
387
|
+
throw renameFailure({ failure, remaining: writePlans.slice(written.length), written, entered })
|
|
276
388
|
}
|
|
277
389
|
|
|
278
390
|
const editCount = writePlans.reduce((sum, plan) => sum + plan.edits, 0)
|
|
@@ -284,8 +396,6 @@ export function createLspTools({ manager, ctx, agent }) {
|
|
|
284
396
|
},
|
|
285
397
|
},
|
|
286
398
|
]
|
|
287
|
-
// Only a definition built with the captured service is admitted by Edit Lock.
|
|
288
|
-
if (editLock) for (const tool of tools) if (tool.name === 'lsp_rename') editLock.claim?.(tool)
|
|
289
399
|
return tools
|
|
290
400
|
}
|
|
291
401
|
|
package/src/notify/commands.js
CHANGED
|
@@ -54,7 +54,10 @@ function linux(note, sound) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* PowerShell source of the Windows toast; reads title/body from the environment
|
|
59
|
+
* and reports an OS-level block through stderr plus a non-zero exit.
|
|
60
|
+
*/
|
|
58
61
|
const WINDOWS_SCRIPT = [
|
|
59
62
|
"$ErrorActionPreference = 'Stop'",
|
|
60
63
|
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
|
|
@@ -66,7 +69,17 @@ const WINDOWS_SCRIPT = [
|
|
|
66
69
|
'$xml.LoadXml("<toast><visual><binding template=\'ToastGeneric\'><text>$title</text><text>$body</text></binding></visual>$audio</toast>")',
|
|
67
70
|
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)',
|
|
68
71
|
"$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
69
|
-
|
|
72
|
+
// `Show()` has no synchronous failure return, and the asynchronous
|
|
73
|
+
// `ToastNotification.Failed` event cannot be awaited by a process that exits
|
|
74
|
+
// right after firing, so `ToastNotifier.Setting` is the synchronous proxy: a
|
|
75
|
+
// non-`Enabled` value (disabled for the app, the user, by group policy or by
|
|
76
|
+
// manifest) becomes a non-zero exit whose stderr reaches the warn-once path
|
|
77
|
+
// instead of vanishing into silence. Explicit variables, not a chained
|
|
78
|
+
// expression: PowerShell 5.1 chaining behaviour must not be relied on.
|
|
79
|
+
'$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)',
|
|
80
|
+
'$setting = $notifier.Setting',
|
|
81
|
+
'if ("$setting" -ne \'Enabled\') { [Console]::Error.WriteLine("Windows toast notifications are unavailable: $setting"); exit 3 }',
|
|
82
|
+
'$notifier.Show($toast)',
|
|
70
83
|
].join('\n')
|
|
71
84
|
|
|
72
85
|
/**
|
package/src/notify/index.js
CHANGED
|
@@ -30,6 +30,32 @@ const STALE_EVENT_MS = 60_000
|
|
|
30
30
|
/** Longest parent chain walked to find the session the user actually sees. */
|
|
31
31
|
const MAX_ANCESTORS = 8
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Platforms whose page can both DISPLAY a notification and REPORT whether it
|
|
35
|
+
* appeared, so handing the note to the page first is sound there: macOS and
|
|
36
|
+
* Linux. Windows is deliberately absent — the page's web `Notification` can
|
|
37
|
+
* neither display nor report failure there. Electron only shows a toast for an
|
|
38
|
+
* app that owns a Start Menu shortcut with an AppUserModelID and a
|
|
39
|
+
* ToastActivatorCLSID (the DSH desktop bundle declares no app identity), so
|
|
40
|
+
* Chromium drops it with nothing but a debug log; and in a secure context with
|
|
41
|
+
* permission granted, Blink never fails the constructor and never fires
|
|
42
|
+
* `error`. The page could therefore only answer `shown`/`pending`, both
|
|
43
|
+
* terminal, which would suppress the fallback forever. So Windows goes
|
|
44
|
+
* straight to the platform command, whose AUMID points at an OS-registered
|
|
45
|
+
* shortcut. The cost: Windows attributes the toast to "Windows PowerShell".
|
|
46
|
+
*/
|
|
47
|
+
export const PAGE_DELIVERY_PLATFORMS = Object.freeze(['darwin', 'linux'])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether the page leg can carry a delivery on this platform.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} platform - a `process.platform` value
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function usesPageDelivery(platform) {
|
|
56
|
+
return PAGE_DELIVERY_PLATFORMS.includes(platform)
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/**
|
|
34
60
|
* Resolve the effective options NOW (the settings service is live: a
|
|
35
61
|
* volatile commit takes effect on the next event, no remount). Unusable
|
|
@@ -238,26 +264,38 @@ export function wire(ctx, config, { notifier, channel, now = Date.now, setTimer,
|
|
|
238
264
|
}
|
|
239
265
|
}
|
|
240
266
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
267
|
+
/**
|
|
268
|
+
* @param {any} ctx
|
|
269
|
+
* @param {object} [config]
|
|
270
|
+
* @param {{ platform?: string }} [options] - test seam; cordis calls (ctx, config) only
|
|
271
|
+
*/
|
|
272
|
+
function apply(ctx, config = {}, { platform = process.platform } = {}) {
|
|
273
|
+
const notifier = createNotifier({ platform, logger: ctx.logger })
|
|
274
|
+
if (!notifier.supported) ctx.logger?.warn?.(`notify: system notifications are not supported on platform "${platform}"`)
|
|
275
|
+
// The page delivers (as DeepSeek Harness) where it can also confirm; on the
|
|
276
|
+
// other platforms the system command is the only leg (PAGE_DELIVERY_PLATFORMS).
|
|
277
|
+
const channel = usesPageDelivery(platform)
|
|
278
|
+
? createWebChannel({ fallback: (note, options) => notifier.send(note, options), logger: ctx.logger })
|
|
279
|
+
: undefined
|
|
247
280
|
const offWire = wire(ctx, config, { notifier, channel })
|
|
248
281
|
// Page endpoints (pull/ack) and the macOS permission panel's endpoints. They
|
|
249
282
|
// register from this row, whose `./notify` subpath already exists, so no new
|
|
250
283
|
// package subpath is needed (S19); services come through ctx.inject (S20).
|
|
251
284
|
const offEndpoints = wireNotifyPermissions(ctx, {
|
|
285
|
+
platform,
|
|
252
286
|
channel,
|
|
253
|
-
// The permission panel's test goes through
|
|
254
|
-
// with the window in front (the user is looking at
|
|
255
|
-
|
|
287
|
+
// The permission panel's test goes through whichever path the platform
|
|
288
|
+
// uses, and shows even with the window in front (the user is looking at
|
|
289
|
+
// the settings page).
|
|
290
|
+
sendTest: () =>
|
|
291
|
+
channel
|
|
292
|
+
? channel.send({ ...TEST_NOTE, tag: tagFor('test', 'test'), renotify: RENOTIFY, sound: true, foreground: 'always' })
|
|
293
|
+
: notifier.send(TEST_NOTE, { sound: true }),
|
|
256
294
|
})
|
|
257
295
|
return () => {
|
|
258
296
|
offWire()
|
|
259
297
|
offEndpoints()
|
|
260
|
-
channel
|
|
298
|
+
channel?.close()
|
|
261
299
|
}
|
|
262
300
|
}
|
|
263
301
|
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Safety contract (spec "待发通知通道对失败与异常页面安全"):
|
|
11
11
|
// - a note is handed to exactly ONE puller;
|
|
12
|
-
// - a note is delivered at most once: a page that showed it,
|
|
13
|
-
//
|
|
12
|
+
// - a note is delivered at most once: a page that showed it, suppressed it on
|
|
13
|
+
// purpose (window in the foreground), or handed it off without confirmation
|
|
14
|
+
// (`pending`) is final — no fallback;
|
|
15
|
+
// - `pending` is final but is NOT confirmation: it means the page could not
|
|
16
|
+
// tell whether the notification appeared, so it is reported once per cause
|
|
17
|
+
// through the logger instead of falling back — a silent platform drop would
|
|
18
|
+
// otherwise be invisible to the user and to the log;
|
|
14
19
|
// - a note is never lost: no puller, a failed page, or a missing ack all fall
|
|
15
20
|
// back to the system command;
|
|
16
21
|
// - nothing here touches a session. Plain ESM, ctx-free; clock and timers are
|
|
@@ -25,11 +30,16 @@ export const ACK_TIMEOUT_MS = 4_000
|
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* What the page may report. `shown`/`pending`/`suppressed` are final (no
|
|
28
|
-
* fallback); everything else falls back.
|
|
33
|
+
* fallback); everything else falls back. `pending` is final without being
|
|
34
|
+
* confirmation — the page handed the note off and could not tell whether it
|
|
35
|
+
* appeared — so it is reported through the logger instead of falling back.
|
|
29
36
|
*/
|
|
30
37
|
export const FINAL_RESULTS = Object.freeze(['shown', 'pending', 'suppressed'])
|
|
31
38
|
export const FALLBACK_RESULTS = Object.freeze(['blocked', 'error', 'threw', 'unsupported'])
|
|
32
39
|
|
|
40
|
+
/** Reported once per channel when a page hands a note off but never confirms it. */
|
|
41
|
+
const UNCONFIRMED = 'the page sent a notification but could not confirm it appeared'
|
|
42
|
+
|
|
33
43
|
/**
|
|
34
44
|
* @typedef {{ title: string, body: string, urgent: boolean }} Note
|
|
35
45
|
* @typedef {Note & { tag: string, renotify: boolean, sound: boolean, foreground: 'skip' | 'always' }} WebNote
|
|
@@ -63,6 +73,15 @@ export function createWebChannel({ fallback, now = Date.now, setTimer, clearTime
|
|
|
63
73
|
let nextId = 1
|
|
64
74
|
let closed = false
|
|
65
75
|
|
|
76
|
+
/** Distinct warnings already emitted (one per cause, like the notifier). @type {Set<string>} */
|
|
77
|
+
const reported = new Set()
|
|
78
|
+
/** @param {string} cause */
|
|
79
|
+
const warnOnce = (cause) => {
|
|
80
|
+
if (reported.has(cause)) return
|
|
81
|
+
reported.add(cause)
|
|
82
|
+
logger?.warn?.(`notify: ${cause}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
const hasPuller = () => waiters.length > 0 || now() - lastPullAt < PULLER_FRESH_MS
|
|
67
86
|
|
|
68
87
|
/** @param {WebNote} note */
|
|
@@ -165,6 +184,10 @@ export function createWebChannel({ fallback, now = Date.now, setTimer, clearTime
|
|
|
165
184
|
inFlight.delete(id)
|
|
166
185
|
stop(entry.timer)
|
|
167
186
|
if (!FINAL_RESULTS.includes(result)) useFallback(entry.note)
|
|
187
|
+
// `pending` is final (never fall back) but it is not confirmation: the
|
|
188
|
+
// page handed the note off and could not tell whether it appeared. Say so
|
|
189
|
+
// once per cause, or a silent platform drop stays invisible.
|
|
190
|
+
else if (result === 'pending') warnOnce(UNCONFIRMED)
|
|
168
191
|
return true
|
|
169
192
|
},
|
|
170
193
|
|
package/src/process/index.js
CHANGED
|
@@ -39,6 +39,8 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
|
|
|
39
39
|
// describe, not which separator to spell, so a test on any host can point
|
|
40
40
|
// the seam at a real fixture directory.
|
|
41
41
|
const roaming = env.APPDATA ?? join(home, 'AppData', 'Roaming')
|
|
42
|
+
const localAppData = env.LOCALAPPDATA ?? join(home, 'AppData', 'Local')
|
|
43
|
+
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
|
42
44
|
return [
|
|
43
45
|
// npm's own global prefix: the .cmd/.ps1 shims live directly here.
|
|
44
46
|
join(roaming, 'npm'),
|
|
@@ -51,6 +53,15 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
|
|
|
51
53
|
join(home, 'go', 'bin'),
|
|
52
54
|
join(home, 'scoop', 'shims'),
|
|
53
55
|
join(roaming, 'npm', 'node_modules', '.bin'),
|
|
56
|
+
// LLVM's NSIS installer skips the "add to PATH" step in silent mode, so
|
|
57
|
+
// these two are the ONLY way a winget-installed clangd resolves (D4).
|
|
58
|
+
join(programFiles, 'LLVM', 'bin'),
|
|
59
|
+
join(localAppData, 'Programs', 'LLVM', 'bin'),
|
|
60
|
+
// WinGet portable packages expose their command aliases here (LuaLS).
|
|
61
|
+
join(localAppData, 'Microsoft', 'WinGet', 'Links'),
|
|
62
|
+
// winget.exe itself lives here; a long-running GUI's PATH can predate the
|
|
63
|
+
// App Installer update, so the directory scan must cover it too.
|
|
64
|
+
join(localAppData, 'Microsoft', 'WindowsApps'),
|
|
54
65
|
]
|
|
55
66
|
}
|
|
56
67
|
const nvmBins = []
|
package/src/settings/index.js
CHANGED
|
@@ -14,32 +14,14 @@ import { configValue } from './volatile.js'
|
|
|
14
14
|
import { computeSections } from './sections.js'
|
|
15
15
|
import { wireLspAdmin } from '../lsp/admin.js'
|
|
16
16
|
import { wireEnvAdmin } from './env-admin.js'
|
|
17
|
-
import { createEditLockEvidence, wireEditLockMaintenance } from '../edit-lock/maintenance.js'
|
|
18
|
-
import { managementRootFor } from '../edit-lock/domains.js'
|
|
19
|
-
import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
|
|
20
|
-
|
|
21
|
-
// Host lifetime, not settings-row lifetime: remounting settings must not forget
|
|
22
|
-
// still-live preset rows. Weak keys isolate hosts and release stopped hosts.
|
|
23
|
-
const evidenceByHost = new WeakMap()
|
|
24
17
|
|
|
25
18
|
export { Config } from './sections.js'
|
|
26
19
|
|
|
27
20
|
const name = 'weir-settings'
|
|
28
21
|
const inject = []
|
|
29
22
|
|
|
30
|
-
// Explicit resolver seam keeps root-discovery tests away from ancestor authorities.
|
|
31
|
-
export function maintenanceRoots(agents, resolveRoot = managementRootFor) {
|
|
32
|
-
const roots = new Set()
|
|
33
|
-
for (const agent of agents?.roots?.() ?? []) {
|
|
34
|
-
const cwd = agent.session?.header?.cwd
|
|
35
|
-
if (typeof cwd !== 'string') continue
|
|
36
|
-
try { roots.add(resolveRoot(cwd)) } catch { /* unavailable root */ }
|
|
37
|
-
}
|
|
38
|
-
return [...roots]
|
|
39
|
-
}
|
|
40
|
-
/** @param {{ resolveRoot?: typeof managementRootFor }} [dependencies] */
|
|
41
|
-
export function createSettingsPlugin({ resolveRoot = managementRootFor } = {}) {
|
|
42
23
|
// Arrow apply: cordis must retain the returned endpoint disposer, not construct it.
|
|
24
|
+
export function createSettingsPlugin() {
|
|
43
25
|
return (ctx, config = {}) => {
|
|
44
26
|
// The whitelist defaults cache: the defaults file is read once per process,
|
|
45
27
|
// and an explicit reload entry (robashDefaultsReload) clears it. Nothing here
|
|
@@ -55,21 +37,9 @@ return (ctx, config = {}) => {
|
|
|
55
37
|
// Compute eagerly: malformed categoryChains/lspServers/robash lists fail
|
|
56
38
|
// activation loud.
|
|
57
39
|
compute()
|
|
58
|
-
const host = ctx.root ?? ctx
|
|
59
|
-
let editLockEvidence = evidenceByHost.get(host)
|
|
60
|
-
if (!editLockEvidence) {
|
|
61
|
-
editLockEvidence = createEditLockEvidence()
|
|
62
|
-
evidenceByHost.set(host, editLockEvidence)
|
|
63
|
-
}
|
|
64
|
-
const candidateRoots = () => maintenanceRoots(ctx.get?.('agents'), resolveRoot)
|
|
65
|
-
const savedEnabled = () => compute().editLock?.enabled === true
|
|
66
|
-
const audit = createAudit(ctx)
|
|
67
|
-
let lastEnabled = savedEnabled()
|
|
68
|
-
|
|
69
40
|
const listeners = new Set()
|
|
70
41
|
// Host-plane service: preset modules read their override section on demand.
|
|
71
42
|
ctx.reflect.provide('weirSettings', {
|
|
72
|
-
editLockEvidence,
|
|
73
43
|
get(key) {
|
|
74
44
|
return compute()[key]
|
|
75
45
|
},
|
|
@@ -97,17 +67,6 @@ return (ctx, config = {}) => {
|
|
|
97
67
|
observeReloadEntry()
|
|
98
68
|
ctx.on('loader/volatile-update', () => {
|
|
99
69
|
observeReloadEntry()
|
|
100
|
-
const enabled = savedEnabled()
|
|
101
|
-
if (enabled !== lastEnabled) {
|
|
102
|
-
lastEnabled = enabled
|
|
103
|
-
// Profile intent has no session log. Mirror only when a server-derived
|
|
104
|
-
// workspace exists; no cwd fallback to the developer's current directory.
|
|
105
|
-
const data = { kind: enabled ? 'enable-requested' : 'disable-requested', scope: 'profile', appliesAfterRestart: true }
|
|
106
|
-
let roots = []
|
|
107
|
-
try { roots = candidateRoots() } catch { /* emit remains available */ }
|
|
108
|
-
if (roots.length) for (const root of roots) audit(null, AUDIT_TYPES.editLockMaintenance, data, { root })
|
|
109
|
-
else audit(null, AUDIT_TYPES.editLockMaintenance, data)
|
|
110
|
-
}
|
|
111
70
|
for (const callback of listeners) callback()
|
|
112
71
|
})
|
|
113
72
|
|
|
@@ -126,11 +85,10 @@ return (ctx, config = {}) => {
|
|
|
126
85
|
// built-in catalog.
|
|
127
86
|
const offLspAdmin = wireLspAdmin(ctx, () => compute().lsp?.servers)
|
|
128
87
|
|
|
129
|
-
const offMaintenance = wireEditLockMaintenance(ctx, { savedEnabled, evidence: editLockEvidence, candidateRoots })
|
|
130
88
|
// Environment facts endpoint (settings-page `when: { env }` rows): same
|
|
131
89
|
// settings-row wiring as the LSP endpoints above (S19).
|
|
132
90
|
const offEnvAdmin = wireEnvAdmin(ctx)
|
|
133
|
-
return () => {
|
|
91
|
+
return () => { offLspAdmin(); offEnvAdmin(); listeners.clear() }
|
|
134
92
|
}
|
|
135
93
|
}
|
|
136
94
|
const apply = createSettingsPlugin()
|
package/src/settings/sections.js
CHANGED
|
@@ -43,17 +43,6 @@ const FIELDS = [
|
|
|
43
43
|
{ key: 'guardSoftThreshold', section: 'contextGuard', field: 'softThreshold', type: 'number', description: 'Soft pressure threshold (live advisory; 0 < soft < hard < 1)' },
|
|
44
44
|
{ key: 'guardHardThreshold', section: 'contextGuard', field: 'hardThreshold', type: 'number', description: 'Hard pressure threshold (live maintenance request; not a completion guarantee)' },
|
|
45
45
|
{ key: 'hashlineHideStockEdit', section: 'hashlineEdit', field: 'hideStockEdit', type: 'boolean', description: 'Hide the stock edit tool (hash_edit only)', restart: true },
|
|
46
|
-
{ key: 'editLockEnabled', section: 'editLock', field: 'enabled', type: 'boolean', description: 'Edit Lock cross-session file ownership (default on; explicit off applies after restart)', restart: true },
|
|
47
|
-
// Retention (design D1/D2): one lock batch shares one cumulative allowance, so a
|
|
48
|
-
// session reads one expiry instead of N countdowns. These keys are policy, not
|
|
49
|
-
// mechanism: the kernel never sees them, the lifecycle resolves them per call.
|
|
50
|
-
{ key: 'editLockHoldDefaultMinutes', section: 'editLock', field: 'holdDefaultMinutes', type: 'number', description: 'Edit Lock: retention minutes used when a session asks to keep its locks without giving a period (default 30)', page: false },
|
|
51
|
-
{ key: 'editLockHoldSingleMaxMinutes', section: 'editLock', field: 'holdSingleMaxMinutes', type: 'number', description: 'Edit Lock: most minutes one retention request may ask for (default 30)', page: false },
|
|
52
|
-
{ key: 'editLockHoldCumulativeMaxMinutes', section: 'editLock', field: 'holdCumulativeMaxMinutes', type: 'number', description: 'Edit Lock: cumulative retention minutes one batch of locks may use; asks beyond it are refused and only release remains (default 120)', page: false },
|
|
53
|
-
{ key: 'editLockNudgeAttempts', section: 'editLock', field: 'nudgeAttempts', type: 'number', description: 'Edit Lock: how many times a finished turn is continued to ask for still-held locks to be released or retained (default 2)', page: false },
|
|
54
|
-
{ key: 'editLockNudgeFallback', section: 'editLock', field: 'nudgeFallback', type: { union: ['release', 'abnormal'] }, description: 'Edit Lock: disposition of locks still held after those notices are used up; release frees the files for other sessions, abnormal keeps them for the user (default release)', page: false },
|
|
55
|
-
{ key: 'editLockAutoResume', section: 'editLock', field: 'autoResume', type: 'boolean', description: 'Edit Lock: a genuine user message automatically resumes a stopped or administratively revoked session and retries a duplicate-binding failure (default on)' },
|
|
56
|
-
{ key: 'editLockStaleSweep', section: 'editLock', field: 'staleSweep', type: 'boolean', description: 'Edit Lock: a genuine user message silently releases locks whose target file no longer exists (default on; read per message, applies immediately)' },
|
|
57
46
|
{ key: 'robashEnabled', section: 'robash', field: 'enabled', type: 'boolean', description: 'Guarded read-only bash for curated agents (master switch)' },
|
|
58
47
|
{ key: 'robashAllow', section: 'robash', field: 'allow', type: 'string', list: true, description: 'JSON array of command names to APPEND to the product-default bash allow list (empty adds nothing; the defaults are always in effect)' },
|
|
59
48
|
{ key: 'robashGitAllow', section: 'robash', field: 'gitAllow', type: 'string', list: true, description: 'JSON array of git subcommands to APPEND to the product-default git allow list (empty adds nothing; the defaults are always in effect)' },
|
|
@@ -72,7 +61,7 @@ const FIELDS = [
|
|
|
72
61
|
{ key: 'worktreeAutoSetup', section: 'worktree', field: 'autoSetup', type: 'boolean', description: 'Install dependencies in a new lane automatically (configured setup, else lockfile-derived)' },
|
|
73
62
|
{ key: 'worktreeWatchTimeoutMinutes', section: 'worktree', field: 'watchTimeoutMinutes', type: 'number', description: 'Minutes a lane-state watch (worktree_watch) lives before expiring with one notice (default 360 = 6 hours, minimum 1); frozen per watch at subscribe time' },
|
|
74
63
|
{ key: 'worktreeAutoApprove', section: 'worktree', field: 'autoApprove', type: { union: ['manual', 'auto-keep', 'auto-clean'] }, description: 'Worktree auto-approve mode: manual shows every decision card; auto-keep auto-approves merges and abandons, removing the worktree but keeping the branch; auto-clean also deletes the branch. The initial mode for sessions without a /worktree approve override (default auto-clean); a session override wins over this global value' },
|
|
75
|
-
{ key: 'lspServers', section: 'lsp', field: 'servers', type: 'string', description: 'JSON map of custom language servers: family → { command, args?, manifests?, installHint?, install? }' },
|
|
64
|
+
{ key: 'lspServers', section: 'lsp', field: 'servers', type: 'string', description: 'JSON map of custom language servers: family → { command, args?, manifests?, installHint?, platforms?, install? }; a platforms map ({ darwin?, linux?, win32?, default? }) overrides only the fields its row declares for that platform' },
|
|
76
65
|
{ key: 'notifyEnabled', section: 'notify', field: 'enabled', type: 'boolean', description: 'System notifications master switch: tell the user when a session needs them or a turn finishes' },
|
|
77
66
|
{ key: 'notifyOnComplete', section: 'notify', field: 'onComplete', type: 'boolean', description: 'Notify when a turn finishes (only turns longer than notifyMinTurnSeconds)' },
|
|
78
67
|
{ key: 'notifyOnAttention', section: 'notify', field: 'onAttention', type: 'boolean', description: 'Notify when a session needs the user: approval, question, plan review, a failed or stopped turn' },
|
|
@@ -98,8 +87,8 @@ export { FIELDS }
|
|
|
98
87
|
|
|
99
88
|
/** Product defaults of the flat settings keys, mirrored from the consuming
|
|
100
89
|
* modules' code defaults (intent-gate classifier, delegate supervision,
|
|
101
|
-
* todo driver, context guard,
|
|
102
|
-
*
|
|
90
|
+
* todo driver, context guard, robash, lsp, worktree, notify). This is the
|
|
91
|
+
* canonical declaration: the cordis.patch.yml
|
|
103
92
|
* weir-settings row carries the same values so saved profiles resolve
|
|
104
93
|
* them, and the settings page mirrors this table client-side to SHOW the
|
|
105
94
|
* effective default of an unset field (both directions pinned by test).
|
|
@@ -121,14 +110,6 @@ export const FIELD_DEFAULTS = Object.freeze({
|
|
|
121
110
|
guardSoftThreshold: 0.72,
|
|
122
111
|
guardHardThreshold: 0.88,
|
|
123
112
|
hashlineHideStockEdit: true,
|
|
124
|
-
editLockEnabled: true,
|
|
125
|
-
editLockHoldDefaultMinutes: 30,
|
|
126
|
-
editLockHoldSingleMaxMinutes: 30,
|
|
127
|
-
editLockHoldCumulativeMaxMinutes: 120,
|
|
128
|
-
editLockNudgeAttempts: 2,
|
|
129
|
-
editLockNudgeFallback: 'release',
|
|
130
|
-
editLockAutoResume: true,
|
|
131
|
-
editLockStaleSweep: true,
|
|
132
113
|
robashEnabled: true,
|
|
133
114
|
lspEnabled: false,
|
|
134
115
|
lspIdleMs: 600000,
|
|
@@ -227,19 +208,42 @@ function chainsValidator(key) {
|
|
|
227
208
|
return validate
|
|
228
209
|
}
|
|
229
210
|
|
|
230
|
-
/**
|
|
211
|
+
/** True for a JSON object literal (arrays and null are not plain objects). */
|
|
212
|
+
function isPlainObject(value) {
|
|
213
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parse + validate the lspServers JSON map (bad input fails activation loud).
|
|
217
|
+
* A family may declare `platforms: { darwin?, linux?, win32?, default? }`,
|
|
218
|
+
* each row shaped like an entry with every field optional; a malformed row
|
|
219
|
+
* names the family and field so activation failure is diagnosable (D6). */
|
|
231
220
|
function validateLspServers(raw) {
|
|
232
221
|
const parsed = JSON.parse(raw)
|
|
233
|
-
if (
|
|
222
|
+
if (!isPlainObject(parsed)) {
|
|
234
223
|
throw new Error('weir-settings: lspServers must be a JSON object map')
|
|
235
224
|
}
|
|
236
225
|
for (const [family, entry] of Object.entries(parsed)) {
|
|
237
|
-
if (!entry || typeof entry
|
|
226
|
+
if (!isPlainObject(entry) || typeof entry.command !== 'string' || entry.command.trim().length === 0) {
|
|
238
227
|
throw new Error(`weir-settings: lspServers.${family} needs a { command } entry`)
|
|
239
228
|
}
|
|
240
229
|
if (entry.args !== undefined && !Array.isArray(entry.args)) {
|
|
241
230
|
throw new Error(`weir-settings: lspServers.${family}.args must be an array`)
|
|
242
231
|
}
|
|
232
|
+
if (entry.platforms === undefined) continue
|
|
233
|
+
if (!isPlainObject(entry.platforms)) {
|
|
234
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms must be a JSON object map`)
|
|
235
|
+
}
|
|
236
|
+
for (const [platform, row] of Object.entries(entry.platforms)) {
|
|
237
|
+
if (!isPlainObject(row)) {
|
|
238
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms.${platform} must be a JSON object`)
|
|
239
|
+
}
|
|
240
|
+
if (row.command !== undefined && (typeof row.command !== 'string' || row.command.trim().length === 0)) {
|
|
241
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms.${platform}.command must be a non-empty string`)
|
|
242
|
+
}
|
|
243
|
+
if (row.args !== undefined && !Array.isArray(row.args)) {
|
|
244
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms.${platform}.args must be an array`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
243
247
|
}
|
|
244
248
|
return parsed
|
|
245
249
|
}
|
|
@@ -337,9 +341,6 @@ export function computeSections(config, { readDefaults } = {}) {
|
|
|
337
341
|
}
|
|
338
342
|
}
|
|
339
343
|
}
|
|
340
|
-
// A saved profile row replaces the bundle row wholesale. An omitted key
|
|
341
|
-
// must still activate protection; a saved false remains an explicit opt-out.
|
|
342
|
-
if (key === 'editLock' && out.enabled === undefined) out.enabled = FIELD_DEFAULTS.editLockEnabled
|
|
343
344
|
if (key === 'contextGuard') guardOptions(out)
|
|
344
345
|
if (key === 'lsp' && typeof out.servers === 'string' && out.servers.trim().length > 0) {
|
|
345
346
|
out.servers = parseJsonField(validateLspServers, out.servers)
|
package/src/shared/audit.js
CHANGED
|
@@ -23,7 +23,6 @@ export const AUDIT_TYPES = Object.freeze({
|
|
|
23
23
|
compaction: 'compaction',
|
|
24
24
|
supervision: 'supervision',
|
|
25
25
|
worktree: 'worktree',
|
|
26
|
-
editLockMaintenance: 'edit-lock-maintenance',
|
|
27
26
|
// Session capability manager (tasks 4.2+): one record per durable Apply
|
|
28
27
|
// acceptance or receipt-query recovery; never commit evidence by itself.
|
|
29
28
|
capabilityApply: 'capability-apply',
|
|
@@ -48,6 +47,12 @@ export const AUDIT_TYPES = Object.freeze({
|
|
|
48
47
|
// spawn carrying the composition the child's creation window applies, so a
|
|
49
48
|
// host restart can replay it — the shim itself is runtime-only state.
|
|
50
49
|
delegate: 'delegate',
|
|
50
|
+
// File-lease degraded managed writes (edit-lock-rebuild D7): one record per
|
|
51
|
+
// degradation, carrying the kind, the owner (session and lineage), the path
|
|
52
|
+
// COUNT and the failure reason — never path contents. The lease client owns
|
|
53
|
+
// the per-process rate limit (one record per window); the emit site adds no
|
|
54
|
+
// throttle of its own and a failed emit never blocks a write.
|
|
55
|
+
fileLease: 'file-lease',
|
|
51
56
|
})
|
|
52
57
|
/**
|
|
53
58
|
* Known dynamic sub-event kinds: `<type>/<kind>` events emitted beside a base
|