dsh-tower 2.1.0 → 2.3.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 +103 -0
- package/COMPATIBILITY.md +5 -1
- package/CONTRIBUTING.md +64 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +167 -36
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/config.generated.md +1 -0
- package/docs/reference/doctor.md +48 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +5 -4
- package/docs/reference/tools.md +4 -4
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +4 -4
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +25 -1
- package/domain/contracts/outputs.js +6 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -42
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +248 -0
- package/{lib → host}/doctor.js +116 -9
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +58 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +3 -3
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +1 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /package/{lib → host}/presets.js +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// dsh-tower-ui 0.2.2 — the toolview card surface (one keyed view per tool).
|
|
2
|
+
//
|
|
3
|
+
// The chat-driven card publishes lifecycle transitions into the notice store
|
|
4
|
+
// the root-scoped overlay injects. Package-local chunk (#71).
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: 'dsh-tower-ui',
|
|
7
|
+
chunk: 'client.toolview.js',
|
|
8
|
+
factory() {
|
|
9
|
+
function createToolview(env) {
|
|
10
|
+
const { React, h, translate, toolLabel, seatT, ST, dotStyle, helpers, cards, notice } = env
|
|
11
|
+
const { isTowerTool, parsePayload, isPlainObject, truncate } = helpers
|
|
12
|
+
const { rowElement, cardModel } = cards
|
|
13
|
+
const publishNotice = notice.publish
|
|
14
|
+
|
|
15
|
+
// Mounting an already-settled result (history) stays silent; a live
|
|
16
|
+
// preparing/start row and a start→result transition publish.
|
|
17
|
+
function useToolviewLifecycle(props) {
|
|
18
|
+
const phase = props ? props.phase : undefined
|
|
19
|
+
const toolName = props ? props.toolName : undefined
|
|
20
|
+
const callId = props ? props.callId : undefined
|
|
21
|
+
const failed = Boolean(props && props.block && (props.block.isError === true || isPlainObject(props.block.error)))
|
|
22
|
+
const previous = React.useRef ? React.useRef(null) : { current: null }
|
|
23
|
+
React.useEffect(
|
|
24
|
+
function () {
|
|
25
|
+
const before = previous.current
|
|
26
|
+
previous.current = { phase, callId }
|
|
27
|
+
if (!isTowerTool(toolName)) return
|
|
28
|
+
const sameCall = before !== null && before.callId === callId
|
|
29
|
+
if (phase !== 'result') {
|
|
30
|
+
if (!sameCall || before.phase !== phase) publishNotice({ tool: toolName, running: true })
|
|
31
|
+
} else if (sameCall && before.phase !== 'result') {
|
|
32
|
+
publishNotice({ tool: toolName, running: false, ok: failed !== true })
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
[phase, callId, toolName, failed],
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TowerToolCard(props) {
|
|
40
|
+
const t = seatT(props)
|
|
41
|
+
const toolName = props && typeof props.toolName === 'string' ? props.toolName : ''
|
|
42
|
+
const phase = props && typeof props.phase === 'string' ? props.phase : 'start'
|
|
43
|
+
const running = phase !== 'result'
|
|
44
|
+
useToolviewLifecycle(props)
|
|
45
|
+
let payload = null
|
|
46
|
+
if (!running) payload = parsePayload(props ? props.block : undefined)
|
|
47
|
+
const model = running
|
|
48
|
+
? { tone: 'idle', headline: translate(t, 'card.running'), rows: [] }
|
|
49
|
+
: cardModel(toolName, payload, t)
|
|
50
|
+
const title =
|
|
51
|
+
toolLabel(t, toolName) +
|
|
52
|
+
' · ' +
|
|
53
|
+
(running ? translate(t, 'card.running') : model.headline)
|
|
54
|
+
return h(
|
|
55
|
+
'div',
|
|
56
|
+
{
|
|
57
|
+
'data-tower-toolview': toolName === '' ? 'tower_unknown' : toolName,
|
|
58
|
+
'data-tower-phase': phase,
|
|
59
|
+
style: ST.card,
|
|
60
|
+
title,
|
|
61
|
+
},
|
|
62
|
+
h(
|
|
63
|
+
'div',
|
|
64
|
+
{ style: ST.header },
|
|
65
|
+
h('span', { 'aria-hidden': true, style: dotStyle(running ? 'idle' : model.tone) }),
|
|
66
|
+
h('span', { style: ST.title }, translate(t, 'card.title')),
|
|
67
|
+
h('span', { style: ST.dim }, '·'),
|
|
68
|
+
h('span', { style: ST.title }, toolLabel(t, toolName)),
|
|
69
|
+
h('span', { style: running ? ST.dim : model.tone === 'error' ? ST.rowError : ST.dim }, truncate(model.headline, 100)),
|
|
70
|
+
),
|
|
71
|
+
model.rows.slice(0, 6).map(function (row, index) {
|
|
72
|
+
return rowElement(t, index, row)
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { TowerToolCard }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { createToolview }
|
|
81
|
+
},
|
|
82
|
+
})
|
package/ui-bundle/package.json
CHANGED
package/lib/persistence.js
DELETED
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
// Shared durable-persistence contract (#56 P2b).
|
|
2
|
-
//
|
|
3
|
-
// state.json (lib/store.js) and modes.json (lib/modeStore.js) both persist a
|
|
4
|
-
// whole JSON document behind a token-owned writer lock. They must agree on:
|
|
5
|
-
//
|
|
6
|
-
// - atomic replacement: serialise completely, write a temp file, rename;
|
|
7
|
-
// - dead-PID reclamation: a provably dead owner is moved to a quarantine
|
|
8
|
-
// name, verified, and only then removed; a live/foreign lock is never
|
|
9
|
-
// stolen or deleted;
|
|
10
|
-
// - bounded waiting: a held lock is reported after a timeout, not stolen.
|
|
11
|
-
//
|
|
12
|
-
// Keeping the primitives here means a protocol fix applies to both contracts
|
|
13
|
-
// at once. This module is the only lib/ file besides store.js that talks to
|
|
14
|
-
// the fs adapter; it never imports node:fs directly (#50).
|
|
15
|
-
import { fs, fsp } from '../adapters/store-fs.js'
|
|
16
|
-
import path from 'node:path'
|
|
17
|
-
import crypto from 'node:crypto'
|
|
18
|
-
import { TowerToolError } from '../domain/errors.js'
|
|
19
|
-
|
|
20
|
-
export const DEFAULT_LOCK_TIMEOUT_MS = 5000
|
|
21
|
-
export const DEFAULT_LOCK_POLL_MS = 25
|
|
22
|
-
|
|
23
|
-
// Exported with the retry loop so a test can assert the budget and the exact
|
|
24
|
-
// transient set instead of copying them (#86).
|
|
25
|
-
export const RENAME_ATTEMPTS = 12
|
|
26
|
-
export const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
27
|
-
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
28
|
-
|
|
29
|
-
export function sleep(ms) {
|
|
30
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function sleepSync(ms) {
|
|
34
|
-
try {
|
|
35
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
36
|
-
} catch {
|
|
37
|
-
// best effort; a busy loop would be worse
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// JSON document I/O
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
function parseJson(file, text) {
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(text)
|
|
48
|
-
} catch (error) {
|
|
49
|
-
throw new TowerToolError('state-corrupt', `corrupt tower state at ${file}: ${error.message}`)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function readJson(file) {
|
|
54
|
-
return parseJson(file, await fsp.readFile(file, 'utf8'))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Synchronous counterpart for the mode store's short critical section. */
|
|
58
|
-
export function readJsonSync(file) {
|
|
59
|
-
return parseJson(file, fs.readFileSync(file, 'utf8'))
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function writeJsonAtomic(file, value) {
|
|
63
|
-
const dir = path.dirname(file)
|
|
64
|
-
const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
|
|
65
|
-
const handle = await fsp.open(tmp, 'w')
|
|
66
|
-
try {
|
|
67
|
-
await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
|
|
68
|
-
await handle.sync()
|
|
69
|
-
} finally {
|
|
70
|
-
await handle.close()
|
|
71
|
-
}
|
|
72
|
-
await renameWithRetry(tmp, file)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Windows can reject a rename while an indexer, antivirus, or a concurrent
|
|
77
|
-
* reader still holds the destination; retry briefly instead of failing a
|
|
78
|
-
* successful mutation. The temp file is removed when the rename never lands.
|
|
79
|
-
*
|
|
80
|
-
* `options.rename` and `options.sleep` inject the syscall and the backoff so
|
|
81
|
-
* both outcomes are testable on any OS (#86); production callers omit them.
|
|
82
|
-
*/
|
|
83
|
-
export async function renameWithRetry(tmp, file, options = {}) {
|
|
84
|
-
const rename = options.rename ?? fsp.rename
|
|
85
|
-
const wait = options.sleep ?? sleep
|
|
86
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
87
|
-
try {
|
|
88
|
-
await rename(tmp, file)
|
|
89
|
-
return
|
|
90
|
-
} catch (error) {
|
|
91
|
-
if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
|
|
92
|
-
await fsp.unlink(tmp).catch(() => {})
|
|
93
|
-
throw error
|
|
94
|
-
}
|
|
95
|
-
await wait(10 + attempt * 15)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Synchronous counterpart used by the mode store's short critical section. */
|
|
101
|
-
export function writeJsonAtomicSync(file, value) {
|
|
102
|
-
const dir = path.dirname(file)
|
|
103
|
-
const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
|
|
104
|
-
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
|
105
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
106
|
-
try {
|
|
107
|
-
fs.renameSync(tmp, file)
|
|
108
|
-
return
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
|
|
111
|
-
try {
|
|
112
|
-
fs.unlinkSync(tmp)
|
|
113
|
-
} catch {
|
|
114
|
-
// best effort
|
|
115
|
-
}
|
|
116
|
-
throw error
|
|
117
|
-
}
|
|
118
|
-
sleepSync(10 + attempt * 15)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function appendJsonl(file, value) {
|
|
124
|
-
await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
// Lock records
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* true = process exists, false = provably gone, null = the record carries no
|
|
133
|
-
* usable PID. The tri-state matters: a malformed state lock must be reported,
|
|
134
|
-
* not silently reclaimed as if its owner were dead.
|
|
135
|
-
*/
|
|
136
|
-
export function processAlive(pid) {
|
|
137
|
-
const value = Number(pid)
|
|
138
|
-
if (!Number.isInteger(value) || value <= 0) return null
|
|
139
|
-
try {
|
|
140
|
-
process.kill(value, 0)
|
|
141
|
-
return true
|
|
142
|
-
} catch (error) {
|
|
143
|
-
if (error?.code === 'ESRCH') return false
|
|
144
|
-
// EPERM means the process exists but belongs to another user.
|
|
145
|
-
return true
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function lockRecord(token, extra = {}) {
|
|
150
|
-
return { token, pid: process.pid, ...extra, at: Date.now() }
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isDeadLock(record, options) {
|
|
154
|
-
const alive = processAlive(record?.pid)
|
|
155
|
-
if (alive === false) return true
|
|
156
|
-
// The mode store historically treated a lock without a usable PID as dead;
|
|
157
|
-
// preserve that where callers opt in, while the state lock stays defensive.
|
|
158
|
-
return alive === null && options.invalidPidIsDead === true
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
// Lock inspection / reclamation
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
export async function readLock(lockPath) {
|
|
166
|
-
try {
|
|
167
|
-
const raw = await fsp.readFile(lockPath, 'utf8')
|
|
168
|
-
const record = JSON.parse(raw)
|
|
169
|
-
return { path: lockPath, record, alive: processAlive(record?.pid), raw }
|
|
170
|
-
} catch (error) {
|
|
171
|
-
if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
|
|
172
|
-
return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function readLockSync(lockPath) {
|
|
177
|
-
try {
|
|
178
|
-
const raw = fs.readFileSync(lockPath, 'utf8')
|
|
179
|
-
const record = JSON.parse(raw)
|
|
180
|
-
return { path: lockPath, record, alive: processAlive(record?.pid), raw }
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
|
|
183
|
-
return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function reclaimDeadLock(lockPath, options = {}) {
|
|
188
|
-
let original
|
|
189
|
-
try {
|
|
190
|
-
original = await fsp.readFile(lockPath, 'utf8')
|
|
191
|
-
} catch {
|
|
192
|
-
return false
|
|
193
|
-
}
|
|
194
|
-
let parsed
|
|
195
|
-
try {
|
|
196
|
-
parsed = JSON.parse(original)
|
|
197
|
-
} catch {
|
|
198
|
-
return false
|
|
199
|
-
}
|
|
200
|
-
if (!isDeadLock(parsed, options)) return false
|
|
201
|
-
// Move, never delete: a concurrent writer may have replaced the record
|
|
202
|
-
// between the read and the rename, so verify the quarantined bytes first.
|
|
203
|
-
const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
|
|
204
|
-
try {
|
|
205
|
-
await fsp.rename(lockPath, quarantine)
|
|
206
|
-
} catch {
|
|
207
|
-
return false
|
|
208
|
-
}
|
|
209
|
-
const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
|
|
210
|
-
if (moved === null || moved !== original) {
|
|
211
|
-
if (moved !== null) await fsp.rename(quarantine, lockPath).catch(() => {})
|
|
212
|
-
return false
|
|
213
|
-
}
|
|
214
|
-
await fsp.unlink(quarantine).catch(() => {})
|
|
215
|
-
return true
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function reclaimDeadLockSync(lockPath, options = {}) {
|
|
219
|
-
let original
|
|
220
|
-
try {
|
|
221
|
-
original = fs.readFileSync(lockPath, 'utf8')
|
|
222
|
-
} catch {
|
|
223
|
-
return false
|
|
224
|
-
}
|
|
225
|
-
let parsed
|
|
226
|
-
try {
|
|
227
|
-
parsed = JSON.parse(original)
|
|
228
|
-
} catch {
|
|
229
|
-
return false
|
|
230
|
-
}
|
|
231
|
-
if (!isDeadLock(parsed, options)) return false
|
|
232
|
-
const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
|
|
233
|
-
try {
|
|
234
|
-
fs.renameSync(lockPath, quarantine)
|
|
235
|
-
} catch {
|
|
236
|
-
return false
|
|
237
|
-
}
|
|
238
|
-
let moved = null
|
|
239
|
-
try {
|
|
240
|
-
moved = fs.readFileSync(quarantine, 'utf8')
|
|
241
|
-
} catch {
|
|
242
|
-
moved = null
|
|
243
|
-
}
|
|
244
|
-
if (moved === null || moved !== original) {
|
|
245
|
-
if (moved !== null) {
|
|
246
|
-
try {
|
|
247
|
-
fs.renameSync(quarantine, lockPath)
|
|
248
|
-
} catch {
|
|
249
|
-
// the newer record could not be restored; never delete it silently
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return false
|
|
253
|
-
}
|
|
254
|
-
try {
|
|
255
|
-
fs.unlinkSync(quarantine)
|
|
256
|
-
} catch {
|
|
257
|
-
// the lock is already reclaimed; a leftover quarantine is inert
|
|
258
|
-
}
|
|
259
|
-
return true
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ---------------------------------------------------------------------------
|
|
263
|
-
// Lock acquire / token-checked release
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
function handleFor(lockPath, token, record) {
|
|
267
|
-
return {
|
|
268
|
-
path: lockPath,
|
|
269
|
-
token,
|
|
270
|
-
record,
|
|
271
|
-
async release() {
|
|
272
|
-
return releaseLock(lockPath, token)
|
|
273
|
-
},
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function syncHandleFor(lockPath, token, record) {
|
|
278
|
-
return {
|
|
279
|
-
path: lockPath,
|
|
280
|
-
token,
|
|
281
|
-
record,
|
|
282
|
-
release() {
|
|
283
|
-
return releaseLockSync(lockPath, token)
|
|
284
|
-
},
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Acquire a token-owned lock with a bounded wait. Returns null when another
|
|
290
|
-
* writer still holds a live lock at the deadline; callers translate that into
|
|
291
|
-
* their own [tower:state-locked] message (or a false return for modes).
|
|
292
|
-
*/
|
|
293
|
-
export async function acquireLock(lockPath, options = {}) {
|
|
294
|
-
const token = options.token ?? crypto.randomUUID()
|
|
295
|
-
const record = options.record ?? lockRecord(token)
|
|
296
|
-
const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
|
297
|
-
const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
|
|
298
|
-
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
299
|
-
for (;;) {
|
|
300
|
-
try {
|
|
301
|
-
await fsp.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' })
|
|
302
|
-
return handleFor(lockPath, token, record)
|
|
303
|
-
} catch (error) {
|
|
304
|
-
if (error?.code !== 'EEXIST') throw error
|
|
305
|
-
if (await reclaimDeadLock(lockPath, options)) continue
|
|
306
|
-
if (Date.now() > deadline) return null
|
|
307
|
-
await sleep(pollMs)
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export function acquireLockSync(lockPath, options = {}) {
|
|
313
|
-
const token = options.token ?? crypto.randomUUID()
|
|
314
|
-
const record = options.record ?? lockRecord(token)
|
|
315
|
-
const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
|
316
|
-
const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
|
|
317
|
-
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
318
|
-
for (;;) {
|
|
319
|
-
try {
|
|
320
|
-
fs.writeFileSync(lockPath, JSON.stringify(record), { flag: 'wx' })
|
|
321
|
-
return syncHandleFor(lockPath, token, record)
|
|
322
|
-
} catch (error) {
|
|
323
|
-
if (error?.code !== 'EEXIST') throw error
|
|
324
|
-
if (reclaimDeadLockSync(lockPath, options)) continue
|
|
325
|
-
if (Date.now() > deadline) return null
|
|
326
|
-
sleepSync(pollMs)
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
export async function releaseLock(lockPath, token) {
|
|
332
|
-
try {
|
|
333
|
-
const current = await fsp.readFile(lockPath, 'utf8').catch(() => null)
|
|
334
|
-
if (current === null) return false
|
|
335
|
-
const parsed = JSON.parse(current)
|
|
336
|
-
if (!parsed || parsed.token !== token) return false
|
|
337
|
-
await fsp.unlink(lockPath)
|
|
338
|
-
return true
|
|
339
|
-
} catch {
|
|
340
|
-
// a foreign or unreadable lock is not ours to remove
|
|
341
|
-
return false
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function releaseLockSync(lockPath, token) {
|
|
346
|
-
try {
|
|
347
|
-
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'))
|
|
348
|
-
if (!parsed || parsed.token !== token) return false
|
|
349
|
-
fs.unlinkSync(lockPath)
|
|
350
|
-
return true
|
|
351
|
-
} catch {
|
|
352
|
-
return false
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
export { PROCESS_STARTED_AT }
|
package/lib/store.js
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { TowerToolError } from '../domain/errors.js'
|
|
2
|
-
// Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
|
|
3
|
-
// The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
|
|
4
|
-
import { fsp } from '../adapters/store-fs.js'
|
|
5
|
-
import {
|
|
6
|
-
PROCESS_STARTED_AT,
|
|
7
|
-
acquireLock,
|
|
8
|
-
lockRecord,
|
|
9
|
-
readLock,
|
|
10
|
-
} from './persistence.js'
|
|
11
|
-
import path from 'node:path'
|
|
12
|
-
import crypto from 'node:crypto'
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
readJson,
|
|
16
|
-
writeJsonAtomic,
|
|
17
|
-
writeJsonAtomicSync,
|
|
18
|
-
renameWithRetry,
|
|
19
|
-
appendJsonl,
|
|
20
|
-
} from './persistence.js'
|
|
21
|
-
|
|
22
|
-
export function pathsFor(commonDir) {
|
|
23
|
-
const dir = path.join(commonDir, 'dsh-tower')
|
|
24
|
-
return {
|
|
25
|
-
dir,
|
|
26
|
-
state: path.join(dir, 'state.json'),
|
|
27
|
-
lock: path.join(dir, 'state.lock'),
|
|
28
|
-
gitLock: path.join(dir, 'git.lock'),
|
|
29
|
-
activity: path.join(dir, 'activity.jsonl'),
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function exists(file) {
|
|
34
|
-
try {
|
|
35
|
-
await fsp.access(file)
|
|
36
|
-
return true
|
|
37
|
-
} catch {
|
|
38
|
-
return false
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function ensureDir(dir) {
|
|
43
|
-
await fsp.mkdir(dir, { recursive: true })
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function shortHash(text) {
|
|
47
|
-
return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function readActivityTail(file, limit = 12) {
|
|
51
|
-
const text = await fsp.readFile(file, 'utf8').catch(() => '')
|
|
52
|
-
const lines = text.split('\n').filter(Boolean)
|
|
53
|
-
const tail = lines.slice(Math.max(0, lines.length - limit))
|
|
54
|
-
const out = []
|
|
55
|
-
for (const line of tail) {
|
|
56
|
-
try {
|
|
57
|
-
out.push(JSON.parse(line))
|
|
58
|
-
} catch {
|
|
59
|
-
// keep going: a truncated activity line must not hide the rest
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return out
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function readStateLock(paths) {
|
|
66
|
-
return readLock(paths.lock)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Paths objects whose state lock this process currently holds. A state
|
|
71
|
-
* migration may be persisted only inside `withLock`: the loader reads this to
|
|
72
|
-
* tell a locked mutation (which may write) from a read-only load (which must
|
|
73
|
-
* migrate in memory and leave the file alone).
|
|
74
|
-
*/
|
|
75
|
-
const HELD_STATE_LOCKS = new WeakSet()
|
|
76
|
-
|
|
77
|
-
/** @param {object} [paths] @returns {boolean} */
|
|
78
|
-
export function isStateLockHeld(paths) {
|
|
79
|
-
return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
|
|
84
|
-
* provably dead is reclaimed atomically; a live lock is reported, never stolen.
|
|
85
|
-
*/
|
|
86
|
-
export async function withLock(paths, fn, options = {}) {
|
|
87
|
-
const timeoutMs = options.timeoutMs ?? 5000
|
|
88
|
-
const pollMs = options.pollMs ?? 25
|
|
89
|
-
const token = crypto.randomUUID()
|
|
90
|
-
const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
|
|
91
|
-
const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
|
|
92
|
-
if (!held) {
|
|
93
|
-
const owner = await readStateLock(paths)
|
|
94
|
-
const ownerRecord = owner.record
|
|
95
|
-
const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
|
|
96
|
-
throw new TowerToolError(
|
|
97
|
-
'state-locked',
|
|
98
|
-
`tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
const startedAt = Date.now()
|
|
102
|
-
HELD_STATE_LOCKS.add(paths)
|
|
103
|
-
try {
|
|
104
|
-
return await fn(paths)
|
|
105
|
-
} finally {
|
|
106
|
-
HELD_STATE_LOCKS.delete(paths)
|
|
107
|
-
await held.release()
|
|
108
|
-
await fsp
|
|
109
|
-
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
|
|
110
|
-
.catch(() => {})
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Serialize git mutations without holding the state lock. */
|
|
115
|
-
export async function withGitLock(paths, fn, options = {}) {
|
|
116
|
-
const timeoutMs = options.timeoutMs ?? 60000
|
|
117
|
-
const pollMs = options.pollMs ?? 25
|
|
118
|
-
const lock = paths.gitLock
|
|
119
|
-
const token = crypto.randomUUID()
|
|
120
|
-
const startedAt = Date.now()
|
|
121
|
-
const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
|
|
122
|
-
if (!held) {
|
|
123
|
-
throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
return await fn(paths)
|
|
127
|
-
} finally {
|
|
128
|
-
await held.release()
|
|
129
|
-
await fsp
|
|
130
|
-
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
|
|
131
|
-
.catch(() => {})
|
|
132
|
-
}
|
|
133
|
-
}
|
|
File without changes
|
/package/{lib → app}/briefing.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/{lib → host}/config.js
RENAMED
|
File without changes
|
/package/{lib → host}/hmr.js
RENAMED
|
File without changes
|
|
File without changes
|
/package/{lib → host}/presets.js
RENAMED
|
File without changes
|