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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Pure init decisions: explicit/fallback base selection, ownership and reanchor
|
|
2
|
+
// policy, snapshot decisions, and the initial/existing result projections. All
|
|
3
|
+
// fs/git work and state persistence stay in app/init.js.
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { SCHEMA_VERSION, TERMINAL_STATUSES } from '../mission.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
9
|
+
|
|
10
|
+
export const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} commit
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function snapshotRefFor(commit) {
|
|
17
|
+
return `${SNAPSHOT_REF_PREFIX}${commit}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {string | null}
|
|
23
|
+
*/
|
|
24
|
+
export function explicitBase(value) {
|
|
25
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An explicit base must be a local branch name, never a raw ref or remote ref.
|
|
30
|
+
* @param {string} base
|
|
31
|
+
* @returns {string | null} the failure message, or null when valid
|
|
32
|
+
*/
|
|
33
|
+
export function invalidBaseMessage(base) {
|
|
34
|
+
return /^refs\//.test(base) || base.startsWith('origin/') ? `base must be a local branch name, not ${base}` : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Without an explicit base, use the checked-out branch and fall back to `main`
|
|
39
|
+
* only for an unborn repository.
|
|
40
|
+
* @param {object} input
|
|
41
|
+
* @param {string | null} [input.branch]
|
|
42
|
+
* @param {boolean} input.committed
|
|
43
|
+
* @returns {{ base: string } | { error: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function fallbackBase({ branch, committed }) {
|
|
46
|
+
let base = branch
|
|
47
|
+
if (!base && !committed) base = 'main'
|
|
48
|
+
if (!base) return { error: 'repository is in detached HEAD; pass an explicit local base branch' }
|
|
49
|
+
return { base }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string | null} owner
|
|
54
|
+
* @param {string | null} caller
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function ownedByOther(owner, caller) {
|
|
58
|
+
return Boolean(owner && caller && owner !== caller)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string | null} owner
|
|
63
|
+
* @param {string | null} caller
|
|
64
|
+
* @returns {string | null} the caller that claims an unowned state
|
|
65
|
+
*/
|
|
66
|
+
export function claimCaller(owner, caller) {
|
|
67
|
+
return !owner && caller ? caller : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {Mission[]} missions
|
|
72
|
+
* @returns {string | null} refusal message, or null when reanchor may proceed
|
|
73
|
+
*/
|
|
74
|
+
export function reanchorBlockedMessage(missions) {
|
|
75
|
+
const active = missions.filter((mission) => !TERMINAL_STATUSES.has(mission.status))
|
|
76
|
+
return active.length > 0 ? `cannot reanchor base while ${active.length} mission(s) are not merged or abandoned` : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {object} input
|
|
81
|
+
* @param {boolean} input.reanchor
|
|
82
|
+
* @param {string | null} input.reanchorBase
|
|
83
|
+
* @param {string} input.currentBase
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function shouldReanchor({ reanchor, reanchorBase, currentBase }) {
|
|
87
|
+
return reanchor === true && reanchorBase !== currentBase
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} input
|
|
92
|
+
* @param {boolean} input.snapshotDirty
|
|
93
|
+
* @param {boolean} input.dirty
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function shouldSnapshot({ snapshotDirty, dirty }) {
|
|
97
|
+
return snapshotDirty === true && dirty
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} input
|
|
102
|
+
* @param {TowerState} input.state
|
|
103
|
+
* @param {string} input.statePath
|
|
104
|
+
* @param {boolean} input.dirty
|
|
105
|
+
* @returns {object}
|
|
106
|
+
*/
|
|
107
|
+
export function existingInitResult({ state, statePath, dirty }) {
|
|
108
|
+
return {
|
|
109
|
+
created: false,
|
|
110
|
+
repoRoot: state.repoRoot,
|
|
111
|
+
commonDir: state.commonDir,
|
|
112
|
+
statePath,
|
|
113
|
+
base: state.base,
|
|
114
|
+
revision: state.revision,
|
|
115
|
+
missions: state.missions.length,
|
|
116
|
+
reanchored: false,
|
|
117
|
+
dirty,
|
|
118
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
119
|
+
caseInsensitive: state.caseInsensitive ?? null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {object} input
|
|
125
|
+
* @param {string} input.repoRoot
|
|
126
|
+
* @param {string} input.commonDir
|
|
127
|
+
* @param {string} input.base
|
|
128
|
+
* @param {boolean} input.dirty
|
|
129
|
+
* @param {string | null} [input.towerAgentId]
|
|
130
|
+
* @param {string | null} [input.snapshotCommit]
|
|
131
|
+
* @param {string | null} [input.snapshotRef]
|
|
132
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
133
|
+
* @param {number} input.now
|
|
134
|
+
* @returns {TowerState}
|
|
135
|
+
*/
|
|
136
|
+
export function initialState({
|
|
137
|
+
repoRoot,
|
|
138
|
+
commonDir,
|
|
139
|
+
base,
|
|
140
|
+
dirty,
|
|
141
|
+
towerAgentId = null,
|
|
142
|
+
snapshotCommit = null,
|
|
143
|
+
snapshotRef = null,
|
|
144
|
+
caseInsensitive = null,
|
|
145
|
+
now,
|
|
146
|
+
}) {
|
|
147
|
+
return {
|
|
148
|
+
schemaVersion: SCHEMA_VERSION,
|
|
149
|
+
repoRoot,
|
|
150
|
+
commonDir,
|
|
151
|
+
base,
|
|
152
|
+
revision: 0,
|
|
153
|
+
createdAt: now,
|
|
154
|
+
updatedAt: now,
|
|
155
|
+
dirtyBase: dirty,
|
|
156
|
+
caseInsensitive,
|
|
157
|
+
...(towerAgentId ? { towerAgentId: String(towerAgentId) } : {}),
|
|
158
|
+
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
159
|
+
missions: [],
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {object} input
|
|
165
|
+
* @param {string} input.repoRoot
|
|
166
|
+
* @param {string} input.commonDir
|
|
167
|
+
* @param {string} input.statePath
|
|
168
|
+
* @param {string} input.base
|
|
169
|
+
* @param {boolean} input.dirty
|
|
170
|
+
* @param {string | null} input.snapshotCommit
|
|
171
|
+
* @param {string | null} input.snapshotRef
|
|
172
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
173
|
+
* @returns {object}
|
|
174
|
+
*/
|
|
175
|
+
export function createdInitResult({
|
|
176
|
+
repoRoot,
|
|
177
|
+
commonDir,
|
|
178
|
+
statePath,
|
|
179
|
+
base,
|
|
180
|
+
dirty,
|
|
181
|
+
snapshotCommit,
|
|
182
|
+
snapshotRef,
|
|
183
|
+
caseInsensitive = null,
|
|
184
|
+
}) {
|
|
185
|
+
return {
|
|
186
|
+
created: true,
|
|
187
|
+
repoRoot,
|
|
188
|
+
commonDir,
|
|
189
|
+
statePath,
|
|
190
|
+
base,
|
|
191
|
+
revision: 0,
|
|
192
|
+
missions: 0,
|
|
193
|
+
reanchored: false,
|
|
194
|
+
dirty,
|
|
195
|
+
snapshotCommit,
|
|
196
|
+
snapshotRef,
|
|
197
|
+
caseInsensitive,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {object} input
|
|
203
|
+
* @param {TowerState} input.state
|
|
204
|
+
* @param {string} input.statePath
|
|
205
|
+
* @param {boolean} input.dirty
|
|
206
|
+
* @param {boolean} input.reanchored
|
|
207
|
+
* @returns {object}
|
|
208
|
+
*/
|
|
209
|
+
export function existingInitWriteResult({ state, statePath, dirty, reanchored }) {
|
|
210
|
+
return {
|
|
211
|
+
created: false,
|
|
212
|
+
repoRoot: state.repoRoot,
|
|
213
|
+
commonDir: state.commonDir,
|
|
214
|
+
statePath,
|
|
215
|
+
base: state.base,
|
|
216
|
+
revision: state.revision,
|
|
217
|
+
missions: state.missions.length,
|
|
218
|
+
reanchored,
|
|
219
|
+
dirty,
|
|
220
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
221
|
+
caseInsensitive: state.caseInsensitive ?? null,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {object} input
|
|
227
|
+
* @param {string} input.base
|
|
228
|
+
* @param {string | null} [input.snapshotCommit]
|
|
229
|
+
* @param {boolean} input.dirty
|
|
230
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
export function initActivityText({ base, snapshotCommit = null, dirty, caseInsensitive = null }) {
|
|
234
|
+
const mode = caseInsensitive === null ? '' : ` (${caseInsensitive ? 'case-insensitive' : 'case-sensitive'} filesystem)`
|
|
235
|
+
return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}${mode}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Activity line for a probe recorded on an existing state.
|
|
240
|
+
* @param {boolean} caseInsensitive
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function caseProbeActivityText(caseInsensitive) {
|
|
244
|
+
return `case sensitivity detected: ${caseInsensitive ? 'case-insensitive' : 'case-sensitive'}`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {string} caller
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
export function ownerClaimActivityText(caller) {
|
|
252
|
+
return `tower owner claimed by ${caller}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} base
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
export function reanchorActivityText(base) {
|
|
260
|
+
return `base reanchored to ${base}`
|
|
261
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pure roster-liveness decisions shared by rebase/teardown guards. A live
|
|
2
|
+
// worker is a roster row for the mission that is neither inactive nor finished
|
|
3
|
+
// and whose observed activity is "running".
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { ROSTER_INACTIVE } from '../roster.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
8
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {TowerState} state
|
|
12
|
+
* @param {string} missionId
|
|
13
|
+
* @param {Record<string, string | undefined>} activity
|
|
14
|
+
* @returns {RosterEntry | undefined}
|
|
15
|
+
*/
|
|
16
|
+
export function liveAgentFor(state, missionId, activity) {
|
|
17
|
+
return (state.roster ?? []).find(
|
|
18
|
+
(entry) =>
|
|
19
|
+
entry.missionId === missionId && !ROSTER_INACTIVE.has(entry.state) && activity[String(entry.agentId)] === 'running',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {TowerState} state
|
|
25
|
+
* @param {string} missionId
|
|
26
|
+
* @param {Record<string, string | undefined>} activity
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function hasLiveAgent(state, missionId, activity) {
|
|
30
|
+
return liveAgentFor(state, missionId, activity) !== undefined
|
|
31
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Pure merge decisions: merge-intent / parent / tip validation, commit-stage
|
|
2
|
+
// classification, and already-landed reconciliation decisions. Git reads and
|
|
3
|
+
// writes, the state lock, and persistence stay in app/merge.js.
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { isReviewExempt, latestReview, reviewsForBranch } from './review.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../../types/index.js').Review} Review */
|
|
9
|
+
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} MergeIntent
|
|
13
|
+
* @property {string} missionId
|
|
14
|
+
* @property {string} baseTip
|
|
15
|
+
* @property {string} branchTip
|
|
16
|
+
* @property {string} [gateTip]
|
|
17
|
+
* @property {number} [startedAt]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} LandedMerge
|
|
22
|
+
* @property {string} commit
|
|
23
|
+
* @property {string[]} parents
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} missionId
|
|
28
|
+
* @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
|
|
29
|
+
*/
|
|
30
|
+
export function missionMissingResult(missionId) {
|
|
31
|
+
return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${missionId}"` }] }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} missionId
|
|
36
|
+
* @param {string} detail
|
|
37
|
+
* @returns {{ ok: false, missionId: string, blockers: Array<{ code: string, detail: string }> }}
|
|
38
|
+
*/
|
|
39
|
+
export function staleBaseResult(missionId, detail) {
|
|
40
|
+
return { ok: false, missionId, blockers: [{ code: 'stale-base', detail }] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} missionId
|
|
45
|
+
* @param {string} detail
|
|
46
|
+
* @returns {{ ok: false, missionId: string, blockers: Array<{ code: string, detail: string }> }}
|
|
47
|
+
*/
|
|
48
|
+
export function tipMovedResult(missionId, detail) {
|
|
49
|
+
return { ok: false, missionId, blockers: [{ code: 'tip-moved', detail }] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Mission} mission
|
|
54
|
+
* @param {string} baseTip
|
|
55
|
+
* @returns {{ ok: true, alreadyMerged: true, mergedCommit: string | null, baseTip: string }}
|
|
56
|
+
*/
|
|
57
|
+
export function alreadyMergedResult(mission, baseTip) {
|
|
58
|
+
return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The gate captured exact tips; a moved tip invalidates the prepared merge.
|
|
63
|
+
* Base drift keeps the historical `stale-base` code, branch drift `tip-moved`.
|
|
64
|
+
* @param {object} input
|
|
65
|
+
* @param {string} input.missionId
|
|
66
|
+
* @param {string | null} [input.gateBaseTip]
|
|
67
|
+
* @param {string | null} [input.gateBranchTip]
|
|
68
|
+
* @param {string} input.baseTip
|
|
69
|
+
* @param {string} input.branchTip
|
|
70
|
+
* @returns {ReturnType<typeof staleBaseResult> | ReturnType<typeof tipMovedResult> | null}
|
|
71
|
+
*/
|
|
72
|
+
export function gateTipDrift({ missionId, gateBaseTip = null, gateBranchTip = null, baseTip, branchTip }) {
|
|
73
|
+
if (gateBaseTip && gateBaseTip !== baseTip) {
|
|
74
|
+
return staleBaseResult(
|
|
75
|
+
missionId,
|
|
76
|
+
`base moved after gate (${gateBaseTip.slice(0, 7)} -> ${baseTip.slice(0, 7)}); re-run preflight`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (gateBranchTip && gateBranchTip !== branchTip) {
|
|
80
|
+
return tipMovedResult(
|
|
81
|
+
missionId,
|
|
82
|
+
`branch moved after gate (${gateBranchTip.slice(0, 7)} -> ${branchTip.slice(0, 7)}); re-run preflight`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {MergeIntent | null | undefined} current
|
|
90
|
+
* @param {MergeIntent} intent
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function intentMatches(current, intent) {
|
|
94
|
+
return Boolean(
|
|
95
|
+
current &&
|
|
96
|
+
current.missionId === intent.missionId &&
|
|
97
|
+
current.baseTip === intent.baseTip &&
|
|
98
|
+
current.branchTip === intent.branchTip,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `--no-ff` puts the integrated branch on the second parent.
|
|
104
|
+
* @param {string[] | null | undefined} parents
|
|
105
|
+
* @param {string} baseTip
|
|
106
|
+
* @param {string} branchTip
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function isExpectedMergeShape(parents, baseTip, branchTip) {
|
|
110
|
+
return Array.isArray(parents) && parents.length === 2 && parents[0] === baseTip && parents[1] === branchTip
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Only one merge may be in flight at a time.
|
|
115
|
+
* @param {string} missionId
|
|
116
|
+
* @param {MergeIntent} existing
|
|
117
|
+
* @returns {{ ok: false, missionId: string, blockers: Array<{ code: string, detail: string }> }}
|
|
118
|
+
*/
|
|
119
|
+
export function pendingMergeIntentResult(missionId, existing) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
missionId,
|
|
123
|
+
blockers: [
|
|
124
|
+
{
|
|
125
|
+
code: 'merge-intent-pending',
|
|
126
|
+
detail: `a merge intent for ${existing.missionId} is recorded; run tower_merge to reconcile it before starting another merge`,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {object} input
|
|
134
|
+
* @param {string} input.missionId
|
|
135
|
+
* @param {string} input.baseTip
|
|
136
|
+
* @param {string} input.branchTip
|
|
137
|
+
* @param {string} input.gateTip
|
|
138
|
+
* @param {number} input.startedAt
|
|
139
|
+
* @returns {MergeIntent}
|
|
140
|
+
*/
|
|
141
|
+
export function buildMergeIntent({ missionId, baseTip, branchTip, gateTip, startedAt }) {
|
|
142
|
+
return { missionId, baseTip, branchTip, gateTip, startedAt }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A "merged" observation only counts when the parents are the captured tips.
|
|
147
|
+
* @param {string} kind
|
|
148
|
+
* @param {string[]} parents
|
|
149
|
+
* @param {string} baseTip
|
|
150
|
+
* @param {string} branchTip
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export function classifyCommitOutcome(kind, parents, baseTip, branchTip) {
|
|
154
|
+
return kind === 'merged' && !isExpectedMergeShape(parents, baseTip, branchTip) ? 'merge-shape' : kind
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {boolean} conflict
|
|
159
|
+
* @returns {'merge-conflict' | 'merge-hook-failed'}
|
|
160
|
+
*/
|
|
161
|
+
export function mergeFailureKind(conflict) {
|
|
162
|
+
return conflict ? 'merge-conflict' : 'merge-hook-failed'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string[]} parents
|
|
167
|
+
* @param {string} baseTip
|
|
168
|
+
* @param {string} branchTip
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
export function mergeShapeDetail(parents, baseTip, branchTip) {
|
|
172
|
+
return `unexpected merge parents [${parents.join(', ')}], expected [${baseTip}, ${branchTip}]`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {string} branchTip
|
|
177
|
+
* @param {string} base
|
|
178
|
+
* @param {string} newTip
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function mergeSuccessNote(branchTip, base, newTip) {
|
|
182
|
+
return `merged ${branchTip.slice(0, 7)} into ${base} as ${newTip.slice(0, 7)}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {string} detail
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
export function mergeConflictNote(detail) {
|
|
190
|
+
return `merge conflict: ${detail.slice(0, 240)}`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} detail
|
|
195
|
+
* @param {string} status
|
|
196
|
+
* @returns {string}
|
|
197
|
+
*/
|
|
198
|
+
export function mergeHookFailureNote(detail, status) {
|
|
199
|
+
return `merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${status} for retry`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {object} input
|
|
204
|
+
* @param {string} input.missionId
|
|
205
|
+
* @param {string} input.code
|
|
206
|
+
* @param {string} input.detail
|
|
207
|
+
* @param {string[]} [input.warnings]
|
|
208
|
+
* @param {boolean} [input.timeout]
|
|
209
|
+
* @param {string} [input.cleanup]
|
|
210
|
+
* @returns {object}
|
|
211
|
+
*/
|
|
212
|
+
export function mergeBlockedResult({ missionId, code, detail, warnings = [], timeout = false, cleanup }) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
missionId,
|
|
216
|
+
blockers: [
|
|
217
|
+
{
|
|
218
|
+
code,
|
|
219
|
+
detail,
|
|
220
|
+
...(timeout === true ? { timeout: true } : {}),
|
|
221
|
+
...(cleanup === undefined ? {} : { cleanup }),
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Already-landed reconciliation only considers completed/blocked missions with
|
|
230
|
+
* a worktree whose review rules can be satisfied by the snapshot.
|
|
231
|
+
* @param {Mission} mission
|
|
232
|
+
* @param {Review[] | null | undefined} reviews
|
|
233
|
+
* @param {TowerConfig} [config]
|
|
234
|
+
* @returns {{ exempt: boolean, latest: Review | null } | null}
|
|
235
|
+
*/
|
|
236
|
+
export function landedMergeCandidate(mission, reviews, config = {}) {
|
|
237
|
+
if (!['completed', 'blocked'].includes(mission.status)) return null
|
|
238
|
+
if (!mission.worktree) return null
|
|
239
|
+
const branchReviews = reviewsForBranch(reviews, mission.worktree.branch)
|
|
240
|
+
const exempt = isReviewExempt(mission, config)
|
|
241
|
+
if (branchReviews.length === 0 && !exempt) return null
|
|
242
|
+
const latest = latestReview(branchReviews)
|
|
243
|
+
if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
|
|
244
|
+
if (!latest && !exempt) return null
|
|
245
|
+
return { exempt, latest }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {object} input
|
|
250
|
+
* @param {string} input.missionId
|
|
251
|
+
* @param {string} input.base
|
|
252
|
+
* @param {string} input.branchTip
|
|
253
|
+
* @param {string} input.reviewedTip
|
|
254
|
+
* @returns {object}
|
|
255
|
+
*/
|
|
256
|
+
export function reviewStaleResult({ missionId, base, branchTip, reviewedTip }) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
missionId,
|
|
260
|
+
blockers: [
|
|
261
|
+
{
|
|
262
|
+
code: 'review-stale',
|
|
263
|
+
detail: `reviewed commit ${reviewedTip.slice(0, 7)} is contained in ${base}, but branch tip ${branchTip.slice(0, 7)} is not; the branch was partially landed or moved — re-review or repair manually`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Decide what a landed-merge observation means from the two ancestry answers.
|
|
271
|
+
* `skip` means there is nothing safe to reconcile yet.
|
|
272
|
+
* @param {object} input
|
|
273
|
+
* @param {string} input.missionId
|
|
274
|
+
* @param {string} input.base
|
|
275
|
+
* @param {string} input.baseTip
|
|
276
|
+
* @param {string} input.branchTip
|
|
277
|
+
* @param {string | null} input.reviewedTip
|
|
278
|
+
* @param {boolean} input.branchLanded
|
|
279
|
+
* @param {boolean} input.reviewedLanded
|
|
280
|
+
* @param {LandedMerge | null} input.found
|
|
281
|
+
* @param {Review | null} input.latest
|
|
282
|
+
* @returns {{ skip: true } | { reviewStale: object } | { measured: object }}
|
|
283
|
+
*/
|
|
284
|
+
export function landedMergeEvidence({
|
|
285
|
+
missionId,
|
|
286
|
+
base,
|
|
287
|
+
baseTip,
|
|
288
|
+
branchTip,
|
|
289
|
+
reviewedTip,
|
|
290
|
+
branchLanded,
|
|
291
|
+
reviewedLanded,
|
|
292
|
+
found,
|
|
293
|
+
latest,
|
|
294
|
+
}) {
|
|
295
|
+
if (!branchLanded) {
|
|
296
|
+
if (!reviewedLanded) return { skip: true }
|
|
297
|
+
return { reviewStale: reviewStaleResult({ missionId, base, branchTip, reviewedTip: /** @type {string} */ (reviewedTip) }) }
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
measured: {
|
|
301
|
+
baseTip,
|
|
302
|
+
branchTip,
|
|
303
|
+
reviewedTip,
|
|
304
|
+
found,
|
|
305
|
+
mergedCommit: found ? found.commit : branchTip,
|
|
306
|
+
latest,
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {object} observed
|
|
313
|
+
* @param {string} observed.mergedCommit
|
|
314
|
+
* @param {string} observed.baseTip
|
|
315
|
+
* @param {string} observed.branchTip
|
|
316
|
+
* @param {LandedMerge | null} observed.found
|
|
317
|
+
* @param {Review | null} observed.latest
|
|
318
|
+
* @param {string | null} observed.reviewedTip
|
|
319
|
+
* @returns {object}
|
|
320
|
+
*/
|
|
321
|
+
export function landedMergeResult(observed) {
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
reconciled: true,
|
|
325
|
+
alreadyLanded: true,
|
|
326
|
+
mergedCommit: observed.mergedCommit,
|
|
327
|
+
baseTip: observed.baseTip,
|
|
328
|
+
branchTip: observed.branchTip,
|
|
329
|
+
parents: observed.found ? observed.found.parents : null,
|
|
330
|
+
review: observed.latest
|
|
331
|
+
? {
|
|
332
|
+
round: observed.latest.round,
|
|
333
|
+
status: observed.latest.status,
|
|
334
|
+
mergeVerdict: observed.latest.mergeVerdict,
|
|
335
|
+
reviewer: observed.latest.reviewer,
|
|
336
|
+
reviewedCommit: observed.reviewedTip,
|
|
337
|
+
}
|
|
338
|
+
: null,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {object} input
|
|
344
|
+
* @param {LandedMerge | null} input.found
|
|
345
|
+
* @param {string} input.mergedCommit
|
|
346
|
+
* @param {string} input.base
|
|
347
|
+
* @returns {string}
|
|
348
|
+
*/
|
|
349
|
+
export function landedMergeNote({ found, mergedCommit, base }) {
|
|
350
|
+
return found
|
|
351
|
+
? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
|
|
352
|
+
: `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${base} (no merge commit found)`
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @param {object} input
|
|
357
|
+
* @param {string} input.head
|
|
358
|
+
* @param {string} input.baseTip
|
|
359
|
+
* @param {boolean} input.mergeInProgress
|
|
360
|
+
* @param {LandedMerge | null} input.landed
|
|
361
|
+
* @returns {{ landed: LandedMerge | null } | null}
|
|
362
|
+
*/
|
|
363
|
+
export function mergeIntentEvidence({ head, baseTip, mergeInProgress, landed }) {
|
|
364
|
+
if (mergeInProgress) return null
|
|
365
|
+
if (head === baseTip) return { landed: null }
|
|
366
|
+
return landed ? { landed } : null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {{ landed: LandedMerge | null } | null} evidence
|
|
371
|
+
* @returns {string}
|
|
372
|
+
*/
|
|
373
|
+
export function mergeIntentReconcileNote(evidence) {
|
|
374
|
+
return evidence && evidence.landed
|
|
375
|
+
? `reconciled merge intent; found landed commit ${String(evidence.landed.commit).slice(0, 7)}`
|
|
376
|
+
: 'cleared a merge intent that created no commit'
|
|
377
|
+
}
|