dsh-tower 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +78 -5
- package/PRINCIPLES.md +163 -0
- package/README.en.md +35 -19
- package/README.md +28 -18
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/cordis.patch.yml +56 -28
- package/docs/README.md +7 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +70 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +52 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +192 -43
- package/docs/guide/hot-reload.md +5 -5
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/operations/security.md +1 -1
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -9
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +53 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +157 -0
- package/docs/reference/tools.generated.md +8 -5
- package/docs/reference/tools.md +10 -7
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +4 -4
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +10 -8
- package/domain/batch.js +2 -2
- package/domain/components.js +641 -0
- package/domain/config/schema.js +62 -28
- package/domain/contracts/outputs.js +16 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +308 -43
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +12 -1
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +347 -0
- package/host/capability-batch.js +15 -0
- package/host/capability-hmr.js +30 -0
- package/host/capability-import.js +11 -0
- package/host/capability-leases.js +11 -0
- package/host/capability-registry.js +149 -0
- package/host/capability-tests.js +16 -0
- package/{lib → host}/doctor.js +157 -18
- package/{lib → host}/hmr.js +2 -4
- package/{lib → host}/modeStore.js +1 -1
- package/host/presets.js +77 -0
- package/{lib → host}/tool-registry.js +235 -44
- package/host/tower.js +32 -0
- package/index.js +40 -25
- package/locale/en.json +31 -11
- package/locale/zh.json +30 -10
- package/package.json +24 -4
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-components.mjs +235 -0
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-component-surface.mjs +59 -0
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +18 -25
- package/scripts/generate-types.mjs +127 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/lib/tool-surface.mjs +85 -0
- package/scripts/mount-smoke/fixtures.mjs +3 -3
- package/scripts/mount-smoke/surface.mjs +96 -27
- package/scripts/mount-smoke.mjs +3 -1
- package/scripts/pack-manifest.txt +75 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +52 -0
- package/scripts/surface-baseline.mjs +118 -0
- package/scripts/types-coverage.mjs +184 -0
- package/scripts/types-strict-manifest.txt +10 -0
- package/scripts/types-strict.mjs +141 -0
- package/skills.js +8 -1
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +9 -8
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +36 -40
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +11 -4
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +29 -13
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/presets.js +0 -35
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
package/{lib → host}/doctor.js
RENAMED
|
@@ -2,19 +2,34 @@
|
|
|
2
2
|
import { execFile } from '../adapters/vcs-git.js'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
5
6
|
import { fsp } from '../adapters/store-fs.js'
|
|
6
|
-
import { git } from '
|
|
7
|
-
import { readStateLock } from '
|
|
8
|
-
import {
|
|
7
|
+
import { git } from '../adapters/git.js'
|
|
8
|
+
import { readStateLock, readGitLock, gitLockView } from '../adapters/store.js'
|
|
9
|
+
import { lockTiming } from '../adapters/persistence.js'
|
|
10
|
+
import { inspectArchive } from '../adapters/archive.js'
|
|
9
11
|
import { inspectStateVersion } from '../domain/state.js'
|
|
10
12
|
import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
11
13
|
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
12
14
|
import { comparisonMode } from '../domain/scope.js'
|
|
15
|
+
import { configSurface } from '../domain/config/schema.js'
|
|
16
|
+
import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
|
|
13
17
|
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
14
18
|
import { profileConfig } from './config.js'
|
|
15
|
-
import { inspectWorkspaceLock } from '
|
|
19
|
+
import { inspectWorkspaceLock } from '../adapters/workspaceLock.js'
|
|
20
|
+
import { componentReport, componentSummary } from './capability-registry.js'
|
|
16
21
|
|
|
17
22
|
const execFileAsync = promisify(execFile)
|
|
23
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
|
|
25
|
+
/** Read the committed concept-budget baseline read-only; null when absent. */
|
|
26
|
+
async function readSurfaceBaseline() {
|
|
27
|
+
try {
|
|
28
|
+
return parseBaseline(await fsp.readFile(path.join(PACKAGE_ROOT, SURFACE_BASELINE_FILE), 'utf8'))
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
18
33
|
|
|
19
34
|
async function exists(target) {
|
|
20
35
|
try {
|
|
@@ -38,6 +53,12 @@ function check(id, status, detail, action) {
|
|
|
38
53
|
return { id, status, detail, ...(action ? { action } : {}) }
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
/** Render a lock age the same way the state-lock checker does. */
|
|
57
|
+
function durationText(ms) {
|
|
58
|
+
if (!Number.isFinite(ms)) return 'an unknown duration'
|
|
59
|
+
return `${Math.max(0, Math.round(ms / 1000))} s`
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
/** Read the on-disk state version without loading, migrating, or locking. */
|
|
42
63
|
async function inspectStateSchema(paths) {
|
|
43
64
|
try {
|
|
@@ -116,9 +137,10 @@ function stateSchemaReport(info) {
|
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
/** Run every read-only diagnostic; never writes state or takes the state lock. */
|
|
119
|
-
export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
|
|
140
|
+
export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows, surface }) {
|
|
120
141
|
const checks = []
|
|
121
142
|
let reportsReport = null
|
|
143
|
+
let stateLockReport = null
|
|
122
144
|
const version = await gitVersion()
|
|
123
145
|
checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
|
|
124
146
|
|
|
@@ -167,6 +189,19 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
167
189
|
}
|
|
168
190
|
|
|
169
191
|
checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
|
|
192
|
+
// The repo-scoped git mutex can legally be held for a whole merge (15 min by
|
|
193
|
+
// default). Report the holder so a stalled tower has a visible cause (#93).
|
|
194
|
+
const gitLock = gitLockView(await readGitLock(tower.paths))
|
|
195
|
+
checks.push(
|
|
196
|
+
gitLock.held
|
|
197
|
+
? check(
|
|
198
|
+
'git-lock',
|
|
199
|
+
'warn',
|
|
200
|
+
`held by pid ${gitLock.pid ?? 'unknown'}${gitLock.phase ? ` (${gitLock.phase})` : ''} for ${durationText(gitLock.heldMs)}; the repo-scoped git mutex is busy`,
|
|
201
|
+
'wait for the git mutation to finish; tower_status reports the same holder',
|
|
202
|
+
)
|
|
203
|
+
: check('git-lock', 'pass', 'free'),
|
|
204
|
+
)
|
|
170
205
|
const schemaInfo = await inspectStateSchema(tower.paths)
|
|
171
206
|
let state = null
|
|
172
207
|
try {
|
|
@@ -226,17 +261,59 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
226
261
|
'optional: add <git-common-dir>/dsh-tower/config.json',
|
|
227
262
|
),
|
|
228
263
|
)
|
|
264
|
+
// #84: report the concept-budget surface instead of implying it. The counts
|
|
265
|
+
// are the live schema/tool surface; the date is the committed baseline, so a
|
|
266
|
+
// reader sees both the number and the decision it was frozen at.
|
|
267
|
+
const configCounts = configSurface()
|
|
268
|
+
const baseline = await readSurfaceBaseline()
|
|
269
|
+
const toolCount = Number.isInteger(surface?.tools) ? surface.tools : null
|
|
270
|
+
const surfaceCounts = {
|
|
271
|
+
configKeys: configCounts.count,
|
|
272
|
+
sections: configCounts.sections.length,
|
|
273
|
+
tools: toolCount,
|
|
274
|
+
}
|
|
275
|
+
const surfaceReport = {
|
|
276
|
+
...surfaceCounts,
|
|
277
|
+
baselineDate: baseline?.date ?? null,
|
|
278
|
+
line: surfaceLine(surfaceCounts),
|
|
279
|
+
}
|
|
280
|
+
checks.push(
|
|
281
|
+
toolCount === null
|
|
282
|
+
? check('surface', 'warn', `${surfaceReport.line} (tool count unavailable; pass the registered surface)`)
|
|
283
|
+
: check(
|
|
284
|
+
'surface',
|
|
285
|
+
'pass',
|
|
286
|
+
`${surfaceReport.line}${baseline?.date ? ` (baseline ${baseline.date})` : ''}`,
|
|
287
|
+
baseline?.date ? undefined : `commit ${SURFACE_BASELINE_FILE} so growth is compared against a decision`,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
229
290
|
const presets = ctx.get?.('agentPresets')
|
|
291
|
+
const reviewerRequired = config.review?.requireReview === true
|
|
230
292
|
if (presets && typeof presets.list === 'function') {
|
|
231
293
|
try {
|
|
232
294
|
const available = (await presets.list()).map((entry) => String(entry.id))
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
295
|
+
const worker = config.spawn?.workerPreset ?? 'tower-worker'
|
|
296
|
+
const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
|
|
297
|
+
const required = reviewerRequired ? [worker, reviewer] : [worker]
|
|
298
|
+
const missing = required.filter((id) => !available.includes(id))
|
|
299
|
+
// #121: `preset-tower-reviewer` is a conditional component. Its off state
|
|
300
|
+
// is supported while review.requireReview is false; with the gate on it is
|
|
301
|
+
// a fail, because no independent review can be dispatched.
|
|
302
|
+
if (missing.length === 0) {
|
|
303
|
+
const reviewerNote = available.includes(reviewer)
|
|
304
|
+
? `reviewer=${reviewer}`
|
|
305
|
+
: `reviewer=${reviewer} (disabled by component preset-tower-reviewer)`
|
|
306
|
+
checks.push(check('presets', 'pass', `worker=${worker} ${reviewerNote}`))
|
|
307
|
+
} else {
|
|
308
|
+
checks.push(
|
|
309
|
+
check(
|
|
310
|
+
'presets',
|
|
311
|
+
'fail',
|
|
312
|
+
`missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.requireReview is true, so preset-tower-reviewer may not be off)' : ''}`,
|
|
313
|
+
'enable the Tower preset component and retry',
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
}
|
|
240
317
|
} catch (error) {
|
|
241
318
|
checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
|
|
242
319
|
}
|
|
@@ -244,6 +321,16 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
244
321
|
checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
|
|
245
322
|
}
|
|
246
323
|
|
|
324
|
+
// #121: the optional component rows are a diagnostic fact. A disabled row is
|
|
325
|
+
// reported with the exact "disabled by component <id>" reason rather than
|
|
326
|
+
// disappearing silently from the tool surface.
|
|
327
|
+
const components = await componentReport(ctx)
|
|
328
|
+
checks.push(
|
|
329
|
+
components.disabled.length > 0
|
|
330
|
+
? check('components', 'warn', componentSummary(components), 'enable the reported row in the plugin page and restart DSH')
|
|
331
|
+
: check('components', 'pass', componentSummary(components)),
|
|
332
|
+
)
|
|
333
|
+
|
|
247
334
|
const subagents = ctx.get?.('subagents')
|
|
248
335
|
checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
|
|
249
336
|
const hmr = ctx.get?.('hmr')
|
|
@@ -251,12 +338,45 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
251
338
|
|
|
252
339
|
if (state) {
|
|
253
340
|
const lock = await readStateLock(tower.paths)
|
|
341
|
+
const timing = lockTiming(lock.record)
|
|
342
|
+
stateLockReport = {
|
|
343
|
+
held: Boolean(lock.record),
|
|
344
|
+
pid: lock.record?.pid ?? null,
|
|
345
|
+
startedAt: lock.record?.startedAt ?? null,
|
|
346
|
+
at: lock.record?.at ?? null,
|
|
347
|
+
alive: lock.alive,
|
|
348
|
+
ageMs: timing.ageMs,
|
|
349
|
+
heartbeatAgeMs: timing.heartbeatAgeMs,
|
|
350
|
+
staleAfterMs: timing.staleAfterMs,
|
|
351
|
+
stale: timing.stale,
|
|
352
|
+
}
|
|
353
|
+
// The PID alone cannot distinguish a live writer from a recycled PID
|
|
354
|
+
// (#92); report the heartbeat age so a stuck tower is diagnosable without
|
|
355
|
+
// reading the file.
|
|
356
|
+
const heartbeatText =
|
|
357
|
+
timing.heartbeatAgeMs === null
|
|
358
|
+
? 'no heartbeat timestamp'
|
|
359
|
+
: `last heartbeat ${timing.heartbeatAgeMs}ms ago (stale after ${timing.staleAfterMs}ms)`
|
|
254
360
|
checks.push(
|
|
255
361
|
!lock.record
|
|
256
362
|
? check('state-lock', 'pass', 'no state lock file')
|
|
257
363
|
: lock.alive === false
|
|
258
|
-
? check(
|
|
259
|
-
|
|
364
|
+
? check(
|
|
365
|
+
'state-lock',
|
|
366
|
+
'warn',
|
|
367
|
+
`stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone; ${heartbeatText}); the next mutation reclaims it atomically`,
|
|
368
|
+
)
|
|
369
|
+
: timing.stale
|
|
370
|
+
? check(
|
|
371
|
+
'state-lock',
|
|
372
|
+
'warn',
|
|
373
|
+
`state lock pid ${lock.record.pid ?? 'unknown'} is alive but silent (${heartbeatText}); the next mutation reclaims it after byte verification, or tower_recover clear-stale-lock force:true moves it to a backup`,
|
|
374
|
+
)
|
|
375
|
+
: check(
|
|
376
|
+
'state-lock',
|
|
377
|
+
'warn',
|
|
378
|
+
`state lock held by pid ${lock.record.pid ?? 'unknown'}; ${heartbeatText}; retry after it finishes`,
|
|
379
|
+
),
|
|
260
380
|
)
|
|
261
381
|
const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
|
|
262
382
|
try {
|
|
@@ -306,11 +426,15 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
306
426
|
)
|
|
307
427
|
|
|
308
428
|
const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
|
|
429
|
+
const testsDisabled =
|
|
430
|
+
config.tests?.enabled === false || components.disabled.some((entry) => entry.id === 'tower-tests')
|
|
309
431
|
checks.push(
|
|
310
432
|
check(
|
|
311
433
|
'tests',
|
|
312
|
-
|
|
313
|
-
|
|
434
|
+
testsDisabled ? 'warn' : 'pass',
|
|
435
|
+
testsDisabled
|
|
436
|
+
? `test discovery disabled${config.tests?.enabled === false ? ' (tests.enabled:false)' : ' (disabled by component tower-tests)'}`
|
|
437
|
+
: `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
|
|
314
438
|
),
|
|
315
439
|
)
|
|
316
440
|
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
@@ -342,7 +466,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
342
466
|
}
|
|
343
467
|
|
|
344
468
|
try {
|
|
345
|
-
const { inspectLeases } = await import('
|
|
469
|
+
const { inspectLeases } = await import('../adapters/leases.js')
|
|
346
470
|
const leases = await inspectLeases(tower.commonDir)
|
|
347
471
|
checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
|
|
348
472
|
} catch (error) {
|
|
@@ -359,7 +483,18 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
359
483
|
|
|
360
484
|
try {
|
|
361
485
|
const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
|
|
362
|
-
|
|
486
|
+
// These percentiles are about the state lock. Git-mutex holds are logged
|
|
487
|
+
// too (kind: 'git' / the pre-#93 phase: 'git' form) and can legitimately
|
|
488
|
+
// span a merge, so they must not be counted as slow state mutations.
|
|
489
|
+
const samples = text
|
|
490
|
+
.split('\n')
|
|
491
|
+
.filter(Boolean)
|
|
492
|
+
.slice(-200)
|
|
493
|
+
.map((line) => JSON.parse(line))
|
|
494
|
+
.filter((hold) => hold.kind !== 'git' && hold.phase !== 'git')
|
|
495
|
+
.map((hold) => hold.ms)
|
|
496
|
+
.filter((ms) => Number.isFinite(ms))
|
|
497
|
+
.sort((a, b) => a - b)
|
|
363
498
|
if (samples.length === 0) {
|
|
364
499
|
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
365
500
|
} else {
|
|
@@ -410,9 +545,13 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
410
545
|
return {
|
|
411
546
|
ok: failed === 0,
|
|
412
547
|
checks,
|
|
548
|
+
stateLock: stateLockReport,
|
|
549
|
+
gitLock,
|
|
413
550
|
stateSchema: schemaReport.stateSchema,
|
|
414
551
|
// reports (from #89) and archive (from #73) are both kept
|
|
415
552
|
reports: reportsReport,
|
|
553
|
+
surface: surfaceReport,
|
|
554
|
+
components,
|
|
416
555
|
archive: {
|
|
417
556
|
missions: state ? state.missions.length : null,
|
|
418
557
|
archivedMissions: archiveInventory.missions,
|
package/{lib → host}/hmr.js
RENAMED
|
@@ -20,13 +20,11 @@
|
|
|
20
20
|
// one, so a tool-triggered reload is safe.
|
|
21
21
|
import { TowerToolError } from '../domain/errors.js'
|
|
22
22
|
|
|
23
|
-
/** Entry ids Tower expects to be active after a reconciliation. */
|
|
23
|
+
/** Entry ids Tower expects to be active after a reconciliation (#121 rows). */
|
|
24
24
|
export const DEFAULT_REQUIRED_IDS = Object.freeze([
|
|
25
|
-
'tower-bundle',
|
|
26
25
|
'tower',
|
|
27
|
-
'tower-
|
|
26
|
+
'tower-host',
|
|
28
27
|
'preset-tower-worker',
|
|
29
|
-
'preset-tower-reviewer',
|
|
30
28
|
])
|
|
31
29
|
|
|
32
30
|
/** Debounce window for the dev hot-reload hook (milliseconds). */
|
|
@@ -6,7 +6,7 @@
|
|
|
6
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 '
|
|
9
|
+
import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from '../adapters/persistence.js'
|
|
10
10
|
|
|
11
11
|
const DEFAULT_MAX_ENTRIES = 200
|
|
12
12
|
|
package/host/presets.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Cross-field preset and component validation (#64, #121): fail before work
|
|
2
|
+
// starts, not at spawn.
|
|
3
|
+
//
|
|
4
|
+
// Two invariants travel with the optional rows:
|
|
5
|
+
//
|
|
6
|
+
// 1. `preset-tower-worker` is a mandatory dependency: planning, mode entry and
|
|
7
|
+
// spawn must refuse when the worker preset is absent.
|
|
8
|
+
// 2. `preset-tower-reviewer` is a *conditional* component. It may be off while
|
|
9
|
+
// `review.requireReview` is false (merge is unaffected); with the review
|
|
10
|
+
// gate on it must be locked with an explanatory error, never silently
|
|
11
|
+
// allowed.
|
|
12
|
+
//
|
|
13
|
+
// `tower-tests` adds a third: with the component row off there is no
|
|
14
|
+
// `tower_test` tool and no receipt, so `gates.requireTests: true` is refused
|
|
15
|
+
// here rather than left to fail at merge time with a misleading blocker.
|
|
16
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
17
|
+
import { componentEnabled, disabledReason } from './capability-registry.js'
|
|
18
|
+
|
|
19
|
+
export function presetIds(config = {}) {
|
|
20
|
+
return {
|
|
21
|
+
worker: String(config.spawn?.workerPreset ?? 'tower-worker'),
|
|
22
|
+
reviewer: String(config.spawn?.reviewerPreset ?? 'tower-reviewer'),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rowId(preset) {
|
|
27
|
+
return preset === 'tower-worker' ? 'preset-tower-worker' : preset === 'tower-reviewer' ? 'preset-tower-reviewer' : preset
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the presets against the profile's agentPresets service. Fail closed
|
|
32
|
+
* with preset-missing before mode entry/planning when a *required* preset is not
|
|
33
|
+
* registered. If the service is unavailable, doctor reports that separately and
|
|
34
|
+
* this check cannot make a claim either way.
|
|
35
|
+
*
|
|
36
|
+
* The reviewer preset is required only when `review.requireReview` is true; the
|
|
37
|
+
* off state is otherwise supported by the component contract.
|
|
38
|
+
*/
|
|
39
|
+
export async function assertPresetsAvailable(ctx, config = {}) {
|
|
40
|
+
const service = ctx?.get?.('agentPresets')
|
|
41
|
+
if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
|
|
42
|
+
const presets = presetIds(config)
|
|
43
|
+
const required = [presets.worker]
|
|
44
|
+
if (config.review?.requireReview === true) required.push(presets.reviewer)
|
|
45
|
+
const available = new Set((await service.list()).map((entry) => String(entry.id)))
|
|
46
|
+
const missing = required.filter((id) => !available.has(id))
|
|
47
|
+
if (missing.length === 0) return { checked: true, missing: [] }
|
|
48
|
+
const rows = [...new Set(missing.map(rowId))]
|
|
49
|
+
const reviewLock =
|
|
50
|
+
config.review?.requireReview === true && missing.includes(presets.reviewer)
|
|
51
|
+
? ` review.requireReview is true, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
|
|
52
|
+
: ''
|
|
53
|
+
throw new TowerToolError(
|
|
54
|
+
'preset-missing',
|
|
55
|
+
`required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH.${reviewLock}`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every runtime invariant that depends on which component rows are mounted.
|
|
61
|
+
* Called before planning and before entering Tower mode, so a configuration
|
|
62
|
+
* that a component switch has invalidated is rejected with an explanation
|
|
63
|
+
* instead of failing later at merge time.
|
|
64
|
+
*
|
|
65
|
+
* @param {any} ctx
|
|
66
|
+
* @param {Record<string, any>} config
|
|
67
|
+
*/
|
|
68
|
+
export async function assertRuntimePreconditions(ctx, config = {}) {
|
|
69
|
+
const presets = await assertPresetsAvailable(ctx, config)
|
|
70
|
+
if (config.gates?.requireTests === true && !componentEnabled('tower-tests')) {
|
|
71
|
+
throw new TowerToolError(
|
|
72
|
+
'config-invalid',
|
|
73
|
+
`gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return presets
|
|
77
|
+
}
|