dsh-tower 2.0.4 → 2.1.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 +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- 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/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- 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 +427 -0
- package/domain/contracts/outputs.js +305 -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 +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -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 +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +212 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +45 -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 +102 -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 +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
package/app/merge.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// Merge use case, split into three phases (#51):
|
|
2
|
+
//
|
|
3
|
+
// prepare — outside the state lock: load state, reconcile a stale merge
|
|
4
|
+
// intent / an already-landed merge (Git is only read here, never
|
|
5
|
+
// under the state lock), evaluate the gate, capture the exact
|
|
6
|
+
// base/branch tips, and build the merge plan.
|
|
7
|
+
// act — record the merge intent under a short state lock, then run the
|
|
8
|
+
// exact captured `git merge` and its failure cleanup outside the
|
|
9
|
+
// state lock but under the repo-scoped git mutex (`withGitLock`).
|
|
10
|
+
// commit — reload state under a short state lock, verify the state revision
|
|
11
|
+
// policy and the observed merge parents, then write the same
|
|
12
|
+
// merged/blocked/notes outcome the single-phase implementation did.
|
|
13
|
+
//
|
|
14
|
+
// No git call or process spawn runs inside a `withLock` scope. Reconciliation
|
|
15
|
+
// is two-phase too: observe Git unlocked, then write the resulting state
|
|
16
|
+
// mutation under a short lock that revalidates the revision it was prepared
|
|
17
|
+
// from. A writer that slipped in between observation and write makes the
|
|
18
|
+
// prepare stale, so the whole prepare is retried, never partially applied.
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { withLock, withGitLock, writeJsonAtomic, appendJsonl, exists } from '../lib/store.js'
|
|
21
|
+
import { validateState } from '../domain/state.js'
|
|
22
|
+
import {
|
|
23
|
+
alreadyMergedResult,
|
|
24
|
+
buildMergeIntent,
|
|
25
|
+
classifyCommitOutcome,
|
|
26
|
+
gateTipDrift,
|
|
27
|
+
intentMatches,
|
|
28
|
+
isExpectedMergeShape,
|
|
29
|
+
landedMergeCandidate,
|
|
30
|
+
landedMergeEvidence,
|
|
31
|
+
landedMergeNote,
|
|
32
|
+
landedMergeResult,
|
|
33
|
+
mergeBlockedResult,
|
|
34
|
+
mergeConflictNote,
|
|
35
|
+
mergeFailureKind,
|
|
36
|
+
mergeHookFailureNote,
|
|
37
|
+
mergeIntentEvidence,
|
|
38
|
+
mergeIntentReconcileNote,
|
|
39
|
+
mergeShapeDetail,
|
|
40
|
+
mergeSuccessNote,
|
|
41
|
+
missionMissingResult,
|
|
42
|
+
pendingMergeIntentResult,
|
|
43
|
+
staleBaseResult,
|
|
44
|
+
} from '../domain/integration/merge.js'
|
|
45
|
+
|
|
46
|
+
// A revision CAS that loses a race is retried this many times before the merge
|
|
47
|
+
// reports the same retryable stale-base the previous revision policy produced.
|
|
48
|
+
const PREPARE_ATTEMPTS = 3
|
|
49
|
+
|
|
50
|
+
/** `rev-list --parents -n 1` through the Tower git port (never under the state lock). */
|
|
51
|
+
async function readCommitParents(tower, sha) {
|
|
52
|
+
const line = await tower.tryGit(tower.root, ['rev-list', '--parents', '-n', '1', sha])
|
|
53
|
+
return line ? line.trim().split(/\s+/).slice(1) : []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find a reachable merge commit whose second parent is one of the known tips.
|
|
58
|
+
* `--no-ff` merges put the integrated branch on the second parent, so this is
|
|
59
|
+
* the concrete evidence that a mission's work already landed in the base.
|
|
60
|
+
* Callers must not hold the state lock: this runs Git.
|
|
61
|
+
*/
|
|
62
|
+
async function findLandedMerge(tower, baseTip, tips) {
|
|
63
|
+
const revs = (await tower.tryGit(tower.root, ['rev-list', '--merges', baseTip])) ?? ''
|
|
64
|
+
for (const sha of revs.split('\n').filter(Boolean)) {
|
|
65
|
+
const parents = await readCommitParents(tower, sha)
|
|
66
|
+
if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Unlocked observation half of `reconcileMergeIntent`: decide from Git whether
|
|
73
|
+
* a recorded intent can be consumed. Returns null when it cannot: the merge is
|
|
74
|
+
* still in progress, HEAD cannot be read, or no evidence shows the intent's
|
|
75
|
+
* merge landed.
|
|
76
|
+
*/
|
|
77
|
+
async function observeMergeIntent(tower, state, mission) {
|
|
78
|
+
const intent = state.mergeIntent
|
|
79
|
+
if (!intent || intent.missionId !== mission.id || !mission.worktree) return null
|
|
80
|
+
let head
|
|
81
|
+
try {
|
|
82
|
+
head = await tower.revParse(tower.root, 'HEAD')
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const mergeInProgress = await exists(path.join(tower.commonDir, 'MERGE_HEAD'))
|
|
87
|
+
if (mergeInProgress) return null
|
|
88
|
+
if (head === intent.baseTip) return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed: null })
|
|
89
|
+
const tips = new Set([intent.branchTip, intent.baseTip].filter(Boolean))
|
|
90
|
+
const landed = await findLandedMerge(tower, head, tips)
|
|
91
|
+
return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Locked write half of `reconcileMergeIntent`: consume the intent exactly as
|
|
96
|
+
* observed. The revision check makes the write a CAS: if any writer changed
|
|
97
|
+
* state after the observation the reconcile is abandoned and retried against
|
|
98
|
+
* the new state instead of overwriting it.
|
|
99
|
+
*/
|
|
100
|
+
async function applyMergeIntentReconcile(tower, stateRevision, missionId, evidence) {
|
|
101
|
+
return withLock(tower.paths, async () => {
|
|
102
|
+
const state = await tower.load()
|
|
103
|
+
if (state.revision !== stateRevision) return { retry: true }
|
|
104
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
105
|
+
if (!mission) return { result: missionMissingResult(missionId) }
|
|
106
|
+
if (!state.mergeIntent || state.mergeIntent.missionId !== mission.id) return { state }
|
|
107
|
+
const now = Date.now()
|
|
108
|
+
delete state.mergeIntent
|
|
109
|
+
state.revision += 1
|
|
110
|
+
state.updatedAt = now
|
|
111
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
112
|
+
await appendJsonl(tower.paths.activity, {
|
|
113
|
+
at: now,
|
|
114
|
+
type: 'merge',
|
|
115
|
+
missionId: mission.id,
|
|
116
|
+
text: mergeIntentReconcileNote(evidence),
|
|
117
|
+
})
|
|
118
|
+
return { state }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Unlocked observation half of `reconcileLandedMerge`: the same review /
|
|
124
|
+
* exemption / ancestry evidence rules the domain reconciler applies, but only
|
|
125
|
+
* reading Git. Returns null when there is nothing to reconcile, a
|
|
126
|
+
* `reviewStale` blocker when only the reviewed commit landed, or the evidence
|
|
127
|
+
* needed by the locked write half.
|
|
128
|
+
*/
|
|
129
|
+
async function observeLandedMerge(tower, state, mission) {
|
|
130
|
+
const candidate = landedMergeCandidate(mission, state.reviews, tower.config)
|
|
131
|
+
if (!candidate) return null
|
|
132
|
+
const { latest } = candidate
|
|
133
|
+
let baseTip
|
|
134
|
+
let branchTip
|
|
135
|
+
try {
|
|
136
|
+
baseTip = await tower.revParse(tower.root, state.base)
|
|
137
|
+
branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
const reviewedTip = latest?.reviewedCommit ?? null
|
|
142
|
+
const branchLanded = await tower.isAncestor(tower.root, branchTip, baseTip)
|
|
143
|
+
const reviewedLanded = reviewedTip ? await tower.isAncestor(tower.root, reviewedTip, baseTip) : false
|
|
144
|
+
if (!branchLanded) {
|
|
145
|
+
const evidence = landedMergeEvidence({
|
|
146
|
+
missionId: mission.id,
|
|
147
|
+
base: state.base,
|
|
148
|
+
baseTip,
|
|
149
|
+
branchTip,
|
|
150
|
+
reviewedTip,
|
|
151
|
+
branchLanded,
|
|
152
|
+
reviewedLanded,
|
|
153
|
+
found: null,
|
|
154
|
+
latest,
|
|
155
|
+
})
|
|
156
|
+
return 'reviewStale' in evidence ? { reviewStale: evidence.reviewStale } : null
|
|
157
|
+
}
|
|
158
|
+
const tips = new Set([branchTip, reviewedTip].filter(Boolean))
|
|
159
|
+
const found = await findLandedMerge(tower, baseTip, tips)
|
|
160
|
+
const evidence = landedMergeEvidence({
|
|
161
|
+
missionId: mission.id,
|
|
162
|
+
base: state.base,
|
|
163
|
+
baseTip,
|
|
164
|
+
branchTip,
|
|
165
|
+
reviewedTip,
|
|
166
|
+
branchLanded,
|
|
167
|
+
reviewedLanded,
|
|
168
|
+
found,
|
|
169
|
+
latest,
|
|
170
|
+
})
|
|
171
|
+
return 'measured' in evidence ? evidence.measured : null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Locked write half of `reconcileLandedMerge`, guarded by the revision CAS. */
|
|
175
|
+
async function applyLandedMergeReconcile(tower, stateRevision, missionId, observed) {
|
|
176
|
+
return withLock(tower.paths, async () => {
|
|
177
|
+
const state = await tower.load()
|
|
178
|
+
if (state.revision !== stateRevision) return { retry: true }
|
|
179
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
180
|
+
if (!mission) return { result: missionMissingResult(missionId) }
|
|
181
|
+
const now = Date.now()
|
|
182
|
+
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
183
|
+
mission.status = 'merged'
|
|
184
|
+
mission.mergedAt = now
|
|
185
|
+
mission.mergedCommit = observed.mergedCommit
|
|
186
|
+
mission.blockers = []
|
|
187
|
+
mission.notes.push(
|
|
188
|
+
landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
|
|
189
|
+
)
|
|
190
|
+
mission.updatedAt = now
|
|
191
|
+
state.revision += 1
|
|
192
|
+
state.updatedAt = now
|
|
193
|
+
validateState(state, tower.paths.state)
|
|
194
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
195
|
+
await appendJsonl(tower.paths.activity, {
|
|
196
|
+
at: now,
|
|
197
|
+
type: 'merge',
|
|
198
|
+
missionId: mission.id,
|
|
199
|
+
text: `reconciled already-landed ${observed.mergedCommit.slice(0, 7)} on ${state.base}`,
|
|
200
|
+
})
|
|
201
|
+
return { result: landedMergeResult(observed) }
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function dirtyPaths(tower) {
|
|
206
|
+
const text = await tower.git(tower.root, ['status', '--porcelain'])
|
|
207
|
+
return text.split('\n').filter(Boolean).map((line) => line.slice(3).trim())
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Phase 1: everything that can be decided without holding the state lock.
|
|
212
|
+
*
|
|
213
|
+
* Reconciliation is the one mutation that must happen before the gate: a stale
|
|
214
|
+
* merge intent (crashed merge, merge that landed before it was recorded) would
|
|
215
|
+
* otherwise surface as a permanent `merge-intent-pending` blocker. Git evidence
|
|
216
|
+
* is gathered unlocked; the state write happens under a short lock that
|
|
217
|
+
* revalidates the revision the observation was made from. A writer that slipped
|
|
218
|
+
* in between makes the prepare stale and it is simply retried, never partially
|
|
219
|
+
* applied and never overwritten.
|
|
220
|
+
*/
|
|
221
|
+
async function prepareMerge(tower, input, deps) {
|
|
222
|
+
for (let attempt = 0; attempt < PREPARE_ATTEMPTS; attempt += 1) {
|
|
223
|
+
const prepared = await prepareFromState(tower, input, deps)
|
|
224
|
+
if (!prepared.retry) return prepared
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
result: staleBaseResult(input.missionId, 'tower state changed while the merge was being prepared; retry'),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function prepareFromState(tower, input, deps) {
|
|
232
|
+
const { mergeTimeoutMs, MERGE_MAX_BUFFER } = deps
|
|
233
|
+
let state = await tower.load()
|
|
234
|
+
let mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
235
|
+
if (!mission) {
|
|
236
|
+
// An archived terminal mission still answers `alreadyMerged` (or the same
|
|
237
|
+
// blockers) instead of turning into mission-missing after the move (#73).
|
|
238
|
+
const archived = await tower.archivedMission(input.missionId)
|
|
239
|
+
if (archived.ok && archived.entry) mission = archived.entry.mission
|
|
240
|
+
}
|
|
241
|
+
if (!mission) return { result: missionMissingResult(input.missionId) }
|
|
242
|
+
|
|
243
|
+
// Reconcile a stale merge intent: Git runs here, outside every state lock.
|
|
244
|
+
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
245
|
+
const evidence = await observeMergeIntent(tower, state, mission)
|
|
246
|
+
if (evidence) {
|
|
247
|
+
const applied = await applyMergeIntentReconcile(tower, state.revision, mission.id, evidence)
|
|
248
|
+
if (applied.retry) return applied
|
|
249
|
+
if (applied.result) return applied
|
|
250
|
+
state = applied.state
|
|
251
|
+
mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
252
|
+
if (!mission) return { result: missionMissingResult(input.missionId) }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (mission.status === 'merged') {
|
|
257
|
+
return { result: alreadyMergedResult(mission, await tower.revParse(tower.root, state.base)) }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Reconcile a merge that already landed in Git: observe unlocked, write CAS.
|
|
261
|
+
const landed = await observeLandedMerge(tower, state, mission)
|
|
262
|
+
if (landed) {
|
|
263
|
+
if (landed.reviewStale) return { result: landed.reviewStale }
|
|
264
|
+
const applied = await applyLandedMergeReconcile(tower, state.revision, mission.id, landed)
|
|
265
|
+
if (applied.retry) return applied
|
|
266
|
+
return applied
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const gate = await tower.evaluateGate(input)
|
|
270
|
+
if (!gate.ok) return { result: { ok: false, blockers: gate.blockers, missionId: input.missionId } }
|
|
271
|
+
|
|
272
|
+
const worktree = mission.worktree
|
|
273
|
+
// Capture the exact commits this merge is allowed to integrate. The merge
|
|
274
|
+
// command below uses the captured branch SHA, never the mutable branch name.
|
|
275
|
+
const baseTip = await tower.revParse(tower.root, 'HEAD')
|
|
276
|
+
const branchTip = await tower.revParse(worktree.path, 'HEAD')
|
|
277
|
+
const drift = gateTipDrift({
|
|
278
|
+
missionId: mission.id,
|
|
279
|
+
gateBaseTip: gate.baseTip,
|
|
280
|
+
gateBranchTip: gate.branchTip,
|
|
281
|
+
baseTip,
|
|
282
|
+
branchTip,
|
|
283
|
+
})
|
|
284
|
+
if (drift) return { result: drift }
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
stateRevision: state.revision,
|
|
288
|
+
missionId: mission.id,
|
|
289
|
+
missionTitle: mission.title,
|
|
290
|
+
baseTip,
|
|
291
|
+
branchTip,
|
|
292
|
+
gateTip: gate.branchTip ?? branchTip,
|
|
293
|
+
changedFiles: gate.changedFiles ?? [],
|
|
294
|
+
timeoutMs: mergeTimeoutMs(input.timeoutMs, tower.config),
|
|
295
|
+
maxBuffer: tower.config.merge.maxBufferBytes || MERGE_MAX_BUFFER,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Record the intent under a short state lock, immediately before Git runs.
|
|
301
|
+
* A crash between the merge commit and the outcome write is reconciled by the
|
|
302
|
+
* next tower_merge/preflight instead of leaving an unexplained base tip.
|
|
303
|
+
*/
|
|
304
|
+
async function recordMergeIntent(tower, plan) {
|
|
305
|
+
return withLock(tower.paths, async () => {
|
|
306
|
+
const state = await tower.load()
|
|
307
|
+
// State revision policy: the plan was built from `plan.stateRevision`; a
|
|
308
|
+
// writer that slipped in before any Git mutation makes the plan stale and
|
|
309
|
+
// the operation safely retryable.
|
|
310
|
+
if (state.revision !== plan.stateRevision) {
|
|
311
|
+
return {
|
|
312
|
+
result: staleBaseResult(
|
|
313
|
+
plan.missionId,
|
|
314
|
+
`tower state changed while the merge was being prepared (${plan.stateRevision} -> ${state.revision}); retry`,
|
|
315
|
+
),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const mission = state.missions.find((entry) => entry.id === plan.missionId)
|
|
319
|
+
if (!mission) return { result: missionMissingResult(plan.missionId) }
|
|
320
|
+
if (mission.status === 'merged') {
|
|
321
|
+
return { result: alreadyMergedResult(mission, plan.baseTip) }
|
|
322
|
+
}
|
|
323
|
+
// Only one merge may be in flight: two prepared merges would otherwise
|
|
324
|
+
// capture the same base tip and the second would build a merge on a moved
|
|
325
|
+
// base. Leave the recorded intent alone for the next reconcile.
|
|
326
|
+
if (state.mergeIntent) {
|
|
327
|
+
return { result: pendingMergeIntentResult(plan.missionId, state.mergeIntent) }
|
|
328
|
+
}
|
|
329
|
+
const now = Date.now()
|
|
330
|
+
state.mergeIntent = buildMergeIntent({
|
|
331
|
+
missionId: mission.id,
|
|
332
|
+
baseTip: plan.baseTip,
|
|
333
|
+
branchTip: plan.branchTip,
|
|
334
|
+
gateTip: plan.gateTip,
|
|
335
|
+
startedAt: now,
|
|
336
|
+
})
|
|
337
|
+
state.revision += 1
|
|
338
|
+
state.updatedAt = now
|
|
339
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
340
|
+
return { intent: state.mergeIntent, intentRevision: state.revision }
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Phase 2: run the exact captured merge and its failure cleanup. No state lock
|
|
346
|
+
* is held here; `withGitLock` serializes this against other Tower git mutations.
|
|
347
|
+
*/
|
|
348
|
+
async function runMergeGit(tower, plan, intent, deps) {
|
|
349
|
+
const { describeGitFailure, commitParents, removeStaleGitLocks } = deps
|
|
350
|
+
let failure = null
|
|
351
|
+
try {
|
|
352
|
+
// Merge the exact reviewed commit, never a mutable branch name. A branch
|
|
353
|
+
// move after the gate must not smuggle extra commits into base.
|
|
354
|
+
await tower.git(
|
|
355
|
+
tower.root,
|
|
356
|
+
['merge', '--no-ff', intent.branchTip, '-m', `tower: merge ${plan.missionId} — ${plan.missionTitle}`],
|
|
357
|
+
{ timeoutMs: plan.timeoutMs, maxBuffer: plan.maxBuffer },
|
|
358
|
+
)
|
|
359
|
+
} catch (error) {
|
|
360
|
+
failure = error
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const newTip = await tower.revParse(tower.root, 'HEAD')
|
|
364
|
+
const parents = await commitParents(tower.root, newTip)
|
|
365
|
+
const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
|
|
366
|
+
const warnings = []
|
|
367
|
+
|
|
368
|
+
if (failure && expectedShape) {
|
|
369
|
+
warnings.push(
|
|
370
|
+
`merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
|
|
371
|
+
)
|
|
372
|
+
const dirty = await dirtyPaths(tower)
|
|
373
|
+
if (dirty.length > 0) {
|
|
374
|
+
const changed = new Set(plan.changedFiles)
|
|
375
|
+
if (dirty.every((file) => changed.has(file))) {
|
|
376
|
+
await tower.git(tower.root, ['reset', '--hard', 'HEAD'])
|
|
377
|
+
warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
|
|
378
|
+
} else {
|
|
379
|
+
warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} else if (failure) {
|
|
383
|
+
const unmerged = (await tower.tryGit(tower.root, ['ls-files', '-u'])) ?? ''
|
|
384
|
+
const conflict = unmerged.trim().length > 0
|
|
385
|
+
let cleanup = 'none'
|
|
386
|
+
const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
|
|
387
|
+
if (await exists(mergeHeadPath)) {
|
|
388
|
+
try {
|
|
389
|
+
await tower.git(tower.root, ['merge', '--abort'])
|
|
390
|
+
cleanup = 'aborted'
|
|
391
|
+
} catch {
|
|
392
|
+
await removeStaleGitLocks(tower.commonDir)
|
|
393
|
+
try {
|
|
394
|
+
await tower.git(tower.root, ['merge', '--abort'])
|
|
395
|
+
cleanup = 'aborted-after-lock-cleanup'
|
|
396
|
+
} catch {
|
|
397
|
+
await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
|
|
398
|
+
cleanup = 'reset'
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} else if (newTip !== intent.baseTip) {
|
|
402
|
+
// HEAD moved without Tower's expected merge shape. This may be a
|
|
403
|
+
// concurrent commit; never hard-reset it away. Leave a visible
|
|
404
|
+
// manual-recovery state instead.
|
|
405
|
+
cleanup = 'manual-required'
|
|
406
|
+
warnings.push('HEAD moved unexpectedly during a failed merge; left in place for manual recovery')
|
|
407
|
+
}
|
|
408
|
+
// A killed git process can leave the index staged even without MERGE_HEAD.
|
|
409
|
+
// Restore only when every dirty path belongs to this merge.
|
|
410
|
+
const dirtyAfter = await dirtyPaths(tower)
|
|
411
|
+
if (dirtyAfter.length > 0) {
|
|
412
|
+
const changed = new Set(plan.changedFiles)
|
|
413
|
+
if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
|
|
414
|
+
await removeStaleGitLocks(tower.commonDir)
|
|
415
|
+
await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
|
|
416
|
+
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
417
|
+
} else {
|
|
418
|
+
warnings.push(`cleanup left changes for manual review: ${dirtyAfter.slice(0, 5).join(', ')}`)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
kind: mergeFailureKind(conflict),
|
|
423
|
+
detail: describeGitFailure(failure).slice(0, 2000),
|
|
424
|
+
cleanup,
|
|
425
|
+
warnings,
|
|
426
|
+
timeout: failure.timedOut === true,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!expectedShape) {
|
|
431
|
+
return { kind: 'merge-shape', newTip, parents, warnings }
|
|
432
|
+
}
|
|
433
|
+
return { kind: 'merged', newTip, parents, warnings }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Phase 3: apply the observed outcome under a short state lock. */
|
|
437
|
+
async function commitMerge(tower, plan, recorded, outcome) {
|
|
438
|
+
return withLock(tower.paths, async () => {
|
|
439
|
+
const state = await tower.load()
|
|
440
|
+
const mission = state.missions.find((entry) => entry.id === plan.missionId)
|
|
441
|
+
if (!mission) return missionMissingResult(plan.missionId)
|
|
442
|
+
// State revision policy: another writer changed the state while Git was
|
|
443
|
+
// running. Do not overwrite their update and do not guess: keep the merge
|
|
444
|
+
// intent (the pending marker) so the next tower_merge/preflight reconciles
|
|
445
|
+
// the landed commit. Nothing has been written by this call yet.
|
|
446
|
+
if (state.revision !== recorded.intentRevision) {
|
|
447
|
+
return staleBaseResult(
|
|
448
|
+
plan.missionId,
|
|
449
|
+
`tower state changed while the merge was running (${recorded.intentRevision} -> ${state.revision}); the merge intent is retained — retry tower_merge to reconcile`,
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
const intentActive = intentMatches(state.mergeIntent, recorded.intent)
|
|
453
|
+
// The parents were read from HEAD immediately after the merge while the git
|
|
454
|
+
// mutex was still held. Re-verify them here: recording a "merged" mission is
|
|
455
|
+
// only safe when HEAD is [intent.baseTip, intent.branchTip].
|
|
456
|
+
const kind = classifyCommitOutcome(
|
|
457
|
+
outcome.kind,
|
|
458
|
+
outcome.parents,
|
|
459
|
+
recorded.intent.baseTip,
|
|
460
|
+
recorded.intent.branchTip,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if (kind === 'merged') {
|
|
464
|
+
const now = Date.now()
|
|
465
|
+
if (intentActive) delete state.mergeIntent
|
|
466
|
+
mission.status = 'merged'
|
|
467
|
+
mission.mergedAt = now
|
|
468
|
+
mission.mergedCommit = outcome.newTip
|
|
469
|
+
mission.updatedAt = now
|
|
470
|
+
mission.notes.push(mergeSuccessNote(recorded.intent.branchTip, state.base, outcome.newTip))
|
|
471
|
+
state.revision += 1
|
|
472
|
+
state.updatedAt = now
|
|
473
|
+
validateState(state, tower.paths.state)
|
|
474
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
475
|
+
await appendJsonl(tower.paths.activity, {
|
|
476
|
+
at: now,
|
|
477
|
+
type: 'merge',
|
|
478
|
+
missionId: mission.id,
|
|
479
|
+
text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
|
|
480
|
+
})
|
|
481
|
+
return {
|
|
482
|
+
ok: true,
|
|
483
|
+
mergedCommit: outcome.newTip,
|
|
484
|
+
baseTip: recorded.intent.baseTip,
|
|
485
|
+
branchTip: recorded.intent.branchTip,
|
|
486
|
+
parents: outcome.parents,
|
|
487
|
+
...(outcome.warnings.length > 0 ? { warnings: outcome.warnings, recoveredFromFailure: true } : {}),
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (kind === 'merge-shape') {
|
|
492
|
+
// Keep the intent and the observed tip. A shape mismatch means Tower
|
|
493
|
+
// cannot prove what landed; deleting the intent here would make the
|
|
494
|
+
// moved base unreconcilable and could discard a real commit.
|
|
495
|
+
const detail = mergeShapeDetail(outcome.parents, recorded.intent.baseTip, recorded.intent.branchTip)
|
|
496
|
+
if (intentActive) {
|
|
497
|
+
state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
|
|
498
|
+
state.revision += 1
|
|
499
|
+
state.updatedAt = Date.now()
|
|
500
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
501
|
+
await appendJsonl(tower.paths.activity, {
|
|
502
|
+
at: state.updatedAt,
|
|
503
|
+
type: 'merge',
|
|
504
|
+
missionId: mission.id,
|
|
505
|
+
text: `shape-mismatch newTip=${outcome.newTip.slice(0, 12)} parents=${outcome.parents.length}`,
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
missionId: mission.id,
|
|
511
|
+
blockers: [{ code: 'merge-shape', detail, cleanup: 'manual-required' }],
|
|
512
|
+
newTip: outcome.newTip,
|
|
513
|
+
parents: outcome.parents,
|
|
514
|
+
intentRetained: true,
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// merge-conflict / merge-hook-failed: the merge created no commit.
|
|
519
|
+
const conflict = kind === 'merge-conflict'
|
|
520
|
+
const now = Date.now()
|
|
521
|
+
if (intentActive) delete state.mergeIntent
|
|
522
|
+
if (conflict) {
|
|
523
|
+
mission.status = 'blocked'
|
|
524
|
+
mission.blockers.push(mergeConflictNote(outcome.detail))
|
|
525
|
+
} else {
|
|
526
|
+
mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
|
|
527
|
+
}
|
|
528
|
+
mission.updatedAt = now
|
|
529
|
+
state.revision += 1
|
|
530
|
+
state.updatedAt = now
|
|
531
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
532
|
+
await appendJsonl(tower.paths.activity, {
|
|
533
|
+
at: now,
|
|
534
|
+
type: 'merge',
|
|
535
|
+
missionId: mission.id,
|
|
536
|
+
text: `${conflict ? 'conflict' : 'failed-before-commit'} (${outcome.cleanup})`,
|
|
537
|
+
})
|
|
538
|
+
return mergeBlockedResult({
|
|
539
|
+
missionId: mission.id,
|
|
540
|
+
code: conflict ? 'merge-conflict' : 'merge-hook-failed',
|
|
541
|
+
detail: outcome.detail,
|
|
542
|
+
timeout: outcome.timeout === true,
|
|
543
|
+
cleanup: outcome.cleanup,
|
|
544
|
+
warnings: outcome.warnings,
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function merge(tower, input, deps) {
|
|
550
|
+
const plan = await prepareMerge(tower, input, deps)
|
|
551
|
+
if (plan.result) return plan.result
|
|
552
|
+
|
|
553
|
+
const acted = await withGitLock(tower.paths, async () => {
|
|
554
|
+
// No Git mutation has happened yet: a base that moved after prepare is a
|
|
555
|
+
// clean retryable stale-base rejection, with no intent left behind.
|
|
556
|
+
const head = await tower.revParse(tower.root, 'HEAD')
|
|
557
|
+
if (head !== plan.baseTip) {
|
|
558
|
+
return {
|
|
559
|
+
result: staleBaseResult(
|
|
560
|
+
plan.missionId,
|
|
561
|
+
`base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
|
|
562
|
+
),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const recorded = await recordMergeIntent(tower, plan)
|
|
566
|
+
if (recorded.result) return recorded
|
|
567
|
+
const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
|
|
568
|
+
return { recorded, outcome }
|
|
569
|
+
})
|
|
570
|
+
if (acted.result) return acted.result
|
|
571
|
+
|
|
572
|
+
return commitMerge(tower, plan, acted.recorded, acted.outcome)
|
|
573
|
+
}
|
package/app/messages.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Inbox/message use cases.
|
|
2
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
|
+
import { validateState } from '../domain/state.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import {
|
|
6
|
+
assertMessageRecipient,
|
|
7
|
+
createMessage,
|
|
8
|
+
addDelivery,
|
|
9
|
+
inboxView,
|
|
10
|
+
} from '../domain/collab/messages.js'
|
|
11
|
+
|
|
12
|
+
function fail(message) {
|
|
13
|
+
throw new TowerToolError('invalid-argument', message)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function recordMessage(tower, input) {
|
|
17
|
+
return withLock(tower.paths, async () => {
|
|
18
|
+
const state = await tower.load()
|
|
19
|
+
if (!Array.isArray(state.messages)) state.messages = []
|
|
20
|
+
assertMessageRecipient({ roster: state.roster ?? [], to: input.to, from: input.from })
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
const seq = (state.messageSeq ?? 0) + 1
|
|
23
|
+
state.messageSeq = seq
|
|
24
|
+
const message = createMessage(input, seq, now)
|
|
25
|
+
state.messages.push(message)
|
|
26
|
+
state.revision += 1
|
|
27
|
+
state.updatedAt = now
|
|
28
|
+
|
|
29
|
+
validateState(state, tower.paths.state)
|
|
30
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
31
|
+
await appendJsonl(tower.paths.activity, {
|
|
32
|
+
at: now,
|
|
33
|
+
type: 'message',
|
|
34
|
+
text: `${input.from} -> ${input.to}: ${input.subject}`,
|
|
35
|
+
})
|
|
36
|
+
return { ...message, readBy: [], delivery: [] }
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function recordDelivery(tower, messageId, results) {
|
|
41
|
+
return withLock(tower.paths, async () => {
|
|
42
|
+
const state = await tower.load()
|
|
43
|
+
const message = (state.messages ?? []).find((entry) => entry.id === messageId)
|
|
44
|
+
if (!message) fail(`unknown message "${messageId}"`)
|
|
45
|
+
addDelivery(message, results)
|
|
46
|
+
state.revision += 1
|
|
47
|
+
state.updatedAt = Date.now()
|
|
48
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
49
|
+
return message.delivery
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function inbox(tower, input) {
|
|
54
|
+
return withLock(tower.paths, async () => {
|
|
55
|
+
const state = await tower.load()
|
|
56
|
+
const now = Date.now()
|
|
57
|
+
const { payload, changed } = inboxView(state.messages ?? [], input, now)
|
|
58
|
+
if (changed) {
|
|
59
|
+
state.revision += 1
|
|
60
|
+
state.updatedAt = now
|
|
61
|
+
validateState(state, tower.paths.state)
|
|
62
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
63
|
+
}
|
|
64
|
+
return payload
|
|
65
|
+
})
|
|
66
|
+
}
|
package/app/mission.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Mission lifecycle update use case.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
|
|
4
|
+
import { validateState } from '../domain/state.js'
|
|
5
|
+
import { fullMission } from '../domain/summary.js'
|
|
6
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
7
|
+
import { sweepArchive } from './archive.js'
|
|
8
|
+
|
|
9
|
+
function fail(message, code = 'invalid-argument') {
|
|
10
|
+
throw new TowerToolError(code, message)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function updateMission(tower, input = {}) {
|
|
14
|
+
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
15
|
+
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
16
|
+
return withLock(tower.paths, async () => {
|
|
17
|
+
const state = await tower.load()
|
|
18
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
19
|
+
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
20
|
+
const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
mission.updatedAt = now
|
|
23
|
+
await sweepArchive(tower, state, { now })
|
|
24
|
+
state.revision += 1
|
|
25
|
+
state.updatedAt = now
|
|
26
|
+
|
|
27
|
+
validateState(state, tower.paths.state)
|
|
28
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
29
|
+
await appendJsonl(tower.paths.activity, {
|
|
30
|
+
at: now,
|
|
31
|
+
type: 'mission',
|
|
32
|
+
missionId: mission.id,
|
|
33
|
+
text: changes.join('; '),
|
|
34
|
+
})
|
|
35
|
+
return { revision: state.revision, mission: fullMission(mission) }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Mission context projection use case (reviews, findings, dependencies).
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { missionContextView } from '../domain/read/missionContext.js'
|
|
4
|
+
|
|
5
|
+
function fail(message, code = 'invalid-argument') {
|
|
6
|
+
throw new TowerToolError(code, message)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function missionContext(tower, missionId) {
|
|
10
|
+
const state = await tower.load()
|
|
11
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
12
|
+
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
13
|
+
return missionContextView({ state, mission })
|
|
14
|
+
}
|