dsh-tower 2.0.3 → 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 +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- 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 +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- 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 +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- 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 +35 -33
- 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 +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -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 +44 -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/skills/tower-recovery.md +7 -5
- 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/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/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
package/lib/git.js
CHANGED
|
@@ -1,21 +1,68 @@
|
|
|
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
|
|
8
|
+
let configuredTimeoutMs = GIT_TIMEOUT_MS
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
/** @typedef {{ timeoutMs?: number, maxBuffer?: number }} GitOptions */
|
|
11
|
+
/** @typedef {{ path: string, branch?: string, head?: string }} WorktreeEntry */
|
|
12
|
+
|
|
13
|
+
/** Override the default Git timeout from resolved Tower config. */
|
|
14
|
+
export function setGitTimeout(ms) {
|
|
15
|
+
const value = Number(ms)
|
|
16
|
+
if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Process default retained for manual adapter use; Tower instances pass their own. */
|
|
20
|
+
export function configuredGitTimeout() {
|
|
21
|
+
return configuredTimeoutMs
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BLOCKED_GIT_ENV = new Set([
|
|
25
|
+
'GIT_DIR',
|
|
26
|
+
'GIT_WORK_TREE',
|
|
27
|
+
'GIT_INDEX_FILE',
|
|
28
|
+
'GIT_OBJECT_DIRECTORY',
|
|
29
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
30
|
+
'GIT_COMMON_DIR',
|
|
31
|
+
'GIT_NAMESPACE',
|
|
32
|
+
'GIT_PREFIX',
|
|
33
|
+
'GIT_CEILING_DIRECTORIES',
|
|
34
|
+
'GIT_CONFIG_GLOBAL',
|
|
35
|
+
'GIT_CONFIG_SYSTEM',
|
|
36
|
+
'GIT_CONFIG_NOSYSTEM',
|
|
37
|
+
'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
38
|
+
'GIT_QUARANTINE_PATH',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Git must not inherit a repository redirect from the DSH process environment.
|
|
43
|
+
* @param {NodeJS.ProcessEnv} [source]
|
|
44
|
+
* @returns {NodeJS.ProcessEnv}
|
|
45
|
+
*/
|
|
46
|
+
export function gitEnv(source = process.env) {
|
|
47
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
48
|
+
const env = {}
|
|
49
|
+
for (const [key, value] of Object.entries(source)) {
|
|
50
|
+
if (BLOCKED_GIT_ENV.has(key)) continue
|
|
51
|
+
env[key] = value
|
|
16
52
|
}
|
|
53
|
+
return env
|
|
17
54
|
}
|
|
18
55
|
|
|
56
|
+
import { GitError } from '../domain/errors.js'
|
|
57
|
+
export { GitError }
|
|
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
|
+
*/
|
|
19
66
|
export function git(cwd, args, options = {}) {
|
|
20
67
|
return new Promise((resolve, reject) => {
|
|
21
68
|
execFile(
|
|
@@ -23,10 +70,10 @@ export function git(cwd, args, options = {}) {
|
|
|
23
70
|
args,
|
|
24
71
|
{
|
|
25
72
|
cwd,
|
|
26
|
-
timeout: options.timeoutMs ??
|
|
73
|
+
timeout: options.timeoutMs ?? configuredTimeoutMs,
|
|
27
74
|
maxBuffer: options.maxBuffer ?? MAX_BUFFER,
|
|
28
75
|
windowsHide: true,
|
|
29
|
-
env:
|
|
76
|
+
env: gitEnv(),
|
|
30
77
|
},
|
|
31
78
|
(error, stdout, stderr) => {
|
|
32
79
|
if (error) {
|
|
@@ -48,6 +95,13 @@ export function git(cwd, args, options = {}) {
|
|
|
48
95
|
})
|
|
49
96
|
}
|
|
50
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
|
+
*/
|
|
51
105
|
export async function tryGit(cwd, args, options) {
|
|
52
106
|
try {
|
|
53
107
|
return await git(cwd, args, options)
|
|
@@ -59,17 +113,29 @@ export async function tryGit(cwd, args, options) {
|
|
|
59
113
|
/**
|
|
60
114
|
* Resolve an absolute caller path to the repository root, its common git dir
|
|
61
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 }>}
|
|
62
118
|
*/
|
|
63
119
|
export async function resolveRepo(repoRoot) {
|
|
64
120
|
if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
|
|
65
|
-
throw new
|
|
121
|
+
throw new TowerToolError('repo-invalid', 'repoRoot is required')
|
|
66
122
|
}
|
|
67
123
|
if (!path.isAbsolute(repoRoot)) {
|
|
68
|
-
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 })
|
|
69
131
|
}
|
|
70
|
-
const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
|
|
71
132
|
const root = path.resolve(top)
|
|
72
|
-
|
|
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
|
+
}
|
|
73
139
|
const commonDir = path.isAbsolute(commonRaw)
|
|
74
140
|
? path.resolve(commonRaw)
|
|
75
141
|
: path.resolve(root, commonRaw)
|
|
@@ -81,25 +147,30 @@ export async function resolveRepo(repoRoot) {
|
|
|
81
147
|
}
|
|
82
148
|
}
|
|
83
149
|
|
|
150
|
+
/** @param {string} root @returns {Promise<boolean>} */
|
|
84
151
|
export async function hasAnyCommit(root) {
|
|
85
152
|
const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
|
|
86
153
|
return Boolean(out && out.trim())
|
|
87
154
|
}
|
|
88
155
|
|
|
156
|
+
/** @param {string} root @param {string} branch @returns {Promise<boolean>} */
|
|
89
157
|
export async function localBranchExists(root, branch) {
|
|
90
158
|
const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
|
|
91
159
|
return out !== null
|
|
92
160
|
}
|
|
93
161
|
|
|
162
|
+
/** @param {string} cwd @param {string} ref @returns {Promise<string>} */
|
|
94
163
|
export async function revParse(cwd, ref) {
|
|
95
164
|
const out = await git(cwd, ['rev-parse', ref])
|
|
96
165
|
return out.trim()
|
|
97
166
|
}
|
|
98
167
|
|
|
168
|
+
/** @param {string} cwd @param {string} branch @returns {Promise<boolean>} */
|
|
99
169
|
export async function branchExists(cwd, branch) {
|
|
100
170
|
return (await localBranchExists(cwd, branch)) === true
|
|
101
171
|
}
|
|
102
172
|
|
|
173
|
+
/** @param {string} cwd @returns {Promise<string | null>} */
|
|
103
174
|
export async function currentBranch(cwd) {
|
|
104
175
|
const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
|
|
105
176
|
return out && out.trim() ? out.trim() : null
|
|
@@ -110,9 +181,12 @@ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
|
|
|
110
181
|
return worktreePath
|
|
111
182
|
}
|
|
112
183
|
|
|
184
|
+
/** @param {string} cwd @returns {Promise<WorktreeEntry[]>} */
|
|
113
185
|
export async function listWorktrees(cwd) {
|
|
114
186
|
const out = await git(cwd, ['worktree', 'list', '--porcelain'])
|
|
187
|
+
/** @type {WorktreeEntry[]} */
|
|
115
188
|
const entries = []
|
|
189
|
+
/** @type {WorktreeEntry | null} */
|
|
116
190
|
let current = null
|
|
117
191
|
for (const line of out.split('\n')) {
|
|
118
192
|
if (line.startsWith('worktree ')) {
|
|
@@ -128,22 +202,26 @@ export async function listWorktrees(cwd) {
|
|
|
128
202
|
return entries
|
|
129
203
|
}
|
|
130
204
|
|
|
205
|
+
/** @param {string} cwd @returns {Promise<boolean>} */
|
|
131
206
|
export async function isClean(cwd) {
|
|
132
207
|
const out = await git(cwd, ['status', '--porcelain'])
|
|
133
208
|
return out.trim().length === 0
|
|
134
209
|
}
|
|
135
210
|
|
|
211
|
+
/** @param {string} cwd @returns {Promise<string | null>} */
|
|
136
212
|
export async function currentCommonDir(cwd) {
|
|
137
213
|
const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
|
|
138
214
|
if (raw === null) return null
|
|
139
215
|
return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
|
|
140
216
|
}
|
|
141
217
|
|
|
218
|
+
/** @param {string} cwd @param {string} fromRef @returns {Promise<number>} */
|
|
142
219
|
export async function countCommits(cwd, fromRef) {
|
|
143
220
|
const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
|
|
144
221
|
return Number(out.trim() || 0)
|
|
145
222
|
}
|
|
146
223
|
|
|
224
|
+
/** @param {string} cwd @param {string} ancestor @param {string} descendant @returns {Promise<boolean>} */
|
|
147
225
|
export async function isAncestor(cwd, ancestor, descendant) {
|
|
148
226
|
const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
|
|
149
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
|
}
|
|
@@ -62,33 +55,42 @@ export function loadModes(baseDir) {
|
|
|
62
55
|
return modes
|
|
63
56
|
}
|
|
64
57
|
|
|
65
|
-
/** Persist the current modes atomically
|
|
66
|
-
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
58
|
+
/** Persist the current modes atomically with a short cross-process lock. */
|
|
59
|
+
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES, options = {}) {
|
|
60
|
+
const dir = storeDir(baseDir)
|
|
61
|
+
const file = storeFile(baseDir)
|
|
62
|
+
const lock = path.join(dir, 'modes.lock')
|
|
63
|
+
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 5000
|
|
67
64
|
try {
|
|
68
|
-
const dir = storeDir(baseDir)
|
|
69
65
|
fs.mkdirSync(dir, { recursive: true })
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
|
|
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) {
|
|
66
|
+
const held = acquireLockSync(lock, { timeoutMs, invalidPidIsDead: true })
|
|
67
|
+
if (!held) return false
|
|
68
|
+
try {
|
|
69
|
+
const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
|
|
70
|
+
let disk = {}
|
|
80
71
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} catch
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
72
|
+
const parsed = readJsonSync(file)
|
|
73
|
+
if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
|
|
74
|
+
} catch {
|
|
75
|
+
disk = {}
|
|
76
|
+
}
|
|
77
|
+
// Merge by timestamp so two sessions saving concurrently do not erase
|
|
78
|
+
// each other's mode changes. A disk-only entry from another process is
|
|
79
|
+
// preserved unless this process has a newer record for the same key.
|
|
80
|
+
const merged = { ...disk }
|
|
81
|
+
for (const [key, value] of modes.entries()) {
|
|
82
|
+
const previous = merged[key]
|
|
83
|
+
if (!previous || (Number(value?.at) || 0) >= (Number(previous?.at) || 0)) merged[key] = value
|
|
87
84
|
}
|
|
85
|
+
const entries = Object.entries(merged)
|
|
86
|
+
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
87
|
+
.slice(0, limit)
|
|
88
|
+
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
89
|
+
writeJsonAtomicSync(file, payload)
|
|
90
|
+
return true
|
|
91
|
+
} finally {
|
|
92
|
+
held.release()
|
|
88
93
|
}
|
|
89
|
-
try { fs.unlinkSync(tmp) } catch {}
|
|
90
|
-
if (lastError) return false
|
|
91
|
-
return true
|
|
92
94
|
} catch {
|
|
93
95
|
return false
|
|
94
96
|
}
|