dsh-tower 2.1.0-beta.1 → 2.3.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 +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -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 +28 -8
- package/app/tower.js +121 -39
- package/docs/README.md +6 -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 +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- 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-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- 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 +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +64 -19
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +3 -4
- 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 +7 -7
- package/tools/init.js +4 -7
- 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 +7 -6
- package/tools/preflight.js +3 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- 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 +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -1
- 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/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /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}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /package/{lib → host}/presets.js +0 -0
package/app/merge.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
// from. A writer that slipped in between observation and write makes the
|
|
18
18
|
// prepare stale, so the whole prepare is retried, never partially applied.
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
import { withLock, withGitLock, writeJsonAtomic, appendJsonl, exists } from '../lib/store.js'
|
|
21
20
|
import { validateState } from '../domain/state.js'
|
|
22
21
|
import {
|
|
23
22
|
alreadyMergedResult,
|
|
23
|
+
applyLandedMerge,
|
|
24
|
+
applyMergeSuccess,
|
|
24
25
|
buildMergeIntent,
|
|
25
26
|
classifyCommitOutcome,
|
|
26
27
|
gateTipDrift,
|
|
@@ -37,7 +38,6 @@ import {
|
|
|
37
38
|
mergeIntentEvidence,
|
|
38
39
|
mergeIntentReconcileNote,
|
|
39
40
|
mergeShapeDetail,
|
|
40
|
-
mergeSuccessNote,
|
|
41
41
|
missionMissingResult,
|
|
42
42
|
pendingMergeIntentResult,
|
|
43
43
|
staleBaseResult,
|
|
@@ -83,7 +83,7 @@ async function observeMergeIntent(tower, state, mission) {
|
|
|
83
83
|
} catch {
|
|
84
84
|
return null
|
|
85
85
|
}
|
|
86
|
-
const mergeInProgress = await exists(path.join(tower.commonDir, 'MERGE_HEAD'))
|
|
86
|
+
const mergeInProgress = await tower.deps.store.exists(path.join(tower.commonDir, 'MERGE_HEAD'))
|
|
87
87
|
if (mergeInProgress) return null
|
|
88
88
|
if (head === intent.baseTip) return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed: null })
|
|
89
89
|
const tips = new Set([intent.branchTip, intent.baseTip].filter(Boolean))
|
|
@@ -98,7 +98,7 @@ async function observeMergeIntent(tower, state, mission) {
|
|
|
98
98
|
* the new state instead of overwriting it.
|
|
99
99
|
*/
|
|
100
100
|
async function applyMergeIntentReconcile(tower, stateRevision, missionId, evidence) {
|
|
101
|
-
return withLock(tower.paths, async () => {
|
|
101
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
102
102
|
const state = await tower.load()
|
|
103
103
|
if (state.revision !== stateRevision) return { retry: true }
|
|
104
104
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
@@ -108,8 +108,8 @@ async function applyMergeIntentReconcile(tower, stateRevision, missionId, eviden
|
|
|
108
108
|
delete state.mergeIntent
|
|
109
109
|
state.revision += 1
|
|
110
110
|
state.updatedAt = now
|
|
111
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
112
|
-
await appendJsonl(tower.paths.activity, {
|
|
111
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
112
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
113
113
|
at: now,
|
|
114
114
|
type: 'merge',
|
|
115
115
|
missionId: mission.id,
|
|
@@ -173,31 +173,30 @@ async function observeLandedMerge(tower, state, mission) {
|
|
|
173
173
|
|
|
174
174
|
/** Locked write half of `reconcileLandedMerge`, guarded by the revision CAS. */
|
|
175
175
|
async function applyLandedMergeReconcile(tower, stateRevision, missionId, observed) {
|
|
176
|
-
return withLock(tower.paths, async () => {
|
|
176
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
177
177
|
const state = await tower.load()
|
|
178
178
|
if (state.revision !== stateRevision) return { retry: true }
|
|
179
179
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
180
180
|
if (!mission) return { result: missionMissingResult(missionId) }
|
|
181
181
|
const now = Date.now()
|
|
182
182
|
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
mission.updatedAt = now
|
|
183
|
+
const applied = applyLandedMerge({
|
|
184
|
+
mission,
|
|
185
|
+
mergedCommit: observed.mergedCommit,
|
|
186
|
+
note: landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
|
|
187
|
+
now,
|
|
188
|
+
})
|
|
191
189
|
state.revision += 1
|
|
192
190
|
state.updatedAt = now
|
|
193
191
|
validateState(state, tower.paths.state)
|
|
194
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
195
|
-
await appendJsonl(tower.paths.activity, {
|
|
192
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
193
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
196
194
|
at: now,
|
|
197
195
|
type: 'merge',
|
|
198
196
|
missionId: mission.id,
|
|
199
197
|
text: `reconciled already-landed ${observed.mergedCommit.slice(0, 7)} on ${state.base}`,
|
|
200
198
|
})
|
|
199
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
201
200
|
return { result: landedMergeResult(observed) }
|
|
202
201
|
})
|
|
203
202
|
}
|
|
@@ -232,6 +231,12 @@ async function prepareFromState(tower, input, deps) {
|
|
|
232
231
|
const { mergeTimeoutMs, MERGE_MAX_BUFFER } = deps
|
|
233
232
|
let state = await tower.load()
|
|
234
233
|
let mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
234
|
+
if (!mission) {
|
|
235
|
+
// An archived terminal mission still answers `alreadyMerged` (or the same
|
|
236
|
+
// blockers) instead of turning into mission-missing after the move (#73).
|
|
237
|
+
const archived = await tower.archivedMission(input.missionId)
|
|
238
|
+
if (archived.ok && archived.entry) mission = archived.entry.mission
|
|
239
|
+
}
|
|
235
240
|
if (!mission) return { result: missionMissingResult(input.missionId) }
|
|
236
241
|
|
|
237
242
|
// Reconcile a stale merge intent: Git runs here, outside every state lock.
|
|
@@ -296,7 +301,7 @@ async function prepareFromState(tower, input, deps) {
|
|
|
296
301
|
* next tower_merge/preflight instead of leaving an unexplained base tip.
|
|
297
302
|
*/
|
|
298
303
|
async function recordMergeIntent(tower, plan) {
|
|
299
|
-
return withLock(tower.paths, async () => {
|
|
304
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
300
305
|
const state = await tower.load()
|
|
301
306
|
// State revision policy: the plan was built from `plan.stateRevision`; a
|
|
302
307
|
// writer that slipped in before any Git mutation makes the plan stale and
|
|
@@ -330,7 +335,7 @@ async function recordMergeIntent(tower, plan) {
|
|
|
330
335
|
})
|
|
331
336
|
state.revision += 1
|
|
332
337
|
state.updatedAt = now
|
|
333
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
338
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
334
339
|
return { intent: state.mergeIntent, intentRevision: state.revision }
|
|
335
340
|
})
|
|
336
341
|
}
|
|
@@ -355,7 +360,7 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
355
360
|
}
|
|
356
361
|
|
|
357
362
|
const newTip = await tower.revParse(tower.root, 'HEAD')
|
|
358
|
-
const parents = await commitParents(tower.root, newTip)
|
|
363
|
+
const parents = await commitParents(tower, tower.root, newTip)
|
|
359
364
|
const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
|
|
360
365
|
const warnings = []
|
|
361
366
|
|
|
@@ -378,12 +383,12 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
378
383
|
const conflict = unmerged.trim().length > 0
|
|
379
384
|
let cleanup = 'none'
|
|
380
385
|
const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
|
|
381
|
-
if (await exists(mergeHeadPath)) {
|
|
386
|
+
if (await tower.deps.store.exists(mergeHeadPath)) {
|
|
382
387
|
try {
|
|
383
388
|
await tower.git(tower.root, ['merge', '--abort'])
|
|
384
389
|
cleanup = 'aborted'
|
|
385
390
|
} catch {
|
|
386
|
-
await removeStaleGitLocks(tower.commonDir)
|
|
391
|
+
await removeStaleGitLocks(tower, tower.commonDir)
|
|
387
392
|
try {
|
|
388
393
|
await tower.git(tower.root, ['merge', '--abort'])
|
|
389
394
|
cleanup = 'aborted-after-lock-cleanup'
|
|
@@ -405,7 +410,7 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
405
410
|
if (dirtyAfter.length > 0) {
|
|
406
411
|
const changed = new Set(plan.changedFiles)
|
|
407
412
|
if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
|
|
408
|
-
await removeStaleGitLocks(tower.commonDir)
|
|
413
|
+
await removeStaleGitLocks(tower, tower.commonDir)
|
|
409
414
|
await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
|
|
410
415
|
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
411
416
|
} else {
|
|
@@ -429,7 +434,7 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
429
434
|
|
|
430
435
|
/** Phase 3: apply the observed outcome under a short state lock. */
|
|
431
436
|
async function commitMerge(tower, plan, recorded, outcome) {
|
|
432
|
-
return withLock(tower.paths, async () => {
|
|
437
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
433
438
|
const state = await tower.load()
|
|
434
439
|
const mission = state.missions.find((entry) => entry.id === plan.missionId)
|
|
435
440
|
if (!mission) return missionMissingResult(plan.missionId)
|
|
@@ -457,21 +462,24 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
457
462
|
if (kind === 'merged') {
|
|
458
463
|
const now = Date.now()
|
|
459
464
|
if (intentActive) delete state.mergeIntent
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
+
const applied = applyMergeSuccess({
|
|
466
|
+
mission,
|
|
467
|
+
branchTip: recorded.intent.branchTip,
|
|
468
|
+
base: state.base,
|
|
469
|
+
newTip: outcome.newTip,
|
|
470
|
+
now,
|
|
471
|
+
})
|
|
465
472
|
state.revision += 1
|
|
466
473
|
state.updatedAt = now
|
|
467
474
|
validateState(state, tower.paths.state)
|
|
468
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
469
|
-
await appendJsonl(tower.paths.activity, {
|
|
475
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
476
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
470
477
|
at: now,
|
|
471
478
|
type: 'merge',
|
|
472
479
|
missionId: mission.id,
|
|
473
480
|
text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
|
|
474
481
|
})
|
|
482
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
475
483
|
return {
|
|
476
484
|
ok: true,
|
|
477
485
|
mergedCommit: outcome.newTip,
|
|
@@ -491,8 +499,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
491
499
|
state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
|
|
492
500
|
state.revision += 1
|
|
493
501
|
state.updatedAt = Date.now()
|
|
494
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
495
|
-
await appendJsonl(tower.paths.activity, {
|
|
502
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
503
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
496
504
|
at: state.updatedAt,
|
|
497
505
|
type: 'merge',
|
|
498
506
|
missionId: mission.id,
|
|
@@ -522,8 +530,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
522
530
|
mission.updatedAt = now
|
|
523
531
|
state.revision += 1
|
|
524
532
|
state.updatedAt = now
|
|
525
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
526
|
-
await appendJsonl(tower.paths.activity, {
|
|
533
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
534
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
527
535
|
at: now,
|
|
528
536
|
type: 'merge',
|
|
529
537
|
missionId: mission.id,
|
|
@@ -544,23 +552,28 @@ export async function merge(tower, input, deps) {
|
|
|
544
552
|
const plan = await prepareMerge(tower, input, deps)
|
|
545
553
|
if (plan.result) return plan.result
|
|
546
554
|
|
|
547
|
-
const acted = await
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
555
|
+
const acted = await tower.deps.store.withGitLock(
|
|
556
|
+
tower.paths,
|
|
557
|
+
async () => {
|
|
558
|
+
// No Git mutation has happened yet: a base that moved after prepare is a
|
|
559
|
+
// clean retryable stale-base rejection, with no intent left behind.
|
|
560
|
+
const head = await tower.revParse(tower.root, 'HEAD')
|
|
561
|
+
if (head !== plan.baseTip) {
|
|
562
|
+
return {
|
|
563
|
+
result: staleBaseResult(
|
|
564
|
+
plan.missionId,
|
|
565
|
+
`base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
|
|
566
|
+
),
|
|
567
|
+
}
|
|
557
568
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
569
|
+
const recorded = await recordMergeIntent(tower, plan)
|
|
570
|
+
if (recorded.result) return recorded
|
|
571
|
+
const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
|
|
572
|
+
return { recorded, outcome }
|
|
573
|
+
},
|
|
574
|
+
// The merge itself may hold the mutex for plan.timeoutMs (#93).
|
|
575
|
+
tower.gitMutexOptions({ phase: 'merge', mergeTimeoutMs: plan.timeoutMs }),
|
|
576
|
+
)
|
|
564
577
|
if (acted.result) return acted.result
|
|
565
578
|
|
|
566
579
|
return commitMerge(tower, plan, acted.recorded, acted.outcome)
|
package/app/messages.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Inbox/message use cases.
|
|
2
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
2
|
import { validateState } from '../domain/state.js'
|
|
4
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
5
4
|
import {
|
|
@@ -14,7 +13,7 @@ function fail(message) {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
export async function recordMessage(tower, input) {
|
|
17
|
-
return withLock(tower.paths, async () => {
|
|
16
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
18
17
|
const state = await tower.load()
|
|
19
18
|
if (!Array.isArray(state.messages)) state.messages = []
|
|
20
19
|
assertMessageRecipient({ roster: state.roster ?? [], to: input.to, from: input.from })
|
|
@@ -27,8 +26,8 @@ export async function recordMessage(tower, input) {
|
|
|
27
26
|
state.updatedAt = now
|
|
28
27
|
|
|
29
28
|
validateState(state, tower.paths.state)
|
|
30
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
31
|
-
await appendJsonl(tower.paths.activity, {
|
|
29
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
30
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
32
31
|
at: now,
|
|
33
32
|
type: 'message',
|
|
34
33
|
text: `${input.from} -> ${input.to}: ${input.subject}`,
|
|
@@ -38,20 +37,20 @@ export async function recordMessage(tower, input) {
|
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
export async function recordDelivery(tower, messageId, results) {
|
|
41
|
-
return withLock(tower.paths, async () => {
|
|
40
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
42
41
|
const state = await tower.load()
|
|
43
42
|
const message = (state.messages ?? []).find((entry) => entry.id === messageId)
|
|
44
43
|
if (!message) fail(`unknown message "${messageId}"`)
|
|
45
44
|
addDelivery(message, results)
|
|
46
45
|
state.revision += 1
|
|
47
46
|
state.updatedAt = Date.now()
|
|
48
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
47
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
49
48
|
return message.delivery
|
|
50
49
|
})
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
export async function inbox(tower, input) {
|
|
54
|
-
return withLock(tower.paths, async () => {
|
|
53
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
55
54
|
const state = await tower.load()
|
|
56
55
|
const now = Date.now()
|
|
57
56
|
const { payload, changed } = inboxView(state.messages ?? [], input, now)
|
|
@@ -59,7 +58,7 @@ export async function recordMessage(tower, input) {
|
|
|
59
58
|
state.revision += 1
|
|
60
59
|
state.updatedAt = now
|
|
61
60
|
validateState(state, tower.paths.state)
|
|
62
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
61
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
63
62
|
}
|
|
64
63
|
return payload
|
|
65
64
|
})
|
package/app/mission.js
CHANGED
|
@@ -3,7 +3,7 @@ import { TowerToolError } from '../domain/errors.js'
|
|
|
3
3
|
import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
|
|
4
4
|
import { validateState } from '../domain/state.js'
|
|
5
5
|
import { fullMission } from '../domain/summary.js'
|
|
6
|
-
import {
|
|
6
|
+
import { sweepArchive } from './archive.js'
|
|
7
7
|
|
|
8
8
|
function fail(message, code = 'invalid-argument') {
|
|
9
9
|
throw new TowerToolError(code, message)
|
|
@@ -12,24 +12,26 @@ function fail(message, code = 'invalid-argument') {
|
|
|
12
12
|
export async function updateMission(tower, input = {}) {
|
|
13
13
|
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
14
14
|
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
15
|
-
return withLock(tower.paths, async () => {
|
|
15
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
16
16
|
const state = await tower.load()
|
|
17
17
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
18
18
|
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
19
|
-
const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
|
|
19
|
+
const { changes, events } = applyMissionUpdate({ mission, state, input, config: tower.config })
|
|
20
20
|
const now = Date.now()
|
|
21
21
|
mission.updatedAt = now
|
|
22
|
+
await sweepArchive(tower, state, { now })
|
|
22
23
|
state.revision += 1
|
|
23
24
|
state.updatedAt = now
|
|
24
25
|
|
|
25
26
|
validateState(state, tower.paths.state)
|
|
26
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
27
|
-
await appendJsonl(tower.paths.activity, {
|
|
27
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
28
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
28
29
|
at: now,
|
|
29
30
|
type: 'mission',
|
|
30
31
|
missionId: mission.id,
|
|
31
32
|
text: changes.join('; '),
|
|
32
33
|
})
|
|
34
|
+
tower.emitEvents(events, { revision: state.revision, at: now })
|
|
33
35
|
return { revision: state.revision, mission: fullMission(mission) }
|
|
34
36
|
})
|
|
35
37
|
}
|
package/app/plan.js
CHANGED
|
@@ -5,38 +5,49 @@ import {
|
|
|
5
5
|
assignMissionIds,
|
|
6
6
|
assertKnownDependencies,
|
|
7
7
|
newMission,
|
|
8
|
+
applyPlan,
|
|
8
9
|
} from '../domain/missions/plan.js'
|
|
9
10
|
import { validateState } from '../domain/state.js'
|
|
10
11
|
import { fullMission } from '../domain/summary.js'
|
|
11
|
-
import {
|
|
12
|
+
import { sweepArchive } from './archive.js'
|
|
12
13
|
|
|
13
14
|
export async function plan(tower, input = {}) {
|
|
14
15
|
const prepared = normalizePlanMissions(input)
|
|
15
|
-
return withLock(tower.paths, async () => {
|
|
16
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
16
17
|
const state = await tower.load()
|
|
17
18
|
const now = Date.now()
|
|
18
|
-
|
|
19
|
+
// Archived ids stay in the known set: a new mission must not reuse an id
|
|
20
|
+
// and a merged dependency may live in the archive index (#73).
|
|
21
|
+
const archive = await tower.archiveIndex()
|
|
22
|
+
const known = [...state.missions, ...(archive.ok ? archive.missions : [])]
|
|
23
|
+
const assigned = assignMissionIds(prepared, nextMissionNumber(known))
|
|
19
24
|
|
|
20
|
-
assertKnownDependencies(assigned,
|
|
25
|
+
assertKnownDependencies(assigned, known)
|
|
21
26
|
|
|
22
|
-
addAutoDeps(assigned, state.
|
|
23
|
-
assertAcyclic([...
|
|
27
|
+
addAutoDeps(assigned, known, { caseInsensitive: state.caseInsensitive === true })
|
|
28
|
+
assertAcyclic([...known, ...assigned])
|
|
24
29
|
|
|
25
30
|
const created = assigned.map((mission) => newMission(mission.id, mission, now))
|
|
26
|
-
state
|
|
31
|
+
const applied = applyPlan(state, created)
|
|
32
|
+
const swept = await sweepArchive(tower, state, { now })
|
|
27
33
|
state.revision += 1
|
|
28
34
|
state.updatedAt = now
|
|
29
35
|
|
|
30
36
|
validateState(state, tower.paths.state)
|
|
31
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
32
|
-
await appendJsonl(tower.paths.activity, {
|
|
37
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
38
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
33
39
|
at: now,
|
|
34
40
|
type: 'plan',
|
|
35
41
|
text: `created ${created.map((mission) => mission.id).join(', ')}`,
|
|
36
42
|
})
|
|
43
|
+
// Persist first, then describe: the event carries the revision the caller
|
|
44
|
+
// can read back from state.json (#75).
|
|
45
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
37
46
|
return {
|
|
38
47
|
revision: state.revision,
|
|
39
48
|
missions: created.map((mission) => fullMission(mission)),
|
|
49
|
+
...(swept.archived.length > 0 ? { archived: swept.archived } : {}),
|
|
50
|
+
...(swept.error ? { archiveError: swept.error } : {}),
|
|
40
51
|
}
|
|
41
52
|
})
|
|
42
53
|
}
|
package/app/provision.js
CHANGED
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
// mission still has no worktree, then records it. A rejected
|
|
8
8
|
// commit rolls the created worktree/branch back outside the lock.
|
|
9
9
|
import path from 'node:path'
|
|
10
|
-
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl, shortHash } from '../lib/store.js'
|
|
11
10
|
import { validateState } from '../domain/state.js'
|
|
12
|
-
import { canonicalPath } from '../lib/worktreeOwnership.js'
|
|
13
11
|
import { TowerToolError } from '../domain/errors.js'
|
|
14
12
|
import {
|
|
15
13
|
baseCommitChoice,
|
|
@@ -53,13 +51,13 @@ async function prepareProvision(tower, input) {
|
|
|
53
51
|
worktreeRoot: input.worktreeRoot,
|
|
54
52
|
configuredRoot: tower.config.worktrees.root,
|
|
55
53
|
repoRoot: tower.root,
|
|
56
|
-
commonDirHash: shortHash(tower.commonDir),
|
|
54
|
+
commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
|
|
57
55
|
})
|
|
58
56
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
59
57
|
tower.assertOutside(root)
|
|
60
|
-
await ensureDir(root)
|
|
58
|
+
await tower.deps.store.ensureDir(root)
|
|
61
59
|
const wtPath = worktreePath(root, mission.id)
|
|
62
|
-
if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
60
|
+
if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
63
61
|
if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null) {
|
|
64
62
|
fail(`branch already exists: ${branch}`)
|
|
65
63
|
}
|
|
@@ -68,7 +66,7 @@ async function prepareProvision(tower, input) {
|
|
|
68
66
|
|
|
69
67
|
/** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
|
|
70
68
|
async function actProvision(tower, prepared) {
|
|
71
|
-
return withGitLock(tower.paths, async () => {
|
|
69
|
+
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
72
70
|
let fresh
|
|
73
71
|
try {
|
|
74
72
|
fresh = await tower.load()
|
|
@@ -84,7 +82,7 @@ async function actProvision(tower, prepared) {
|
|
|
84
82
|
await tower.assertWorktree(mission.worktree)
|
|
85
83
|
return { phase: 'existing', mission, worktree: mission.worktree }
|
|
86
84
|
}
|
|
87
|
-
if (await exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
|
|
85
|
+
if (await tower.deps.store.exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
|
|
88
86
|
if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${prepared.branch}`])) !== null) {
|
|
89
87
|
fail(`branch already exists: ${prepared.branch}`)
|
|
90
88
|
}
|
|
@@ -95,15 +93,15 @@ async function actProvision(tower, prepared) {
|
|
|
95
93
|
branch: prepared.branch,
|
|
96
94
|
baseCommit: prepared.baseCommit,
|
|
97
95
|
createdAt: Date.now(),
|
|
98
|
-
realPath: await canonicalPath(prepared.wtPath),
|
|
96
|
+
realPath: await tower.deps.ownership.canonicalPath(prepared.wtPath),
|
|
99
97
|
})
|
|
100
98
|
return { phase: 'created', worktree }
|
|
101
|
-
})
|
|
99
|
+
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
/** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
|
|
105
103
|
async function commitProvision(tower, prepared, acted) {
|
|
106
|
-
return withLock(tower.paths, async () => {
|
|
104
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
107
105
|
const state = await tower.load()
|
|
108
106
|
const mission = state.missions.find((entry) => entry.id === prepared.missionId)
|
|
109
107
|
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
@@ -119,11 +117,11 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
119
117
|
state.updatedAt = now
|
|
120
118
|
try {
|
|
121
119
|
validateState(state, tower.paths.state)
|
|
122
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
120
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
123
121
|
} catch (error) {
|
|
124
122
|
return { phase: 'persist-error', error }
|
|
125
123
|
}
|
|
126
|
-
await appendJsonl(tower.paths.activity, {
|
|
124
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
127
125
|
at: now,
|
|
128
126
|
type: 'worktree',
|
|
129
127
|
missionId: prepared.missionId,
|
|
@@ -135,10 +133,10 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
135
133
|
|
|
136
134
|
/** Undo a created worktree/branch outside the state lock (git mutex only). */
|
|
137
135
|
async function rollbackProvision(tower, prepared) {
|
|
138
|
-
await withGitLock(tower.paths, async () => {
|
|
136
|
+
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
139
137
|
await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
|
|
140
138
|
await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
|
|
141
|
-
})
|
|
139
|
+
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
/** #51 two-phase provision: prepare/act/commit with rollback on rejection. */
|
package/app/read.js
CHANGED
|
@@ -1,30 +1,89 @@
|
|
|
1
1
|
// Read-only dashboard/detail use cases.
|
|
2
2
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
3
|
import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
|
|
4
|
-
import {
|
|
4
|
+
import { archiveView, archivedSummary } from './archive.js'
|
|
5
5
|
|
|
6
6
|
function fail(message, code = 'invalid-argument') {
|
|
7
7
|
throw new TowerToolError(code, message)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Full detail for one mission. A mission that was archived is reconstructed
|
|
12
|
+
* from its year file, including the reviews, findings and activity that left
|
|
13
|
+
* state.json with it. An unreadable archive is a diagnostic, never a throw:
|
|
14
|
+
* `tower_status` must keep rendering the hot state.
|
|
15
|
+
*/
|
|
10
16
|
export async function missionDetail(tower, missionId) {
|
|
11
17
|
const state = await tower.load()
|
|
12
18
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 200)
|
|
20
|
+
if (mission) return missionDetailView({ state, mission, activity })
|
|
21
|
+
|
|
22
|
+
const archived = await tower.archivedMission(missionId)
|
|
23
|
+
if (archived.ok && archived.entry) {
|
|
24
|
+
const entry = archived.entry
|
|
25
|
+
const hotFindings = (state.findings ?? []).filter((finding) => finding.missionId === missionId)
|
|
26
|
+
const byId = new Map()
|
|
27
|
+
for (const finding of [...(entry.findings ?? []), ...hotFindings]) {
|
|
28
|
+
if (finding && typeof finding.id === 'string') byId.set(finding.id, finding)
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
...missionDetailView({
|
|
32
|
+
state: /** @type {any} */ ({ reviews: entry.reviews ?? [], findings: [...byId.values()] }),
|
|
33
|
+
mission: /** @type {any} */ (entry.mission),
|
|
34
|
+
activity,
|
|
35
|
+
}),
|
|
36
|
+
archived: true,
|
|
37
|
+
archivedAt: entry.archivedAt,
|
|
38
|
+
archivedYear: entry.archivedYear,
|
|
39
|
+
branch: entry.branch ?? null,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (archived.unreadable) {
|
|
43
|
+
return {
|
|
44
|
+
archived: true,
|
|
45
|
+
archivedAt: null,
|
|
46
|
+
branch: null,
|
|
47
|
+
mission: null,
|
|
48
|
+
reviews: [],
|
|
49
|
+
findings: [],
|
|
50
|
+
activity: [],
|
|
51
|
+
error: archived.error ?? `tower archive is unreadable; mission ${missionId} cannot be loaded`,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
16
55
|
}
|
|
17
56
|
|
|
18
57
|
export async function status(tower, input = {}) {
|
|
19
58
|
const state = await tower.load()
|
|
20
|
-
const activity = await readActivityTail(tower.paths.activity, 12)
|
|
59
|
+
const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 12)
|
|
60
|
+
// The repo-scoped git mutex can be held for a whole merge; expose the
|
|
61
|
+
// holder so "the tower is stuck" has a visible cause (#93).
|
|
62
|
+
const gitLock = tower.deps.store.gitLockView(await tower.deps.store.readGitLock(tower.paths))
|
|
63
|
+
const index = await tower.archiveIndex()
|
|
64
|
+
const activeIds = new Set(state.missions.map((mission) => mission.id))
|
|
65
|
+
const wanted = Array.isArray(input.missionIds) ? new Set(input.missionIds) : null
|
|
66
|
+
// An explicit missionId lookup includes its archived summary; the dashboard
|
|
67
|
+
// includes them only on demand. Archived summaries never carry report bodies.
|
|
68
|
+
const includeArchived = input.includeArchived === true || wanted !== null
|
|
69
|
+
const missions = await tower.enrichedMissions(state, input.missionIds)
|
|
70
|
+
if (includeArchived && index.ok) {
|
|
71
|
+
for (const entry of index.missions) {
|
|
72
|
+
if (activeIds.has(entry.id)) continue
|
|
73
|
+
if (wanted !== null && !wanted.has(entry.id)) continue
|
|
74
|
+
missions.push(archivedSummary(entry))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
21
77
|
return statusView({
|
|
22
78
|
state,
|
|
23
79
|
statePath: tower.paths.state,
|
|
24
80
|
activity,
|
|
25
81
|
unreadTower: tower.unreadFor(state, 'tower').length,
|
|
26
82
|
config: tower.config,
|
|
27
|
-
missions
|
|
83
|
+
missions,
|
|
84
|
+
archivedMissions: index.ok ? index.missions.length : null,
|
|
85
|
+
archive: archiveView(index),
|
|
86
|
+
gitLock,
|
|
28
87
|
})
|
|
29
88
|
}
|
|
30
89
|
|