dsh-tower 2.1.0-beta.1 → 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 +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -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 +28 -8
- package/app/tower.js +121 -39
- 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/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -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 +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- 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 +64 -19
- 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 +3 -4
- 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 +4 -7
- 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 +7 -6
- package/tools/preflight.js +3 -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 +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -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/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /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
|
|
@@ -288,8 +290,12 @@ export function testProcessEnv(source = process.env) {
|
|
|
288
290
|
return env
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Platform shell for discovery commands (PowerShell 7 on Windows with a
|
|
295
|
+
* `powershell` fallback, bash on POSIX). Exported so both branches are asserted
|
|
296
|
+
* on every OS instead of only on the platform that happens to run CI (#86).
|
|
297
|
+
*/
|
|
298
|
+
export function shellSpec(platform = process.platform) {
|
|
293
299
|
if (platform === 'win32') {
|
|
294
300
|
const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
|
|
295
301
|
return {
|
|
@@ -336,7 +342,8 @@ function closeStreams(streams) {
|
|
|
336
342
|
return Promise.all(
|
|
337
343
|
streams.map(
|
|
338
344
|
(stream) =>
|
|
339
|
-
|
|
345
|
+
/** @type {Promise<void>} */
|
|
346
|
+
(new Promise((resolve) => {
|
|
340
347
|
if (stream.closed || stream.destroyed) {
|
|
341
348
|
resolve()
|
|
342
349
|
return
|
|
@@ -344,7 +351,7 @@ function closeStreams(streams) {
|
|
|
344
351
|
stream.once('close', resolve)
|
|
345
352
|
stream.once('error', resolve)
|
|
346
353
|
stream.end()
|
|
347
|
-
}),
|
|
354
|
+
})),
|
|
348
355
|
),
|
|
349
356
|
)
|
|
350
357
|
}
|
|
@@ -363,6 +370,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
|
|
|
363
370
|
|
|
364
371
|
return new Promise((resolve) => {
|
|
365
372
|
let settled = false
|
|
373
|
+
/** @type {import('node:child_process').ChildProcess | null} */
|
|
366
374
|
let child = null
|
|
367
375
|
let timer = null
|
|
368
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 { 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'
|
|
@@ -22,18 +22,29 @@ function isNonEmptyString(value) {
|
|
|
22
22
|
return typeof value === 'string' && value.trim().length > 0
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Path identity follows the injected platform's semantics so both branches are
|
|
26
|
+
// testable on any OS (#86); on the real platform this is exactly `path`.
|
|
27
|
+
export function compareKey(target, platform = process.platform) {
|
|
28
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix
|
|
29
|
+
const resolved = pathImpl.resolve(target)
|
|
30
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nativeRealpath(target) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.realpathSync.native(target)
|
|
36
|
+
} catch {
|
|
37
|
+
return path.resolve(target)
|
|
38
|
+
}
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
function samePath(a, b) {
|
|
31
|
-
return compareKey(a) === compareKey(b)
|
|
42
|
+
return compareKey(nativeRealpath(a)) === compareKey(nativeRealpath(b))
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
async function realpathOrResolve(target) {
|
|
35
46
|
try {
|
|
36
|
-
return
|
|
47
|
+
return fs.realpathSync.native(target)
|
|
37
48
|
} catch (error) {
|
|
38
49
|
if (error?.code === 'ENOENT') return path.resolve(target)
|
|
39
50
|
throw error
|
|
@@ -48,7 +59,7 @@ export async function canonicalPath(target) {
|
|
|
48
59
|
if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
|
|
49
60
|
if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
|
|
50
61
|
try {
|
|
51
|
-
return
|
|
62
|
+
return fs.realpathSync.native(target)
|
|
52
63
|
} catch (error) {
|
|
53
64
|
if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
|
|
54
65
|
throw error
|
package/app/archive.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Terminal-mission archival use case (#73).
|
|
2
|
+
//
|
|
3
|
+
// The policy sweep runs inside an existing state lock, immediately before the
|
|
4
|
+
// caller writes state.json: archive files are written first, then the state
|
|
5
|
+
// document is compacted, so a crash between the two leaves the mission in state
|
|
6
|
+
// and the archive entry is deduped by id on retry. Nothing is deleted: the full
|
|
7
|
+
// mission, its linked reviews and its findings are moved to archive/<year>.json.
|
|
8
|
+
import { hasLiveAgent } from '../domain/integration/liveness.js'
|
|
9
|
+
import { missionSummary } from '../domain/summary.js'
|
|
10
|
+
import {
|
|
11
|
+
archiveYearFor,
|
|
12
|
+
archivedEntry,
|
|
13
|
+
isClosedFinding,
|
|
14
|
+
linkedFindings,
|
|
15
|
+
linkedReviews,
|
|
16
|
+
selectForArchive,
|
|
17
|
+
} from '../domain/state/archive.js'
|
|
18
|
+
|
|
19
|
+
/** @typedef {import('../types/index.js').Mission} Mission */
|
|
20
|
+
/** @typedef {import('../types/index.js').TowerState} TowerState */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read `archive/index.json` through the Tower's store. Never throws: the
|
|
24
|
+
* returned `ok:false` carries the diagnostic that status/doctor surface.
|
|
25
|
+
* @param {any} tower
|
|
26
|
+
* @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
|
|
27
|
+
*/
|
|
28
|
+
export function archiveIndex(tower) {
|
|
29
|
+
return tower.deps.archive.readIndex(tower.deps.store, tower.paths)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load one archived mission with its linked reviews and findings.
|
|
34
|
+
* @param {any} tower
|
|
35
|
+
* @param {string} missionId
|
|
36
|
+
*/
|
|
37
|
+
export function archivedMission(tower, missionId) {
|
|
38
|
+
return tower.deps.archive.readEntry(tower.deps.store, tower.paths, missionId)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A compact archived mission is rendered through the same summary projection as
|
|
43
|
+
* an active one, plus `archived: true`. The completion report and task bodies
|
|
44
|
+
* are intentionally absent: they live in the year file and are loaded only by
|
|
45
|
+
* `tower_status missionId`.
|
|
46
|
+
* @param {Record<string, any>} entry
|
|
47
|
+
* @returns {any}
|
|
48
|
+
*/
|
|
49
|
+
export function archivedSummary(entry) {
|
|
50
|
+
const mission = /** @type {any} */ ({
|
|
51
|
+
id: entry.id,
|
|
52
|
+
title: entry.title,
|
|
53
|
+
kind: entry.kind ?? 'build',
|
|
54
|
+
status: entry.status,
|
|
55
|
+
scope: Array.isArray(entry.scope) ? entry.scope : [],
|
|
56
|
+
deps: Array.isArray(entry.deps) ? entry.deps : [],
|
|
57
|
+
owner: entry.owner ?? null,
|
|
58
|
+
worktree: null,
|
|
59
|
+
blockers: [],
|
|
60
|
+
tasks: [],
|
|
61
|
+
reviewPolicy: 'required-clean',
|
|
62
|
+
mergedCommit: entry.mergedCommit ?? null,
|
|
63
|
+
mergedAt: entry.mergedAt ?? null,
|
|
64
|
+
updatedAt: entry.updatedAt ?? null,
|
|
65
|
+
})
|
|
66
|
+
return {
|
|
67
|
+
...missionSummary(mission),
|
|
68
|
+
archived: true,
|
|
69
|
+
archivedAt: entry.archivedAt,
|
|
70
|
+
archivedYear: entry.archivedYear ?? null,
|
|
71
|
+
branch: entry.branch ?? null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Status/board view of the archive index: a count plus a diagnostic, never a
|
|
77
|
+
* thrown error.
|
|
78
|
+
* @param {{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }} index
|
|
79
|
+
* @returns {Record<string, any>}
|
|
80
|
+
*/
|
|
81
|
+
export function archiveView(index) {
|
|
82
|
+
const years = index.ok
|
|
83
|
+
? [...new Set(index.missions.map((entry) => entry.archivedYear).filter((year) => Number.isInteger(year)))].sort(
|
|
84
|
+
(left, right) => left - right,
|
|
85
|
+
)
|
|
86
|
+
: []
|
|
87
|
+
return {
|
|
88
|
+
count: index.ok ? index.missions.length : 0,
|
|
89
|
+
years,
|
|
90
|
+
file: index.file,
|
|
91
|
+
present: index.exists === true,
|
|
92
|
+
unreadable: index.ok ? false : true,
|
|
93
|
+
...(index.ok ? {} : { message: index.error ?? 'tower archive is unreadable' }),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Move the eligible terminal missions out of `state` and into the archive.
|
|
99
|
+
* Mutates `state` in memory (missions, linked reviews and closed findings) and
|
|
100
|
+
* leaves the caller to validate/persist it under the lock it already holds.
|
|
101
|
+
*
|
|
102
|
+
* @param {any} tower
|
|
103
|
+
* @param {TowerState} state
|
|
104
|
+
* @param {{ now?: number, force?: boolean, activity?: Record<string, string | undefined> }} [options]
|
|
105
|
+
* @returns {Promise<{ archived: string[], written: boolean, files?: string[], error: string | null, year?: number }>}
|
|
106
|
+
*/
|
|
107
|
+
export async function sweepArchive(tower, state, options = {}) {
|
|
108
|
+
const policy = tower.config.archive ?? {}
|
|
109
|
+
if (policy.enabled === false) return { archived: [], written: false, error: null }
|
|
110
|
+
const now = options.now ?? Date.now()
|
|
111
|
+
const activity = options.activity ?? {}
|
|
112
|
+
|
|
113
|
+
// Open batches still need their members; a live agent's mission is never
|
|
114
|
+
// archived even if it is terminal (defence in depth; teardown keeps it too).
|
|
115
|
+
const protectedIds = new Set()
|
|
116
|
+
for (const batch of state.batches ?? []) {
|
|
117
|
+
const record = /** @type {Record<string, any>} */ (batch)
|
|
118
|
+
if (record.status !== 'open') continue
|
|
119
|
+
for (const missionId of Array.isArray(record.missionIds) ? record.missionIds : []) {
|
|
120
|
+
protectedIds.add(String(missionId))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const mission of state.missions) {
|
|
124
|
+
if (hasLiveAgent(state, mission.id, activity)) protectedIds.add(mission.id)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const selected = selectForArchive(state.missions, policy, {
|
|
128
|
+
now,
|
|
129
|
+
force: options.force === true,
|
|
130
|
+
protectedIds,
|
|
131
|
+
})
|
|
132
|
+
if (selected.length === 0) return { archived: [], written: false, error: null }
|
|
133
|
+
|
|
134
|
+
const archivedYear = archiveYearFor(now)
|
|
135
|
+
const entries = selected.map((mission) =>
|
|
136
|
+
archivedEntry(mission, {
|
|
137
|
+
archivedAt: now,
|
|
138
|
+
archivedYear,
|
|
139
|
+
reviews: linkedReviews(mission, state.reviews),
|
|
140
|
+
findings: linkedFindings(mission, state.findings),
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
let written
|
|
145
|
+
try {
|
|
146
|
+
written = await tower.deps.archive.writeEntries(tower.deps.store, tower.paths, { entries, now })
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { archived: [], written: false, error: `archive write failed: ${String(error?.message ?? error)}` }
|
|
149
|
+
}
|
|
150
|
+
if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
|
|
151
|
+
|
|
152
|
+
// The archive now holds the full record: compact state.json in the same lock.
|
|
153
|
+
const ids = new Set(selected.map((mission) => mission.id))
|
|
154
|
+
const branches = new Set(
|
|
155
|
+
selected
|
|
156
|
+
.map((mission) => mission.worktree?.branch)
|
|
157
|
+
.filter((branch) => typeof branch === 'string'),
|
|
158
|
+
)
|
|
159
|
+
state.missions = state.missions.filter((mission) => !ids.has(mission.id))
|
|
160
|
+
if (Array.isArray(state.reviews)) {
|
|
161
|
+
state.reviews = state.reviews.filter((review) => !ids.has(review.missionId) && !branches.has(review.branch))
|
|
162
|
+
}
|
|
163
|
+
// Open findings stay actionable in state.json; the archive keeps a snapshot.
|
|
164
|
+
if (Array.isArray(state.findings)) {
|
|
165
|
+
state.findings = state.findings.filter(
|
|
166
|
+
(finding) => !(finding.missionId != null && ids.has(finding.missionId) && isClosedFinding(finding)),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
state.revision += 1
|
|
170
|
+
state.updatedAt = now
|
|
171
|
+
|
|
172
|
+
for (const mission of selected) {
|
|
173
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
174
|
+
at: now,
|
|
175
|
+
type: 'archive',
|
|
176
|
+
missionId: mission.id,
|
|
177
|
+
text: `archived to archive/${archivedYear}.json`,
|
|
178
|
+
}).catch(() => {})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
archived: [...ids],
|
|
183
|
+
written: true,
|
|
184
|
+
files: written.files,
|
|
185
|
+
error: null,
|
|
186
|
+
year: archivedYear,
|
|
187
|
+
}
|
|
188
|
+
}
|
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,6 @@
|
|
|
1
1
|
// Mission board (MISSIONS.md) writing use cases.
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import { renameWithRetry } from '../lib/store.js'
|
|
3
|
+
import { archiveView } from './archive.js'
|
|
5
4
|
|
|
6
5
|
export function boardFile(tower) {
|
|
7
6
|
return tower.config.docs?.boardLocation === 'repo'
|
|
@@ -13,11 +12,12 @@ export async function writeBoard(tower) {
|
|
|
13
12
|
if (tower.config.docs?.board === false) return null
|
|
14
13
|
try {
|
|
15
14
|
const state = await tower.load()
|
|
15
|
+
const index = await tower.archiveIndex()
|
|
16
16
|
const file = boardFile(tower)
|
|
17
17
|
await tower.ensureExcluded(file)
|
|
18
18
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
19
|
-
await
|
|
20
|
-
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)
|
|
21
21
|
return file
|
|
22
22
|
} catch {
|
|
23
23
|
return null
|