dsh-tower 1.3.0 → 2.0.1
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 +109 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +44 -8
- package/README.md +42 -8
- package/cordis.patch.yml +11 -1
- package/docs/README.md +10 -5
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +45 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/guide/scenarios-and-philosophy.md +309 -0
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +33 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +55 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +60 -0
- package/docs/zh/faq.md +81 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +31 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +50 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +275 -9
- package/lib/doctor.js +171 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1566 -184
- package/lib/tower.js +986 -103
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
package/lib/legacy.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Read-only legacy snapshot support.
|
|
2
|
+
//
|
|
3
|
+
// Validates, and stores an immutable copy of, a Tower Codex (schemaVersion: 1)
|
|
4
|
+
// or DSH `state.json` payload under `<common>/dsh-tower/legacy/<sha256>.json`.
|
|
5
|
+
// Loading a snapshot never activates, adopts, or resumes anything: `paused`
|
|
6
|
+
// missions stay paused, no mission/worktree/state identity is created, and this
|
|
7
|
+
// module never writes `state.json` — the caller records the returned snapshot
|
|
8
|
+
// summary in `state.legacySnapshots` inside its own lock.
|
|
9
|
+
//
|
|
10
|
+
// `validateLegacyMap` is the audit-only mapping check: a map is a record that
|
|
11
|
+
// legacy id X corresponds to DSH mission Y. It never adopts or restores work.
|
|
12
|
+
import fsp from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
const SCHEMA_VERSION = 1
|
|
17
|
+
const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
|
|
18
|
+
const DSH_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
|
|
19
|
+
// Tower Codex also retires missions; retired is read-only history here.
|
|
20
|
+
const CODEX_STATUSES = [...DSH_STATUSES, 'retired']
|
|
21
|
+
const DSH_ID_RE = /^M\d+$/
|
|
22
|
+
const CODEX_ID_RE = /^T\d{4,}$/
|
|
23
|
+
const SHA256_RE = /^[a-f0-9]{64}$/
|
|
24
|
+
|
|
25
|
+
function fail(code, message) {
|
|
26
|
+
const error = new Error(message)
|
|
27
|
+
error.code = code
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value) {
|
|
32
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isNonEmptyString(value) {
|
|
36
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sha256(buffer) {
|
|
40
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function win32Lower(value) {
|
|
44
|
+
return process.platform === 'win32' ? value.toLowerCase() : value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isInside(parent, child) {
|
|
48
|
+
const relative = path.relative(win32Lower(path.resolve(parent)), win32Lower(path.resolve(child)))
|
|
49
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function realpathOrResolve(target) {
|
|
53
|
+
try {
|
|
54
|
+
return await fsp.realpath(target)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error?.code === 'ENOENT') return path.resolve(target)
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function invalidResult(error, format = null) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
format,
|
|
65
|
+
schemaVersion: null,
|
|
66
|
+
missions: [],
|
|
67
|
+
missionIds: [],
|
|
68
|
+
missionCount: 0,
|
|
69
|
+
paused: [],
|
|
70
|
+
events: null,
|
|
71
|
+
legacySnapshots: null,
|
|
72
|
+
repoRoot: null,
|
|
73
|
+
baseBranch: null,
|
|
74
|
+
error,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateScope(scope, id) {
|
|
79
|
+
if (!Array.isArray(scope)) return `mission ${id} has no scope array`
|
|
80
|
+
for (const entry of scope) {
|
|
81
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) return `mission ${id} scope entries must be non-empty strings`
|
|
82
|
+
const value = entry.replace(/\\/g, '/').trim()
|
|
83
|
+
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return `mission ${id} scope must be relative: ${entry}`
|
|
84
|
+
if (value.split('/').some((part) => part === '..')) return `mission ${id} scope must not contain "..": ${entry}`
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateTasks(tasks, id) {
|
|
90
|
+
if (!Array.isArray(tasks)) return `mission ${id} has no tasks array`
|
|
91
|
+
for (const entry of tasks) {
|
|
92
|
+
if (typeof entry === 'string') {
|
|
93
|
+
if (entry.trim().length === 0) return `mission ${id} task entries must be non-empty strings`
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (!isPlainObject(entry) || !isNonEmptyString(entry.text)) {
|
|
97
|
+
return `mission ${id} task entries must be strings or objects with a text field`
|
|
98
|
+
}
|
|
99
|
+
if (entry.status !== undefined && typeof entry.status !== 'string') {
|
|
100
|
+
return `mission ${id} task status must be a string`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateDeps(deps, id) {
|
|
107
|
+
if (!Array.isArray(deps)) return `mission ${id} has no deps array`
|
|
108
|
+
for (const entry of deps) {
|
|
109
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
110
|
+
return `mission ${id} deps entries must be non-empty mission ids`
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateReviews(reviews, id) {
|
|
117
|
+
if (!Array.isArray(reviews)) return `mission ${id} has no reviews array`
|
|
118
|
+
for (const entry of reviews) {
|
|
119
|
+
if (!isPlainObject(entry)) return `mission ${id} review entries must be objects`
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateMissions(payload, format) {
|
|
125
|
+
const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
|
|
126
|
+
const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
|
|
127
|
+
const missions = []
|
|
128
|
+
const missionIds = []
|
|
129
|
+
const paused = []
|
|
130
|
+
const seen = new Set()
|
|
131
|
+
for (let index = 0; index < payload.missions.length; index += 1) {
|
|
132
|
+
const mission = payload.missions[index]
|
|
133
|
+
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object` }
|
|
134
|
+
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#'})` }
|
|
136
|
+
}
|
|
137
|
+
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}` }
|
|
138
|
+
seen.add(mission.id)
|
|
139
|
+
if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
|
|
140
|
+
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}` }
|
|
141
|
+
}
|
|
142
|
+
if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
|
|
143
|
+
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}` }
|
|
144
|
+
}
|
|
145
|
+
for (const problem of [
|
|
146
|
+
validateScope(mission.scope, mission.id),
|
|
147
|
+
validateTasks(mission.tasks, mission.id),
|
|
148
|
+
validateDeps(mission.deps, mission.id),
|
|
149
|
+
validateReviews(mission.reviews, mission.id),
|
|
150
|
+
]) {
|
|
151
|
+
if (problem) return { error: problem }
|
|
152
|
+
}
|
|
153
|
+
missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
|
|
154
|
+
missionIds.push(mission.id)
|
|
155
|
+
if (mission.status === 'paused') paused.push(mission.id)
|
|
156
|
+
}
|
|
157
|
+
return { missions, missionIds, paused, error: null }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate a legacy payload without touching the filesystem or the Tower state.
|
|
162
|
+
* Supports a Tower Codex `schemaVersion: 1` state (repoRoot/baseBranch/
|
|
163
|
+
* missions/events/legacySnapshots with T#### missions) and a DSH `state.json`
|
|
164
|
+
* (repoRoot/missions with M# missions).
|
|
165
|
+
*
|
|
166
|
+
* @returns {{ok:boolean, format:'tower-codex'|'dsh'|null, schemaVersion:number|null,
|
|
167
|
+
* missions:Array<{id:string,status:string,kind:string}>, missionIds:string[],
|
|
168
|
+
* missionCount:number, paused:string[], events:number|null,
|
|
169
|
+
* legacySnapshots:number|null, repoRoot:string|null, baseBranch:string|null,
|
|
170
|
+
* error:string|null}}
|
|
171
|
+
*/
|
|
172
|
+
export function validateLegacySnapshot(payload) {
|
|
173
|
+
if (!isPlainObject(payload)) return invalidResult('legacy snapshot must be a JSON object')
|
|
174
|
+
if (payload.schemaVersion !== SCHEMA_VERSION) {
|
|
175
|
+
return invalidResult(
|
|
176
|
+
payload.schemaVersion === undefined
|
|
177
|
+
? 'legacy snapshot has no schemaVersion (expected 1)'
|
|
178
|
+
: `unsupported legacy schemaVersion ${JSON.stringify(payload.schemaVersion)} (expected 1)`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
|
|
182
|
+
|
|
183
|
+
let format
|
|
184
|
+
if (payload.missions.length === 0) {
|
|
185
|
+
format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
|
|
186
|
+
} else {
|
|
187
|
+
const firstId = payload.missions[0]?.id
|
|
188
|
+
if (typeof firstId === 'string' && CODEX_ID_RE.test(firstId)) format = 'tower-codex'
|
|
189
|
+
else if (typeof firstId === 'string' && DSH_ID_RE.test(firstId)) format = 'dsh'
|
|
190
|
+
else {
|
|
191
|
+
return invalidResult(
|
|
192
|
+
`unsupported legacy mission id ${JSON.stringify(firstId)} (expected T#### for Tower Codex or M# for DSH)`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const rejected = (error) => invalidResult(error, format)
|
|
198
|
+
const validated = validateMissions(payload, format)
|
|
199
|
+
if (validated.error) return rejected(validated.error)
|
|
200
|
+
|
|
201
|
+
if (isNonEmptyString(payload.repoRoot) === false) {
|
|
202
|
+
return rejected(`legacy ${format} snapshot has no repoRoot string`)
|
|
203
|
+
}
|
|
204
|
+
if (format === 'tower-codex' && isNonEmptyString(payload.baseBranch) === false) {
|
|
205
|
+
return rejected('legacy tower-codex snapshot has no baseBranch string')
|
|
206
|
+
}
|
|
207
|
+
if (payload.events !== undefined && !Array.isArray(payload.events)) {
|
|
208
|
+
return rejected('legacy snapshot events must be an array')
|
|
209
|
+
}
|
|
210
|
+
if (payload.legacySnapshots !== undefined && !Array.isArray(payload.legacySnapshots)) {
|
|
211
|
+
return rejected('legacy snapshot legacySnapshots must be an array')
|
|
212
|
+
}
|
|
213
|
+
if (payload.revision !== undefined && (!Number.isInteger(payload.revision) || payload.revision < 0)) {
|
|
214
|
+
return rejected('legacy snapshot revision must be a non-negative integer')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
format,
|
|
220
|
+
schemaVersion: SCHEMA_VERSION,
|
|
221
|
+
missions: validated.missions,
|
|
222
|
+
missionIds: validated.missionIds,
|
|
223
|
+
missionCount: validated.missionIds.length,
|
|
224
|
+
paused: validated.paused,
|
|
225
|
+
events: Array.isArray(payload.events) ? payload.events.length : null,
|
|
226
|
+
legacySnapshots: Array.isArray(payload.legacySnapshots) ? payload.legacySnapshots.length : null,
|
|
227
|
+
repoRoot: payload.repoRoot.trim(),
|
|
228
|
+
baseBranch: format === 'tower-codex' ? payload.baseBranch.trim() : (isNonEmptyString(payload.base) ? payload.base.trim() : null),
|
|
229
|
+
error: null,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read a legacy snapshot, validate it, and store the exact payload bytes as
|
|
235
|
+
* `<common>/dsh-tower/legacy/<sha256>.json`. The import is idempotent for the
|
|
236
|
+
* same bytes; it never writes state.json, never activates/adopts a mission, and
|
|
237
|
+
* `paused` stays paused in the returned summary.
|
|
238
|
+
*
|
|
239
|
+
* @returns {{summary:object, snapshot:object}} where `snapshot` is the record
|
|
240
|
+
* the caller may append to `state.legacySnapshots`.
|
|
241
|
+
*/
|
|
242
|
+
export async function importLegacySnapshot(input = {}) {
|
|
243
|
+
const repoRoot = input?.repoRoot
|
|
244
|
+
const commonDir = input?.commonDir
|
|
245
|
+
const sourcePath = input?.sourcePath
|
|
246
|
+
|
|
247
|
+
if (!isNonEmptyString(repoRoot)) fail('invalid-argument', 'repoRoot is required')
|
|
248
|
+
if (!path.isAbsolute(repoRoot)) fail('invalid-argument', `repoRoot must be an absolute path: ${repoRoot}`)
|
|
249
|
+
if (!isNonEmptyString(commonDir)) fail('invalid-argument', 'commonDir is required')
|
|
250
|
+
if (!path.isAbsolute(commonDir)) fail('invalid-argument', `commonDir must be an absolute path: ${commonDir}`)
|
|
251
|
+
if (!isNonEmptyString(sourcePath)) fail('invalid-argument', 'sourcePath is required')
|
|
252
|
+
if (!path.isAbsolute(sourcePath)) fail('invalid-argument', `sourcePath must be an absolute path: ${sourcePath}`)
|
|
253
|
+
|
|
254
|
+
const resolvedSource = path.resolve(sourcePath)
|
|
255
|
+
let sourceStat
|
|
256
|
+
try {
|
|
257
|
+
sourceStat = await fsp.stat(resolvedSource)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error?.code === 'ENOENT') fail('invalid-argument', `sourcePath does not exist: ${resolvedSource}`)
|
|
260
|
+
throw error
|
|
261
|
+
}
|
|
262
|
+
if (!sourceStat.isFile()) fail('invalid-argument', `sourcePath is not a regular file: ${resolvedSource}`)
|
|
263
|
+
|
|
264
|
+
const stateDir = path.join(path.resolve(commonDir), 'dsh-tower')
|
|
265
|
+
const stateReal = await realpathOrResolve(stateDir)
|
|
266
|
+
const sourceReal = await realpathOrResolve(resolvedSource)
|
|
267
|
+
if (isInside(stateReal, sourceReal)) {
|
|
268
|
+
fail('legacy-invalid', `sourcePath must not be inside the Tower state directory: ${stateReal}`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const buffer = await fsp.readFile(sourceReal)
|
|
272
|
+
if (buffer.length > MAX_SNAPSHOT_BYTES) {
|
|
273
|
+
fail(
|
|
274
|
+
'legacy-invalid',
|
|
275
|
+
`legacy snapshot is too large: ${buffer.length} bytes (max ${MAX_SNAPSHOT_BYTES})`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
let text = buffer.toString('utf8')
|
|
279
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
|
280
|
+
let payload
|
|
281
|
+
try {
|
|
282
|
+
payload = JSON.parse(text)
|
|
283
|
+
} catch (error) {
|
|
284
|
+
fail('legacy-invalid', `invalid legacy JSON at ${resolvedSource}: ${error.message}`)
|
|
285
|
+
}
|
|
286
|
+
const validation = validateLegacySnapshot(payload)
|
|
287
|
+
if (!validation.ok) fail('legacy-invalid', `${resolvedSource}: ${validation.error}`)
|
|
288
|
+
|
|
289
|
+
const sourceHash = sha256(buffer)
|
|
290
|
+
const legacyDir = path.join(stateDir, 'legacy')
|
|
291
|
+
const storedPath = path.join(legacyDir, `${sourceHash}.json`)
|
|
292
|
+
await fsp.mkdir(legacyDir, { recursive: true })
|
|
293
|
+
|
|
294
|
+
let idempotent = false
|
|
295
|
+
try {
|
|
296
|
+
const handle = await fsp.open(storedPath, 'wx')
|
|
297
|
+
try {
|
|
298
|
+
await handle.writeFile(buffer)
|
|
299
|
+
await handle.sync()
|
|
300
|
+
} finally {
|
|
301
|
+
await handle.close()
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error?.code !== 'EEXIST') throw error
|
|
305
|
+
const existing = await fsp.readFile(storedPath).catch(() => null)
|
|
306
|
+
if (!existing || sha256(existing) !== sourceHash) {
|
|
307
|
+
fail('legacy-invalid', `legacy snapshot path exists with different contents: ${storedPath}`)
|
|
308
|
+
}
|
|
309
|
+
idempotent = true
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const importedAt = Date.now()
|
|
313
|
+
const summary = {
|
|
314
|
+
format: validation.format,
|
|
315
|
+
schemaVersion: SCHEMA_VERSION,
|
|
316
|
+
missionCount: validation.missionCount,
|
|
317
|
+
missionIds: validation.missionIds,
|
|
318
|
+
paused: validation.paused,
|
|
319
|
+
pausedCount: validation.paused.length,
|
|
320
|
+
events: validation.events,
|
|
321
|
+
legacySnapshots: validation.legacySnapshots,
|
|
322
|
+
sourcePath: resolvedSource,
|
|
323
|
+
sourceHash,
|
|
324
|
+
storedPath,
|
|
325
|
+
bytes: buffer.length,
|
|
326
|
+
idempotent,
|
|
327
|
+
importedAt,
|
|
328
|
+
}
|
|
329
|
+
const snapshot = {
|
|
330
|
+
sourceHash,
|
|
331
|
+
sourcePath: resolvedSource,
|
|
332
|
+
storedPath,
|
|
333
|
+
format: validation.format,
|
|
334
|
+
schemaVersion: SCHEMA_VERSION,
|
|
335
|
+
repoRoot: validation.repoRoot,
|
|
336
|
+
baseBranch: validation.baseBranch,
|
|
337
|
+
missionIds: validation.missionIds,
|
|
338
|
+
missionCount: validation.missionCount,
|
|
339
|
+
pausedMissionIds: validation.paused,
|
|
340
|
+
bytes: buffer.length,
|
|
341
|
+
importedAt,
|
|
342
|
+
}
|
|
343
|
+
return { summary, snapshot }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Audit-only validation for a legacy-id → DSH-mission mapping. The caller
|
|
348
|
+
* records `{sourceHash, oldId, newMissionId}` (plus its own timestamp) in
|
|
349
|
+
* `state.legacyMaps`; mapping never adopts, restores, or resumes anything.
|
|
350
|
+
*
|
|
351
|
+
* @returns {{ok:true, duplicate:boolean, map:{sourceHash:string, oldId:string,
|
|
352
|
+
* newMissionId:string|null}}}
|
|
353
|
+
*/
|
|
354
|
+
export function validateLegacyMap(state, input = {}) {
|
|
355
|
+
if (!isPlainObject(state)) fail('invalid-argument', 'state is required')
|
|
356
|
+
const sourceHash = input?.sourceHash
|
|
357
|
+
const oldId = input?.oldId
|
|
358
|
+
const rawNewMissionId = input?.newMissionId
|
|
359
|
+
|
|
360
|
+
if (!isNonEmptyString(sourceHash)) fail('invalid-argument', 'sourceHash is required')
|
|
361
|
+
if (!SHA256_RE.test(sourceHash)) {
|
|
362
|
+
fail('invalid-argument', `sourceHash must be a 64-character hex SHA256: ${sourceHash}`)
|
|
363
|
+
}
|
|
364
|
+
if (!isNonEmptyString(oldId)) fail('invalid-argument', 'oldId is required')
|
|
365
|
+
if (rawNewMissionId !== undefined && rawNewMissionId !== null && rawNewMissionId !== '' && typeof rawNewMissionId !== 'string') {
|
|
366
|
+
fail('invalid-argument', `newMissionId must be a mission id string: ${JSON.stringify(rawNewMissionId)}`)
|
|
367
|
+
}
|
|
368
|
+
const newMissionId = isNonEmptyString(rawNewMissionId) ? rawNewMissionId.trim() : null
|
|
369
|
+
|
|
370
|
+
const snapshots = Array.isArray(state.legacySnapshots) ? state.legacySnapshots : []
|
|
371
|
+
const snapshot = snapshots.find((entry) => {
|
|
372
|
+
if (!isPlainObject(entry)) return false
|
|
373
|
+
const hash = entry.sourceHash ?? entry.hash ?? entry.sha256
|
|
374
|
+
return hash === sourceHash
|
|
375
|
+
})
|
|
376
|
+
if (!snapshot) {
|
|
377
|
+
fail('legacy-invalid', `unknown legacy snapshot ${sourceHash.slice(0, 12)}; import it with tower_import_legacy first`)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const snapshotIds = Array.isArray(snapshot.missionIds)
|
|
381
|
+
? snapshot.missionIds.filter((id) => typeof id === 'string')
|
|
382
|
+
: Array.isArray(snapshot.missions)
|
|
383
|
+
? snapshot.missions.map((mission) => (typeof mission === 'string' ? mission : mission?.id)).filter((id) => typeof id === 'string')
|
|
384
|
+
: []
|
|
385
|
+
const missionIds = (Array.isArray(state.missions) ? state.missions : [])
|
|
386
|
+
.map((mission) => mission?.id)
|
|
387
|
+
.filter((id) => typeof id === 'string')
|
|
388
|
+
|
|
389
|
+
if (!snapshotIds.includes(oldId) && !missionIds.includes(oldId)) {
|
|
390
|
+
fail(
|
|
391
|
+
'legacy-invalid',
|
|
392
|
+
`legacy id ${oldId} is not in snapshot ${sourceHash.slice(0, 12)} and is not a known mission`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
if (newMissionId !== null && !missionIds.includes(newMissionId)) {
|
|
396
|
+
fail('legacy-invalid', `unknown new mission "${newMissionId}"; plan it before mapping`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const maps = Array.isArray(state.legacyMaps) ? state.legacyMaps : []
|
|
400
|
+
const existing = maps.find((entry) => isPlainObject(entry) && entry.sourceHash === sourceHash && entry.oldId === oldId)
|
|
401
|
+
if (existing) {
|
|
402
|
+
const existingTarget = existing.newMissionId ?? null
|
|
403
|
+
if (existingTarget !== newMissionId) {
|
|
404
|
+
fail(
|
|
405
|
+
'legacy-invalid',
|
|
406
|
+
`legacy map for ${oldId} already points to ${existingTarget ?? 'no mission'}; mappings are append-only and must not be rewritten`,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, duplicate: true, map: existing }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { ok: true, duplicate: false, map: { sourceHash, oldId, newMissionId } }
|
|
413
|
+
}
|
package/lib/modeStore.js
CHANGED
|
@@ -9,6 +9,14 @@ import path from 'node:path'
|
|
|
9
9
|
|
|
10
10
|
const DEFAULT_MAX_ENTRIES = 200
|
|
11
11
|
|
|
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
|
+
|
|
12
20
|
function defaultStoreDir() {
|
|
13
21
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
14
22
|
return path.join(home, 'tower-mode')
|
|
@@ -33,10 +41,17 @@ export function loadModes(baseDir) {
|
|
|
33
41
|
: {}
|
|
34
42
|
for (const [key, value] of Object.entries(entries)) {
|
|
35
43
|
if (value && value.on === true) {
|
|
44
|
+
const lock = value.lock && typeof value.lock === 'object'
|
|
45
|
+
&& typeof value.lock.token === 'string'
|
|
46
|
+
&& typeof value.lock.commonDir === 'string'
|
|
47
|
+
? { token: value.lock.token, commonDir: value.lock.commonDir }
|
|
48
|
+
: null
|
|
36
49
|
modes.set(key, {
|
|
37
50
|
on: true,
|
|
38
51
|
pausedGoal: value.pausedGoal && typeof value.pausedGoal === 'object' ? value.pausedGoal : null,
|
|
39
52
|
lastChange: value.lastChange && typeof value.lastChange === 'object' ? value.lastChange : null,
|
|
53
|
+
lock,
|
|
54
|
+
baseBranch: typeof value.baseBranch === 'string' && value.baseBranch.trim() ? value.baseBranch.trim() : null,
|
|
40
55
|
at: Number(value.at) || 0,
|
|
41
56
|
})
|
|
42
57
|
}
|
|
@@ -60,7 +75,19 @@ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
|
60
75
|
const file = storeFile(baseDir)
|
|
61
76
|
const tmp = `${file}.tmp-${process.pid}`
|
|
62
77
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
63
|
-
|
|
78
|
+
let lastError = null
|
|
79
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
80
|
+
try {
|
|
81
|
+
fs.renameSync(tmp, file)
|
|
82
|
+
return true
|
|
83
|
+
} catch (error) {
|
|
84
|
+
lastError = error
|
|
85
|
+
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
|
|
86
|
+
sleepSync(10 * (attempt + 1))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
90
|
+
if (lastError) return false
|
|
64
91
|
return true
|
|
65
92
|
} catch {
|
|
66
93
|
return false
|
package/lib/store.js
CHANGED
|
@@ -45,9 +45,32 @@ export async function writeJsonAtomic(file, value) {
|
|
|
45
45
|
} finally {
|
|
46
46
|
await handle.close()
|
|
47
47
|
}
|
|
48
|
-
await
|
|
48
|
+
await renameWithRetry(tmp, file)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Windows can reject a rename while an indexer, antivirus, or a concurrent
|
|
53
|
+
* reader still holds the destination; retry briefly instead of failing a
|
|
54
|
+
* successful mutation. The temp file is removed when the rename never lands.
|
|
55
|
+
*/
|
|
56
|
+
async function renameWithRetry(tmp, file) {
|
|
57
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
58
|
+
try {
|
|
59
|
+
await fsp.rename(tmp, file)
|
|
60
|
+
return
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
|
|
63
|
+
if (!transient || attempt >= 12) {
|
|
64
|
+
await fsp.unlink(tmp).catch(() => {})
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
await sleep(10 + attempt * 15)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { renameWithRetry }
|
|
73
|
+
|
|
51
74
|
export async function appendJsonl(file, value) {
|
|
52
75
|
await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
53
76
|
}
|