dsh-tower 2.0.4 → 2.1.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 +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- 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/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +427 -0
- package/domain/contracts/outputs.js +305 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +212 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +45 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +102 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
package/lib/legacy.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// `validateLegacyMap` is the audit-only mapping check: a map is a record that
|
|
11
11
|
// legacy id X corresponds to DSH mission Y. It never adopts or restores work.
|
|
12
|
-
import fsp from '
|
|
12
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
15
|
|
|
@@ -23,6 +23,7 @@ const CODEX_ID_RE = /^T\d{4,}$/
|
|
|
23
23
|
const SHA256_RE = /^[a-f0-9]{64}$/
|
|
24
24
|
|
|
25
25
|
function fail(code, message) {
|
|
26
|
+
/** @type {Error & {code?: string}} */
|
|
26
27
|
const error = new Error(message)
|
|
27
28
|
error.code = code
|
|
28
29
|
throw error
|
|
@@ -40,13 +41,27 @@ function sha256(buffer) {
|
|
|
40
41
|
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
// Path semantics are selected from an injected platform so both branches are
|
|
45
|
+
// testable on any OS (#86). On the real platform this is exactly `path`.
|
|
46
|
+
function pathFor(platform) {
|
|
47
|
+
return platform === 'win32' ? path.win32 : path.posix
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
export function win32Lower(value, platform = process.platform) {
|
|
51
|
+
return platform === 'win32' ? value.toLowerCase() : value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Is `child` inside `parent` (or the same path)? Windows reconciles case
|
|
56
|
+
* because NTFS is case-insensitive; the POSIX branch stays case-sensitive.
|
|
57
|
+
*/
|
|
58
|
+
export function isInside(parent, child, platform = process.platform) {
|
|
59
|
+
const pathImpl = pathFor(platform)
|
|
60
|
+
const relative = pathImpl.relative(
|
|
61
|
+
win32Lower(pathImpl.resolve(parent), platform),
|
|
62
|
+
win32Lower(pathImpl.resolve(child), platform),
|
|
63
|
+
)
|
|
64
|
+
return relative === '' || (!relative.startsWith('..') && !pathImpl.isAbsolute(relative))
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
async function realpathOrResolve(target) {
|
|
@@ -58,6 +73,10 @@ async function realpathOrResolve(target) {
|
|
|
58
73
|
}
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} error
|
|
78
|
+
* @param {'tower-codex'|'dsh'|null} [format]
|
|
79
|
+
*/
|
|
61
80
|
function invalidResult(error, format = null) {
|
|
62
81
|
return {
|
|
63
82
|
ok: false,
|
|
@@ -121,6 +140,11 @@ function validateReviews(reviews, id) {
|
|
|
121
140
|
return null
|
|
122
141
|
}
|
|
123
142
|
|
|
143
|
+
/**
|
|
144
|
+
* @param {any} payload
|
|
145
|
+
* @param {'tower-codex'|'dsh'} format
|
|
146
|
+
* @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[]}}
|
|
147
|
+
*/
|
|
124
148
|
function validateMissions(payload, format) {
|
|
125
149
|
const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
|
|
126
150
|
const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
|
|
@@ -130,17 +154,17 @@ function validateMissions(payload, format) {
|
|
|
130
154
|
const seen = new Set()
|
|
131
155
|
for (let index = 0; index < payload.missions.length; index += 1) {
|
|
132
156
|
const mission = payload.missions[index]
|
|
133
|
-
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object
|
|
157
|
+
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
|
|
134
158
|
if (typeof mission.id !== 'string' || !idRe.test(mission.id)) {
|
|
135
|
-
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})
|
|
159
|
+
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})`, missions: [], missionIds: [], paused: [] }
|
|
136
160
|
}
|
|
137
|
-
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}
|
|
161
|
+
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
|
|
138
162
|
seen.add(mission.id)
|
|
139
163
|
if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
|
|
140
|
-
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}
|
|
164
|
+
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
|
|
141
165
|
}
|
|
142
166
|
if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
|
|
143
|
-
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}
|
|
167
|
+
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [] }
|
|
144
168
|
}
|
|
145
169
|
for (const problem of [
|
|
146
170
|
validateScope(mission.scope, mission.id),
|
|
@@ -148,7 +172,7 @@ function validateMissions(payload, format) {
|
|
|
148
172
|
validateDeps(mission.deps, mission.id),
|
|
149
173
|
validateReviews(mission.reviews, mission.id),
|
|
150
174
|
]) {
|
|
151
|
-
if (problem) return { error: problem }
|
|
175
|
+
if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
|
|
152
176
|
}
|
|
153
177
|
missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
|
|
154
178
|
missionIds.push(mission.id)
|
|
@@ -180,7 +204,8 @@ export function validateLegacySnapshot(payload) {
|
|
|
180
204
|
}
|
|
181
205
|
if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
|
|
182
206
|
|
|
183
|
-
|
|
207
|
+
/** @type {'tower-codex'|'dsh'} */
|
|
208
|
+
let format = 'dsh'
|
|
184
209
|
if (payload.missions.length === 0) {
|
|
185
210
|
format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
|
|
186
211
|
} else {
|
|
@@ -236,7 +261,7 @@ export function validateLegacySnapshot(payload) {
|
|
|
236
261
|
* same bytes; it never writes state.json, never activates/adopts a mission, and
|
|
237
262
|
* `paused` stays paused in the returned summary.
|
|
238
263
|
*
|
|
239
|
-
* @returns {{summary:object, snapshot:object}} where `snapshot` is the record
|
|
264
|
+
* @returns {Promise<{summary:object, snapshot:object}>} where `snapshot` is the record
|
|
240
265
|
* the caller may append to `state.legacySnapshots`.
|
|
241
266
|
*/
|
|
242
267
|
export async function importLegacySnapshot(input = {}) {
|
package/lib/modeState.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Owned mode state instead of a bare module-level Map.
|
|
2
|
+
export class ModeState {
|
|
3
|
+
constructor(entries) {
|
|
4
|
+
this.map = new Map(entries ?? [])
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
has(key) {
|
|
8
|
+
return this.map.has(key)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get(key) {
|
|
12
|
+
return this.map.get(key)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
set(key, value) {
|
|
16
|
+
this.map.set(key, value)
|
|
17
|
+
return this
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
delete(key) {
|
|
21
|
+
return this.map.delete(key)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
values() {
|
|
25
|
+
return this.map.values()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
entries() {
|
|
29
|
+
return this.map.entries()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
replace(entries) {
|
|
33
|
+
this.map = new Map(entries ?? [])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear() {
|
|
37
|
+
this.map.clear()
|
|
38
|
+
}
|
|
39
|
+
}
|
package/lib/modeStore.js
CHANGED
|
@@ -3,20 +3,13 @@
|
|
|
3
3
|
// The session-projection route needs zod schemas, which this bundle cannot
|
|
4
4
|
// resolve from the DSH installation on every deployment; a small atomic JSON
|
|
5
5
|
// store under DSH_HOME/tower-mode keeps the mode across restarts.
|
|
6
|
-
import fs from '
|
|
6
|
+
import { fs } from '../adapters/store-fs.js'
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
+
import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from './persistence.js'
|
|
9
10
|
|
|
10
11
|
const DEFAULT_MAX_ENTRIES = 200
|
|
11
12
|
|
|
12
|
-
function sleepSync(ms) {
|
|
13
|
-
try {
|
|
14
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
15
|
-
} catch {
|
|
16
|
-
// best effort; a busy loop would be worse
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
13
|
function defaultStoreDir() {
|
|
21
14
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
22
15
|
return path.join(home, 'tower-mode')
|
|
@@ -34,8 +27,7 @@ function storeFile(baseDir) {
|
|
|
34
27
|
export function loadModes(baseDir) {
|
|
35
28
|
const modes = new Map()
|
|
36
29
|
try {
|
|
37
|
-
const
|
|
38
|
-
const parsed = JSON.parse(raw)
|
|
30
|
+
const parsed = readJsonSync(storeFile(baseDir))
|
|
39
31
|
const entries = parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object'
|
|
40
32
|
? parsed.modes
|
|
41
33
|
: {}
|
|
@@ -52,6 +44,7 @@ export function loadModes(baseDir) {
|
|
|
52
44
|
lastChange: value.lastChange && typeof value.lastChange === 'object' ? value.lastChange : null,
|
|
53
45
|
lock,
|
|
54
46
|
baseBranch: typeof value.baseBranch === 'string' && value.baseBranch.trim() ? value.baseBranch.trim() : null,
|
|
47
|
+
lastDisclosure: typeof value.lastDisclosure === 'string' ? value.lastDisclosure : null,
|
|
55
48
|
at: Number(value.at) || 0,
|
|
56
49
|
})
|
|
57
50
|
}
|
|
@@ -63,31 +56,20 @@ export function loadModes(baseDir) {
|
|
|
63
56
|
}
|
|
64
57
|
|
|
65
58
|
/** Persist the current modes atomically with a short cross-process lock. */
|
|
66
|
-
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
59
|
+
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES, options = {}) {
|
|
67
60
|
const dir = storeDir(baseDir)
|
|
68
61
|
const file = storeFile(baseDir)
|
|
69
62
|
const lock = path.join(dir, 'modes.lock')
|
|
63
|
+
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 5000
|
|
70
64
|
try {
|
|
71
65
|
fs.mkdirSync(dir, { recursive: true })
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const fd = fs.openSync(lock, 'wx')
|
|
76
|
-
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }))
|
|
77
|
-
fs.closeSync(fd)
|
|
78
|
-
acquired = true
|
|
79
|
-
break
|
|
80
|
-
} catch (error) {
|
|
81
|
-
if (error?.code !== 'EEXIST') throw error
|
|
82
|
-
sleepSync(5)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
if (!acquired) return false
|
|
66
|
+
const held = acquireLockSync(lock, { timeoutMs, invalidPidIsDead: true })
|
|
67
|
+
if (!held) return false
|
|
86
68
|
try {
|
|
87
69
|
const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
|
|
88
70
|
let disk = {}
|
|
89
71
|
try {
|
|
90
|
-
const parsed =
|
|
72
|
+
const parsed = readJsonSync(file)
|
|
91
73
|
if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
|
|
92
74
|
} catch {
|
|
93
75
|
disk = {}
|
|
@@ -104,24 +86,10 @@ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
|
104
86
|
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
105
87
|
.slice(0, limit)
|
|
106
88
|
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
107
|
-
|
|
108
|
-
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
109
|
-
let lastError = null
|
|
110
|
-
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
111
|
-
try {
|
|
112
|
-
fs.renameSync(tmp, file)
|
|
113
|
-
return true
|
|
114
|
-
} catch (error) {
|
|
115
|
-
lastError = error
|
|
116
|
-
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
|
|
117
|
-
sleepSync(10 * (attempt + 1))
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
try { fs.unlinkSync(tmp) } catch {}
|
|
121
|
-
if (lastError) return false
|
|
89
|
+
writeJsonAtomicSync(file, payload)
|
|
122
90
|
return true
|
|
123
91
|
} finally {
|
|
124
|
-
|
|
92
|
+
held.release()
|
|
125
93
|
}
|
|
126
94
|
} catch {
|
|
127
95
|
return false
|
|
@@ -0,0 +1,356 @@
|
|
|
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/presets.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Cross-field preset validation (#64): fail before work starts, not at spawn.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
|
|
4
|
+
export function presetIds(config = {}) {
|
|
5
|
+
return {
|
|
6
|
+
worker: String(config.spawn?.workerPreset ?? 'tower-worker'),
|
|
7
|
+
reviewer: String(config.spawn?.reviewerPreset ?? 'tower-reviewer'),
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rowId(preset) {
|
|
12
|
+
return preset === 'tower-worker' ? 'preset-tower-worker' : preset === 'tower-reviewer' ? 'preset-tower-reviewer' : preset
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve both presets against the profile's agentPresets service. Fail closed
|
|
17
|
+
* with preset-missing before mode entry/planning when a required preset is not
|
|
18
|
+
* registered. If the service is unavailable, doctor reports that separately and
|
|
19
|
+
* this check cannot make a claim either way.
|
|
20
|
+
*/
|
|
21
|
+
export async function assertPresetsAvailable(ctx, config = {}) {
|
|
22
|
+
const service = ctx?.get?.('agentPresets')
|
|
23
|
+
if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
|
|
24
|
+
const presets = presetIds(config)
|
|
25
|
+
const available = new Set((await service.list()).map((entry) => String(entry.id)))
|
|
26
|
+
const missing = [presets.worker, presets.reviewer].filter((id) => !available.has(id))
|
|
27
|
+
if (missing.length > 0) {
|
|
28
|
+
const rows = [...new Set(missing.map(rowId))]
|
|
29
|
+
throw new TowerToolError(
|
|
30
|
+
'preset-missing',
|
|
31
|
+
`required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return { checked: true, missing: [] }
|
|
35
|
+
}
|