dsh-tower 2.0.2 → 2.0.4
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 +57 -0
- package/README.en.md +22 -16
- package/README.md +22 -16
- package/SECURITY.md +4 -1
- package/docs/README.md +30 -0
- package/docs/architecture/overview.md +3 -2
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +15 -6
- package/docs/guide/install-upgrade-migration.md +14 -5
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/scenarios-and-philosophy.md +150 -62
- package/docs/operations/security.md +23 -12
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.md +8 -6
- package/docs/reference/errors.md +7 -1
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +1 -1
- package/docs/zh/faq.md +12 -4
- package/docs/zh/guide/install-upgrade-migration.md +11 -4
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
- package/docs/zh/operations/security.md +16 -6
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +8 -6
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +7 -6
- package/index.js +26 -17
- package/lib/config.js +103 -6
- package/lib/doctor.js +11 -5
- package/lib/errors.js +14 -2
- package/lib/git.js +36 -2
- package/lib/modeStore.js +54 -20
- package/lib/store.js +65 -10
- package/lib/tests.js +35 -1
- package/lib/tools.js +257 -47
- package/lib/tower.js +281 -55
- package/lib/workspaceLock.js +44 -20
- package/package.json +3 -2
- package/scripts/check-docs.mjs +35 -0
- package/skills/tower-recovery.md +7 -5
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
package/lib/modeStore.js
CHANGED
|
@@ -62,33 +62,67 @@ export function loadModes(baseDir) {
|
|
|
62
62
|
return modes
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** Persist the current modes atomically
|
|
65
|
+
/** Persist the current modes atomically with a short cross-process lock. */
|
|
66
66
|
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
67
|
+
const dir = storeDir(baseDir)
|
|
68
|
+
const file = storeFile(baseDir)
|
|
69
|
+
const lock = path.join(dir, 'modes.lock')
|
|
67
70
|
try {
|
|
68
|
-
const dir = storeDir(baseDir)
|
|
69
71
|
fs.mkdirSync(dir, { recursive: true })
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
73
|
-
.slice(0, limit)
|
|
74
|
-
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
75
|
-
const file = storeFile(baseDir)
|
|
76
|
-
const tmp = `${file}.tmp-${process.pid}`
|
|
77
|
-
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
78
|
-
let lastError = null
|
|
79
|
-
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
72
|
+
let acquired = false
|
|
73
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
80
74
|
try {
|
|
81
|
-
fs.
|
|
82
|
-
|
|
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
|
|
83
80
|
} catch (error) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
sleepSync(10 * (attempt + 1))
|
|
81
|
+
if (error?.code !== 'EEXIST') throw error
|
|
82
|
+
sleepSync(5)
|
|
87
83
|
}
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
if (!acquired) return false
|
|
86
|
+
try {
|
|
87
|
+
const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
|
|
88
|
+
let disk = {}
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
91
|
+
if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
|
|
92
|
+
} catch {
|
|
93
|
+
disk = {}
|
|
94
|
+
}
|
|
95
|
+
// Merge by timestamp so two sessions saving concurrently do not erase
|
|
96
|
+
// each other's mode changes. A disk-only entry from another process is
|
|
97
|
+
// preserved unless this process has a newer record for the same key.
|
|
98
|
+
const merged = { ...disk }
|
|
99
|
+
for (const [key, value] of modes.entries()) {
|
|
100
|
+
const previous = merged[key]
|
|
101
|
+
if (!previous || (Number(value?.at) || 0) >= (Number(previous?.at) || 0)) merged[key] = value
|
|
102
|
+
}
|
|
103
|
+
const entries = Object.entries(merged)
|
|
104
|
+
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
105
|
+
.slice(0, limit)
|
|
106
|
+
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
107
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
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
|
|
122
|
+
return true
|
|
123
|
+
} finally {
|
|
124
|
+
try { fs.unlinkSync(lock) } catch {}
|
|
125
|
+
}
|
|
92
126
|
} catch {
|
|
93
127
|
return false
|
|
94
128
|
}
|
package/lib/store.js
CHANGED
|
@@ -98,15 +98,73 @@ function sleep(ms) {
|
|
|
98
98
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
102
|
+
|
|
103
|
+
function processAlive(pid) {
|
|
104
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null
|
|
105
|
+
try {
|
|
106
|
+
process.kill(Number(pid), 0)
|
|
107
|
+
return true
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code === 'ESRCH') return false
|
|
110
|
+
// EPERM means the process exists but belongs to another user.
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Read the current lock record without acquiring the lock. */
|
|
116
|
+
export async function readStateLock(paths) {
|
|
117
|
+
try {
|
|
118
|
+
const text = await fsp.readFile(paths.lock, 'utf8')
|
|
119
|
+
const record = JSON.parse(text)
|
|
120
|
+
const alive = processAlive(record?.pid)
|
|
121
|
+
return { path: paths.lock, record, alive, raw: text }
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error?.code === 'ENOENT') return { path: paths.lock, record: null, alive: null, raw: null }
|
|
124
|
+
return { path: paths.lock, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function reclaimDeadLock(paths) {
|
|
129
|
+
let original
|
|
130
|
+
try {
|
|
131
|
+
original = await fsp.readFile(paths.lock, 'utf8')
|
|
132
|
+
} catch {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
let parsed
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(original)
|
|
138
|
+
} catch {
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
if (processAlive(parsed?.pid) !== false) return false
|
|
142
|
+
const quarantine = `${paths.lock}.stale-${process.pid}-${crypto.randomUUID()}`
|
|
143
|
+
try {
|
|
144
|
+
await fsp.rename(paths.lock, quarantine)
|
|
145
|
+
} catch {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
|
|
149
|
+
if (moved === null || moved !== original) {
|
|
150
|
+
// A concurrent writer replaced the lock between read and rename. Put the
|
|
151
|
+
// newer record back rather than deleting it.
|
|
152
|
+
if (moved !== null) await fsp.rename(quarantine, paths.lock).catch(() => {})
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
await fsp.unlink(quarantine).catch(() => {})
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
101
159
|
/**
|
|
102
|
-
* Hold the repo-wide tower lock for one mutation.
|
|
103
|
-
*
|
|
160
|
+
* Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
|
|
161
|
+
* provably dead is reclaimed atomically; a live lock is reported, never stolen.
|
|
104
162
|
*/
|
|
105
163
|
export async function withLock(paths, fn, options = {}) {
|
|
106
164
|
const timeoutMs = options.timeoutMs ?? 5000
|
|
107
165
|
const pollMs = options.pollMs ?? 25
|
|
108
166
|
const token = crypto.randomUUID()
|
|
109
|
-
const record = JSON.stringify({ token, pid: process.pid, at: Date.now() })
|
|
167
|
+
const record = JSON.stringify({ token, pid: process.pid, startedAt: PROCESS_STARTED_AT, at: Date.now() })
|
|
110
168
|
const deadline = Date.now() + timeoutMs
|
|
111
169
|
|
|
112
170
|
for (;;) {
|
|
@@ -115,15 +173,12 @@ export async function withLock(paths, fn, options = {}) {
|
|
|
115
173
|
break
|
|
116
174
|
} catch (error) {
|
|
117
175
|
if (error.code !== 'EEXIST') throw error
|
|
176
|
+
if (await reclaimDeadLock(paths)) continue
|
|
118
177
|
if (Date.now() > deadline) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
owner = await fsp.readFile(paths.lock, 'utf8')
|
|
122
|
-
} catch {
|
|
123
|
-
owner = null
|
|
124
|
-
}
|
|
178
|
+
const owner = await readStateLock(paths)
|
|
179
|
+
const payload = owner.raw ?? (owner.record ? JSON.stringify(owner.record) : null)
|
|
125
180
|
throw new Error(
|
|
126
|
-
`tower state is locked by another writer${
|
|
181
|
+
`tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
|
|
127
182
|
)
|
|
128
183
|
}
|
|
129
184
|
await sleep(pollMs)
|
package/lib/tests.js
CHANGED
|
@@ -253,6 +253,40 @@ export function parseJsonArray(stdout) {
|
|
|
253
253
|
return { tests, ok: failed === 0, count: tests.length, failed, error: null }
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/** Test discovery is repository-controlled code: do not hand it secrets. */
|
|
257
|
+
const TEST_ENV_ALLOW = new Set([
|
|
258
|
+
'PATH',
|
|
259
|
+
'Path',
|
|
260
|
+
'PATHEXT',
|
|
261
|
+
'SystemRoot',
|
|
262
|
+
'WINDIR',
|
|
263
|
+
'ComSpec',
|
|
264
|
+
'HOME',
|
|
265
|
+
'USERPROFILE',
|
|
266
|
+
'TMP',
|
|
267
|
+
'TEMP',
|
|
268
|
+
'TMPDIR',
|
|
269
|
+
'LANG',
|
|
270
|
+
'LC_ALL',
|
|
271
|
+
'LC_CTYPE',
|
|
272
|
+
'SHELL',
|
|
273
|
+
'TERM',
|
|
274
|
+
'APPDATA',
|
|
275
|
+
'LOCALAPPDATA',
|
|
276
|
+
'ProgramFiles',
|
|
277
|
+
'ProgramFiles(x86)',
|
|
278
|
+
'NODE_PATH',
|
|
279
|
+
])
|
|
280
|
+
|
|
281
|
+
export function testProcessEnv(source = process.env) {
|
|
282
|
+
const env = {}
|
|
283
|
+
for (const [key, value] of Object.entries(source)) {
|
|
284
|
+
if (!TEST_ENV_ALLOW.has(key)) continue
|
|
285
|
+
env[key] = value
|
|
286
|
+
}
|
|
287
|
+
return env
|
|
288
|
+
}
|
|
289
|
+
|
|
256
290
|
/** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
|
|
257
291
|
function shellSpec(platform = process.platform) {
|
|
258
292
|
if (platform === 'win32') {
|
|
@@ -379,7 +413,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
|
|
|
379
413
|
windowsHide: true,
|
|
380
414
|
// POSIX: a process group lets a timeout kill grandchildren too.
|
|
381
415
|
detached: process.platform !== 'win32',
|
|
382
|
-
env:
|
|
416
|
+
env: testProcessEnv(),
|
|
383
417
|
})
|
|
384
418
|
} catch (error) {
|
|
385
419
|
spawnError = error
|