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,204 @@
|
|
|
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 './store-fs.js'
|
|
5
|
+
import {
|
|
6
|
+
PROCESS_STARTED_AT,
|
|
7
|
+
acquireLock,
|
|
8
|
+
lockRecord,
|
|
9
|
+
lockTiming,
|
|
10
|
+
readLock,
|
|
11
|
+
} from './persistence.js'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import crypto from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
readJson,
|
|
17
|
+
writeJsonAtomic,
|
|
18
|
+
writeJsonAtomicSync,
|
|
19
|
+
renameWithRetry,
|
|
20
|
+
appendJsonl,
|
|
21
|
+
} from './persistence.js'
|
|
22
|
+
|
|
23
|
+
export function pathsFor(commonDir) {
|
|
24
|
+
const dir = path.join(commonDir, 'dsh-tower')
|
|
25
|
+
return {
|
|
26
|
+
dir,
|
|
27
|
+
state: path.join(dir, 'state.json'),
|
|
28
|
+
lock: path.join(dir, 'state.lock'),
|
|
29
|
+
gitLock: path.join(dir, 'git.lock'),
|
|
30
|
+
activity: path.join(dir, 'activity.jsonl'),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function exists(file) {
|
|
35
|
+
try {
|
|
36
|
+
await fsp.access(file)
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ensureDir(dir) {
|
|
44
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function shortHash(text) {
|
|
48
|
+
return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readActivityTail(file, limit = 12) {
|
|
52
|
+
const text = await fsp.readFile(file, 'utf8').catch(() => '')
|
|
53
|
+
const lines = text.split('\n').filter(Boolean)
|
|
54
|
+
const tail = lines.slice(Math.max(0, lines.length - limit))
|
|
55
|
+
const out = []
|
|
56
|
+
for (const line of tail) {
|
|
57
|
+
try {
|
|
58
|
+
out.push(JSON.parse(line))
|
|
59
|
+
} catch {
|
|
60
|
+
// keep going: a truncated activity line must not hide the rest
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readStateLock(paths, options = {}) {
|
|
67
|
+
return readLock(paths.lock, options)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read the repo-scoped git mutex; a missing/free lock is not an error. */
|
|
71
|
+
export async function readGitLock(paths, options = {}) {
|
|
72
|
+
return readLock(paths.gitLock, options)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The git-mutex budget is pure policy over the resolved config (#93) and is
|
|
76
|
+
// shared with the app layer, so it lives in the domain (#69). Re-exported here
|
|
77
|
+
// for the store's callers and tests; the state lock keeps its short 5 s default.
|
|
78
|
+
export { GIT_LOCK_MARGIN_MS, gitLockTimeoutMs } from '../domain/lockBudget.js'
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read-only projection of the git mutex for tower_status/tower_doctor: the
|
|
82
|
+
* shape is `{ held: true, pid, phase, heldMs }` while a holder exists and
|
|
83
|
+
* `{ held: false }` when it is free.
|
|
84
|
+
*/
|
|
85
|
+
export function gitLockView(lock, options = {}) {
|
|
86
|
+
const record = lock?.record
|
|
87
|
+
if (!record) return { held: false }
|
|
88
|
+
return {
|
|
89
|
+
held: true,
|
|
90
|
+
pid: record.pid ?? null,
|
|
91
|
+
phase: record.phase ?? null,
|
|
92
|
+
heldMs: lockTiming(record, options).ageMs,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Paths objects whose state lock this process currently holds. A state
|
|
98
|
+
* migration may be persisted only inside `withLock`: the loader reads this to
|
|
99
|
+
* tell a locked mutation (which may write) from a read-only load (which must
|
|
100
|
+
* migrate in memory and leave the file alone).
|
|
101
|
+
*/
|
|
102
|
+
const HELD_STATE_LOCKS = new WeakSet()
|
|
103
|
+
|
|
104
|
+
/** @param {object} [paths] @returns {boolean} */
|
|
105
|
+
export function isStateLockHeld(paths) {
|
|
106
|
+
return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
|
|
111
|
+
* provably dead is reclaimed atomically; a live lock is reported, never
|
|
112
|
+
* stolen. While held the record heartbeats, so another process can tell a live
|
|
113
|
+
* writer from a recycled PID (#92). `heartbeatMs`/`heartbeatStaleMs`/`now` are
|
|
114
|
+
* injectable for tests; production callers use the defaults.
|
|
115
|
+
*/
|
|
116
|
+
export async function withLock(paths, fn, options = {}) {
|
|
117
|
+
const timeoutMs = options.timeoutMs ?? 5000
|
|
118
|
+
const pollMs = options.pollMs ?? 25
|
|
119
|
+
const token = crypto.randomUUID()
|
|
120
|
+
const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT }, options)
|
|
121
|
+
const held = await acquireLock(paths.lock, { ...options, token, record, timeoutMs, pollMs })
|
|
122
|
+
if (!held) {
|
|
123
|
+
const owner = await readStateLock(paths, options)
|
|
124
|
+
const ownerRecord = owner.record
|
|
125
|
+
const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
|
|
126
|
+
throw new TowerToolError(
|
|
127
|
+
'state-locked',
|
|
128
|
+
`tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
const startedAt = Date.now()
|
|
132
|
+
HELD_STATE_LOCKS.add(paths)
|
|
133
|
+
try {
|
|
134
|
+
return await fn(paths)
|
|
135
|
+
} finally {
|
|
136
|
+
HELD_STATE_LOCKS.delete(paths)
|
|
137
|
+
await held.release()
|
|
138
|
+
await fsp
|
|
139
|
+
.appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
|
|
140
|
+
.catch(() => {})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatSeconds(ms) {
|
|
145
|
+
if (!Number.isFinite(ms)) return 'an unknown time'
|
|
146
|
+
return `${Math.max(0, Math.round(ms / 1000))} s`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Serialize git mutations without holding the state lock. The caller owns the
|
|
151
|
+
* wait budget: a merge passes its resolved `plan.timeoutMs`, and the other git
|
|
152
|
+
* mutations pass the same merge-derived budget (#93). While held the record
|
|
153
|
+
* carries `kind: 'git'` and the caller's `phase` (merge/rebase/init/teardown/
|
|
154
|
+
* provision), so a waiter and the read-only diagnostics can name the holder.
|
|
155
|
+
*/
|
|
156
|
+
export async function withGitLock(paths, fn, options = {}) {
|
|
157
|
+
const timeoutMs = options.timeoutMs ?? 60000
|
|
158
|
+
const pollMs = options.pollMs ?? 25
|
|
159
|
+
const phase = options.phase ?? 'git'
|
|
160
|
+
const lock = paths.gitLock
|
|
161
|
+
const token = crypto.randomUUID()
|
|
162
|
+
const clock = typeof options.now === 'function' ? options.now : Date.now
|
|
163
|
+
const waitStartedAt = clock()
|
|
164
|
+
const startedAt = Date.now()
|
|
165
|
+
// The heartbeat matters here too: a legitimate pre-merge hook can hold this
|
|
166
|
+
// lock for minutes, and a stale-heartbeat reclaim must not mistake that for
|
|
167
|
+
// a crash (#92, #93).
|
|
168
|
+
const held = await acquireLock(lock, {
|
|
169
|
+
...options,
|
|
170
|
+
token,
|
|
171
|
+
record: lockRecord(token, { kind: 'git', phase }, options),
|
|
172
|
+
timeoutMs,
|
|
173
|
+
pollMs,
|
|
174
|
+
})
|
|
175
|
+
if (!held) {
|
|
176
|
+
const owner = await readLock(lock, options)
|
|
177
|
+
const record = owner.record
|
|
178
|
+
const waitedMs = Math.max(0, clock() - waitStartedAt)
|
|
179
|
+
const heldMs = record ? lockTiming(record, options).ageMs : null
|
|
180
|
+
const holderPid = record?.pid ?? null
|
|
181
|
+
const holderPhase = record?.phase ?? null
|
|
182
|
+
const facts = record
|
|
183
|
+
? `held by pid ${holderPid ?? 'unknown'}${holderPhase ? ` (${holderPhase})` : ''} for ${formatSeconds(heldMs)}; waited ${formatSeconds(waitedMs)}`
|
|
184
|
+
: `the lock was released before it could be inspected; waited ${formatSeconds(waitedMs)}`
|
|
185
|
+
throw new TowerToolError(
|
|
186
|
+
'state-locked',
|
|
187
|
+
`another git mutation is in progress for this repository: ${facts}; retry after it finishes`,
|
|
188
|
+
{ details: { holderPid, holderPhase, heldMs, waitedMs } },
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
const waitedMs = Math.max(0, clock() - waitStartedAt)
|
|
192
|
+
try {
|
|
193
|
+
return await fn(paths)
|
|
194
|
+
} finally {
|
|
195
|
+
await held.release()
|
|
196
|
+
await fsp
|
|
197
|
+
.appendFile(
|
|
198
|
+
path.join(paths.dir, 'lock-holds.jsonl'),
|
|
199
|
+
`${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, kind: 'git', phase, budgetMs: timeoutMs, waitedMs })}\n`,
|
|
200
|
+
'utf8',
|
|
201
|
+
)
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -19,10 +19,10 @@ import { TowerToolError } from '../domain/errors.js'
|
|
|
19
19
|
// minCount/required shortfall) returns `ok: false` in the receipt;
|
|
20
20
|
// - an invalid discovery configuration (or invalid call arguments) throws a
|
|
21
21
|
// plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
|
|
22
|
-
import { spawn } from '
|
|
23
|
-
import { createWriteStream } from '
|
|
22
|
+
import { spawn } from './vcs-git.js'
|
|
23
|
+
import { createWriteStream } from './store-fs.js'
|
|
24
24
|
import crypto from 'node:crypto'
|
|
25
|
-
import { fsp } from '
|
|
25
|
+
import { fsp } from './store-fs.js'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
|
|
28
28
|
import { tryGit } from './git.js'
|
|
@@ -279,7 +279,9 @@ const TEST_ENV_ALLOW = new Set([
|
|
|
279
279
|
'NODE_PATH',
|
|
280
280
|
])
|
|
281
281
|
|
|
282
|
+
/** @returns {NodeJS.ProcessEnv} */
|
|
282
283
|
export function testProcessEnv(source = process.env) {
|
|
284
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
283
285
|
const env = {}
|
|
284
286
|
for (const [key, value] of Object.entries(source)) {
|
|
285
287
|
if (!TEST_ENV_ALLOW.has(key)) continue
|
|
@@ -340,7 +342,8 @@ function closeStreams(streams) {
|
|
|
340
342
|
return Promise.all(
|
|
341
343
|
streams.map(
|
|
342
344
|
(stream) =>
|
|
343
|
-
|
|
345
|
+
/** @type {Promise<void>} */
|
|
346
|
+
(new Promise((resolve) => {
|
|
344
347
|
if (stream.closed || stream.destroyed) {
|
|
345
348
|
resolve()
|
|
346
349
|
return
|
|
@@ -348,7 +351,7 @@ function closeStreams(streams) {
|
|
|
348
351
|
stream.once('close', resolve)
|
|
349
352
|
stream.once('error', resolve)
|
|
350
353
|
stream.end()
|
|
351
|
-
}),
|
|
354
|
+
})),
|
|
352
355
|
),
|
|
353
356
|
)
|
|
354
357
|
}
|
|
@@ -367,6 +370,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
|
|
|
367
370
|
|
|
368
371
|
return new Promise((resolve) => {
|
|
369
372
|
let settled = false
|
|
373
|
+
/** @type {import('node:child_process').ChildProcess | null} */
|
|
370
374
|
let child = null
|
|
371
375
|
let timer = null
|
|
372
376
|
let stdoutBytes = 0
|
package/adapters/vcs-git.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
// The only
|
|
1
|
+
// The only module in the plugin that imports node:child_process (#50).
|
|
2
|
+
//
|
|
3
|
+
// Since #69 this is a real adapter, not a re-export shim: `createVcsPort()`
|
|
4
|
+
// returns a runtime implementation of VcsPort (ports/vcs.js) that the
|
|
5
|
+
// composition root injects into the app layer. The bounded-command
|
|
6
|
+
// implementation lives next door in adapters/git.js (#70).
|
|
2
7
|
import { execFile, spawn } from 'node:child_process'
|
|
8
|
+
import * as git from './git.js'
|
|
3
9
|
|
|
4
10
|
export { execFile, spawn }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the Git vcs port (#69). The composition root assembles this once and
|
|
14
|
+
* injects it; a scripted or in-memory vcs can replace it wholesale.
|
|
15
|
+
*/
|
|
16
|
+
export function createVcsPort() {
|
|
17
|
+
return Object.freeze({ ...git })
|
|
18
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `mode.workspaceLock` is enabled. Same-user trusted model, like the rest of
|
|
3
3
|
// the Tower store: it prevents accidental double control, not a malicious
|
|
4
4
|
// writer.
|
|
5
|
-
import { fsp } from '
|
|
5
|
+
import { fsp } from './store-fs.js'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import crypto from 'node:crypto'
|
|
8
8
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// and must still be the Git worktree of the recorded branch in the recorded
|
|
7
7
|
// repository. Every mismatch is rejected with an actionable message; nothing
|
|
8
8
|
// is repaired or recreated here.
|
|
9
|
-
import { fs, fsp } from '
|
|
9
|
+
import { fs, fsp } from './store-fs.js'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
12
12
|
import { git } from './git.js'
|
package/app/archive.js
CHANGED
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
// document is compacted, so a crash between the two leaves the mission in state
|
|
6
6
|
// and the archive entry is deduped by id on retry. Nothing is deleted: the full
|
|
7
7
|
// mission, its linked reviews and its findings are moved to archive/<year>.json.
|
|
8
|
-
import { appendJsonl } from '../lib/store.js'
|
|
9
|
-
import { readArchiveIndex, readArchivedEntry, writeArchiveEntries } from '../lib/archive.js'
|
|
10
8
|
import { hasLiveAgent } from '../domain/integration/liveness.js'
|
|
11
9
|
import { missionSummary } from '../domain/summary.js'
|
|
12
10
|
import {
|
|
@@ -28,7 +26,7 @@ import {
|
|
|
28
26
|
* @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
|
|
29
27
|
*/
|
|
30
28
|
export function archiveIndex(tower) {
|
|
31
|
-
return
|
|
29
|
+
return tower.deps.archive.readIndex(tower.deps.store, tower.paths)
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
/**
|
|
@@ -37,7 +35,7 @@ export function archiveIndex(tower) {
|
|
|
37
35
|
* @param {string} missionId
|
|
38
36
|
*/
|
|
39
37
|
export function archivedMission(tower, missionId) {
|
|
40
|
-
return
|
|
38
|
+
return tower.deps.archive.readEntry(tower.deps.store, tower.paths, missionId)
|
|
41
39
|
}
|
|
42
40
|
|
|
43
41
|
/**
|
|
@@ -145,7 +143,7 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
145
143
|
|
|
146
144
|
let written
|
|
147
145
|
try {
|
|
148
|
-
written = await
|
|
146
|
+
written = await tower.deps.archive.writeEntries(tower.deps.store, tower.paths, { entries, now })
|
|
149
147
|
} catch (error) {
|
|
150
148
|
return { archived: [], written: false, error: `archive write failed: ${String(error?.message ?? error)}` }
|
|
151
149
|
}
|
|
@@ -172,7 +170,7 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
172
170
|
state.updatedAt = now
|
|
173
171
|
|
|
174
172
|
for (const mission of selected) {
|
|
175
|
-
await appendJsonl(tower.paths.activity, {
|
|
173
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
176
174
|
at: now,
|
|
177
175
|
type: 'archive',
|
|
178
176
|
missionId: mission.id,
|
package/app/batch.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// Batch use cases.
|
|
2
2
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
4
3
|
import { validateState } from '../domain/state.js'
|
|
5
4
|
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose } from '../domain/batch.js'
|
|
6
5
|
import {
|
|
@@ -17,7 +16,7 @@ function fail(message, code = 'batch-invalid') {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export async function createBatch(tower, input = {}, actor = 'tower') {
|
|
20
|
-
return withLock(tower.paths, async () => {
|
|
19
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
21
20
|
const state = await tower.load()
|
|
22
21
|
const normalized = validateBatchCreate(state, input)
|
|
23
22
|
if (!Array.isArray(state.batches)) state.batches = []
|
|
@@ -26,8 +25,8 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
|
|
|
26
25
|
state.revision += 1
|
|
27
26
|
state.updatedAt = batch.createdAt
|
|
28
27
|
validateState(state, tower.paths.state)
|
|
29
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
30
|
-
await appendJsonl(tower.paths.activity, {
|
|
28
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
29
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
31
30
|
at: batch.createdAt,
|
|
32
31
|
type: 'batch',
|
|
33
32
|
text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
|
|
@@ -37,7 +36,7 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
export async function closeBatch(tower, batchId, options = {}, actor = 'tower') {
|
|
40
|
-
return withLock(tower.paths, async () => {
|
|
39
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
41
40
|
const state = await tower.load()
|
|
42
41
|
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
43
42
|
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
@@ -48,15 +47,15 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
|
|
|
48
47
|
state.revision += 1
|
|
49
48
|
state.updatedAt = batch.closedAt
|
|
50
49
|
validateState(state, tower.paths.state)
|
|
51
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
52
|
-
await appendJsonl(tower.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
|
|
50
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
51
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
|
|
53
52
|
return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
|
|
54
53
|
})
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
/** @param {string} batchId @param {string} [actor] @param {string | null} [reason] */
|
|
58
57
|
export async function abandonBatch(tower, batchId, actor = 'tower', reason = null) {
|
|
59
|
-
return withLock(tower.paths, async () => {
|
|
58
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
60
59
|
const state = await tower.load()
|
|
61
60
|
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
62
61
|
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
@@ -67,8 +66,8 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
|
|
|
67
66
|
state.revision += 1
|
|
68
67
|
state.updatedAt = batch.abandonedAt
|
|
69
68
|
validateState(state, tower.paths.state)
|
|
70
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
71
|
-
await appendJsonl(tower.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
|
|
69
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
70
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
|
|
72
71
|
return { ok: true, batch, alreadyAbandoned: false }
|
|
73
72
|
})
|
|
74
73
|
}
|
package/app/board.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// Mission board (MISSIONS.md) writing use cases.
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { fsp } from '../adapters/store-fs.js'
|
|
4
|
-
import { renameWithRetry } from '../lib/store.js'
|
|
5
3
|
import { archiveView } from './archive.js'
|
|
6
4
|
|
|
7
5
|
export function boardFile(tower) {
|
|
@@ -18,8 +16,8 @@ export async function writeBoard(tower) {
|
|
|
18
16
|
const file = boardFile(tower)
|
|
19
17
|
await tower.ensureExcluded(file)
|
|
20
18
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
21
|
-
await
|
|
22
|
-
await renameWithRetry(tmp, file)
|
|
19
|
+
await tower.deps.store.writeText(tmp, tower.boardMarkdown(state, archiveView(index)), 'utf8')
|
|
20
|
+
await tower.deps.store.renameWithRetry(tmp, file)
|
|
23
21
|
return file
|
|
24
22
|
} catch {
|
|
25
23
|
return null
|
package/app/checkCompletion.js
CHANGED
|
@@ -5,11 +5,10 @@ import {
|
|
|
5
5
|
assertCompletionRequest,
|
|
6
6
|
assertCompletionInbox,
|
|
7
7
|
completionReport,
|
|
8
|
+
applyCompletion,
|
|
8
9
|
} from '../domain/missions/completion.js'
|
|
9
10
|
import { fullMission } from '../domain/summary.js'
|
|
10
11
|
import { validateState } from '../domain/state.js'
|
|
11
|
-
import { countCommits, isClean } from '../lib/git.js'
|
|
12
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
13
12
|
import { sweepArchive } from './archive.js'
|
|
14
13
|
|
|
15
14
|
export { normalizeChecks }
|
|
@@ -27,14 +26,14 @@ export async function checkCompletion(tower, input) {
|
|
|
27
26
|
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
28
27
|
})
|
|
29
28
|
await tower.assertWorktree(mission.worktree)
|
|
30
|
-
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
29
|
+
const commits = await tower.deps.vcs.countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
31
30
|
if (commits < 1) {
|
|
32
31
|
throw new TowerToolError(
|
|
33
32
|
'invalid-argument',
|
|
34
33
|
`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
|
|
35
34
|
)
|
|
36
35
|
}
|
|
37
|
-
if (!(await isClean(mission.worktree.path))) {
|
|
36
|
+
if (!(await tower.deps.vcs.isClean(mission.worktree.path))) {
|
|
38
37
|
const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
|
|
39
38
|
.split('\n')
|
|
40
39
|
.filter(Boolean)
|
|
@@ -53,7 +52,7 @@ export async function complete(tower, input) {
|
|
|
53
52
|
// Two-phase pilot (#51): expensive git/state checks run before the lock;
|
|
54
53
|
// the lock only revalidates the state revision and commits the transition.
|
|
55
54
|
const prepared = await tower.checkCompletion(input)
|
|
56
|
-
return withLock(tower.paths, async () => {
|
|
55
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
57
56
|
const state = await tower.load()
|
|
58
57
|
if (state.revision !== prepared.state.revision) {
|
|
59
58
|
throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
|
|
@@ -63,27 +62,27 @@ export async function complete(tower, input) {
|
|
|
63
62
|
const { worker, commits } = prepared
|
|
64
63
|
const checks = normalizeChecks(input.checks)
|
|
65
64
|
const now = Date.now()
|
|
66
|
-
|
|
67
|
-
mission.completionReport = completionReport({
|
|
65
|
+
const storedReport = completionReport({
|
|
68
66
|
report: input.report,
|
|
69
67
|
worker,
|
|
70
68
|
checks,
|
|
71
69
|
now,
|
|
72
70
|
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
73
71
|
})
|
|
74
|
-
|
|
72
|
+
const applied = applyCompletion({ mission, report: storedReport, now })
|
|
75
73
|
await sweepArchive(tower, state, { now })
|
|
76
74
|
state.revision += 1
|
|
77
75
|
state.updatedAt = now
|
|
78
76
|
|
|
79
77
|
validateState(state, tower.paths.state)
|
|
80
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
81
|
-
await appendJsonl(tower.paths.activity, {
|
|
78
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
79
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
82
80
|
at: now,
|
|
83
81
|
type: 'complete',
|
|
84
82
|
missionId: mission.id,
|
|
85
83
|
text: `${worker.name} reported completion (${commits} commit(s))`,
|
|
86
84
|
})
|
|
85
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
87
86
|
return { mission: fullMission(mission), commits, unread: 0 }
|
|
88
87
|
})
|
|
89
88
|
}
|
package/app/enrichedMissions.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// Dashboard mission enrichment use case.
|
|
2
|
-
import { exists } from '../lib/store.js'
|
|
3
|
-
import { revParse } from '../lib/git.js'
|
|
4
2
|
import {
|
|
5
3
|
enrichedMissionView,
|
|
6
4
|
latestReviewForMission,
|
|
@@ -12,9 +10,9 @@ export async function enrichedMissions(tower, state, onlyIds) {
|
|
|
12
10
|
for (const mission of missionsToEnrich(state.missions, onlyIds)) {
|
|
13
11
|
const latest = latestReviewForMission(state, mission)
|
|
14
12
|
let tipMoved = false
|
|
15
|
-
if (latest && mission.worktree && (await exists(mission.worktree.path))) {
|
|
13
|
+
if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
|
|
16
14
|
try {
|
|
17
|
-
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
15
|
+
const tip = await tower.deps.vcs.revParse(mission.worktree.path, 'HEAD')
|
|
18
16
|
tipMoved = tip !== latest.reviewedCommit
|
|
19
17
|
} catch {
|
|
20
18
|
tipMoved = false
|
package/app/findings.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Findings use cases.
|
|
2
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
2
|
import { validateState } from '../domain/state.js'
|
|
4
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
5
4
|
import {
|
|
@@ -17,7 +16,7 @@ function fail(message, code = 'invalid-argument') {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export async function addFinding(tower, input) {
|
|
20
|
-
return withLock(tower.paths, async () => {
|
|
19
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
21
20
|
const state = await tower.load()
|
|
22
21
|
if (!Array.isArray(state.findings)) state.findings = []
|
|
23
22
|
const fields = normalizeFindingInput(input, state.missions)
|
|
@@ -30,8 +29,8 @@ export async function addFinding(tower, input) {
|
|
|
30
29
|
state.updatedAt = now
|
|
31
30
|
|
|
32
31
|
validateState(state, tower.paths.state)
|
|
33
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
34
|
-
await appendJsonl(tower.paths.activity, {
|
|
32
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
33
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
35
34
|
at: now,
|
|
36
35
|
type: 'finding',
|
|
37
36
|
missionId: finding.missionId,
|
|
@@ -55,7 +54,7 @@ export async function addFinding(tower, input) {
|
|
|
55
54
|
const actor = normalizeFindingActor(input.actor)
|
|
56
55
|
if (!wantsFindingUpdate(input)) return tower.readFindings(input)
|
|
57
56
|
|
|
58
|
-
return withLock(tower.paths, async () => {
|
|
57
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
59
58
|
const state = await tower.load()
|
|
60
59
|
const all = state.findings ?? []
|
|
61
60
|
const finding = all.find((entry) => entry.id === input.id)
|
|
@@ -65,8 +64,8 @@ export async function addFinding(tower, input) {
|
|
|
65
64
|
state.revision += 1
|
|
66
65
|
state.updatedAt = now
|
|
67
66
|
validateState(state, tower.paths.state)
|
|
68
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
69
|
-
await appendJsonl(tower.paths.activity, {
|
|
67
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
68
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
70
69
|
at: now,
|
|
71
70
|
type: 'finding',
|
|
72
71
|
missionId: finding.missionId,
|
package/app/gate.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Merge/preflight gate evaluation.
|
|
2
|
-
import { currentBranch } from '../lib/git.js'
|
|
3
2
|
import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
4
3
|
import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
|
|
5
4
|
import { fullMission } from '../domain/summary.js'
|
|
@@ -105,7 +104,7 @@ export async function evaluateGate(tower, input) {
|
|
|
105
104
|
rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
|
|
106
105
|
})) push(blocker.code, blocker.detail)
|
|
107
106
|
|
|
108
|
-
const checkoutBranch = await currentBranch(tower.root)
|
|
107
|
+
const checkoutBranch = await tower.deps.vcs.currentBranch(tower.root)
|
|
109
108
|
const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
|
|
110
109
|
if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
|
|
111
110
|
|