dsh-tower 1.3.0 → 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/CHANGELOG.md +77 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +22 -8
- package/README.md +20 -8
- package/cordis.patch.yml +10 -0
- package/docs/README.md +8 -4
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +33 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +31 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +52 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +58 -0
- package/docs/zh/faq.md +72 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +30 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +48 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +270 -8
- package/lib/doctor.js +165 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1554 -182
- package/lib/tower.js +957 -102
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
package/lib/hmr.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// dsh-tower — HMR bridge.
|
|
2
|
+
//
|
|
3
|
+
// Wraps the optional `@deepseek-ai/dsh-hmr` profile service so Tower can ask
|
|
4
|
+
// DSH to reconcile its profile (re-read the patch layers, replace changed
|
|
5
|
+
// linked-package modules and report inactive required entries) without
|
|
6
|
+
// importing any DSH package. A profile without the HMR service simply reports
|
|
7
|
+
// `available: false`; nothing here throws at construction time.
|
|
8
|
+
//
|
|
9
|
+
// The bridge is intentionally dependency-free apart from the stable tool error
|
|
10
|
+
// type, so `tests/hmr.test.mjs` can drive it with a fake `ctx.get('hmr')`.
|
|
11
|
+
//
|
|
12
|
+
// DSH facts this relies on:
|
|
13
|
+
// - `hmr.runExclusive(operation)` serializes the operation with all automatic
|
|
14
|
+
// reload paths; `hmr.reconcileProfile(requiredIds)` must run inside it and
|
|
15
|
+
// throws `HMR profile reconciliation must run inside runExclusive` otherwise.
|
|
16
|
+
// - `reconcileProfile` returns `{ warnings, pending[, restartReason] }`:
|
|
17
|
+
// `pending` modules keep running their previous code until a restart, and
|
|
18
|
+
// `restartReason` means the whole process must be restarted.
|
|
19
|
+
// - `runExclusive` rejects nested transactions; a tool call is never inside
|
|
20
|
+
// one, so a tool-triggered reload is safe.
|
|
21
|
+
import { TowerToolError } from './errors.js'
|
|
22
|
+
|
|
23
|
+
/** Entry ids Tower expects to be active after a reconciliation. */
|
|
24
|
+
export const DEFAULT_REQUIRED_IDS = Object.freeze([
|
|
25
|
+
'tower',
|
|
26
|
+
'tower-skills',
|
|
27
|
+
'preset-tower-worker',
|
|
28
|
+
'preset-tower-reviewer',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/** Debounce window for the dev hot-reload hook (milliseconds). */
|
|
32
|
+
export const DEFAULT_DELAY_MS = 50
|
|
33
|
+
|
|
34
|
+
const RESTART_HINT = 'Restart DSH or check the profile hmr configuration.'
|
|
35
|
+
|
|
36
|
+
/** Read the HMR service without assuming anything about the Cordis context. */
|
|
37
|
+
function hmrService(ctx) {
|
|
38
|
+
if (!ctx || typeof ctx.get !== 'function') return undefined
|
|
39
|
+
try {
|
|
40
|
+
return ctx.get('hmr')
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when `candidate` exposes both HMR methods Tower needs. */
|
|
47
|
+
export function isHmrAvailable(candidate) {
|
|
48
|
+
return Boolean(
|
|
49
|
+
candidate &&
|
|
50
|
+
typeof candidate.runExclusive === 'function' &&
|
|
51
|
+
typeof candidate.reconcileProfile === 'function',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeRequiredIds(value) {
|
|
56
|
+
// An explicit array wins (including an empty one); anything else falls back
|
|
57
|
+
// to the Tower entry ids. Trim and drop duplicates so a config typo does not
|
|
58
|
+
// make `reconcileProfile` treat the same entry twice.
|
|
59
|
+
const source = Array.isArray(value) ? value : DEFAULT_REQUIRED_IDS
|
|
60
|
+
const ids = []
|
|
61
|
+
for (const entry of source) {
|
|
62
|
+
if (typeof entry !== 'string') continue
|
|
63
|
+
const id = entry.trim()
|
|
64
|
+
if (id.length === 0 || ids.includes(id)) continue
|
|
65
|
+
ids.push(id)
|
|
66
|
+
}
|
|
67
|
+
return ids
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeDelayMs(value) {
|
|
71
|
+
if (value === undefined || value === null) return DEFAULT_DELAY_MS
|
|
72
|
+
const delay = Number(value)
|
|
73
|
+
if (!Number.isFinite(delay) || delay < 0) return DEFAULT_DELAY_MS
|
|
74
|
+
return Math.max(1, Math.floor(delay))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function messageOf(error) {
|
|
78
|
+
return String(error?.message ?? error)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function warn(logger, message) {
|
|
82
|
+
try {
|
|
83
|
+
logger?.warn?.(message)
|
|
84
|
+
} catch {
|
|
85
|
+
// Diagnostics must never break a reload path.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create the Tower → DSH HMR bridge.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} ctx Cordis context (only `ctx.get` is used).
|
|
93
|
+
* @param {object} [options]
|
|
94
|
+
* @param {string[]} [options.requiredIds] Entry ids whose inactivity is reported as a failure.
|
|
95
|
+
* @param {number} [options.delayMs] Delay before a scheduled reload runs.
|
|
96
|
+
* @param {{ warn?: Function }} [options.logger] Optional logger for failed scheduled reloads.
|
|
97
|
+
* @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function }}
|
|
98
|
+
*/
|
|
99
|
+
export function createHmrBridge(ctx, options = {}) {
|
|
100
|
+
const requiredIds = Object.freeze(normalizeRequiredIds(options.requiredIds))
|
|
101
|
+
const delayMs = normalizeDelayMs(options.delayMs)
|
|
102
|
+
const logger = options.logger
|
|
103
|
+
|
|
104
|
+
let timer = null
|
|
105
|
+
let lastRunAt = null
|
|
106
|
+
let lastError = null
|
|
107
|
+
|
|
108
|
+
const available = () => isHmrAvailable(hmrService(ctx))
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reconcile the profile now. Throws a stable TowerToolError:
|
|
112
|
+
* `reload-unavailable` when the HMR service is absent, `reload-failed` when
|
|
113
|
+
* the reconciliation itself throws (its message carries the cause).
|
|
114
|
+
*/
|
|
115
|
+
async function runNow() {
|
|
116
|
+
const hmr = hmrService(ctx)
|
|
117
|
+
if (!isHmrAvailable(hmr)) {
|
|
118
|
+
const message = `HMR profile reload is unavailable; ${RESTART_HINT}`
|
|
119
|
+
lastError = message
|
|
120
|
+
throw new TowerToolError('reload-unavailable', message)
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const result = await hmr.runExclusive(() => hmr.reconcileProfile(requiredIds))
|
|
124
|
+
lastRunAt = Date.now()
|
|
125
|
+
lastError = null
|
|
126
|
+
return result
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof TowerToolError) {
|
|
129
|
+
lastError = messageOf(error)
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
const cause = messageOf(error)
|
|
133
|
+
lastError = cause
|
|
134
|
+
throw new TowerToolError(
|
|
135
|
+
'reload-failed',
|
|
136
|
+
`HMR profile reload failed: ${cause}. ${RESTART_HINT}`,
|
|
137
|
+
{ cause: error },
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Schedule `runNow()` after `delayMs` and return immediately. A failure is
|
|
144
|
+
* recorded in `status().lastError` and passed to `logger.warn`; it is never
|
|
145
|
+
* surfaced as an unhandled rejection. Calling `schedule()` again replaces a
|
|
146
|
+
* still-pending timer, so a burst of edits results in one reconciliation.
|
|
147
|
+
*/
|
|
148
|
+
function schedule() {
|
|
149
|
+
if (timer !== null) {
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
timer = null
|
|
152
|
+
}
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
timer = null
|
|
155
|
+
runNow().catch((error) => {
|
|
156
|
+
lastError = messageOf(error)
|
|
157
|
+
warn(logger, `[dsh-tower] scheduled HMR reload failed: ${lastError}`)
|
|
158
|
+
})
|
|
159
|
+
}, delayMs)
|
|
160
|
+
const outcome = { scheduled: true, delayMs, requiredIds: [...requiredIds] }
|
|
161
|
+
if (lastError !== null) outcome.lastError = lastError
|
|
162
|
+
return outcome
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Snapshot for tools and diagnostics. `available` is re-read on every call. */
|
|
166
|
+
function status() {
|
|
167
|
+
return {
|
|
168
|
+
available: available(),
|
|
169
|
+
requiredIds: [...requiredIds],
|
|
170
|
+
delayMs,
|
|
171
|
+
scheduled: timer !== null,
|
|
172
|
+
lastRunAt,
|
|
173
|
+
lastError,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Clear a pending scheduled reload (plugin disposal / HMR reload). */
|
|
178
|
+
function dispose() {
|
|
179
|
+
if (timer !== null) {
|
|
180
|
+
clearTimeout(timer)
|
|
181
|
+
timer = null
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
get available() {
|
|
187
|
+
return available()
|
|
188
|
+
},
|
|
189
|
+
requiredIds,
|
|
190
|
+
delayMs,
|
|
191
|
+
status,
|
|
192
|
+
schedule,
|
|
193
|
+
runNow,
|
|
194
|
+
dispose,
|
|
195
|
+
}
|
|
196
|
+
}
|
package/lib/leases.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Repo-scoped coordination leases.
|
|
2
|
+
//
|
|
3
|
+
// One `<common>/dsh-tower/leases.json` per repository (shared by all
|
|
4
|
+
// worktrees) plus a short-lived writer lock `<common>/dsh-tower/leases.json.lock`.
|
|
5
|
+
// A lease records that a named resource (for example `plugin-load` or
|
|
6
|
+
// `heavy-ci`) is claimed by one holder with a token and an absolute TTL.
|
|
7
|
+
// Leases are advisory coordination only: they never grant Git, process, or
|
|
8
|
+
// filesystem authority, and this module never kills, adopts, or hands over
|
|
9
|
+
// work.
|
|
10
|
+
//
|
|
11
|
+
// Fail-closed protocol: a held, expired, or token-mismatched lease is reported,
|
|
12
|
+
// never silently taken over. Only the token holder may release a lease, and
|
|
13
|
+
// releasing an expired lease requires an explicit `force: true`.
|
|
14
|
+
import fsp from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
import { ensureDir, exists, writeJsonAtomic } from './store.js'
|
|
19
|
+
|
|
20
|
+
export const LEASE_SCHEMA_VERSION = 1
|
|
21
|
+
// Matches the `leases.ttlMs` config default (900000); callers normally pass the
|
|
22
|
+
// configured value explicitly.
|
|
23
|
+
export const DEFAULT_LEASE_TTL_MS = 15 * 60 * 1000
|
|
24
|
+
|
|
25
|
+
const LOCK_TIMEOUT_MS = 5000
|
|
26
|
+
const LOCK_STALE_MS = 10000
|
|
27
|
+
const LOCK_POLL_MS = 20
|
|
28
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
29
|
+
|
|
30
|
+
function fail(code, message) {
|
|
31
|
+
const error = new Error(message)
|
|
32
|
+
error.code = code
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sleep(ms) {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Path of the repository's lease ledger (validated, absolute commonDir). */
|
|
45
|
+
export function leasesPath(commonDir) {
|
|
46
|
+
if (typeof commonDir !== 'string' || commonDir.trim().length === 0) fail('invalid-argument', 'commonDir is required')
|
|
47
|
+
if (!path.isAbsolute(commonDir)) fail('invalid-argument', `commonDir must be an absolute path: ${commonDir}`)
|
|
48
|
+
return path.join(commonDir, 'dsh-tower', 'leases.json')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Path of the short-lived lease-ledger writer lock. */
|
|
52
|
+
export function leasesLockPath(commonDir) {
|
|
53
|
+
return `${leasesPath(commonDir)}.lock`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Windows can reject unlink while an indexer, antivirus, or a concurrent
|
|
58
|
+
* reader still holds the file; retry briefly instead of failing the mutation.
|
|
59
|
+
*/
|
|
60
|
+
async function unlinkWithRetry(file) {
|
|
61
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
62
|
+
try {
|
|
63
|
+
await fsp.unlink(file)
|
|
64
|
+
return true
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === 'ENOENT') return false
|
|
67
|
+
const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
|
|
68
|
+
if (!transient || attempt >= 12) throw error
|
|
69
|
+
await sleep(10 + attempt * 15)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read and validate the lease ledger. A missing file is an empty ledger; a
|
|
76
|
+
* corrupt or unsupported file is an error (never silently rewritten).
|
|
77
|
+
*/
|
|
78
|
+
async function readLeaseDocument(commonDir) {
|
|
79
|
+
const file = leasesPath(commonDir)
|
|
80
|
+
let text
|
|
81
|
+
try {
|
|
82
|
+
text = await fsp.readFile(file, 'utf8')
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error?.code === 'ENOENT') {
|
|
85
|
+
return { file, doc: { schemaVersion: LEASE_SCHEMA_VERSION, updatedAt: null, leases: {} }, exists: false }
|
|
86
|
+
}
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
let parsed
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(text)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
fail('state-corrupt', `corrupt tower leases at ${file}: ${error.message}`)
|
|
94
|
+
}
|
|
95
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.leases)) {
|
|
96
|
+
fail('state-corrupt', `corrupt tower leases at ${file}: expected {"schemaVersion":1,"leases":{...}}`)
|
|
97
|
+
}
|
|
98
|
+
if (parsed.schemaVersion !== LEASE_SCHEMA_VERSION) {
|
|
99
|
+
fail('state-corrupt', `unsupported tower leases schemaVersion ${String(parsed.schemaVersion)} at ${file}`)
|
|
100
|
+
}
|
|
101
|
+
const leases = {}
|
|
102
|
+
for (const [name, record] of Object.entries(parsed.leases)) {
|
|
103
|
+
if (
|
|
104
|
+
!isPlainObject(record) ||
|
|
105
|
+
record.name !== name ||
|
|
106
|
+
typeof record.holder !== 'string' ||
|
|
107
|
+
record.holder.length === 0 ||
|
|
108
|
+
typeof record.token !== 'string' ||
|
|
109
|
+
record.token.length === 0 ||
|
|
110
|
+
!Number.isInteger(record.pid) ||
|
|
111
|
+
record.pid <= 0 ||
|
|
112
|
+
!Number.isFinite(record.createdAt) ||
|
|
113
|
+
!Number.isFinite(record.expiresAt)
|
|
114
|
+
) {
|
|
115
|
+
fail('state-corrupt', `corrupt lease entry "${name}" at ${file}`)
|
|
116
|
+
}
|
|
117
|
+
leases[name] = {
|
|
118
|
+
name,
|
|
119
|
+
holder: record.holder,
|
|
120
|
+
token: record.token,
|
|
121
|
+
pid: record.pid,
|
|
122
|
+
createdAt: record.createdAt,
|
|
123
|
+
expiresAt: record.expiresAt,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
file,
|
|
128
|
+
doc: {
|
|
129
|
+
schemaVersion: LEASE_SCHEMA_VERSION,
|
|
130
|
+
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : null,
|
|
131
|
+
leases,
|
|
132
|
+
},
|
|
133
|
+
exists: true,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function lockIsStale(lockFile) {
|
|
138
|
+
let stat
|
|
139
|
+
try {
|
|
140
|
+
stat = await fsp.stat(lockFile)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return error?.code === 'ENOENT'
|
|
143
|
+
}
|
|
144
|
+
let at = null
|
|
145
|
+
try {
|
|
146
|
+
at = Number(JSON.parse(await fsp.readFile(lockFile, 'utf8'))?.at)
|
|
147
|
+
} catch {
|
|
148
|
+
at = null
|
|
149
|
+
}
|
|
150
|
+
const age = Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
|
|
151
|
+
return age > LOCK_STALE_MS
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Serialize one lease mutation behind the `.lock` file (wx create). Waits
|
|
156
|
+
* briefly for a live writer, removes only a demonstrably stale lock, and never
|
|
157
|
+
* steals a fresh one. The lock is released only when the token still matches.
|
|
158
|
+
*/
|
|
159
|
+
async function withLeaseLock(commonDir, fn) {
|
|
160
|
+
const lockFile = leasesLockPath(commonDir)
|
|
161
|
+
await ensureDir(path.dirname(lockFile))
|
|
162
|
+
const token = crypto.randomUUID()
|
|
163
|
+
const record = JSON.stringify({ token, pid: process.pid, at: Date.now() })
|
|
164
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
165
|
+
|
|
166
|
+
for (;;) {
|
|
167
|
+
try {
|
|
168
|
+
await fsp.writeFile(lockFile, record, { flag: 'wx' })
|
|
169
|
+
break
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error?.code !== 'EEXIST') throw error
|
|
172
|
+
if (await lockIsStale(lockFile)) {
|
|
173
|
+
await unlinkWithRetry(lockFile)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (Date.now() > deadline) {
|
|
177
|
+
const owner = await fsp.readFile(lockFile, 'utf8').catch(() => null)
|
|
178
|
+
fail(
|
|
179
|
+
'lease-busy',
|
|
180
|
+
`tower leases are locked by another writer${owner ? ` (${owner})` : ''}; retry after it finishes`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
await sleep(LOCK_POLL_MS)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
return await fn()
|
|
189
|
+
} finally {
|
|
190
|
+
try {
|
|
191
|
+
const current = await fsp.readFile(lockFile, 'utf8').catch(() => null)
|
|
192
|
+
if (current) {
|
|
193
|
+
const parsed = JSON.parse(current)
|
|
194
|
+
if (parsed && parsed.token === token) await unlinkWithRetry(lockFile)
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// A lock left behind by a crash is reported by the next writer, not fixed here.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Claim a named resource for `holder`. Re-acquiring with the current token
|
|
204
|
+
* renews the expiry (idempotent refresh); any other live or expired holder is
|
|
205
|
+
* refused. Returns the stored lease `{name, holder, token, pid, createdAt,
|
|
206
|
+
* expiresAt, renewed}`.
|
|
207
|
+
*/
|
|
208
|
+
export async function acquireLease(commonDir, options = {}) {
|
|
209
|
+
const name = options?.name
|
|
210
|
+
const holder = options?.holder
|
|
211
|
+
const token = options?.token ?? crypto.randomUUID()
|
|
212
|
+
const pid = options?.pid ?? process.pid
|
|
213
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_LEASE_TTL_MS
|
|
214
|
+
const now = options?.now ?? Date.now()
|
|
215
|
+
|
|
216
|
+
if (typeof name !== 'string' || !NAME_RE.test(name)) {
|
|
217
|
+
fail('invalid-argument', `lease name must match ${NAME_RE} (for example "plugin-load"): ${JSON.stringify(name)}`)
|
|
218
|
+
}
|
|
219
|
+
if (typeof holder !== 'string' || holder.trim().length === 0) fail('invalid-argument', 'lease holder is required')
|
|
220
|
+
if (typeof token !== 'string' || token.length === 0) fail('invalid-argument', 'lease token must be a non-empty string')
|
|
221
|
+
if (!Number.isInteger(pid) || pid <= 0) fail('invalid-argument', `lease pid must be a positive integer: ${pid}`)
|
|
222
|
+
if (!Number.isInteger(ttlMs) || ttlMs <= 0) fail('invalid-argument', `lease ttlMs must be a positive integer: ${ttlMs}`)
|
|
223
|
+
if (!Number.isFinite(now)) fail('invalid-argument', 'lease now must be a finite timestamp')
|
|
224
|
+
|
|
225
|
+
const trimmedHolder = holder.trim()
|
|
226
|
+
const file = leasesPath(commonDir)
|
|
227
|
+
await ensureDir(path.dirname(file))
|
|
228
|
+
|
|
229
|
+
return withLeaseLock(commonDir, async () => {
|
|
230
|
+
const { doc } = await readLeaseDocument(commonDir)
|
|
231
|
+
const existing = doc.leases[name]
|
|
232
|
+
if (existing) {
|
|
233
|
+
if (existing.token !== token) {
|
|
234
|
+
const expiresAt = new Date(existing.expiresAt).toISOString()
|
|
235
|
+
const expired = now >= existing.expiresAt
|
|
236
|
+
fail(
|
|
237
|
+
'lease-busy',
|
|
238
|
+
`lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) until ${expiresAt}` +
|
|
239
|
+
(expired
|
|
240
|
+
? `; the lease expired at ${expiresAt} but is not taken over automatically — its holder must release it explicitly (force: true)`
|
|
241
|
+
: ''),
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
if (existing.holder !== trimmedHolder) {
|
|
245
|
+
fail('lease-token', `lease "${name}" token belongs to holder "${existing.holder}", not "${trimmedHolder}"`)
|
|
246
|
+
}
|
|
247
|
+
const renewed = { ...existing, expiresAt: now + ttlMs }
|
|
248
|
+
doc.leases[name] = renewed
|
|
249
|
+
doc.updatedAt = now
|
|
250
|
+
await writeJsonAtomic(file, doc)
|
|
251
|
+
return { ...renewed, renewed: true }
|
|
252
|
+
}
|
|
253
|
+
const record = { name, holder: trimmedHolder, token, pid, createdAt: now, expiresAt: now + ttlMs }
|
|
254
|
+
doc.leases[name] = record
|
|
255
|
+
doc.updatedAt = now
|
|
256
|
+
await writeJsonAtomic(file, doc)
|
|
257
|
+
return { ...record, renewed: false }
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Release a lease. The token must match its holder; an expired lease can only
|
|
263
|
+
* be released with an explicit `force: true`. Returns `true` when removed and
|
|
264
|
+
* `false` when no such lease exists.
|
|
265
|
+
*/
|
|
266
|
+
export async function releaseLease(commonDir, options = {}) {
|
|
267
|
+
const name = options?.name
|
|
268
|
+
const token = options?.token
|
|
269
|
+
const force = options?.force === true
|
|
270
|
+
const now = options?.now ?? Date.now()
|
|
271
|
+
|
|
272
|
+
if (typeof name !== 'string' || !NAME_RE.test(name)) {
|
|
273
|
+
fail('invalid-argument', `lease name must match ${NAME_RE} (for example "plugin-load"): ${JSON.stringify(name)}`)
|
|
274
|
+
}
|
|
275
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
276
|
+
fail('invalid-argument', 'lease token is required to release a lease')
|
|
277
|
+
}
|
|
278
|
+
if (!Number.isFinite(now)) fail('invalid-argument', 'lease now must be a finite timestamp')
|
|
279
|
+
|
|
280
|
+
const file = leasesPath(commonDir)
|
|
281
|
+
if (!(await exists(file))) return false
|
|
282
|
+
await ensureDir(path.dirname(file))
|
|
283
|
+
|
|
284
|
+
return withLeaseLock(commonDir, async () => {
|
|
285
|
+
const { doc } = await readLeaseDocument(commonDir)
|
|
286
|
+
const existing = doc.leases[name]
|
|
287
|
+
if (!existing) return false
|
|
288
|
+
if (existing.token !== token) {
|
|
289
|
+
fail('lease-token', `lease "${name}" token does not match its holder; refusing release`)
|
|
290
|
+
}
|
|
291
|
+
if (!force && now >= existing.expiresAt) {
|
|
292
|
+
fail(
|
|
293
|
+
'lease-busy',
|
|
294
|
+
`lease "${name}" expired at ${new Date(existing.expiresAt).toISOString()}; release it explicitly with force: true`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
delete doc.leases[name]
|
|
298
|
+
doc.updatedAt = now
|
|
299
|
+
await writeJsonAtomic(file, doc)
|
|
300
|
+
return true
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read-only view of every lease: `{path, count, leases:[{name, holder, token,
|
|
306
|
+
* pid, createdAt, expiresAt, expired}]}` sorted by name. A corrupt ledger
|
|
307
|
+
* fails closed; an absent ledger is an empty list.
|
|
308
|
+
*/
|
|
309
|
+
export async function inspectLeases(commonDir, options = {}) {
|
|
310
|
+
const now = options?.now ?? Date.now()
|
|
311
|
+
if (!Number.isFinite(now)) fail('invalid-argument', 'lease now must be a finite timestamp')
|
|
312
|
+
const { file, doc } = await readLeaseDocument(commonDir)
|
|
313
|
+
const leases = Object.values(doc.leases)
|
|
314
|
+
.map((record) => ({ ...record, expired: now >= record.expiresAt }))
|
|
315
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
316
|
+
return { path: file, count: leases.length, leases }
|
|
317
|
+
}
|