dsh-tower 2.0.4 → 2.1.0-beta.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 +101 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +40 -3
- package/README.md +38 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/architecture/overview.md +111 -12
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +8 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +4 -1
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +1 -1
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +6 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/config.md +4 -1
- package/docs/zh/reference/errors.md +3 -1
- package/docs/zh/reference/tools.md +1 -1
- 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 +404 -0
- package/domain/contracts/outputs.js +279 -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 +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -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 +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +97 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +54 -137
- package/lib/tests.js +11 -10
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +5 -4
- 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 +203 -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 +44 -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 +94 -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 +339 -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/git.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
1
2
|
// Minimal git port: array arguments only, bounded output, no shell.
|
|
2
|
-
import { execFile } from '
|
|
3
|
+
import { execFile } from '../adapters/vcs-git.js'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
|
|
5
6
|
const GIT_TIMEOUT_MS = 15000
|
|
6
7
|
const MAX_BUFFER = 16 * 1024 * 1024
|
|
7
8
|
let configuredTimeoutMs = GIT_TIMEOUT_MS
|
|
8
9
|
|
|
10
|
+
/** @typedef {{ timeoutMs?: number, maxBuffer?: number }} GitOptions */
|
|
11
|
+
/** @typedef {{ path: string, branch?: string, head?: string }} WorktreeEntry */
|
|
12
|
+
|
|
9
13
|
/** Override the default Git timeout from resolved Tower config. */
|
|
10
14
|
export function setGitTimeout(ms) {
|
|
11
15
|
const value = Number(ms)
|
|
12
16
|
if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
/** Process default retained for manual adapter use; Tower instances pass their own. */
|
|
20
|
+
export function configuredGitTimeout() {
|
|
21
|
+
return configuredTimeoutMs
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
const BLOCKED_GIT_ENV = new Set([
|
|
16
25
|
'GIT_DIR',
|
|
17
26
|
'GIT_WORK_TREE',
|
|
@@ -29,8 +38,13 @@ const BLOCKED_GIT_ENV = new Set([
|
|
|
29
38
|
'GIT_QUARANTINE_PATH',
|
|
30
39
|
])
|
|
31
40
|
|
|
32
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Git must not inherit a repository redirect from the DSH process environment.
|
|
43
|
+
* @param {NodeJS.ProcessEnv} [source]
|
|
44
|
+
* @returns {NodeJS.ProcessEnv}
|
|
45
|
+
*/
|
|
33
46
|
export function gitEnv(source = process.env) {
|
|
47
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
34
48
|
const env = {}
|
|
35
49
|
for (const [key, value] of Object.entries(source)) {
|
|
36
50
|
if (BLOCKED_GIT_ENV.has(key)) continue
|
|
@@ -39,17 +53,16 @@ export function gitEnv(source = process.env) {
|
|
|
39
53
|
return env
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
super(`git ${args.join(' ')} failed: ${detail}`)
|
|
45
|
-
this.name = 'GitError'
|
|
46
|
-
this.code = code
|
|
47
|
-
this.timedOut = meta.timedOut === true
|
|
48
|
-
this.bufferOverflow = meta.bufferOverflow === true
|
|
49
|
-
this.stdout = meta.stdout ?? ''
|
|
50
|
-
}
|
|
51
|
-
}
|
|
56
|
+
import { GitError } from '../domain/errors.js'
|
|
57
|
+
export { GitError }
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Run one git command with bounded output and no shell.
|
|
61
|
+
* @param {string} cwd
|
|
62
|
+
* @param {string[]} args
|
|
63
|
+
* @param {GitOptions} [options]
|
|
64
|
+
* @returns {Promise<string>}
|
|
65
|
+
*/
|
|
53
66
|
export function git(cwd, args, options = {}) {
|
|
54
67
|
return new Promise((resolve, reject) => {
|
|
55
68
|
execFile(
|
|
@@ -82,6 +95,13 @@ export function git(cwd, args, options = {}) {
|
|
|
82
95
|
})
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Run git when its failure is not an error (existence probes).
|
|
100
|
+
* @param {string} cwd
|
|
101
|
+
* @param {string[]} args
|
|
102
|
+
* @param {GitOptions} [options]
|
|
103
|
+
* @returns {Promise<string | null>}
|
|
104
|
+
*/
|
|
85
105
|
export async function tryGit(cwd, args, options) {
|
|
86
106
|
try {
|
|
87
107
|
return await git(cwd, args, options)
|
|
@@ -93,17 +113,29 @@ export async function tryGit(cwd, args, options) {
|
|
|
93
113
|
/**
|
|
94
114
|
* Resolve an absolute caller path to the repository root, its common git dir
|
|
95
115
|
* (shared by all worktrees), and the current branch of that checkout.
|
|
116
|
+
* @param {string} repoRoot
|
|
117
|
+
* @returns {Promise<{ root: string, commonDir: string, branch: string | null }>}
|
|
96
118
|
*/
|
|
97
119
|
export async function resolveRepo(repoRoot) {
|
|
98
120
|
if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
|
|
99
|
-
throw new
|
|
121
|
+
throw new TowerToolError('repo-invalid', 'repoRoot is required')
|
|
100
122
|
}
|
|
101
123
|
if (!path.isAbsolute(repoRoot)) {
|
|
102
|
-
throw new
|
|
124
|
+
throw new TowerToolError('repo-invalid', `repoRoot must be an absolute path: ${repoRoot}`)
|
|
125
|
+
}
|
|
126
|
+
let top
|
|
127
|
+
try {
|
|
128
|
+
top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new TowerToolError('repo-invalid', `not a git repository: ${repoRoot}`, { cause: error })
|
|
103
131
|
}
|
|
104
|
-
const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
|
|
105
132
|
const root = path.resolve(top)
|
|
106
|
-
|
|
133
|
+
let commonRaw
|
|
134
|
+
try {
|
|
135
|
+
commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new TowerToolError('repo-invalid', `not a git repository: ${root}`, { cause: error })
|
|
138
|
+
}
|
|
107
139
|
const commonDir = path.isAbsolute(commonRaw)
|
|
108
140
|
? path.resolve(commonRaw)
|
|
109
141
|
: path.resolve(root, commonRaw)
|
|
@@ -115,25 +147,30 @@ export async function resolveRepo(repoRoot) {
|
|
|
115
147
|
}
|
|
116
148
|
}
|
|
117
149
|
|
|
150
|
+
/** @param {string} root @returns {Promise<boolean>} */
|
|
118
151
|
export async function hasAnyCommit(root) {
|
|
119
152
|
const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
|
|
120
153
|
return Boolean(out && out.trim())
|
|
121
154
|
}
|
|
122
155
|
|
|
156
|
+
/** @param {string} root @param {string} branch @returns {Promise<boolean>} */
|
|
123
157
|
export async function localBranchExists(root, branch) {
|
|
124
158
|
const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
|
|
125
159
|
return out !== null
|
|
126
160
|
}
|
|
127
161
|
|
|
162
|
+
/** @param {string} cwd @param {string} ref @returns {Promise<string>} */
|
|
128
163
|
export async function revParse(cwd, ref) {
|
|
129
164
|
const out = await git(cwd, ['rev-parse', ref])
|
|
130
165
|
return out.trim()
|
|
131
166
|
}
|
|
132
167
|
|
|
168
|
+
/** @param {string} cwd @param {string} branch @returns {Promise<boolean>} */
|
|
133
169
|
export async function branchExists(cwd, branch) {
|
|
134
170
|
return (await localBranchExists(cwd, branch)) === true
|
|
135
171
|
}
|
|
136
172
|
|
|
173
|
+
/** @param {string} cwd @returns {Promise<string | null>} */
|
|
137
174
|
export async function currentBranch(cwd) {
|
|
138
175
|
const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
|
|
139
176
|
return out && out.trim() ? out.trim() : null
|
|
@@ -144,9 +181,12 @@ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
|
|
|
144
181
|
return worktreePath
|
|
145
182
|
}
|
|
146
183
|
|
|
184
|
+
/** @param {string} cwd @returns {Promise<WorktreeEntry[]>} */
|
|
147
185
|
export async function listWorktrees(cwd) {
|
|
148
186
|
const out = await git(cwd, ['worktree', 'list', '--porcelain'])
|
|
187
|
+
/** @type {WorktreeEntry[]} */
|
|
149
188
|
const entries = []
|
|
189
|
+
/** @type {WorktreeEntry | null} */
|
|
150
190
|
let current = null
|
|
151
191
|
for (const line of out.split('\n')) {
|
|
152
192
|
if (line.startsWith('worktree ')) {
|
|
@@ -162,22 +202,26 @@ export async function listWorktrees(cwd) {
|
|
|
162
202
|
return entries
|
|
163
203
|
}
|
|
164
204
|
|
|
205
|
+
/** @param {string} cwd @returns {Promise<boolean>} */
|
|
165
206
|
export async function isClean(cwd) {
|
|
166
207
|
const out = await git(cwd, ['status', '--porcelain'])
|
|
167
208
|
return out.trim().length === 0
|
|
168
209
|
}
|
|
169
210
|
|
|
211
|
+
/** @param {string} cwd @returns {Promise<string | null>} */
|
|
170
212
|
export async function currentCommonDir(cwd) {
|
|
171
213
|
const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
|
|
172
214
|
if (raw === null) return null
|
|
173
215
|
return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
|
|
174
216
|
}
|
|
175
217
|
|
|
218
|
+
/** @param {string} cwd @param {string} fromRef @returns {Promise<number>} */
|
|
176
219
|
export async function countCommits(cwd, fromRef) {
|
|
177
220
|
const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
|
|
178
221
|
return Number(out.trim() || 0)
|
|
179
222
|
}
|
|
180
223
|
|
|
224
|
+
/** @param {string} cwd @param {string} ancestor @param {string} descendant @returns {Promise<boolean>} */
|
|
181
225
|
export async function isAncestor(cwd, ancestor, descendant) {
|
|
182
226
|
const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
|
|
183
227
|
return out !== null
|
package/lib/hmr.js
CHANGED
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
// `restartReason` means the whole process must be restarted.
|
|
19
19
|
// - `runExclusive` rejects nested transactions; a tool call is never inside
|
|
20
20
|
// one, so a tool-triggered reload is safe.
|
|
21
|
-
import { TowerToolError } from '
|
|
21
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
22
22
|
|
|
23
23
|
/** Entry ids Tower expects to be active after a reconciliation. */
|
|
24
24
|
export const DEFAULT_REQUIRED_IDS = Object.freeze([
|
|
25
|
+
'tower-bundle',
|
|
25
26
|
'tower',
|
|
26
27
|
'tower-skills',
|
|
27
28
|
'preset-tower-worker',
|
|
@@ -94,7 +95,7 @@ function warn(logger, message) {
|
|
|
94
95
|
* @param {string[]} [options.requiredIds] Entry ids whose inactivity is reported as a failure.
|
|
95
96
|
* @param {number} [options.delayMs] Delay before a scheduled reload runs.
|
|
96
97
|
* @param {{ warn?: Function }} [options.logger] Optional logger for failed scheduled reloads.
|
|
97
|
-
* @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function }}
|
|
98
|
+
* @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function, dispose: Function }}
|
|
98
99
|
*/
|
|
99
100
|
export function createHmrBridge(ctx, options = {}) {
|
|
100
101
|
const requiredIds = Object.freeze(normalizeRequiredIds(options.requiredIds))
|
|
@@ -186,7 +187,7 @@ export function createHmrBridge(ctx, options = {}) {
|
|
|
186
187
|
get available() {
|
|
187
188
|
return available()
|
|
188
189
|
},
|
|
189
|
-
requiredIds,
|
|
190
|
+
requiredIds: [...requiredIds],
|
|
190
191
|
delayMs,
|
|
191
192
|
status,
|
|
192
193
|
schedule,
|
package/lib/leases.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Fail-closed protocol: a held, expired, or token-mismatched lease is reported,
|
|
12
12
|
// never silently taken over. Only the token holder may release a lease, and
|
|
13
13
|
// releasing an expired lease requires an explicit `force: true`.
|
|
14
|
-
import fsp from '
|
|
14
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import crypto from 'node:crypto'
|
|
17
17
|
|
|
@@ -28,6 +28,7 @@ const LOCK_POLL_MS = 20
|
|
|
28
28
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
29
29
|
|
|
30
30
|
function fail(code, message) {
|
|
31
|
+
/** @type {Error & {code?: string}} */
|
|
31
32
|
const error = new Error(message)
|
|
32
33
|
error.code = code
|
|
33
34
|
throw error
|
|
@@ -147,7 +148,7 @@ async function lockIsStale(lockFile) {
|
|
|
147
148
|
} catch {
|
|
148
149
|
at = null
|
|
149
150
|
}
|
|
150
|
-
const age = Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
|
|
151
|
+
const age = at !== null && Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
|
|
151
152
|
return age > LOCK_STALE_MS
|
|
152
153
|
}
|
|
153
154
|
|
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
|
|
@@ -58,6 +59,10 @@ async function realpathOrResolve(target) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} error
|
|
64
|
+
* @param {'tower-codex'|'dsh'|null} [format]
|
|
65
|
+
*/
|
|
61
66
|
function invalidResult(error, format = null) {
|
|
62
67
|
return {
|
|
63
68
|
ok: false,
|
|
@@ -121,6 +126,11 @@ function validateReviews(reviews, id) {
|
|
|
121
126
|
return null
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/**
|
|
130
|
+
* @param {any} payload
|
|
131
|
+
* @param {'tower-codex'|'dsh'} format
|
|
132
|
+
* @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[]}}
|
|
133
|
+
*/
|
|
124
134
|
function validateMissions(payload, format) {
|
|
125
135
|
const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
|
|
126
136
|
const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
|
|
@@ -130,17 +140,17 @@ function validateMissions(payload, format) {
|
|
|
130
140
|
const seen = new Set()
|
|
131
141
|
for (let index = 0; index < payload.missions.length; index += 1) {
|
|
132
142
|
const mission = payload.missions[index]
|
|
133
|
-
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object
|
|
143
|
+
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
|
|
134
144
|
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#'})
|
|
145
|
+
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})`, missions: [], missionIds: [], paused: [] }
|
|
136
146
|
}
|
|
137
|
-
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}
|
|
147
|
+
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
|
|
138
148
|
seen.add(mission.id)
|
|
139
149
|
if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
|
|
140
|
-
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}
|
|
150
|
+
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
|
|
141
151
|
}
|
|
142
152
|
if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
|
|
143
|
-
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}
|
|
153
|
+
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [] }
|
|
144
154
|
}
|
|
145
155
|
for (const problem of [
|
|
146
156
|
validateScope(mission.scope, mission.id),
|
|
@@ -148,7 +158,7 @@ function validateMissions(payload, format) {
|
|
|
148
158
|
validateDeps(mission.deps, mission.id),
|
|
149
159
|
validateReviews(mission.reviews, mission.id),
|
|
150
160
|
]) {
|
|
151
|
-
if (problem) return { error: problem }
|
|
161
|
+
if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
|
|
152
162
|
}
|
|
153
163
|
missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
|
|
154
164
|
missionIds.push(mission.id)
|
|
@@ -180,7 +190,8 @@ export function validateLegacySnapshot(payload) {
|
|
|
180
190
|
}
|
|
181
191
|
if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
|
|
182
192
|
|
|
183
|
-
|
|
193
|
+
/** @type {'tower-codex'|'dsh'} */
|
|
194
|
+
let format = 'dsh'
|
|
184
195
|
if (payload.missions.length === 0) {
|
|
185
196
|
format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
|
|
186
197
|
} else {
|
|
@@ -236,7 +247,7 @@ export function validateLegacySnapshot(payload) {
|
|
|
236
247
|
* same bytes; it never writes state.json, never activates/adopts a mission, and
|
|
237
248
|
* `paused` stays paused in the returned summary.
|
|
238
249
|
*
|
|
239
|
-
* @returns {{summary:object, snapshot:object}} where `snapshot` is the record
|
|
250
|
+
* @returns {Promise<{summary:object, snapshot:object}>} where `snapshot` is the record
|
|
240
251
|
* the caller may append to `state.legacySnapshots`.
|
|
241
252
|
*/
|
|
242
253
|
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
|