dsh-tower 2.0.3 → 2.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
package/lib/tower.js
DELETED
|
@@ -1,2689 +0,0 @@
|
|
|
1
|
-
// Tower domain: repo-scoped mission state machine, validation, and gates.
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import fsp from 'node:fs/promises'
|
|
4
|
-
import {
|
|
5
|
-
git,
|
|
6
|
-
resolveRepo,
|
|
7
|
-
hasAnyCommit,
|
|
8
|
-
localBranchExists,
|
|
9
|
-
revParse,
|
|
10
|
-
branchExists,
|
|
11
|
-
currentBranch,
|
|
12
|
-
currentCommonDir,
|
|
13
|
-
addWorktree,
|
|
14
|
-
listWorktrees,
|
|
15
|
-
countCommits,
|
|
16
|
-
isClean,
|
|
17
|
-
isAncestor,
|
|
18
|
-
tryGit,
|
|
19
|
-
} from './git.js'
|
|
20
|
-
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from './batch.js'
|
|
21
|
-
import { assertOwnership, canonicalPath } from './worktreeOwnership.js'
|
|
22
|
-
import {
|
|
23
|
-
pathsFor,
|
|
24
|
-
exists,
|
|
25
|
-
ensureDir,
|
|
26
|
-
readJson,
|
|
27
|
-
writeJsonAtomic,
|
|
28
|
-
renameWithRetry,
|
|
29
|
-
appendJsonl,
|
|
30
|
-
readActivityTail,
|
|
31
|
-
withLock,
|
|
32
|
-
shortHash,
|
|
33
|
-
} from './store.js'
|
|
34
|
-
import { runtimeConfig, resolveRepoConfig } from './config.js'
|
|
35
|
-
|
|
36
|
-
export const SCHEMA_VERSION = 1
|
|
37
|
-
export const MISSION_STATUSES = [
|
|
38
|
-
'planned',
|
|
39
|
-
'active',
|
|
40
|
-
'completed',
|
|
41
|
-
'blocked',
|
|
42
|
-
'paused',
|
|
43
|
-
'merged',
|
|
44
|
-
'abandoned',
|
|
45
|
-
]
|
|
46
|
-
const TERMINAL = new Set(['merged', 'abandoned'])
|
|
47
|
-
/** Roster states that no longer represent a live writer. `inactive` is an idle
|
|
48
|
-
* child whose turn finished: it must not block a new owner or consume a worker
|
|
49
|
-
* slot. `unknown` is deliberately NOT included because it cannot be proven
|
|
50
|
-
* stopped. */
|
|
51
|
-
export const ROSTER_INACTIVE_STATES = ['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive']
|
|
52
|
-
const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
|
|
53
|
-
const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
|
|
54
|
-
const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
|
|
55
|
-
const TRANSITIONS = {
|
|
56
|
-
planned: ['active', 'abandoned'],
|
|
57
|
-
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
58
|
-
blocked: ['active', 'paused', 'completed', 'abandoned'],
|
|
59
|
-
paused: ['active', 'abandoned'],
|
|
60
|
-
completed: ['active', 'abandoned'],
|
|
61
|
-
merged: [],
|
|
62
|
-
abandoned: [],
|
|
63
|
-
}
|
|
64
|
-
const GLOB_META = /[*?[{]/
|
|
65
|
-
|
|
66
|
-
function slugify(title) {
|
|
67
|
-
const slug = String(title)
|
|
68
|
-
.toLowerCase()
|
|
69
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
70
|
-
.replace(/^-+|-+$/g, '')
|
|
71
|
-
.slice(0, 24)
|
|
72
|
-
return slug || 'mission'
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
76
|
-
export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
|
|
77
|
-
export const FINDING_STATUSES = ['open', 'triaged', 'fixed', 'wontfix']
|
|
78
|
-
export const FINDING_TRANSITIONS = {
|
|
79
|
-
open: ['triaged', 'fixed', 'wontfix'],
|
|
80
|
-
triaged: ['open', 'fixed', 'wontfix'],
|
|
81
|
-
fixed: ['open'],
|
|
82
|
-
wontfix: ['open'],
|
|
83
|
-
}
|
|
84
|
-
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
85
|
-
const MAX_REVIEW_ROUNDS = 5
|
|
86
|
-
const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
87
|
-
const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
|
|
88
|
-
const MERGE_MAX_BUFFER = 64 * 1024 * 1024
|
|
89
|
-
|
|
90
|
-
function mergeTimeoutMs(value, config) {
|
|
91
|
-
if (value === undefined || value === null) {
|
|
92
|
-
const configured = (config ?? runtimeConfig()).merge.timeoutMs
|
|
93
|
-
return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
|
|
94
|
-
}
|
|
95
|
-
const ms = Number(value)
|
|
96
|
-
if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
|
|
97
|
-
return ms
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function commitParents(cwd, sha) {
|
|
101
|
-
const line = await tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
|
|
102
|
-
return line ? line.trim().split(/\s+/).slice(1) : []
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Find a reachable merge commit whose second parent is one of the known tips.
|
|
107
|
-
* `--no-ff` merges put the integrated branch on the second parent, so this is
|
|
108
|
-
* the concrete evidence that a mission's work already landed in the base.
|
|
109
|
-
*/
|
|
110
|
-
async function findLandedMerge(cwd, baseTip, tips) {
|
|
111
|
-
const revs = (await tryGit(cwd, ['rev-list', '--merges', '-n', '100', baseTip])) ?? ''
|
|
112
|
-
for (const sha of revs.split('\n').filter(Boolean)) {
|
|
113
|
-
const parents = await commitParents(cwd, sha)
|
|
114
|
-
if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
|
|
115
|
-
}
|
|
116
|
-
return null
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function describeGitFailure(error) {
|
|
120
|
-
const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
|
|
121
|
-
return `${prefix}${String(error?.message ?? error)}`
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** A killed git process can leave lock files behind; only used after a failed merge. */
|
|
125
|
-
async function removeStaleGitLocks(commonDir) {
|
|
126
|
-
for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
|
|
127
|
-
await fsp.unlink(path.join(commonDir, name)).catch(() => {})
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function globToRegExp(pattern) {
|
|
132
|
-
let out = '^'
|
|
133
|
-
for (let index = 0; index < pattern.length; index += 1) {
|
|
134
|
-
const char = pattern[index]
|
|
135
|
-
if (char === '*') {
|
|
136
|
-
if (pattern[index + 1] === '*') {
|
|
137
|
-
out += '.*'
|
|
138
|
-
index += 1
|
|
139
|
-
} else {
|
|
140
|
-
out += '[^/]*'
|
|
141
|
-
}
|
|
142
|
-
} else if (char === '?') {
|
|
143
|
-
out += '[^/]'
|
|
144
|
-
} else {
|
|
145
|
-
out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return new RegExp(`${out}$`)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function fileInScope(scope, file) {
|
|
152
|
-
return (scope ?? []).some((pattern) => globToRegExp(pattern).test(file))
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function fail(message) {
|
|
156
|
-
throw new Error(message)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function isPlainObject(value) {
|
|
160
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function validateTitle(title) {
|
|
164
|
-
if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
|
|
165
|
-
if (!/^[\x20-\x7E]+$/.test(title)) {
|
|
166
|
-
fail(`mission title must be printable ASCII: ${JSON.stringify(title)}`)
|
|
167
|
-
}
|
|
168
|
-
return title.trim()
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function normalizeScope(scope, { allowEmpty = false } = {}) {
|
|
172
|
-
if (!Array.isArray(scope)) fail('mission scope must be an array of relative globs')
|
|
173
|
-
if (scope.length === 0 && !allowEmpty) fail('mission scope must not be empty for a build mission')
|
|
174
|
-
const out = []
|
|
175
|
-
for (const raw of scope) {
|
|
176
|
-
if (typeof raw !== 'string' || raw.trim().length === 0) fail('scope entries must be non-empty strings')
|
|
177
|
-
const value = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').trim()
|
|
178
|
-
if (value.length === 0) fail('scope entries must be non-empty strings')
|
|
179
|
-
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) fail(`scope must be relative: ${raw}`)
|
|
180
|
-
if (value.split('/').some((part) => part === '..')) fail(`scope must not contain "..": ${raw}`)
|
|
181
|
-
if (!out.includes(value)) out.push(value)
|
|
182
|
-
}
|
|
183
|
-
return out
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function scopePrefix(pattern) {
|
|
187
|
-
const index = pattern.search(GLOB_META)
|
|
188
|
-
const literal = index < 0 ? pattern : pattern.slice(0, index)
|
|
189
|
-
return literal.replace(/\/+$/, '')
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function scopesOverlap(a, b) {
|
|
193
|
-
for (const pa of a) {
|
|
194
|
-
for (const pb of b) {
|
|
195
|
-
if (pa === pb) return true
|
|
196
|
-
const x = scopePrefix(pa)
|
|
197
|
-
const y = scopePrefix(pb)
|
|
198
|
-
if (!x || !y) return true
|
|
199
|
-
if (x === y) return true
|
|
200
|
-
if (x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return false
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function nextMissionNumber(missions) {
|
|
207
|
-
let max = 0
|
|
208
|
-
for (const mission of missions) {
|
|
209
|
-
const match = /^M(\d+)$/.exec(mission.id)
|
|
210
|
-
if (match) max = Math.max(max, Number(match[1]))
|
|
211
|
-
}
|
|
212
|
-
return max + 1
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function addDependency(mission, dep) {
|
|
216
|
-
if (mission.id === dep) return
|
|
217
|
-
if (!mission.deps.includes(dep)) mission.deps.push(dep)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function hasDependencyPath(missionsById, fromId, toId) {
|
|
221
|
-
const seen = new Set()
|
|
222
|
-
const stack = [fromId]
|
|
223
|
-
while (stack.length > 0) {
|
|
224
|
-
const current = stack.pop()
|
|
225
|
-
if (current === toId) return true
|
|
226
|
-
if (seen.has(current)) continue
|
|
227
|
-
seen.add(current)
|
|
228
|
-
const mission = missionsById.get(current)
|
|
229
|
-
if (!mission) continue
|
|
230
|
-
for (const dep of mission.deps) stack.push(dep)
|
|
231
|
-
}
|
|
232
|
-
return false
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function assertAcyclic(missions) {
|
|
236
|
-
const byId = new Map(missions.map((mission) => [mission.id, mission]))
|
|
237
|
-
const state = new Map()
|
|
238
|
-
const visit = (id, trail) => {
|
|
239
|
-
const mark = state.get(id)
|
|
240
|
-
if (mark === 'done') return
|
|
241
|
-
if (mark === 'visiting') fail(`mission dependencies have a cycle: ${[...trail, id].join(' -> ')}`)
|
|
242
|
-
state.set(id, 'visiting')
|
|
243
|
-
const mission = byId.get(id)
|
|
244
|
-
if (mission) for (const dep of mission.deps) visit(dep, [...trail, id])
|
|
245
|
-
state.set(id, 'done')
|
|
246
|
-
}
|
|
247
|
-
for (const mission of missions) visit(mission.id, [])
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Kimi-style heuristic: overlapping build scopes are serialized by an
|
|
252
|
-
* auto-added dependency. It is conservative, not a proof of disjointness.
|
|
253
|
-
*/
|
|
254
|
-
function addAutoDeps(newMissions, existingMissions) {
|
|
255
|
-
const openExisting = existingMissions.filter((mission) => !TERMINAL.has(mission.status))
|
|
256
|
-
const all = [...existingMissions, ...newMissions]
|
|
257
|
-
const byId = new Map(all.map((mission) => [mission.id, mission]))
|
|
258
|
-
|
|
259
|
-
for (let i = 0; i < newMissions.length; i += 1) {
|
|
260
|
-
for (let j = i + 1; j < newMissions.length; j += 1) {
|
|
261
|
-
const earlier = newMissions[i]
|
|
262
|
-
const later = newMissions[j]
|
|
263
|
-
if (earlier.kind === 'survey' || later.kind === 'survey') continue
|
|
264
|
-
if (!scopesOverlap(earlier.scope, later.scope)) continue
|
|
265
|
-
if (hasDependencyPath(byId, later.id, earlier.id)) continue
|
|
266
|
-
addDependency(later, earlier.id)
|
|
267
|
-
}
|
|
268
|
-
const mission = newMissions[i]
|
|
269
|
-
if (mission.kind === 'survey') continue
|
|
270
|
-
for (const other of openExisting) {
|
|
271
|
-
if (other.kind === 'survey') continue
|
|
272
|
-
if (!scopesOverlap(mission.scope, other.scope)) continue
|
|
273
|
-
if (hasDependencyPath(byId, mission.id, other.id)) continue
|
|
274
|
-
addDependency(mission, other.id)
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function normalizeMissionInput(input, index) {
|
|
280
|
-
if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
|
|
281
|
-
const title = validateTitle(input.title)
|
|
282
|
-
const kind = input.kind === 'survey' ? 'survey' : 'build'
|
|
283
|
-
const scope = normalizeScope(input.scope ?? [], { allowEmpty: kind === 'survey' })
|
|
284
|
-
const tasks = []
|
|
285
|
-
if (input.tasks !== undefined) {
|
|
286
|
-
if (!Array.isArray(input.tasks)) fail(`missions[${index}].tasks must be an array of strings`)
|
|
287
|
-
for (const task of input.tasks) {
|
|
288
|
-
if (typeof task !== 'string' || task.trim().length === 0) {
|
|
289
|
-
fail(`missions[${index}].tasks entries must be non-empty strings`)
|
|
290
|
-
}
|
|
291
|
-
tasks.push({ text: task.trim(), status: 'open' })
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const deps = []
|
|
295
|
-
if (input.deps !== undefined) {
|
|
296
|
-
if (!Array.isArray(input.deps)) fail(`missions[${index}].deps must be an array of mission ids`)
|
|
297
|
-
for (const dep of input.deps) {
|
|
298
|
-
if (typeof dep !== 'string' || dep.trim().length === 0) {
|
|
299
|
-
fail(`missions[${index}].deps entries must be non-empty mission ids`)
|
|
300
|
-
}
|
|
301
|
-
if (!deps.includes(dep.trim())) deps.push(dep.trim())
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
|
|
305
|
-
const references = []
|
|
306
|
-
if (input.references !== undefined) {
|
|
307
|
-
if (!Array.isArray(input.references)) fail(`missions[${index}].references must be an array of pointers`)
|
|
308
|
-
if (input.references.length > 20) fail(`missions[${index}].references supports at most 20 pointers`)
|
|
309
|
-
for (const reference of input.references) {
|
|
310
|
-
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
311
|
-
fail(`missions[${index}].references entries must be non-empty strings`)
|
|
312
|
-
}
|
|
313
|
-
if (!references.includes(reference.trim())) references.push(reference.trim())
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
const reviewPolicy = input.reviewPolicy === undefined ? 'required-clean' : input.reviewPolicy
|
|
317
|
-
if (!['required-clean', 'none'].includes(reviewPolicy)) {
|
|
318
|
-
fail(`missions[${index}].reviewPolicy must be "required-clean" or "none"`)
|
|
319
|
-
}
|
|
320
|
-
const reviewExemptionReason = typeof input.reviewExemptionReason === 'string' ? input.reviewExemptionReason.trim() : ''
|
|
321
|
-
if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
|
|
322
|
-
fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
|
|
323
|
-
}
|
|
324
|
-
return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function newMission(id, input, now) {
|
|
328
|
-
return {
|
|
329
|
-
id,
|
|
330
|
-
title: input.title,
|
|
331
|
-
kind: input.kind,
|
|
332
|
-
scope: input.scope,
|
|
333
|
-
tasks: input.tasks,
|
|
334
|
-
deps: input.deps,
|
|
335
|
-
context: input.context,
|
|
336
|
-
references: input.references ?? [],
|
|
337
|
-
reviewPolicy: input.reviewPolicy ?? 'required-clean',
|
|
338
|
-
reviewExemptionReason: input.reviewExemptionReason ?? '',
|
|
339
|
-
status: 'planned',
|
|
340
|
-
owner: null,
|
|
341
|
-
blockers: [],
|
|
342
|
-
notes: [],
|
|
343
|
-
reviews: [],
|
|
344
|
-
scopeGeneration: 1,
|
|
345
|
-
createdAt: now,
|
|
346
|
-
updatedAt: now,
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
export function validateState(state, statePath) {
|
|
351
|
-
const where = statePath ? ` at ${statePath}` : ''
|
|
352
|
-
if (!isPlainObject(state)) fail(`tower state is not an object${where}`)
|
|
353
|
-
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
354
|
-
fail(`unsupported tower state schemaVersion ${state.schemaVersion}${where}`)
|
|
355
|
-
}
|
|
356
|
-
if (!Array.isArray(state.missions)) fail(`tower state has no missions array${where}`)
|
|
357
|
-
const ids = new Set()
|
|
358
|
-
for (const mission of state.missions) {
|
|
359
|
-
if (!isPlainObject(mission) || typeof mission.id !== 'string') fail(`mission entry without id${where}`)
|
|
360
|
-
if (ids.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
|
|
361
|
-
ids.add(mission.id)
|
|
362
|
-
if (!MISSION_STATUSES.includes(mission.status)) {
|
|
363
|
-
fail(`mission ${mission.id} has unknown status ${mission.status}${where}`)
|
|
364
|
-
}
|
|
365
|
-
if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
|
|
366
|
-
if (!Array.isArray(mission.deps)) fail(`mission ${mission.id} has no deps array${where}`)
|
|
367
|
-
if (mission.references !== undefined && !Array.isArray(mission.references)) {
|
|
368
|
-
fail(`mission ${mission.id} has no references array${where}`)
|
|
369
|
-
}
|
|
370
|
-
if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
|
|
371
|
-
fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
|
|
372
|
-
}
|
|
373
|
-
if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
|
|
374
|
-
fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
|
|
375
|
-
}
|
|
376
|
-
if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
|
|
377
|
-
fail(`mission ${mission.id} has no testRuns array${where}`)
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
for (const key of ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']) {
|
|
381
|
-
if (state[key] !== undefined && !Array.isArray(state[key])) fail(`tower state ${key} is not an array${where}`)
|
|
382
|
-
}
|
|
383
|
-
if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
|
|
384
|
-
fail(`tower state mergeIntent is not an object${where}`)
|
|
385
|
-
}
|
|
386
|
-
if (state.roster !== undefined) {
|
|
387
|
-
if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
|
|
388
|
-
const names = new Set()
|
|
389
|
-
const agents = new Set()
|
|
390
|
-
for (const entry of state.roster) {
|
|
391
|
-
if (!entry || typeof entry.name !== 'string' || typeof entry.agentId !== 'string') {
|
|
392
|
-
fail(`invalid roster entry${where}`)
|
|
393
|
-
}
|
|
394
|
-
if (names.has(entry.name)) fail(`duplicate roster name ${entry.name}${where}`)
|
|
395
|
-
if (agents.has(entry.agentId)) fail(`duplicate roster agent ${entry.agentId}${where}`)
|
|
396
|
-
names.add(entry.name)
|
|
397
|
-
agents.add(entry.agentId)
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
if (state.messages !== undefined) {
|
|
401
|
-
if (!Array.isArray(state.messages)) fail(`tower state messages is not an array${where}`)
|
|
402
|
-
const ids = new Set()
|
|
403
|
-
for (const message of state.messages) {
|
|
404
|
-
if (!message || typeof message.id !== 'string' || typeof message.to !== 'string') {
|
|
405
|
-
fail(`invalid message entry${where}`)
|
|
406
|
-
}
|
|
407
|
-
if (ids.has(message.id)) fail(`duplicate message id ${message.id}${where}`)
|
|
408
|
-
ids.add(message.id)
|
|
409
|
-
if (!Array.isArray(message.readBy)) fail(`message ${message.id} has no readBy array${where}`)
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
if (state.findings !== undefined) {
|
|
413
|
-
if (!Array.isArray(state.findings)) fail(`tower state findings is not an array${where}`)
|
|
414
|
-
const ids = new Set()
|
|
415
|
-
for (const finding of state.findings) {
|
|
416
|
-
if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
|
|
417
|
-
if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
|
|
418
|
-
ids.add(finding.id)
|
|
419
|
-
if (finding.status !== undefined && !FINDING_STATUSES.includes(finding.status)) {
|
|
420
|
-
fail(`finding ${finding.id} has unknown status ${finding.status}${where}`)
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
if (state.reviews !== undefined) {
|
|
425
|
-
if (!Array.isArray(state.reviews)) fail(`tower state reviews is not an array${where}`)
|
|
426
|
-
const ids = new Set()
|
|
427
|
-
for (const review of state.reviews) {
|
|
428
|
-
if (!review || typeof review.id !== 'string') fail(`invalid review entry${where}`)
|
|
429
|
-
if (ids.has(review.id)) fail(`duplicate review id ${review.id}${where}`)
|
|
430
|
-
ids.add(review.id)
|
|
431
|
-
if (!REVIEW_STATUS_RE.test(String(review.status))) {
|
|
432
|
-
fail(`review ${review.id} has unknown status ${review.status}${where}`)
|
|
433
|
-
}
|
|
434
|
-
if (!MERGE_VERDICTS.includes(review.mergeVerdict)) {
|
|
435
|
-
fail(`review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`)
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
return state
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function taskCounts(tasks) {
|
|
443
|
-
const counts = { total: tasks.length, done: 0, open: 0, dropped: 0 }
|
|
444
|
-
for (const task of tasks) {
|
|
445
|
-
if (task.status === 'done') counts.done += 1
|
|
446
|
-
else if (task.status === 'dropped') counts.dropped += 1
|
|
447
|
-
else counts.open += 1
|
|
448
|
-
}
|
|
449
|
-
return counts
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
export function missionSummary(mission) {
|
|
453
|
-
return {
|
|
454
|
-
id: mission.id,
|
|
455
|
-
title: mission.title,
|
|
456
|
-
kind: mission.kind,
|
|
457
|
-
status: mission.status,
|
|
458
|
-
scope: mission.scope,
|
|
459
|
-
deps: mission.deps,
|
|
460
|
-
owner: mission.owner ?? null,
|
|
461
|
-
worktree: mission.worktree
|
|
462
|
-
? {
|
|
463
|
-
path: mission.worktree.path,
|
|
464
|
-
branch: mission.worktree.branch,
|
|
465
|
-
baseCommit: mission.worktree.baseCommit,
|
|
466
|
-
}
|
|
467
|
-
: null,
|
|
468
|
-
blockers: mission.blockers ?? [],
|
|
469
|
-
tasks: taskCounts(mission.tasks ?? []),
|
|
470
|
-
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
471
|
-
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
472
|
-
pauseCause: mission.pauseCause ?? null,
|
|
473
|
-
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
474
|
-
mergedCommit: mission.mergedCommit ?? null,
|
|
475
|
-
mergedAt: mission.mergedAt ?? null,
|
|
476
|
-
updatedAt: mission.updatedAt,
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function fullMission(mission) {
|
|
481
|
-
return {
|
|
482
|
-
...missionSummary(mission),
|
|
483
|
-
tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
|
|
484
|
-
notes: mission.notes ?? [],
|
|
485
|
-
completionReport: mission.completionReport ?? null,
|
|
486
|
-
authorizationRef: mission.authorizationRef ?? null,
|
|
487
|
-
references: mission.references ?? [],
|
|
488
|
-
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
489
|
-
reviewExemptionReason: mission.reviewExemptionReason ?? '',
|
|
490
|
-
testRuns: mission.testRuns ?? [],
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function findingSummary(finding) {
|
|
495
|
-
return {
|
|
496
|
-
id: finding.id,
|
|
497
|
-
seq: finding.seq ?? null,
|
|
498
|
-
type: finding.type,
|
|
499
|
-
title: finding.title,
|
|
500
|
-
severity: finding.severity ?? null,
|
|
501
|
-
status: finding.status ?? 'open',
|
|
502
|
-
missionId: finding.missionId ?? null,
|
|
503
|
-
branch: finding.branch ?? null,
|
|
504
|
-
reportedBy: finding.reportedBy ?? null,
|
|
505
|
-
at: finding.at ?? null,
|
|
506
|
-
updatedAt: finding.updatedAt ?? null,
|
|
507
|
-
closedAt: finding.closedAt ?? null,
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function countFindings(findings) {
|
|
512
|
-
const counts = {}
|
|
513
|
-
for (const status of FINDING_STATUSES) counts[status] = 0
|
|
514
|
-
for (const finding of findings) {
|
|
515
|
-
const status = finding.status ?? 'open'
|
|
516
|
-
counts[status] = (counts[status] ?? 0) + 1
|
|
517
|
-
}
|
|
518
|
-
return counts
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function liveWorkerEntry(state, missionId) {
|
|
522
|
-
return (state.roster ?? []).find(
|
|
523
|
-
(entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
|
|
524
|
-
)
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function workerEntries(state, missionId) {
|
|
528
|
-
return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function countLiveWorkers(state) {
|
|
532
|
-
return (state.roster ?? []).filter((entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state)).length
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function normalizePauseCause(value) {
|
|
536
|
-
if (value === undefined || value === null || value === '') return 'user'
|
|
537
|
-
if (!PAUSE_CAUSES.includes(value)) fail(`pause_cause must be one of ${PAUSE_CAUSES.join('|')}`)
|
|
538
|
-
return value
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function normalizeChecks(checks) {
|
|
542
|
-
if (checks === undefined) return []
|
|
543
|
-
if (!Array.isArray(checks)) fail('checks must be an array')
|
|
544
|
-
if (checks.length > 20) fail('checks may contain at most 20 entries')
|
|
545
|
-
return checks.map((check, index) => {
|
|
546
|
-
if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
|
|
547
|
-
const name = typeof check.name === 'string' ? check.name.trim() : ''
|
|
548
|
-
if (!name) fail(`checks[${index}].name must be a non-empty string`)
|
|
549
|
-
const entry = { name }
|
|
550
|
-
if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
|
|
551
|
-
if (check.exitCode !== undefined && check.exitCode !== null) {
|
|
552
|
-
if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
|
|
553
|
-
entry.exitCode = check.exitCode
|
|
554
|
-
}
|
|
555
|
-
if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
|
|
556
|
-
return entry
|
|
557
|
-
})
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
export class Tower {
|
|
561
|
-
constructor(repo, config) {
|
|
562
|
-
this.root = repo.root
|
|
563
|
-
this.commonDir = repo.commonDir
|
|
564
|
-
this.branch = repo.branch
|
|
565
|
-
this.paths = pathsFor(repo.commonDir)
|
|
566
|
-
// Effective config for this repository: code defaults < profile config <
|
|
567
|
-
// repo config file. Per-call tool arguments are applied by the caller.
|
|
568
|
-
this.config = config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false }
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
static async open(repoRoot) {
|
|
572
|
-
const repo = await resolveRepo(repoRoot)
|
|
573
|
-
const config = await resolveRepoConfig(repo)
|
|
574
|
-
return new Tower(repo, config)
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
async load() {
|
|
578
|
-
if (!(await exists(this.paths.state))) {
|
|
579
|
-
fail(`tower is not initialized for ${this.root} — run tower_init first`)
|
|
580
|
-
}
|
|
581
|
-
const state = await readJson(this.paths.state)
|
|
582
|
-
return validateState(state, this.paths.state)
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async init(input = {}) {
|
|
586
|
-
await ensureDir(this.paths.dir)
|
|
587
|
-
return withLock(this.paths, async () => {
|
|
588
|
-
const now = Date.now()
|
|
589
|
-
const dirty = !(await isClean(this.root))
|
|
590
|
-
const committed = await hasAnyCommit(this.root)
|
|
591
|
-
const resolveBase = async () => {
|
|
592
|
-
let base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
|
|
593
|
-
if (base) {
|
|
594
|
-
if (/^refs\//.test(base) || base.startsWith('origin/')) {
|
|
595
|
-
fail(`base must be a local branch name, not ${base}`)
|
|
596
|
-
}
|
|
597
|
-
if (committed && !(await localBranchExists(this.root, base))) {
|
|
598
|
-
fail(`base branch "${base}" does not exist locally`)
|
|
599
|
-
}
|
|
600
|
-
return base
|
|
601
|
-
}
|
|
602
|
-
base = this.branch
|
|
603
|
-
if (!base && !committed) base = 'main'
|
|
604
|
-
if (!base) fail('repository is in detached HEAD; pass an explicit local base branch')
|
|
605
|
-
return base
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
if (await exists(this.paths.state)) {
|
|
609
|
-
const state = await this.load()
|
|
610
|
-
let reanchored = false
|
|
611
|
-
if (input.reanchor === true) {
|
|
612
|
-
const active = state.missions.filter((mission) => !TERMINAL.has(mission.status))
|
|
613
|
-
if (active.length > 0) {
|
|
614
|
-
fail(`cannot reanchor base while ${active.length} mission(s) are not merged or abandoned`)
|
|
615
|
-
}
|
|
616
|
-
const base = await resolveBase()
|
|
617
|
-
if (base !== state.base) {
|
|
618
|
-
const refs = (await tryGit(this.root, ['for-each-ref', '--format=%(refname)', SNAPSHOT_REF_PREFIX])) ?? ''
|
|
619
|
-
for (const ref of refs.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
620
|
-
await git(this.root, ['update-ref', '-d', ref]).catch(() => {})
|
|
621
|
-
}
|
|
622
|
-
state.base = base
|
|
623
|
-
state.baseChangedAt = now
|
|
624
|
-
delete state.snapshotCommit
|
|
625
|
-
delete state.snapshotRef
|
|
626
|
-
reanchored = true
|
|
627
|
-
state.revision += 1
|
|
628
|
-
state.updatedAt = now
|
|
629
|
-
validateState(state, this.paths.state)
|
|
630
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
631
|
-
await appendJsonl(this.paths.activity, { at: now, type: 'init', text: `base reanchored to ${base}` })
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
return {
|
|
635
|
-
created: false,
|
|
636
|
-
repoRoot: state.repoRoot,
|
|
637
|
-
commonDir: state.commonDir,
|
|
638
|
-
statePath: this.paths.state,
|
|
639
|
-
base: state.base,
|
|
640
|
-
revision: state.revision,
|
|
641
|
-
missions: state.missions.length,
|
|
642
|
-
reanchored,
|
|
643
|
-
dirty,
|
|
644
|
-
snapshotCommit: state.snapshotCommit ?? null,
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const base = await resolveBase()
|
|
649
|
-
let snapshotCommit = null
|
|
650
|
-
let snapshotRef = null
|
|
651
|
-
if (input.snapshotDirty === true && dirty) {
|
|
652
|
-
snapshotCommit = ((await tryGit(this.root, ['stash', 'create'])) ?? '').trim() || null
|
|
653
|
-
if (!snapshotCommit) {
|
|
654
|
-
fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
|
|
655
|
-
}
|
|
656
|
-
// A dangling commit can be pruned by git gc; keep a ref so the mission
|
|
657
|
-
// baseline stays reachable for the life of the state.
|
|
658
|
-
snapshotRef = `${SNAPSHOT_REF_PREFIX}${snapshotCommit}`
|
|
659
|
-
await git(this.root, ['update-ref', snapshotRef, snapshotCommit])
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const state = {
|
|
663
|
-
schemaVersion: SCHEMA_VERSION,
|
|
664
|
-
repoRoot: this.root,
|
|
665
|
-
commonDir: this.commonDir,
|
|
666
|
-
base,
|
|
667
|
-
revision: 0,
|
|
668
|
-
createdAt: now,
|
|
669
|
-
updatedAt: now,
|
|
670
|
-
dirtyBase: dirty,
|
|
671
|
-
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
672
|
-
missions: [],
|
|
673
|
-
}
|
|
674
|
-
validateState(state, this.paths.state)
|
|
675
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
676
|
-
await appendJsonl(this.paths.activity, {
|
|
677
|
-
at: now,
|
|
678
|
-
type: 'init',
|
|
679
|
-
text: `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}`,
|
|
680
|
-
})
|
|
681
|
-
return {
|
|
682
|
-
created: true,
|
|
683
|
-
repoRoot: this.root,
|
|
684
|
-
commonDir: this.commonDir,
|
|
685
|
-
statePath: this.paths.state,
|
|
686
|
-
base,
|
|
687
|
-
revision: 0,
|
|
688
|
-
missions: 0,
|
|
689
|
-
reanchored: false,
|
|
690
|
-
dirty,
|
|
691
|
-
snapshotCommit,
|
|
692
|
-
snapshotRef,
|
|
693
|
-
}
|
|
694
|
-
})
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
async plan(input = {}) {
|
|
698
|
-
if (!Array.isArray(input.missions) || input.missions.length === 0) {
|
|
699
|
-
fail('tower_plan needs at least one mission')
|
|
700
|
-
}
|
|
701
|
-
const prepared = input.missions.map((mission, index) => normalizeMissionInput(mission, index))
|
|
702
|
-
return withLock(this.paths, async () => {
|
|
703
|
-
const state = await this.load()
|
|
704
|
-
const now = Date.now()
|
|
705
|
-
const start = nextMissionNumber(state.missions)
|
|
706
|
-
const assigned = prepared.map((mission, index) => ({ ...mission, id: `M${start + index}` }))
|
|
707
|
-
|
|
708
|
-
const known = new Set([...state.missions.map((mission) => mission.id), ...assigned.map((mission) => mission.id)])
|
|
709
|
-
for (const mission of assigned) {
|
|
710
|
-
for (const dep of mission.deps) {
|
|
711
|
-
if (!known.has(dep)) fail(`mission ${mission.id} depends on unknown mission "${dep}"`)
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
addAutoDeps(assigned, state.missions)
|
|
716
|
-
assertAcyclic([...state.missions, ...assigned])
|
|
717
|
-
|
|
718
|
-
const created = assigned.map((mission) => newMission(mission.id, mission, now))
|
|
719
|
-
state.missions.push(...created)
|
|
720
|
-
state.revision += 1
|
|
721
|
-
state.updatedAt = now
|
|
722
|
-
|
|
723
|
-
validateState(state, this.paths.state)
|
|
724
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
725
|
-
await appendJsonl(this.paths.activity, {
|
|
726
|
-
at: now,
|
|
727
|
-
type: 'plan',
|
|
728
|
-
text: `created ${created.map((mission) => mission.id).join(', ')}`,
|
|
729
|
-
})
|
|
730
|
-
return {
|
|
731
|
-
revision: state.revision,
|
|
732
|
-
missions: created.map((mission) => fullMission(mission)),
|
|
733
|
-
}
|
|
734
|
-
})
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
async mission(input = {}) {
|
|
738
|
-
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
739
|
-
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
740
|
-
return withLock(this.paths, async () => {
|
|
741
|
-
const state = await this.load()
|
|
742
|
-
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
743
|
-
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
744
|
-
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
745
|
-
const workerRequest = actor.role === 'worker'
|
|
746
|
-
if (actor.role === 'reviewer') {
|
|
747
|
-
fail('only the tower or a worker may update a mission; reviewers submit tower_review')
|
|
748
|
-
}
|
|
749
|
-
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
750
|
-
fail(`worker ${actor.name} does not own mission ${mission.id} (owner: ${mission.owner})`)
|
|
751
|
-
}
|
|
752
|
-
if (workerRequest) {
|
|
753
|
-
if (input.status !== undefined) {
|
|
754
|
-
fail('only the tower can change mission status directly; workers report completion with tower_complete')
|
|
755
|
-
}
|
|
756
|
-
if (input.scope !== undefined) fail('only the tower can change mission scope')
|
|
757
|
-
if (input.clear_blockers === true) fail('only the tower can clear blockers')
|
|
758
|
-
if (input.pause_cause !== undefined || input.authorization !== undefined) {
|
|
759
|
-
fail('only the tower can record pause/authorization metadata')
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
if (input.pause_cause !== undefined && input.status !== 'paused') {
|
|
763
|
-
fail('pause_cause is only valid together with status "paused"')
|
|
764
|
-
}
|
|
765
|
-
if (input.status === 'abandoned' && (typeof input.note !== 'string' || input.note.trim().length === 0)) {
|
|
766
|
-
fail('abandoning a mission requires a non-empty note (reason)')
|
|
767
|
-
}
|
|
768
|
-
const now = Date.now()
|
|
769
|
-
const changes = []
|
|
770
|
-
|
|
771
|
-
if (input.status !== undefined) {
|
|
772
|
-
if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
|
|
773
|
-
fail(`unknown mission status "${input.status}"`)
|
|
774
|
-
}
|
|
775
|
-
if (input.status === 'merged') fail('merged is recorded only by tower_merge, not by a generic mission patch')
|
|
776
|
-
const allowed = TRANSITIONS[mission.status] ?? []
|
|
777
|
-
if (!allowed.includes(input.status)) {
|
|
778
|
-
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
|
|
779
|
-
}
|
|
780
|
-
if (input.status === 'paused') {
|
|
781
|
-
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
782
|
-
}
|
|
783
|
-
if (mission.status === 'paused' && input.status === 'active') {
|
|
784
|
-
const requireAuthorization = this.config.mode.requireResumeAuthorization === true
|
|
785
|
-
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
786
|
-
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
787
|
-
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
791
|
-
mission.authorizationRef = input.authorization.trim()
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
mission.status = input.status
|
|
795
|
-
changes.push(`status=${input.status}`)
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
if (input.task_done !== undefined) {
|
|
799
|
-
const needle = String(input.task_done)
|
|
800
|
-
const task = mission.tasks.find(
|
|
801
|
-
(entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
|
|
802
|
-
)
|
|
803
|
-
if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
|
|
804
|
-
task.status = 'done'
|
|
805
|
-
changes.push(`task_done="${task.text}"`)
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
if (input.task_drop !== undefined) {
|
|
809
|
-
if (typeof input.task_drop_reason !== 'string' || input.task_drop_reason.trim().length === 0) {
|
|
810
|
-
fail('task_drop requires task_drop_reason')
|
|
811
|
-
}
|
|
812
|
-
const needle = String(input.task_drop)
|
|
813
|
-
const task = mission.tasks.find(
|
|
814
|
-
(entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
|
|
815
|
-
)
|
|
816
|
-
if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
|
|
817
|
-
task.status = 'dropped'
|
|
818
|
-
task.note = input.task_drop_reason.trim()
|
|
819
|
-
mission.notes.push(`task dropped: ${task.text} — ${task.note}`)
|
|
820
|
-
changes.push(`task_drop="${task.text}"`)
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
if (input.blocker !== undefined) {
|
|
824
|
-
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
825
|
-
fail('blocker must be a non-empty string')
|
|
826
|
-
}
|
|
827
|
-
if (TERMINAL.has(mission.status)) {
|
|
828
|
-
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
829
|
-
}
|
|
830
|
-
mission.blockers.push(input.blocker.trim())
|
|
831
|
-
mission.status = 'blocked'
|
|
832
|
-
changes.push('blocker added')
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
if (input.clear_blockers === true) {
|
|
836
|
-
mission.blockers = []
|
|
837
|
-
changes.push('blockers cleared')
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
if (input.note !== undefined) {
|
|
841
|
-
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
842
|
-
fail('note must be a non-empty string')
|
|
843
|
-
}
|
|
844
|
-
mission.notes.push(input.note.trim())
|
|
845
|
-
changes.push('note added')
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
if (input.scope !== undefined) {
|
|
849
|
-
if (!['active', 'completed'].includes(mission.status)) {
|
|
850
|
-
fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
|
|
851
|
-
}
|
|
852
|
-
const scope = normalizeScope(input.scope, { allowEmpty: mission.kind === 'survey' })
|
|
853
|
-
const others = state.missions.filter(
|
|
854
|
-
(entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
|
|
855
|
-
)
|
|
856
|
-
for (const other of others) {
|
|
857
|
-
if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope)) {
|
|
858
|
-
fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
mission.scope = scope
|
|
862
|
-
mission.scopeGeneration = (mission.scopeGeneration ?? 1) + 1
|
|
863
|
-
mission.status = 'active'
|
|
864
|
-
changes.push('scope widened')
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
if (changes.length === 0) fail('tower_mission requires at least one change')
|
|
868
|
-
mission.updatedAt = now
|
|
869
|
-
state.revision += 1
|
|
870
|
-
state.updatedAt = now
|
|
871
|
-
|
|
872
|
-
validateState(state, this.paths.state)
|
|
873
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
874
|
-
await appendJsonl(this.paths.activity, {
|
|
875
|
-
at: now,
|
|
876
|
-
type: 'mission',
|
|
877
|
-
missionId: mission.id,
|
|
878
|
-
text: changes.join('; '),
|
|
879
|
-
})
|
|
880
|
-
return { revision: state.revision, mission: fullMission(mission) }
|
|
881
|
-
})
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
assertOutside(target) {
|
|
885
|
-
const resolved = path.resolve(target)
|
|
886
|
-
const inside = (parent) => {
|
|
887
|
-
const rel = path.relative(path.resolve(parent), resolved)
|
|
888
|
-
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
|
|
889
|
-
}
|
|
890
|
-
if (resolved === path.resolve(this.root) || inside(this.root)) {
|
|
891
|
-
fail(`worktree path must be outside the main checkout: ${resolved}`)
|
|
892
|
-
}
|
|
893
|
-
if (resolved === path.resolve(this.commonDir) || inside(this.commonDir)) {
|
|
894
|
-
fail(`worktree path must be outside Git metadata: ${resolved}`)
|
|
895
|
-
}
|
|
896
|
-
return resolved
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
async assertWorktree(worktree) {
|
|
900
|
-
if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
|
|
901
|
-
const owned = await assertOwnership({
|
|
902
|
-
storedPath: worktree.path,
|
|
903
|
-
storedRealPath: worktree.realPath,
|
|
904
|
-
branch: worktree.branch,
|
|
905
|
-
commonDir: this.commonDir,
|
|
906
|
-
missionId: worktree.missionId,
|
|
907
|
-
})
|
|
908
|
-
worktree.realPath = owned.realPath
|
|
909
|
-
const registered = await listWorktrees(this.root)
|
|
910
|
-
if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
|
|
911
|
-
fail(`worktree ${owned.path} is not registered with git`)
|
|
912
|
-
}
|
|
913
|
-
return worktree
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
/** Record that a spawned agent failed or went idle, so roster state is truthful. */
|
|
917
|
-
async recordSpawnOutcome(input) {
|
|
918
|
-
return withLock(this.paths, async () => {
|
|
919
|
-
const state = await this.load()
|
|
920
|
-
const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
|
|
921
|
-
if (!entry) return null
|
|
922
|
-
const now = Date.now()
|
|
923
|
-
entry.state = input.state ?? 'failed'
|
|
924
|
-
entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
|
|
925
|
-
entry.outcomeAt = now
|
|
926
|
-
state.revision += 1
|
|
927
|
-
state.updatedAt = now
|
|
928
|
-
validateState(state, this.paths.state)
|
|
929
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
930
|
-
await appendJsonl(this.paths.activity, {
|
|
931
|
-
at: now,
|
|
932
|
-
type: 'spawn',
|
|
933
|
-
missionId: input.missionId,
|
|
934
|
-
text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
|
|
935
|
-
})
|
|
936
|
-
return entry
|
|
937
|
-
})
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/** Record that a live agent was interrupted by the tower. */
|
|
941
|
-
async recordInterrupt(input) {
|
|
942
|
-
return withLock(this.paths, async () => {
|
|
943
|
-
const state = await this.load()
|
|
944
|
-
const entry = (state.roster ?? []).find(
|
|
945
|
-
(row) =>
|
|
946
|
-
(input.agentId !== undefined && row.agentId === input.agentId) ||
|
|
947
|
-
(input.name !== undefined && row.name === input.name),
|
|
948
|
-
)
|
|
949
|
-
if (!entry) fail(`unknown roster agent "${input.name ?? input.agentId}"`)
|
|
950
|
-
const now = Date.now()
|
|
951
|
-
entry.state = 'interrupted'
|
|
952
|
-
entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
|
|
953
|
-
entry.outcomeAt = now
|
|
954
|
-
state.revision += 1
|
|
955
|
-
state.updatedAt = now
|
|
956
|
-
validateState(state, this.paths.state)
|
|
957
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
958
|
-
await appendJsonl(this.paths.activity, {
|
|
959
|
-
at: now,
|
|
960
|
-
type: 'spawn',
|
|
961
|
-
missionId: entry.missionId,
|
|
962
|
-
text: `${entry.name} interrupted: ${entry.outcome}`,
|
|
963
|
-
})
|
|
964
|
-
return entry
|
|
965
|
-
})
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
async provision(input = {}) {
|
|
969
|
-
const missionId = input.missionId
|
|
970
|
-
if (typeof missionId !== 'string' || missionId.trim().length === 0) {
|
|
971
|
-
fail('tower_spawn requires missionId in this version')
|
|
972
|
-
}
|
|
973
|
-
return withLock(this.paths, async () => {
|
|
974
|
-
const state = await this.load()
|
|
975
|
-
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
976
|
-
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
977
|
-
if (mission.worktree) {
|
|
978
|
-
await this.assertWorktree(mission.worktree)
|
|
979
|
-
return { mission, worktree: mission.worktree, created: false }
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
const baseCommit = input.baseCommit
|
|
983
|
-
? String(input.baseCommit)
|
|
984
|
-
: input.includeDirtyBase === false
|
|
985
|
-
? await revParse(this.root, state.base)
|
|
986
|
-
: state.snapshotCommit && (await tryGit(this.root, ['cat-file', '-e', `${state.snapshotCommit}^{commit}`])) !== null
|
|
987
|
-
? state.snapshotCommit
|
|
988
|
-
: await revParse(this.root, state.base)
|
|
989
|
-
const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
990
|
-
const configuredRoot = this.config.worktrees.root
|
|
991
|
-
const root = input.worktreeRoot
|
|
992
|
-
? path.resolve(input.worktreeRoot)
|
|
993
|
-
: configuredRoot
|
|
994
|
-
? path.resolve(configuredRoot)
|
|
995
|
-
: path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
|
|
996
|
-
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
997
|
-
this.assertOutside(root)
|
|
998
|
-
await ensureDir(root)
|
|
999
|
-
|
|
1000
|
-
const wtPath = path.join(root, mission.id)
|
|
1001
|
-
if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
1002
|
-
if (await branchExists(this.root, branch)) fail(`branch already exists: ${branch}`)
|
|
1003
|
-
|
|
1004
|
-
await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
|
|
1005
|
-
const now = Date.now()
|
|
1006
|
-
const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
|
|
1007
|
-
mission.worktree = worktree
|
|
1008
|
-
mission.baseCommit = baseCommit
|
|
1009
|
-
mission.updatedAt = now
|
|
1010
|
-
state.revision += 1
|
|
1011
|
-
state.updatedAt = now
|
|
1012
|
-
|
|
1013
|
-
validateState(state, this.paths.state)
|
|
1014
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1015
|
-
await appendJsonl(this.paths.activity, {
|
|
1016
|
-
at: now,
|
|
1017
|
-
type: 'worktree',
|
|
1018
|
-
missionId,
|
|
1019
|
-
text: `${branch} at ${wtPath}`,
|
|
1020
|
-
})
|
|
1021
|
-
return { mission, worktree, created: true }
|
|
1022
|
-
})
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
async missionContext(missionId) {
|
|
1026
|
-
const state = await this.load()
|
|
1027
|
-
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
1028
|
-
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
1029
|
-
const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
|
|
1030
|
-
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1031
|
-
const reviews = branch
|
|
1032
|
-
? (state.reviews ?? [])
|
|
1033
|
-
.filter((review) => review.branch === branch)
|
|
1034
|
-
.slice()
|
|
1035
|
-
.sort((a, b) => b.round - a.round)
|
|
1036
|
-
.slice(0, 3)
|
|
1037
|
-
: []
|
|
1038
|
-
const findings = (state.findings ?? [])
|
|
1039
|
-
.filter((finding) => finding.missionId === mission.id)
|
|
1040
|
-
.slice()
|
|
1041
|
-
.sort((a, b) => b.seq - a.seq)
|
|
1042
|
-
.slice(0, 10)
|
|
1043
|
-
return {
|
|
1044
|
-
base: state.base,
|
|
1045
|
-
mission: fullMission(mission),
|
|
1046
|
-
snapshotCommit: state.snapshotCommit ?? null,
|
|
1047
|
-
completionReport: mission.completionReport ?? null,
|
|
1048
|
-
reviews: reviews.map((review) => ({
|
|
1049
|
-
id: review.id,
|
|
1050
|
-
round: review.round,
|
|
1051
|
-
status: review.status,
|
|
1052
|
-
mergeVerdict: review.mergeVerdict,
|
|
1053
|
-
reviewer: review.reviewer,
|
|
1054
|
-
findings: review.findings,
|
|
1055
|
-
checks: review.checks ?? [],
|
|
1056
|
-
decision: review.decision,
|
|
1057
|
-
reviewedCommit: review.reviewedCommit,
|
|
1058
|
-
at: review.at,
|
|
1059
|
-
})),
|
|
1060
|
-
findings: findings.map((finding) => findingSummary(finding)),
|
|
1061
|
-
deps: (mission.deps ?? []).map((id) => {
|
|
1062
|
-
const dep = byId.get(id)
|
|
1063
|
-
return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
|
|
1064
|
-
}),
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
async recordSpawn(input) {
|
|
1069
|
-
return withLock(this.paths, async () => {
|
|
1070
|
-
const state = await this.load()
|
|
1071
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1072
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1073
|
-
if (!Array.isArray(state.roster)) state.roster = []
|
|
1074
|
-
if (state.roster.some((entry) => entry.name === input.name)) {
|
|
1075
|
-
fail(`roster name already used: ${input.name}`)
|
|
1076
|
-
}
|
|
1077
|
-
if (state.roster.some((entry) => entry.agentId === input.agentId)) {
|
|
1078
|
-
fail(`agent ${input.agentId} is already in the roster`)
|
|
1079
|
-
}
|
|
1080
|
-
const now = Date.now()
|
|
1081
|
-
const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
|
|
1082
|
-
const entry = {
|
|
1083
|
-
name: input.name,
|
|
1084
|
-
role: input.role,
|
|
1085
|
-
agentId: input.agentId,
|
|
1086
|
-
sessionId: input.sessionId ?? input.agentId,
|
|
1087
|
-
parentSessionId: input.parentSessionId,
|
|
1088
|
-
missionId: input.missionId,
|
|
1089
|
-
cwd: input.cwd ?? null,
|
|
1090
|
-
branch: input.branch ?? null,
|
|
1091
|
-
spawnTip,
|
|
1092
|
-
state: 'active',
|
|
1093
|
-
spawnedAt: now,
|
|
1094
|
-
}
|
|
1095
|
-
if (input.role === 'worker') {
|
|
1096
|
-
const previous = workerEntries(state, mission.id)
|
|
1097
|
-
const live = previous.find((entry) => !ROSTER_INACTIVE.has(entry.state))
|
|
1098
|
-
if (live && !input.takeover) {
|
|
1099
|
-
fail(
|
|
1100
|
-
`mission ${mission.id} already has a live worker "${live.name}"; stop it with tower_interrupt, or pass takeover:true after it is no longer running`,
|
|
1101
|
-
)
|
|
1102
|
-
}
|
|
1103
|
-
// A new owner always retires the mission's older worker entries, including
|
|
1104
|
-
// idle `inactive` ones; otherwise the next spawn would see several entries
|
|
1105
|
-
// for the same mission and the old idle worker would block forever.
|
|
1106
|
-
for (const old of previous) {
|
|
1107
|
-
if (old.state === 'retired') continue
|
|
1108
|
-
old.state = 'retired'
|
|
1109
|
-
old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
|
|
1110
|
-
old.outcomeAt = now
|
|
1111
|
-
}
|
|
1112
|
-
const liveCount = countLiveWorkers(state)
|
|
1113
|
-
const max = Number(this.config.spawn.maxLiveWorkers) || 4
|
|
1114
|
-
if (liveCount >= max) {
|
|
1115
|
-
fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
|
|
1116
|
-
}
|
|
1117
|
-
const generation = (mission.ownerGeneration ?? 0) + 1
|
|
1118
|
-
entry.generation = generation
|
|
1119
|
-
mission.ownerGeneration = generation
|
|
1120
|
-
mission.owner = input.name
|
|
1121
|
-
if (mission.status === 'planned') mission.status = 'active'
|
|
1122
|
-
}
|
|
1123
|
-
state.roster.push(entry)
|
|
1124
|
-
mission.updatedAt = now
|
|
1125
|
-
state.revision += 1
|
|
1126
|
-
state.updatedAt = now
|
|
1127
|
-
|
|
1128
|
-
validateState(state, this.paths.state)
|
|
1129
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1130
|
-
await appendJsonl(this.paths.activity, {
|
|
1131
|
-
at: now,
|
|
1132
|
-
type: 'spawn',
|
|
1133
|
-
missionId: input.missionId,
|
|
1134
|
-
text: `${input.role} ${input.name} (${input.agentId})`,
|
|
1135
|
-
})
|
|
1136
|
-
return entry
|
|
1137
|
-
})
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
async rosterView() {
|
|
1141
|
-
const state = await this.load()
|
|
1142
|
-
return {
|
|
1143
|
-
revision: state.revision,
|
|
1144
|
-
roster: (state.roster ?? []).map((entry) => ({ ...entry })),
|
|
1145
|
-
missions: state.missions.map((mission) => ({
|
|
1146
|
-
id: mission.id,
|
|
1147
|
-
status: mission.status,
|
|
1148
|
-
owner: mission.owner ?? null,
|
|
1149
|
-
worktree: mission.worktree
|
|
1150
|
-
? {
|
|
1151
|
-
path: mission.worktree.path,
|
|
1152
|
-
branch: mission.worktree.branch,
|
|
1153
|
-
baseCommit: mission.worktree.baseCommit,
|
|
1154
|
-
}
|
|
1155
|
-
: null,
|
|
1156
|
-
})),
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
async recordTestRun(input = {}) {
|
|
1161
|
-
return withLock(this.paths, async () => {
|
|
1162
|
-
const state = await this.load()
|
|
1163
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1164
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1165
|
-
if (!mission.testRuns) mission.testRuns = []
|
|
1166
|
-
const receipt = { ...input.receipt, recordedAt: Date.now() }
|
|
1167
|
-
mission.testRuns.push(receipt)
|
|
1168
|
-
if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
|
|
1169
|
-
state.revision += 1
|
|
1170
|
-
state.updatedAt = Date.now()
|
|
1171
|
-
validateState(state, this.paths.state)
|
|
1172
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1173
|
-
await appendJsonl(this.paths.activity, {
|
|
1174
|
-
at: Date.now(),
|
|
1175
|
-
type: 'test',
|
|
1176
|
-
missionId: mission.id,
|
|
1177
|
-
text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
|
|
1178
|
-
})
|
|
1179
|
-
return receipt
|
|
1180
|
-
})
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
async recordLegacySnapshot(snapshot) {
|
|
1184
|
-
return withLock(this.paths, async () => {
|
|
1185
|
-
const state = await this.load()
|
|
1186
|
-
if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
|
|
1187
|
-
const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
|
|
1188
|
-
if (existing) return { snapshot: existing, duplicate: true }
|
|
1189
|
-
state.legacySnapshots.push(snapshot)
|
|
1190
|
-
state.revision += 1
|
|
1191
|
-
state.updatedAt = Date.now()
|
|
1192
|
-
validateState(state, this.paths.state)
|
|
1193
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1194
|
-
return { snapshot, duplicate: false }
|
|
1195
|
-
})
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
async recordLegacyMap(map) {
|
|
1199
|
-
return withLock(this.paths, async () => {
|
|
1200
|
-
const state = await this.load()
|
|
1201
|
-
if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
|
|
1202
|
-
const existing = state.legacyMaps.find(
|
|
1203
|
-
(entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
|
|
1204
|
-
)
|
|
1205
|
-
if (existing) return { map: existing, duplicate: true }
|
|
1206
|
-
state.legacyMaps.push(map)
|
|
1207
|
-
state.revision += 1
|
|
1208
|
-
state.updatedAt = Date.now()
|
|
1209
|
-
validateState(state, this.paths.state)
|
|
1210
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1211
|
-
return { map, duplicate: false }
|
|
1212
|
-
})
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
async createBatch(input = {}, actor = 'tower') {
|
|
1216
|
-
return withLock(this.paths, async () => {
|
|
1217
|
-
const state = await this.load()
|
|
1218
|
-
const normalized = validateBatchCreate(state, input)
|
|
1219
|
-
if (!Array.isArray(state.batches)) state.batches = []
|
|
1220
|
-
const max = state.batches.reduce((value, batch) => {
|
|
1221
|
-
const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
|
|
1222
|
-
return match ? Math.max(value, Number(match[1])) : value
|
|
1223
|
-
}, 0)
|
|
1224
|
-
const batch = {
|
|
1225
|
-
id: `B${max + 1}`,
|
|
1226
|
-
status: 'open',
|
|
1227
|
-
...normalized,
|
|
1228
|
-
createdBy: actor,
|
|
1229
|
-
createdAt: Date.now(),
|
|
1230
|
-
}
|
|
1231
|
-
state.batches.push(batch)
|
|
1232
|
-
state.revision += 1
|
|
1233
|
-
state.updatedAt = batch.createdAt
|
|
1234
|
-
validateState(state, this.paths.state)
|
|
1235
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1236
|
-
await appendJsonl(this.paths.activity, {
|
|
1237
|
-
at: batch.createdAt,
|
|
1238
|
-
type: 'batch',
|
|
1239
|
-
text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
|
|
1240
|
-
})
|
|
1241
|
-
return batch
|
|
1242
|
-
})
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
async closeBatch(batchId, options = {}, actor = 'tower') {
|
|
1246
|
-
return withLock(this.paths, async () => {
|
|
1247
|
-
const state = await this.load()
|
|
1248
|
-
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1249
|
-
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
1250
|
-
const result = validateBatchClose(state, batch, { ...options, state })
|
|
1251
|
-
const blockers = Array.isArray(result.blockers) ? result.blockers : []
|
|
1252
|
-
if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
|
|
1253
|
-
batch.status = 'closed'
|
|
1254
|
-
batch.closedAt = Date.now()
|
|
1255
|
-
batch.closedBy = actor
|
|
1256
|
-
batch.close = {
|
|
1257
|
-
checks: options.checks ?? [],
|
|
1258
|
-
treeTip: options.treeTip ?? null,
|
|
1259
|
-
receiptHashes: options.receiptHashes ?? {},
|
|
1260
|
-
at: batch.closedAt,
|
|
1261
|
-
}
|
|
1262
|
-
state.revision += 1
|
|
1263
|
-
state.updatedAt = batch.closedAt
|
|
1264
|
-
validateState(state, this.paths.state)
|
|
1265
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1266
|
-
await appendJsonl(this.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
|
|
1267
|
-
return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
|
|
1268
|
-
})
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
async abandonBatch(batchId, actor = 'tower', reason = null) {
|
|
1272
|
-
return withLock(this.paths, async () => {
|
|
1273
|
-
const state = await this.load()
|
|
1274
|
-
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1275
|
-
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
1276
|
-
if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
|
|
1277
|
-
if (batch.status === 'abandoned') return { ok: true, batch, alreadyAbandoned: true }
|
|
1278
|
-
batch.status = 'abandoned'
|
|
1279
|
-
batch.abandonedAt = Date.now()
|
|
1280
|
-
batch.abandonedBy = actor
|
|
1281
|
-
batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
|
|
1282
|
-
state.revision += 1
|
|
1283
|
-
state.updatedAt = batch.abandonedAt
|
|
1284
|
-
validateState(state, this.paths.state)
|
|
1285
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1286
|
-
await appendJsonl(this.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
|
|
1287
|
-
return { ok: true, batch, alreadyAbandoned: false }
|
|
1288
|
-
})
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
async batchStatus(batchId) {
|
|
1292
|
-
const state = await this.load()
|
|
1293
|
-
const batch = batchId
|
|
1294
|
-
? (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1295
|
-
: null
|
|
1296
|
-
if (batchId && !batch) fail(`unknown batch "${batchId}"`)
|
|
1297
|
-
return {
|
|
1298
|
-
batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
|
|
1299
|
-
...(batch ? { batch: summarizeBatch(state, batch) } : {}),
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
async markRetired(agentIds) {
|
|
1304
|
-
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
1305
|
-
return withLock(this.paths, async () => {
|
|
1306
|
-
const state = await this.load()
|
|
1307
|
-
const retired = []
|
|
1308
|
-
const now = Date.now()
|
|
1309
|
-
for (const entry of state.roster ?? []) {
|
|
1310
|
-
if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
|
|
1311
|
-
entry.state = 'retired'
|
|
1312
|
-
entry.retiredAt = now
|
|
1313
|
-
retired.push(entry.agentId)
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
if (retired.length > 0) {
|
|
1317
|
-
state.revision += 1
|
|
1318
|
-
state.updatedAt = now
|
|
1319
|
-
validateState(state, this.paths.state)
|
|
1320
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1321
|
-
await appendJsonl(this.paths.activity, {
|
|
1322
|
-
at: now,
|
|
1323
|
-
type: 'adopt',
|
|
1324
|
-
text: `retired ${retired.join(', ')}`,
|
|
1325
|
-
})
|
|
1326
|
-
}
|
|
1327
|
-
return { retired }
|
|
1328
|
-
})
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
async markMissing(agentIds) {
|
|
1332
|
-
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
1333
|
-
return withLock(this.paths, async () => {
|
|
1334
|
-
const state = await this.load()
|
|
1335
|
-
const missing = []
|
|
1336
|
-
const now = Date.now()
|
|
1337
|
-
for (const entry of state.roster ?? []) {
|
|
1338
|
-
if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
|
|
1339
|
-
entry.state = 'missing'
|
|
1340
|
-
entry.missingSince = now
|
|
1341
|
-
missing.push(entry.agentId)
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
if (missing.length > 0) {
|
|
1345
|
-
state.revision += 1
|
|
1346
|
-
state.updatedAt = now
|
|
1347
|
-
validateState(state, this.paths.state)
|
|
1348
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1349
|
-
await appendJsonl(this.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
|
|
1350
|
-
}
|
|
1351
|
-
return { missing }
|
|
1352
|
-
})
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
/** Mark live roster entries whose catalog activity disappeared after staleMs.
|
|
1356
|
-
* `authoritative` must be true only when the parent catalog was readable;
|
|
1357
|
-
* unknown state is never treated as stopped. */
|
|
1358
|
-
async sweepRoster(input = {}) {
|
|
1359
|
-
const staleMs = Number(input.staleMs) || 0
|
|
1360
|
-
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
1361
|
-
const activity = input.activity ?? {}
|
|
1362
|
-
return withLock(this.paths, async () => {
|
|
1363
|
-
const state = await this.load()
|
|
1364
|
-
const now = Date.now()
|
|
1365
|
-
const marked = []
|
|
1366
|
-
const idled = []
|
|
1367
|
-
for (const entry of state.roster ?? []) {
|
|
1368
|
-
if (ROSTER_INACTIVE.has(entry.state)) continue
|
|
1369
|
-
const observed = activity[entry.agentId]
|
|
1370
|
-
if (observed === 'inactive') {
|
|
1371
|
-
entry.state = 'inactive'
|
|
1372
|
-
entry.outcome = entry.outcome ?? 'idle (catalog)'
|
|
1373
|
-
entry.outcomeAt = now
|
|
1374
|
-
idled.push(entry.name)
|
|
1375
|
-
continue
|
|
1376
|
-
}
|
|
1377
|
-
if (observed === 'running') continue
|
|
1378
|
-
if (observed !== undefined) continue
|
|
1379
|
-
const age = now - Number(entry.spawnedAt ?? now)
|
|
1380
|
-
if (age > staleMs) {
|
|
1381
|
-
entry.state = 'stale'
|
|
1382
|
-
entry.stale = true
|
|
1383
|
-
entry.staleAt = now
|
|
1384
|
-
marked.push(entry.name)
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
if (marked.length > 0 || idled.length > 0) {
|
|
1388
|
-
state.revision += 1
|
|
1389
|
-
state.updatedAt = now
|
|
1390
|
-
validateState(state, this.paths.state)
|
|
1391
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1392
|
-
await appendJsonl(this.paths.activity, {
|
|
1393
|
-
at: now,
|
|
1394
|
-
type: 'watchdog',
|
|
1395
|
-
text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
|
|
1396
|
-
})
|
|
1397
|
-
}
|
|
1398
|
-
return { marked, idled }
|
|
1399
|
-
})
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
unreadFor(state, name) {
|
|
1403
|
-
return (state.messages ?? []).filter((message) => {
|
|
1404
|
-
if (message.from === name) return false
|
|
1405
|
-
if (message.to !== name && message.to !== 'all') return false
|
|
1406
|
-
return !(message.readBy ?? []).includes(name)
|
|
1407
|
-
})
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
async recordMessage(input) {
|
|
1411
|
-
return withLock(this.paths, async () => {
|
|
1412
|
-
const state = await this.load()
|
|
1413
|
-
if (!Array.isArray(state.messages)) state.messages = []
|
|
1414
|
-
const names = new Set((state.roster ?? []).map((entry) => entry.name))
|
|
1415
|
-
names.add('tower')
|
|
1416
|
-
if (input.to !== 'all' && !names.has(input.to)) {
|
|
1417
|
-
fail(`unknown message recipient "${input.to}" (known: ${[...names].join(', ')}, all)`)
|
|
1418
|
-
}
|
|
1419
|
-
if (input.to === input.from) fail('refusing to send a message to yourself')
|
|
1420
|
-
const now = Date.now()
|
|
1421
|
-
const seq = (state.messageSeq ?? 0) + 1
|
|
1422
|
-
state.messageSeq = seq
|
|
1423
|
-
const message = {
|
|
1424
|
-
id: `msg-${seq}`,
|
|
1425
|
-
seq,
|
|
1426
|
-
from: input.from,
|
|
1427
|
-
to: input.to,
|
|
1428
|
-
subject: input.subject,
|
|
1429
|
-
body: input.body,
|
|
1430
|
-
scope: input.scope ?? null,
|
|
1431
|
-
consentRef: input.consentRef ?? null,
|
|
1432
|
-
at: now,
|
|
1433
|
-
readBy: [],
|
|
1434
|
-
delivery: [],
|
|
1435
|
-
}
|
|
1436
|
-
state.messages.push(message)
|
|
1437
|
-
state.revision += 1
|
|
1438
|
-
state.updatedAt = now
|
|
1439
|
-
|
|
1440
|
-
validateState(state, this.paths.state)
|
|
1441
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1442
|
-
await appendJsonl(this.paths.activity, {
|
|
1443
|
-
at: now,
|
|
1444
|
-
type: 'message',
|
|
1445
|
-
text: `${input.from} -> ${input.to}: ${input.subject}`,
|
|
1446
|
-
})
|
|
1447
|
-
return { ...message, readBy: [], delivery: [] }
|
|
1448
|
-
})
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
async recordDelivery(messageId, results) {
|
|
1452
|
-
return withLock(this.paths, async () => {
|
|
1453
|
-
const state = await this.load()
|
|
1454
|
-
const message = (state.messages ?? []).find((entry) => entry.id === messageId)
|
|
1455
|
-
if (!message) fail(`unknown message "${messageId}"`)
|
|
1456
|
-
message.delivery = [...(message.delivery ?? []), ...results]
|
|
1457
|
-
state.revision += 1
|
|
1458
|
-
state.updatedAt = Date.now()
|
|
1459
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1460
|
-
return message.delivery
|
|
1461
|
-
})
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
async inbox(input) {
|
|
1465
|
-
const reader = input.reader
|
|
1466
|
-
return withLock(this.paths, async () => {
|
|
1467
|
-
const state = await this.load()
|
|
1468
|
-
const limit = Math.max(1, Math.min(100, input.limit ?? 20))
|
|
1469
|
-
const includeRead = input.includeRead === true
|
|
1470
|
-
const visible = [...(state.messages ?? [])]
|
|
1471
|
-
.reverse()
|
|
1472
|
-
.filter((message) => (message.to === reader || message.to === 'all') && message.from !== reader)
|
|
1473
|
-
const unread = visible.filter((message) => !(message.readBy ?? []).includes(reader))
|
|
1474
|
-
const page = (includeRead ? visible : unread).slice(0, limit)
|
|
1475
|
-
|
|
1476
|
-
let changed = false
|
|
1477
|
-
for (const message of page) {
|
|
1478
|
-
if (!(message.readBy ?? []).includes(reader)) {
|
|
1479
|
-
message.readBy = [...(message.readBy ?? []), reader]
|
|
1480
|
-
message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
|
|
1481
|
-
changed = true
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
const unreadRemaining = visible.filter((message) => !(message.readBy ?? []).includes(reader)).length
|
|
1485
|
-
if (changed) {
|
|
1486
|
-
state.revision += 1
|
|
1487
|
-
state.updatedAt = Date.now()
|
|
1488
|
-
validateState(state, this.paths.state)
|
|
1489
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1490
|
-
}
|
|
1491
|
-
return {
|
|
1492
|
-
reader,
|
|
1493
|
-
messages: page.map((message) => ({
|
|
1494
|
-
id: message.id,
|
|
1495
|
-
from: message.from,
|
|
1496
|
-
to: message.to,
|
|
1497
|
-
subject: message.subject,
|
|
1498
|
-
body: message.body,
|
|
1499
|
-
scope: message.scope ?? null,
|
|
1500
|
-
at: message.at,
|
|
1501
|
-
delivery: message.delivery ?? [],
|
|
1502
|
-
ackAt: message.readAt?.[reader] ?? null,
|
|
1503
|
-
})),
|
|
1504
|
-
pageSize: page.length,
|
|
1505
|
-
unreadRemaining,
|
|
1506
|
-
}
|
|
1507
|
-
})
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
/** Read-only completion preconditions shared by tower_complete and the tool
|
|
1511
|
-
* layer's test-discovery preflight. Throws on the first failed condition. */
|
|
1512
|
-
async checkCompletion(input) {
|
|
1513
|
-
const state = await this.load()
|
|
1514
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1515
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1516
|
-
const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
1517
|
-
if (!worker || worker.missionId !== mission.id) {
|
|
1518
|
-
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
1519
|
-
}
|
|
1520
|
-
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
1521
|
-
if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
|
|
1522
|
-
fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
|
|
1523
|
-
}
|
|
1524
|
-
if (mission.owner && mission.owner !== worker.name) {
|
|
1525
|
-
fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
|
|
1526
|
-
}
|
|
1527
|
-
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
1528
|
-
if (!['active', 'planned'].includes(mission.status)) {
|
|
1529
|
-
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
1530
|
-
}
|
|
1531
|
-
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
1532
|
-
fail('tower_complete requires a non-empty report')
|
|
1533
|
-
}
|
|
1534
|
-
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
1535
|
-
if (openTasks.length > 0) {
|
|
1536
|
-
fail(
|
|
1537
|
-
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
1538
|
-
.map((task) => task.text)
|
|
1539
|
-
.join('; ')}`,
|
|
1540
|
-
)
|
|
1541
|
-
}
|
|
1542
|
-
if ((mission.blockers ?? []).length > 0) {
|
|
1543
|
-
fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
|
|
1544
|
-
}
|
|
1545
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
1546
|
-
await this.assertWorktree(mission.worktree)
|
|
1547
|
-
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
1548
|
-
if (commits < 1) {
|
|
1549
|
-
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
1550
|
-
}
|
|
1551
|
-
const unread = this.unreadFor(state, worker.name)
|
|
1552
|
-
if (unread.length > 0) {
|
|
1553
|
-
fail(
|
|
1554
|
-
`mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
|
|
1555
|
-
)
|
|
1556
|
-
}
|
|
1557
|
-
return { state, mission, worker, commits }
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
async complete(input) {
|
|
1561
|
-
return withLock(this.paths, async () => {
|
|
1562
|
-
const { state, mission, worker, commits } = await this.checkCompletion(input)
|
|
1563
|
-
|
|
1564
|
-
const checks = normalizeChecks(input.checks)
|
|
1565
|
-
const now = Date.now()
|
|
1566
|
-
mission.status = 'completed'
|
|
1567
|
-
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
|
|
1568
|
-
mission.updatedAt = now
|
|
1569
|
-
state.revision += 1
|
|
1570
|
-
state.updatedAt = now
|
|
1571
|
-
|
|
1572
|
-
validateState(state, this.paths.state)
|
|
1573
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1574
|
-
await appendJsonl(this.paths.activity, {
|
|
1575
|
-
at: now,
|
|
1576
|
-
type: 'complete',
|
|
1577
|
-
missionId: mission.id,
|
|
1578
|
-
text: `${worker.name} reported completion (${commits} commit(s))`,
|
|
1579
|
-
})
|
|
1580
|
-
return { mission: fullMission(mission), commits, unread: 0 }
|
|
1581
|
-
})
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
async addFinding(input) {
|
|
1585
|
-
return withLock(this.paths, async () => {
|
|
1586
|
-
const state = await this.load()
|
|
1587
|
-
if (!Array.isArray(state.findings)) state.findings = []
|
|
1588
|
-
if (!FINDING_TYPES.includes(input.type)) {
|
|
1589
|
-
fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
1590
|
-
}
|
|
1591
|
-
const title = String(input.title ?? '').trim()
|
|
1592
|
-
if (!title) fail('finding title is required')
|
|
1593
|
-
const summary = String(input.summary ?? '').trim()
|
|
1594
|
-
if (!summary) fail('finding summary is required')
|
|
1595
|
-
const details = String(input.details ?? '').trim()
|
|
1596
|
-
if (!details) fail('finding details are required')
|
|
1597
|
-
const suggestedFix = String(input.suggested_fix ?? '').trim()
|
|
1598
|
-
if (!suggestedFix) fail('finding suggested_fix is required')
|
|
1599
|
-
const severity = input.severity ?? null
|
|
1600
|
-
if (severity !== null && !FINDING_SEVERITIES.includes(severity)) {
|
|
1601
|
-
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
1602
|
-
}
|
|
1603
|
-
let mission = null
|
|
1604
|
-
if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
|
|
1605
|
-
mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1606
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1607
|
-
}
|
|
1608
|
-
const now = Date.now()
|
|
1609
|
-
const seq = (state.findingSeq ?? 0) + 1
|
|
1610
|
-
state.findingSeq = seq
|
|
1611
|
-
const finding = {
|
|
1612
|
-
id: `F${seq}`,
|
|
1613
|
-
seq,
|
|
1614
|
-
type: input.type,
|
|
1615
|
-
title,
|
|
1616
|
-
severity,
|
|
1617
|
-
summary,
|
|
1618
|
-
location: typeof input.location === 'string' && input.location.trim() ? input.location.trim() : null,
|
|
1619
|
-
details,
|
|
1620
|
-
suggested_fix: suggestedFix,
|
|
1621
|
-
missionId: mission ? mission.id : null,
|
|
1622
|
-
branch: mission && mission.worktree ? mission.worktree.branch : null,
|
|
1623
|
-
reportedBy: input.reportedBy,
|
|
1624
|
-
at: now,
|
|
1625
|
-
status: 'open',
|
|
1626
|
-
}
|
|
1627
|
-
state.findings.push(finding)
|
|
1628
|
-
state.revision += 1
|
|
1629
|
-
state.updatedAt = now
|
|
1630
|
-
|
|
1631
|
-
validateState(state, this.paths.state)
|
|
1632
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1633
|
-
await appendJsonl(this.paths.activity, {
|
|
1634
|
-
at: now,
|
|
1635
|
-
type: 'finding',
|
|
1636
|
-
missionId: finding.missionId,
|
|
1637
|
-
text: `${finding.id} ${finding.type}: ${finding.title}`,
|
|
1638
|
-
})
|
|
1639
|
-
return finding
|
|
1640
|
-
})
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
/** List or read findings. Read-only paths never take the writer lock. */
|
|
1644
|
-
async readFindings(input = {}) {
|
|
1645
|
-
const state = await this.load()
|
|
1646
|
-
const all = state.findings ?? []
|
|
1647
|
-
const counts = countFindings(all)
|
|
1648
|
-
if (input.id !== undefined && input.id !== null && input.id !== '') {
|
|
1649
|
-
const finding = all.find((entry) => entry.id === input.id)
|
|
1650
|
-
if (!finding) fail(`unknown finding "${input.id}"`)
|
|
1651
|
-
return { finding: { ...finding }, counts, total: all.length, filtered: 1, action: 'read' }
|
|
1652
|
-
}
|
|
1653
|
-
if (input.status !== undefined && input.status !== 'all') {
|
|
1654
|
-
if (!FINDING_STATUSES.includes(input.status)) {
|
|
1655
|
-
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
let visible = all
|
|
1659
|
-
if (input.status !== undefined && input.status !== 'all') {
|
|
1660
|
-
visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
|
|
1661
|
-
}
|
|
1662
|
-
if (input.type !== undefined) {
|
|
1663
|
-
if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
1664
|
-
visible = visible.filter((finding) => finding.type === input.type)
|
|
1665
|
-
}
|
|
1666
|
-
if (input.severity !== undefined) {
|
|
1667
|
-
if (!FINDING_SEVERITIES.includes(input.severity)) {
|
|
1668
|
-
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
1669
|
-
}
|
|
1670
|
-
visible = visible.filter((finding) => finding.severity === input.severity)
|
|
1671
|
-
}
|
|
1672
|
-
if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
|
|
1673
|
-
visible = visible.filter((finding) => finding.missionId === input.missionId)
|
|
1674
|
-
}
|
|
1675
|
-
if (input.open === true) {
|
|
1676
|
-
visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
1677
|
-
}
|
|
1678
|
-
const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
|
|
1679
|
-
const page = visible.slice(-limit).reverse()
|
|
1680
|
-
return {
|
|
1681
|
-
findings: page.map((finding) => findingSummary(finding)),
|
|
1682
|
-
counts,
|
|
1683
|
-
total: all.length,
|
|
1684
|
-
filtered: visible.length,
|
|
1685
|
-
action: 'list',
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
/**
|
|
1690
|
-
* List, read, or transition findings. Listing and reading are lock-free;
|
|
1691
|
-
* only a transition takes the writer lock. Reviewers may only read.
|
|
1692
|
-
*/
|
|
1693
|
-
async findings(input = {}) {
|
|
1694
|
-
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
1695
|
-
if (typeof actor.role !== 'string') fail('finding actor role must be a string')
|
|
1696
|
-
const hasId = input.id !== undefined && input.id !== null && input.id !== ''
|
|
1697
|
-
const wantsUpdate = hasId && (input.status !== undefined || input.note !== undefined)
|
|
1698
|
-
if (!wantsUpdate) return this.readFindings(input)
|
|
1699
|
-
|
|
1700
|
-
return withLock(this.paths, async () => {
|
|
1701
|
-
const state = await this.load()
|
|
1702
|
-
const all = state.findings ?? []
|
|
1703
|
-
const finding = all.find((entry) => entry.id === input.id)
|
|
1704
|
-
if (!finding) fail(`unknown finding "${input.id}"`)
|
|
1705
|
-
if (actor.role === 'reviewer') {
|
|
1706
|
-
fail('reviewers may list and read findings, but only the tower or a worker may change finding status')
|
|
1707
|
-
}
|
|
1708
|
-
const now = Date.now()
|
|
1709
|
-
if (input.status !== undefined) {
|
|
1710
|
-
if (input.status === 'all') {
|
|
1711
|
-
fail('finding status "all" is only a list filter; pass a concrete status when updating')
|
|
1712
|
-
}
|
|
1713
|
-
if (!FINDING_STATUSES.includes(input.status)) {
|
|
1714
|
-
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
1715
|
-
}
|
|
1716
|
-
const current = finding.status ?? 'open'
|
|
1717
|
-
const allowed = FINDING_TRANSITIONS[current] ?? []
|
|
1718
|
-
if (!allowed.includes(input.status)) {
|
|
1719
|
-
fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`)
|
|
1720
|
-
}
|
|
1721
|
-
finding.status = input.status
|
|
1722
|
-
if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
|
|
1723
|
-
else delete finding.closedAt
|
|
1724
|
-
}
|
|
1725
|
-
if (input.note !== undefined) {
|
|
1726
|
-
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
1727
|
-
fail('finding note must be a non-empty string')
|
|
1728
|
-
}
|
|
1729
|
-
if (!Array.isArray(finding.notes)) finding.notes = []
|
|
1730
|
-
finding.notes.push({ by: actor.name, at: now, text: input.note.trim() })
|
|
1731
|
-
}
|
|
1732
|
-
finding.updatedAt = now
|
|
1733
|
-
state.revision += 1
|
|
1734
|
-
state.updatedAt = now
|
|
1735
|
-
validateState(state, this.paths.state)
|
|
1736
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1737
|
-
await appendJsonl(this.paths.activity, {
|
|
1738
|
-
at: now,
|
|
1739
|
-
type: 'finding',
|
|
1740
|
-
missionId: finding.missionId,
|
|
1741
|
-
text: `${finding.id} ${finding.status}${input.note !== undefined ? ' +note' : ''} (${actor.name})`,
|
|
1742
|
-
})
|
|
1743
|
-
// Counts must reflect the transition just recorded.
|
|
1744
|
-
return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
|
|
1745
|
-
})
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
async submitReview(input) {
|
|
1749
|
-
return withLock(this.paths, async () => {
|
|
1750
|
-
const state = await this.load()
|
|
1751
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1752
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1753
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to review`)
|
|
1754
|
-
|
|
1755
|
-
const caller = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
1756
|
-
let reviewerName
|
|
1757
|
-
let reviewerRole
|
|
1758
|
-
if (caller) {
|
|
1759
|
-
if (caller.role !== 'reviewer') {
|
|
1760
|
-
fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
|
|
1761
|
-
}
|
|
1762
|
-
if (caller.missionId !== mission.id) {
|
|
1763
|
-
fail(`reviewer ${caller.name} was not assigned to mission ${mission.id}`)
|
|
1764
|
-
}
|
|
1765
|
-
if (mission.owner && caller.name === mission.owner) fail('the reviewer must differ from the worker')
|
|
1766
|
-
reviewerName = caller.name
|
|
1767
|
-
reviewerRole = 'reviewer'
|
|
1768
|
-
} else {
|
|
1769
|
-
if (typeof input.breakGlassReason !== 'string' || input.breakGlassReason.trim().length === 0) {
|
|
1770
|
-
fail('a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
|
|
1771
|
-
}
|
|
1772
|
-
reviewerName = 'tower'
|
|
1773
|
-
reviewerRole = 'tower'
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
if (mission.status !== 'completed') {
|
|
1777
|
-
fail(`mission ${mission.id} is ${mission.status}; only a completed mission (awaiting review) can be reviewed`)
|
|
1778
|
-
}
|
|
1779
|
-
if (!REVIEW_STATUS_RE.test(String(input.status ?? ''))) {
|
|
1780
|
-
fail('status must be "clean", "p1-Nitems", or "p2-Nitems"')
|
|
1781
|
-
}
|
|
1782
|
-
if (!MERGE_VERDICTS.includes(input.merge)) {
|
|
1783
|
-
fail(`merge verdict must be one of ${MERGE_VERDICTS.join('|')}`)
|
|
1784
|
-
}
|
|
1785
|
-
const findingsText = String(input.findings ?? '').trim()
|
|
1786
|
-
if (!findingsText) fail('findings text is required; write "none" for a clean verdict')
|
|
1787
|
-
if (input.status === 'clean' && findingsText.toLowerCase() !== 'none') {
|
|
1788
|
-
fail('a clean verdict requires findings text "none"')
|
|
1789
|
-
}
|
|
1790
|
-
if (input.status !== 'clean' && findingsText.toLowerCase() === 'none') {
|
|
1791
|
-
fail('a non-clean verdict must describe its findings')
|
|
1792
|
-
}
|
|
1793
|
-
const decision = String(input.decision ?? '').trim()
|
|
1794
|
-
if (!decision) fail('decision (the reasoning behind the verdict) is required')
|
|
1795
|
-
let checks = []
|
|
1796
|
-
if (input.checks !== undefined) {
|
|
1797
|
-
if (!Array.isArray(input.checks)) fail('checks must be an array of strings')
|
|
1798
|
-
checks = input.checks.map((entry) => String(entry))
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
await this.assertWorktree(mission.worktree)
|
|
1802
|
-
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
1803
|
-
const branch = mission.worktree.branch
|
|
1804
|
-
if (caller && caller.role === 'reviewer' && caller.spawnTip && caller.spawnTip !== tip) {
|
|
1805
|
-
fail(
|
|
1806
|
-
`reviewer ${caller.name} modified the mission branch after it was spawned (${caller.spawnTip.slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
|
|
1807
|
-
)
|
|
1808
|
-
}
|
|
1809
|
-
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1810
|
-
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
1811
|
-
const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
1812
|
-
if (reviewerRounds >= maxReviewRounds) {
|
|
1813
|
-
fail(
|
|
1814
|
-
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
1815
|
-
)
|
|
1816
|
-
}
|
|
1817
|
-
const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
|
|
1818
|
-
|
|
1819
|
-
const now = Date.now()
|
|
1820
|
-
const seq = (state.reviewSeq ?? 0) + 1
|
|
1821
|
-
state.reviewSeq = seq
|
|
1822
|
-
const review = {
|
|
1823
|
-
id: `R${seq}`,
|
|
1824
|
-
seq,
|
|
1825
|
-
missionId: mission.id,
|
|
1826
|
-
branch,
|
|
1827
|
-
reviewedCommit: tip,
|
|
1828
|
-
status: input.status,
|
|
1829
|
-
mergeVerdict: input.merge,
|
|
1830
|
-
findings: findingsText,
|
|
1831
|
-
checks,
|
|
1832
|
-
decision,
|
|
1833
|
-
reviewer: reviewerName,
|
|
1834
|
-
reviewerRole,
|
|
1835
|
-
round,
|
|
1836
|
-
at: now,
|
|
1837
|
-
...(reviewerRole === 'tower' ? { breakGlassReason: input.breakGlassReason.trim() } : {}),
|
|
1838
|
-
}
|
|
1839
|
-
if (!Array.isArray(state.reviews)) state.reviews = []
|
|
1840
|
-
state.reviews.push(review)
|
|
1841
|
-
|
|
1842
|
-
if (input.status !== 'clean') {
|
|
1843
|
-
mission.status = 'active'
|
|
1844
|
-
mission.notes.push(
|
|
1845
|
-
`review round ${round} by ${reviewerName}: ${input.status} (${input.merge}) — returned to active`,
|
|
1846
|
-
)
|
|
1847
|
-
} else {
|
|
1848
|
-
mission.notes.push(`review round ${round} by ${reviewerName}: clean (${input.merge})`)
|
|
1849
|
-
}
|
|
1850
|
-
mission.updatedAt = now
|
|
1851
|
-
state.revision += 1
|
|
1852
|
-
state.updatedAt = now
|
|
1853
|
-
|
|
1854
|
-
validateState(state, this.paths.state)
|
|
1855
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
1856
|
-
await appendJsonl(this.paths.activity, {
|
|
1857
|
-
at: now,
|
|
1858
|
-
type: 'review',
|
|
1859
|
-
missionId: mission.id,
|
|
1860
|
-
text: `round ${round} ${reviewerName}: ${input.status} / ${input.merge} @ ${tip.slice(0, 12)}`,
|
|
1861
|
-
})
|
|
1862
|
-
return {
|
|
1863
|
-
review,
|
|
1864
|
-
branchTip: tip,
|
|
1865
|
-
missionStatus: mission.status,
|
|
1866
|
-
round,
|
|
1867
|
-
reviewerRounds: reviewerRounds + 1,
|
|
1868
|
-
}
|
|
1869
|
-
})
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
/** Full detail for one mission, including reviews, findings and recent activity. */
|
|
1873
|
-
async missionDetail(missionId) {
|
|
1874
|
-
const state = await this.load()
|
|
1875
|
-
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
1876
|
-
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
1877
|
-
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1878
|
-
const reviews = (state.reviews ?? [])
|
|
1879
|
-
.filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
|
|
1880
|
-
.slice()
|
|
1881
|
-
.sort((a, b) => b.round - a.round)
|
|
1882
|
-
const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
|
|
1883
|
-
const activity = (await readActivityTail(this.paths.activity, 200)).filter(
|
|
1884
|
-
(entry) => entry.missionId === mission.id,
|
|
1885
|
-
)
|
|
1886
|
-
return {
|
|
1887
|
-
mission: fullMission(mission),
|
|
1888
|
-
reviews: reviews.slice(0, 10),
|
|
1889
|
-
findings: findings.slice(-10).map((finding) => findingSummary(finding)),
|
|
1890
|
-
activity: activity.slice(-20),
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
/** Human-readable mission board (markdown) for the current state. */
|
|
1895
|
-
boardMarkdown(state) {
|
|
1896
|
-
const lines = []
|
|
1897
|
-
lines.push(`# Tower missions — ${state.repoRoot}`)
|
|
1898
|
-
lines.push('')
|
|
1899
|
-
lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
|
|
1900
|
-
const counts = {}
|
|
1901
|
-
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
1902
|
-
for (const mission of state.missions) counts[mission.status] += 1
|
|
1903
|
-
lines.push('')
|
|
1904
|
-
lines.push(
|
|
1905
|
-
`Missions: ${state.missions.length} · ` +
|
|
1906
|
-
MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
|
|
1907
|
-
)
|
|
1908
|
-
const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
1909
|
-
lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
|
|
1910
|
-
lines.push('')
|
|
1911
|
-
lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
|
|
1912
|
-
lines.push('|---|---|---|---|---|---|---|---|')
|
|
1913
|
-
for (const mission of state.missions) {
|
|
1914
|
-
const tasks = taskCounts(mission.tasks ?? [])
|
|
1915
|
-
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1916
|
-
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1917
|
-
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1918
|
-
const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
|
|
1919
|
-
lines.push(
|
|
1920
|
-
`| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
|
|
1921
|
-
mission.owner ?? '—'
|
|
1922
|
-
} | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
|
|
1923
|
-
mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
|
|
1924
|
-
} |`,
|
|
1925
|
-
)
|
|
1926
|
-
}
|
|
1927
|
-
if ((state.roster ?? []).length > 0) {
|
|
1928
|
-
lines.push('')
|
|
1929
|
-
lines.push('## Roster')
|
|
1930
|
-
lines.push('')
|
|
1931
|
-
lines.push('| Name | Role | Mission | State | Agent |')
|
|
1932
|
-
lines.push('|---|---|---|---|---|')
|
|
1933
|
-
for (const entry of state.roster) {
|
|
1934
|
-
lines.push(
|
|
1935
|
-
`| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
|
|
1936
|
-
)
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
lines.push('')
|
|
1940
|
-
return lines.join('\n')
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
/** Write the board when docs.board is enabled; failures never break a mutation. */
|
|
1944
|
-
async writeBoard() {
|
|
1945
|
-
if (this.config.docs?.board === false) return null
|
|
1946
|
-
try {
|
|
1947
|
-
const state = await this.load()
|
|
1948
|
-
const file = this.config.docs?.boardLocation === 'repo'
|
|
1949
|
-
? path.join(this.root, 'MISSIONS.md')
|
|
1950
|
-
: path.join(this.paths.dir, 'MISSIONS.md')
|
|
1951
|
-
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
1952
|
-
await fsp.writeFile(tmp, this.boardMarkdown(state), 'utf8')
|
|
1953
|
-
await renameWithRetry(tmp, file)
|
|
1954
|
-
return file
|
|
1955
|
-
} catch {
|
|
1956
|
-
return null
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
boardFile() {
|
|
1961
|
-
return this.config.docs?.boardLocation === 'repo'
|
|
1962
|
-
? path.join(this.root, 'MISSIONS.md')
|
|
1963
|
-
: path.join(this.paths.dir, 'MISSIONS.md')
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
async enrichedMissions(state, onlyIds) {
|
|
1967
|
-
const wanted = onlyIds ? new Set(onlyIds) : null
|
|
1968
|
-
const out = []
|
|
1969
|
-
for (const mission of state.missions) {
|
|
1970
|
-
if (wanted && !wanted.has(mission.id)) continue
|
|
1971
|
-
const summary = missionSummary(mission)
|
|
1972
|
-
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1973
|
-
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1974
|
-
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1975
|
-
let tipMoved = false
|
|
1976
|
-
if (latest && mission.worktree && (await exists(mission.worktree.path))) {
|
|
1977
|
-
try {
|
|
1978
|
-
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
1979
|
-
tipMoved = tip !== latest.reviewedCommit
|
|
1980
|
-
} catch {
|
|
1981
|
-
tipMoved = false
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
out.push({
|
|
1985
|
-
...summary,
|
|
1986
|
-
review: latest
|
|
1987
|
-
? {
|
|
1988
|
-
id: latest.id,
|
|
1989
|
-
round: latest.round,
|
|
1990
|
-
status: latest.status,
|
|
1991
|
-
mergeVerdict: latest.mergeVerdict,
|
|
1992
|
-
reviewer: latest.reviewer,
|
|
1993
|
-
reviewerRole: latest.reviewerRole,
|
|
1994
|
-
reviewedCommit: latest.reviewedCommit,
|
|
1995
|
-
at: latest.at,
|
|
1996
|
-
}
|
|
1997
|
-
: null,
|
|
1998
|
-
tipMoved,
|
|
1999
|
-
})
|
|
2000
|
-
}
|
|
2001
|
-
return out
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
rebaseWaived(mission, review) {
|
|
2005
|
-
const rebase = mission.rebase
|
|
2006
|
-
if (!rebase) return false
|
|
2007
|
-
return review.at <= rebase.at && review.reviewedCommit === rebase.oldTip
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
async evaluateGate(input) {
|
|
2011
|
-
const state = await this.load()
|
|
2012
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
2013
|
-
if (!mission) {
|
|
2014
|
-
return {
|
|
2015
|
-
ok: false,
|
|
2016
|
-
blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }],
|
|
2017
|
-
missionId: input.missionId,
|
|
2018
|
-
branchTip: null,
|
|
2019
|
-
baseTip: null,
|
|
2020
|
-
changedFiles: [],
|
|
2021
|
-
review: null,
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
const activity = input.activity ?? {}
|
|
2025
|
-
const blockers = []
|
|
2026
|
-
const push = (code, detail) => blockers.push({ code, detail })
|
|
2027
|
-
|
|
2028
|
-
if (TERMINAL.has(mission.status)) {
|
|
2029
|
-
push(
|
|
2030
|
-
'branch-owned-by-closed-missions',
|
|
2031
|
-
`mission ${mission.id} is ${mission.status}; a merge never flips a closed mission — re-plan the work under a new title`,
|
|
2032
|
-
)
|
|
2033
|
-
}
|
|
2034
|
-
if (mission.status !== 'completed') {
|
|
2035
|
-
push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion (awaiting review) is required`)
|
|
2036
|
-
}
|
|
2037
|
-
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
2038
|
-
if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
2039
|
-
if ((mission.blockers ?? []).length > 0) push('blockers-present', mission.blockers.join('; '))
|
|
2040
|
-
if (this.config.gates.requireChecks === true) {
|
|
2041
|
-
const checks = mission.completionReport?.checks ?? []
|
|
2042
|
-
if (checks.length === 0) {
|
|
2043
|
-
push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
|
-
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
2047
|
-
push(
|
|
2048
|
-
'merge-intent-pending',
|
|
2049
|
-
`a merge intent for ${state.mergeIntent.missionId} was recorded at ${new Date(Number(state.mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
|
|
2050
|
-
)
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
const worktree = mission.worktree ?? null
|
|
2054
|
-
let branchTip = null
|
|
2055
|
-
let baseTip = null
|
|
2056
|
-
let changedFiles = []
|
|
2057
|
-
if (!worktree) {
|
|
2058
|
-
push('branch-missing', `mission ${mission.id} has no worktree or branch`)
|
|
2059
|
-
} else {
|
|
2060
|
-
try {
|
|
2061
|
-
await this.assertWorktree(worktree)
|
|
2062
|
-
branchTip = await revParse(worktree.path, 'HEAD')
|
|
2063
|
-
baseTip = await revParse(this.root, state.base)
|
|
2064
|
-
const mergeBase = (await git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
2065
|
-
changedFiles = (await git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
|
|
2066
|
-
.split('\n')
|
|
2067
|
-
.filter(Boolean)
|
|
2068
|
-
if (!(await isAncestor(this.root, baseTip, branchTip))) {
|
|
2069
|
-
push(
|
|
2070
|
-
'stale-base',
|
|
2071
|
-
`${worktree.branch} does not contain base "${state.base}" tip ${baseTip.slice(0, 12)}; run tower_rebase first`,
|
|
2072
|
-
)
|
|
2073
|
-
}
|
|
2074
|
-
if (mission.kind === 'survey' && changedFiles.length > 0) {
|
|
2075
|
-
push('read-only-survey', `survey ${mission.id} is read-only but ${changedFiles.length} file(s) changed: ${changedFiles.slice(0, 5).join(', ')}`)
|
|
2076
|
-
}
|
|
2077
|
-
if (mission.kind !== 'survey') {
|
|
2078
|
-
const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file))
|
|
2079
|
-
if (outOfScope.length > 0) {
|
|
2080
|
-
push('out-of-scope', `changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`)
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
const dirty = (await git(this.root, ['status', '--porcelain']))
|
|
2084
|
-
.split('\n')
|
|
2085
|
-
.filter(Boolean)
|
|
2086
|
-
.map((line) => line.slice(3).trim())
|
|
2087
|
-
const wouldOverwrite = changedFiles.filter((file) => dirty.includes(file))
|
|
2088
|
-
if (wouldOverwrite.length > 0) {
|
|
2089
|
-
push('base-dirty', `main checkout has uncommitted changes this merge would overwrite: ${wouldOverwrite.slice(0, 8).join(', ')}`)
|
|
2090
|
-
}
|
|
2091
|
-
} catch (error) {
|
|
2092
|
-
push('branch-missing', String(error?.message ?? error))
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
for (const depId of mission.deps ?? []) {
|
|
2097
|
-
const dep = state.missions.find((entry) => entry.id === depId)
|
|
2098
|
-
if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
|
|
2099
|
-
push('deps-unmerged', `${depId} is ${dep ? dep.status : 'missing'}`)
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
const branch = worktree ? worktree.branch : null
|
|
2104
|
-
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
2105
|
-
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
2106
|
-
const requireReview = this.config.review?.requireReview !== false
|
|
2107
|
-
const exempt =
|
|
2108
|
-
!requireReview ||
|
|
2109
|
-
(mission.reviewPolicy === 'none' &&
|
|
2110
|
-
this.config.gates.allowReviewExemption === true &&
|
|
2111
|
-
String(mission.reviewExemptionReason ?? '').trim().length > 0)
|
|
2112
|
-
if (requireReview && mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
|
|
2113
|
-
push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
|
|
2114
|
-
}
|
|
2115
|
-
if (requireReview && !exempt && !latest) {
|
|
2116
|
-
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
|
|
2117
|
-
} else if (requireReview && !exempt) {
|
|
2118
|
-
const waived = this.rebaseWaived(mission, latest)
|
|
2119
|
-
if (latest.missionId !== mission.id) {
|
|
2120
|
-
push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
|
|
2121
|
-
}
|
|
2122
|
-
if (latest.status !== 'clean') push('not-clean', `latest review round ${latest.round} is "${latest.status}"`)
|
|
2123
|
-
if (latest.mergeVerdict === 'hold') {
|
|
2124
|
-
push('review-hold', `latest review recommends hold — a later clean review must supersede it`)
|
|
2125
|
-
} else if (latest.status === 'clean' && latest.mergeVerdict !== 'merge') {
|
|
2126
|
-
push('review-not-approved', `latest clean review recommends "${latest.mergeVerdict}" — only "merge" approves`)
|
|
2127
|
-
}
|
|
2128
|
-
if (branchTip && latest.reviewedCommit !== branchTip && !waived) {
|
|
2129
|
-
push('tip-moved', `reviewed ${latest.reviewedCommit.slice(0, 7)} but tip is ${branchTip.slice(0, 7)}; re-review required`)
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
|
|
2134
|
-
if (activity[entry.agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
|
|
2135
|
-
else if (input.parentStatus && entry.parentSessionId && input.parentStatus[entry.parentSessionId] !== 'ok') {
|
|
2136
|
-
push(
|
|
2137
|
-
'worker-status-unknown',
|
|
2138
|
-
`cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`,
|
|
2139
|
-
)
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
const staleWorkers = (state.roster ?? []).filter(
|
|
2143
|
-
(row) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
|
|
2144
|
-
)
|
|
2145
|
-
if (staleWorkers.length > 0) {
|
|
2146
|
-
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
2147
|
-
}
|
|
2148
|
-
if (this.config.gates.requireTests === true) {
|
|
2149
|
-
const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
|
|
2150
|
-
if (runs.length === 0) {
|
|
2151
|
-
push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
const checkoutBranch = await currentBranch(this.root)
|
|
2156
|
-
if (checkoutBranch !== state.base) {
|
|
2157
|
-
push('base-wrong-branch', `main checkout is on ${checkoutBranch ?? 'detached HEAD'}, expected "${state.base}"`)
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
return {
|
|
2161
|
-
ok: blockers.length === 0,
|
|
2162
|
-
blockers,
|
|
2163
|
-
mission: fullMission(mission),
|
|
2164
|
-
worktree,
|
|
2165
|
-
branchTip,
|
|
2166
|
-
baseTip,
|
|
2167
|
-
changedFiles,
|
|
2168
|
-
review: latest,
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
/** Read-only mission diff for reviewers: branch changes against the merge base. */
|
|
2173
|
-
async diff(input = {}) {
|
|
2174
|
-
const state = await this.load()
|
|
2175
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
2176
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
2177
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
|
|
2178
|
-
await this.assertWorktree(mission.worktree)
|
|
2179
|
-
const baseTip = await revParse(this.root, state.base)
|
|
2180
|
-
const branchTip = await revParse(mission.worktree.path, 'HEAD')
|
|
2181
|
-
const mergeBase = (await git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
2182
|
-
const files = (await git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
|
|
2183
|
-
.split('\n')
|
|
2184
|
-
.filter(Boolean)
|
|
2185
|
-
const commits = (await git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
|
|
2186
|
-
.split('\n')
|
|
2187
|
-
.filter(Boolean)
|
|
2188
|
-
const fullPatch = await git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
|
|
2189
|
-
const limit = Number.isInteger(input.maxBytes) && input.maxBytes > 0 ? input.maxBytes : 64 * 1024
|
|
2190
|
-
return {
|
|
2191
|
-
missionId: mission.id,
|
|
2192
|
-
branch: mission.worktree.branch,
|
|
2193
|
-
baseTip,
|
|
2194
|
-
branchTip,
|
|
2195
|
-
mergeBase,
|
|
2196
|
-
files,
|
|
2197
|
-
commits,
|
|
2198
|
-
truncated: fullPatch.length > limit,
|
|
2199
|
-
patch: fullPatch.slice(0, limit),
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
/**
|
|
2204
|
-
* Record a merge that already landed in Git without being recorded here:
|
|
2205
|
-
* a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
|
|
2206
|
-
* the branch to the base tip. The evidence is concrete: a clean merge review
|
|
2207
|
-
* exists and either the branch tip or the reviewed commit is already an
|
|
2208
|
-
* ancestor of the base.
|
|
2209
|
-
*/
|
|
2210
|
-
/** Clear a stale merge intent only when the crash left no merge in progress
|
|
2211
|
-
* or no merge commit. Returns true when the intent was consumed. */
|
|
2212
|
-
async reconcileMergeIntent(state, mission) {
|
|
2213
|
-
const intent = state.mergeIntent
|
|
2214
|
-
if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
|
|
2215
|
-
let head = null
|
|
2216
|
-
try {
|
|
2217
|
-
head = await revParse(this.root, 'HEAD')
|
|
2218
|
-
} catch {
|
|
2219
|
-
return false
|
|
2220
|
-
}
|
|
2221
|
-
const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
|
|
2222
|
-
const inProgress = await exists(mergeHeadPath)
|
|
2223
|
-
if (inProgress) return false
|
|
2224
|
-
let landed = null
|
|
2225
|
-
if (head === intent.baseTip) {
|
|
2226
|
-
landed = null // no merge commit was created; the intent is safe to drop
|
|
2227
|
-
} else {
|
|
2228
|
-
landed = await findLandedMerge(
|
|
2229
|
-
this.root,
|
|
2230
|
-
head,
|
|
2231
|
-
new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
|
|
2232
|
-
).catch(() => null)
|
|
2233
|
-
if (!landed) return false
|
|
2234
|
-
}
|
|
2235
|
-
const now = Date.now()
|
|
2236
|
-
delete state.mergeIntent
|
|
2237
|
-
state.revision += 1
|
|
2238
|
-
state.updatedAt = now
|
|
2239
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2240
|
-
await appendJsonl(this.paths.activity, {
|
|
2241
|
-
at: now,
|
|
2242
|
-
type: 'merge',
|
|
2243
|
-
missionId: mission.id,
|
|
2244
|
-
text: landed
|
|
2245
|
-
? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
|
|
2246
|
-
: 'cleared a merge intent that created no commit',
|
|
2247
|
-
})
|
|
2248
|
-
return true
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
async reconcileLandedMerge(state, mission) {
|
|
2252
|
-
if (!['completed', 'blocked'].includes(mission.status)) return null
|
|
2253
|
-
if (!mission.worktree) return null
|
|
2254
|
-
const branch = mission.worktree.branch
|
|
2255
|
-
const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
2256
|
-
const exempt =
|
|
2257
|
-
this.config.review?.requireReview === false ||
|
|
2258
|
-
(mission.reviewPolicy === 'none' &&
|
|
2259
|
-
this.config.gates.allowReviewExemption === true &&
|
|
2260
|
-
String(mission.reviewExemptionReason ?? '').trim().length > 0)
|
|
2261
|
-
if (reviews.length === 0 && !exempt) return null
|
|
2262
|
-
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
2263
|
-
if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
|
|
2264
|
-
if (!latest && !exempt) return null
|
|
2265
|
-
let baseTip
|
|
2266
|
-
let branchTip
|
|
2267
|
-
try {
|
|
2268
|
-
baseTip = await revParse(this.root, state.base)
|
|
2269
|
-
branchTip = await revParse(mission.worktree.path, 'HEAD')
|
|
2270
|
-
} catch {
|
|
2271
|
-
return null
|
|
2272
|
-
}
|
|
2273
|
-
const reviewedTip = latest?.reviewedCommit ?? null
|
|
2274
|
-
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
2275
|
-
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
2276
|
-
if (!branchLanded && !reviewedLanded) return null
|
|
2277
|
-
const tips = new Set([branchTip, reviewedTip].filter(Boolean))
|
|
2278
|
-
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
2279
|
-
const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
|
|
2280
|
-
const now = Date.now()
|
|
2281
|
-
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
2282
|
-
mission.status = 'merged'
|
|
2283
|
-
mission.mergedAt = now
|
|
2284
|
-
mission.mergedCommit = mergedCommit
|
|
2285
|
-
mission.blockers = []
|
|
2286
|
-
mission.notes.push(
|
|
2287
|
-
found
|
|
2288
|
-
? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
|
|
2289
|
-
: `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
|
|
2290
|
-
)
|
|
2291
|
-
mission.updatedAt = now
|
|
2292
|
-
state.revision += 1
|
|
2293
|
-
state.updatedAt = now
|
|
2294
|
-
validateState(state, this.paths.state)
|
|
2295
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2296
|
-
await appendJsonl(this.paths.activity, {
|
|
2297
|
-
at: now,
|
|
2298
|
-
type: 'merge',
|
|
2299
|
-
missionId: mission.id,
|
|
2300
|
-
text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
|
|
2301
|
-
})
|
|
2302
|
-
return {
|
|
2303
|
-
ok: true,
|
|
2304
|
-
reconciled: true,
|
|
2305
|
-
alreadyLanded: true,
|
|
2306
|
-
mergedCommit,
|
|
2307
|
-
baseTip,
|
|
2308
|
-
branchTip,
|
|
2309
|
-
parents: found ? found.parents : null,
|
|
2310
|
-
review: latest
|
|
2311
|
-
? {
|
|
2312
|
-
round: latest.round,
|
|
2313
|
-
status: latest.status,
|
|
2314
|
-
mergeVerdict: latest.mergeVerdict,
|
|
2315
|
-
reviewer: latest.reviewer,
|
|
2316
|
-
reviewedCommit: reviewedTip,
|
|
2317
|
-
}
|
|
2318
|
-
: null,
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
async merge(input) {
|
|
2323
|
-
return withLock(this.paths, async () => {
|
|
2324
|
-
const state = await this.load()
|
|
2325
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
2326
|
-
if (!mission) {
|
|
2327
|
-
return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
|
|
2328
|
-
}
|
|
2329
|
-
await this.reconcileMergeIntent(state, mission)
|
|
2330
|
-
const baseTip = await revParse(this.root, state.base)
|
|
2331
|
-
if (mission.status === 'merged') {
|
|
2332
|
-
return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
|
|
2333
|
-
}
|
|
2334
|
-
const reconciled = await this.reconcileLandedMerge(state, mission)
|
|
2335
|
-
if (reconciled) return reconciled
|
|
2336
|
-
const gate = await this.evaluateGate(input)
|
|
2337
|
-
if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
|
|
2338
|
-
|
|
2339
|
-
const worktree = mission.worktree
|
|
2340
|
-
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
2341
|
-
const branchTip = await revParse(worktree.path, 'HEAD')
|
|
2342
|
-
// Record the intent before touching Git; a crash between merge and state
|
|
2343
|
-
// write can be reconciled by the next tower_merge/preflight instead of
|
|
2344
|
-
// leaving an unexplained base tip.
|
|
2345
|
-
state.mergeIntent = {
|
|
2346
|
-
missionId: mission.id,
|
|
2347
|
-
baseTip: baseTipBefore,
|
|
2348
|
-
branchTip,
|
|
2349
|
-
startedAt: Date.now(),
|
|
2350
|
-
}
|
|
2351
|
-
state.revision += 1
|
|
2352
|
-
state.updatedAt = state.mergeIntent.startedAt
|
|
2353
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2354
|
-
const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
|
|
2355
|
-
let failure = null
|
|
2356
|
-
try {
|
|
2357
|
-
await git(
|
|
2358
|
-
this.root,
|
|
2359
|
-
['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
2360
|
-
{ timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
2361
|
-
)
|
|
2362
|
-
} catch (error) {
|
|
2363
|
-
failure = error
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
const newTip = await revParse(this.root, 'HEAD')
|
|
2367
|
-
const parents = await commitParents(this.root, newTip)
|
|
2368
|
-
const expectedShape = parents.length === 2 && parents[0] === baseTipBefore && parents[1] === branchTip
|
|
2369
|
-
const warnings = []
|
|
2370
|
-
|
|
2371
|
-
if (failure && expectedShape) {
|
|
2372
|
-
warnings.push(
|
|
2373
|
-
`merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
|
|
2374
|
-
)
|
|
2375
|
-
const dirty = (await git(this.root, ['status', '--porcelain']))
|
|
2376
|
-
.split('\n')
|
|
2377
|
-
.filter(Boolean)
|
|
2378
|
-
.map((line) => line.slice(3).trim())
|
|
2379
|
-
if (dirty.length > 0) {
|
|
2380
|
-
const changed = new Set(gate.changedFiles ?? [])
|
|
2381
|
-
if (dirty.every((file) => changed.has(file))) {
|
|
2382
|
-
await git(this.root, ['reset', '--hard', 'HEAD'])
|
|
2383
|
-
warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
|
|
2384
|
-
} else {
|
|
2385
|
-
warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
} else if (failure) {
|
|
2389
|
-
const unmerged = (await tryGit(this.root, ['ls-files', '-u'])) ?? ''
|
|
2390
|
-
const conflict = unmerged.trim().length > 0
|
|
2391
|
-
let cleanup = 'none'
|
|
2392
|
-
const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
|
|
2393
|
-
if (await exists(mergeHeadPath)) {
|
|
2394
|
-
try {
|
|
2395
|
-
await git(this.root, ['merge', '--abort'])
|
|
2396
|
-
cleanup = 'aborted'
|
|
2397
|
-
} catch {
|
|
2398
|
-
await removeStaleGitLocks(this.commonDir)
|
|
2399
|
-
try {
|
|
2400
|
-
await git(this.root, ['merge', '--abort'])
|
|
2401
|
-
cleanup = 'aborted-after-lock-cleanup'
|
|
2402
|
-
} catch {
|
|
2403
|
-
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
2404
|
-
cleanup = 'reset'
|
|
2405
|
-
}
|
|
2406
|
-
}
|
|
2407
|
-
} else if (newTip !== baseTipBefore) {
|
|
2408
|
-
await removeStaleGitLocks(this.commonDir)
|
|
2409
|
-
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
2410
|
-
cleanup = 'reset'
|
|
2411
|
-
}
|
|
2412
|
-
// A killed git process can leave the index staged even without
|
|
2413
|
-
// MERGE_HEAD. Restore only when every dirty path belongs to this merge.
|
|
2414
|
-
const dirtyAfter = (await git(this.root, ['status', '--porcelain']))
|
|
2415
|
-
.split('\n')
|
|
2416
|
-
.filter(Boolean)
|
|
2417
|
-
.map((line) => line.slice(3).trim())
|
|
2418
|
-
if (dirtyAfter.length > 0) {
|
|
2419
|
-
const changed = new Set(gate.changedFiles ?? [])
|
|
2420
|
-
if (dirtyAfter.every((file) => changed.has(file))) {
|
|
2421
|
-
await removeStaleGitLocks(this.commonDir)
|
|
2422
|
-
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
2423
|
-
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
2424
|
-
} else {
|
|
2425
|
-
warnings.push(`cleanup left unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
|
|
2426
|
-
}
|
|
2427
|
-
}
|
|
2428
|
-
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
2429
|
-
const now = Date.now()
|
|
2430
|
-
delete state.mergeIntent
|
|
2431
|
-
if (conflict) {
|
|
2432
|
-
mission.status = 'blocked'
|
|
2433
|
-
mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
|
|
2434
|
-
} else {
|
|
2435
|
-
mission.notes.push(`merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${mission.status} for retry`)
|
|
2436
|
-
}
|
|
2437
|
-
mission.updatedAt = now
|
|
2438
|
-
state.revision += 1
|
|
2439
|
-
state.updatedAt = now
|
|
2440
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2441
|
-
await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: `${conflict ? 'conflict' : 'failed-before-commit'} (${cleanup})` })
|
|
2442
|
-
return {
|
|
2443
|
-
ok: false,
|
|
2444
|
-
missionId: mission.id,
|
|
2445
|
-
blockers: [
|
|
2446
|
-
{
|
|
2447
|
-
code: conflict ? 'merge-conflict' : 'merge-hook-failed',
|
|
2448
|
-
detail,
|
|
2449
|
-
...(failure.timedOut === true ? { timeout: true } : {}),
|
|
2450
|
-
cleanup,
|
|
2451
|
-
},
|
|
2452
|
-
],
|
|
2453
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
if (!expectedShape) {
|
|
2458
|
-
delete state.mergeIntent
|
|
2459
|
-
state.revision += 1
|
|
2460
|
-
state.updatedAt = Date.now()
|
|
2461
|
-
await writeJsonAtomic(this.paths.state, state).catch(() => {})
|
|
2462
|
-
return {
|
|
2463
|
-
ok: false,
|
|
2464
|
-
blockers: [
|
|
2465
|
-
{
|
|
2466
|
-
code: 'merge-shape',
|
|
2467
|
-
detail: `unexpected merge parents [${parents.join(', ')}], expected [${baseTipBefore}, ${branchTip}]`,
|
|
2468
|
-
},
|
|
2469
|
-
],
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
const now = Date.now()
|
|
2474
|
-
delete state.mergeIntent
|
|
2475
|
-
mission.status = 'merged'
|
|
2476
|
-
mission.mergedAt = now
|
|
2477
|
-
mission.mergedCommit = newTip
|
|
2478
|
-
mission.updatedAt = now
|
|
2479
|
-
mission.notes.push(`merged ${branchTip.slice(0, 7)} into ${state.base} as ${newTip.slice(0, 7)}`)
|
|
2480
|
-
state.revision += 1
|
|
2481
|
-
state.updatedAt = now
|
|
2482
|
-
validateState(state, this.paths.state)
|
|
2483
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2484
|
-
await appendJsonl(this.paths.activity, {
|
|
2485
|
-
at: now,
|
|
2486
|
-
type: 'merge',
|
|
2487
|
-
missionId: mission.id,
|
|
2488
|
-
text: `${branchTip.slice(0, 7)} -> ${newTip.slice(0, 7)} on ${state.base}`,
|
|
2489
|
-
})
|
|
2490
|
-
return {
|
|
2491
|
-
ok: true,
|
|
2492
|
-
mergedCommit: newTip,
|
|
2493
|
-
baseTip: baseTipBefore,
|
|
2494
|
-
branchTip,
|
|
2495
|
-
parents,
|
|
2496
|
-
...(warnings.length > 0 ? { warnings, recoveredFromFailure: true } : {}),
|
|
2497
|
-
}
|
|
2498
|
-
})
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
async rebase(input) {
|
|
2502
|
-
return withLock(this.paths, async () => {
|
|
2503
|
-
const state = await this.load()
|
|
2504
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
2505
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
2506
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
2507
|
-
const activity = input.activity ?? {}
|
|
2508
|
-
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
|
|
2509
|
-
if (activity[entry.agentId] === 'running') {
|
|
2510
|
-
return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
await this.assertWorktree(mission.worktree)
|
|
2514
|
-
if (!(await isClean(mission.worktree.path))) {
|
|
2515
|
-
return { ok: false, blockers: [{ code: 'worktree-dirty', detail: `${mission.worktree.path} has uncommitted changes` }] }
|
|
2516
|
-
}
|
|
2517
|
-
const oldTip = await revParse(mission.worktree.path, 'HEAD')
|
|
2518
|
-
const baseTip = await revParse(this.root, state.base)
|
|
2519
|
-
if (await isAncestor(this.root, oldTip, baseTip)) {
|
|
2520
|
-
// The branch is already contained in the base (manual merge, crashed
|
|
2521
|
-
// merge, earlier fast-forward). Rewriting the branch tip to the base
|
|
2522
|
-
// would destroy the reviewed tip and the ability to record the merge.
|
|
2523
|
-
return {
|
|
2524
|
-
ok: true,
|
|
2525
|
-
alreadyMerged: true,
|
|
2526
|
-
skipped: true,
|
|
2527
|
-
oldTip,
|
|
2528
|
-
newTip: oldTip,
|
|
2529
|
-
baseTip,
|
|
2530
|
-
note: `${oldTip.slice(0, 7)} is already contained in ${state.base} ${baseTip.slice(0, 7)}; branch left untouched — call tower_merge to record the landed merge`,
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2533
|
-
if (await isAncestor(this.root, baseTip, oldTip)) {
|
|
2534
|
-
return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
|
|
2535
|
-
}
|
|
2536
|
-
const now = Date.now()
|
|
2537
|
-
try {
|
|
2538
|
-
await git(mission.worktree.path, ['rebase', baseTip])
|
|
2539
|
-
} catch (error) {
|
|
2540
|
-
await git(mission.worktree.path, ['rebase', '--abort']).catch(() => {})
|
|
2541
|
-
mission.status = 'blocked'
|
|
2542
|
-
mission.blockers.push(`rebase conflict onto ${state.base} @ ${baseTip.slice(0, 12)}`)
|
|
2543
|
-
mission.updatedAt = now
|
|
2544
|
-
state.revision += 1
|
|
2545
|
-
state.updatedAt = now
|
|
2546
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2547
|
-
await appendJsonl(this.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
|
|
2548
|
-
return { ok: false, blockers: [{ code: 'rebase-conflict', detail: String(error?.message ?? error) }] }
|
|
2549
|
-
}
|
|
2550
|
-
const newTip = await revParse(mission.worktree.path, 'HEAD')
|
|
2551
|
-
mission.rebase = { at: now, oldTip, newTip, baseTip }
|
|
2552
|
-
if (mission.status === 'blocked') mission.status = 'active'
|
|
2553
|
-
mission.notes.push(
|
|
2554
|
-
`rebased ${oldTip.slice(0, 7)} -> ${newTip.slice(0, 7)} onto ${baseTip.slice(0, 7)}; conflict-free rebase waives re-review`,
|
|
2555
|
-
)
|
|
2556
|
-
mission.updatedAt = now
|
|
2557
|
-
state.revision += 1
|
|
2558
|
-
state.updatedAt = now
|
|
2559
|
-
validateState(state, this.paths.state)
|
|
2560
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2561
|
-
await appendJsonl(this.paths.activity, {
|
|
2562
|
-
at: now,
|
|
2563
|
-
type: 'rebase',
|
|
2564
|
-
missionId: mission.id,
|
|
2565
|
-
text: `${oldTip.slice(0, 7)} -> ${newTip.slice(0, 7)} onto ${baseTip.slice(0, 7)}`,
|
|
2566
|
-
})
|
|
2567
|
-
return { ok: true, oldTip, newTip, baseTip, reviewWaived: true }
|
|
2568
|
-
})
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
async teardown(input) {
|
|
2572
|
-
return withLock(this.paths, async () => {
|
|
2573
|
-
const state = await this.load()
|
|
2574
|
-
const activity = input.activity ?? {}
|
|
2575
|
-
const exclude = new Set(input.exclude ?? [])
|
|
2576
|
-
const decisions = []
|
|
2577
|
-
const removed = []
|
|
2578
|
-
const now = Date.now()
|
|
2579
|
-
|
|
2580
|
-
for (const mission of state.missions) {
|
|
2581
|
-
if (!mission.worktree || mission.worktreeRemovedAt) continue
|
|
2582
|
-
if (exclude.has(mission.id)) {
|
|
2583
|
-
decisions.push({ missionId: mission.id, action: 'keep', reason: 'excluded' })
|
|
2584
|
-
continue
|
|
2585
|
-
}
|
|
2586
|
-
const live = (state.roster ?? []).some(
|
|
2587
|
-
(entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
|
|
2588
|
-
)
|
|
2589
|
-
if (live) {
|
|
2590
|
-
decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
|
|
2591
|
-
continue
|
|
2592
|
-
}
|
|
2593
|
-
if (!['merged', 'abandoned'].includes(mission.status)) {
|
|
2594
|
-
decisions.push({ missionId: mission.id, action: 'keep', reason: `mission is ${mission.status}` })
|
|
2595
|
-
continue
|
|
2596
|
-
}
|
|
2597
|
-
let dirty = true
|
|
2598
|
-
try {
|
|
2599
|
-
dirty = !(await isClean(mission.worktree.path))
|
|
2600
|
-
} catch {
|
|
2601
|
-
dirty = true
|
|
2602
|
-
}
|
|
2603
|
-
if (dirty && input.force !== true) {
|
|
2604
|
-
decisions.push({ missionId: mission.id, action: 'keep', reason: 'uncommitted changes' })
|
|
2605
|
-
continue
|
|
2606
|
-
}
|
|
2607
|
-
if (input.dryRun === true) {
|
|
2608
|
-
decisions.push({ missionId: mission.id, action: 'remove', reason: 'eligible', forced: dirty })
|
|
2609
|
-
continue
|
|
2610
|
-
}
|
|
2611
|
-
try {
|
|
2612
|
-
await git(this.root, ['worktree', 'remove', ...(dirty ? ['--force'] : []), mission.worktree.path])
|
|
2613
|
-
} catch (error) {
|
|
2614
|
-
decisions.push({ missionId: mission.id, action: 'keep', reason: `remove failed: ${String(error?.message ?? error)}` })
|
|
2615
|
-
continue
|
|
2616
|
-
}
|
|
2617
|
-
await git(this.root, ['branch', '-d', mission.worktree.branch]).catch(() => {})
|
|
2618
|
-
mission.worktreeRemovedAt = now
|
|
2619
|
-
mission.updatedAt = now
|
|
2620
|
-
removed.push(mission.id)
|
|
2621
|
-
decisions.push({ missionId: mission.id, action: 'removed', branch: mission.worktree.branch })
|
|
2622
|
-
await appendJsonl(this.paths.activity, {
|
|
2623
|
-
at: now,
|
|
2624
|
-
type: 'teardown',
|
|
2625
|
-
missionId: mission.id,
|
|
2626
|
-
text: mission.worktree.branch,
|
|
2627
|
-
})
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
if (!input.dryRun && removed.length > 0) {
|
|
2631
|
-
state.revision += 1
|
|
2632
|
-
state.updatedAt = now
|
|
2633
|
-
validateState(state, this.paths.state)
|
|
2634
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2635
|
-
}
|
|
2636
|
-
return { ok: true, dryRun: input.dryRun === true, removed, decisions }
|
|
2637
|
-
})
|
|
2638
|
-
}
|
|
2639
|
-
|
|
2640
|
-
async status(input = {}) {
|
|
2641
|
-
const state = await this.load()
|
|
2642
|
-
const activity = await readActivityTail(this.paths.activity, 12)
|
|
2643
|
-
const counts = {}
|
|
2644
|
-
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
2645
|
-
for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
|
|
2646
|
-
const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks).open, 0)
|
|
2647
|
-
const findingsByStatus = {}
|
|
2648
|
-
for (const status of FINDING_STATUSES) findingsByStatus[status] = 0
|
|
2649
|
-
for (const finding of state.findings ?? []) {
|
|
2650
|
-
const status = finding.status ?? 'open'
|
|
2651
|
-
findingsByStatus[status] = (findingsByStatus[status] ?? 0) + 1
|
|
2652
|
-
}
|
|
2653
|
-
return {
|
|
2654
|
-
repoRoot: state.repoRoot,
|
|
2655
|
-
commonDir: state.commonDir,
|
|
2656
|
-
statePath: this.paths.state,
|
|
2657
|
-
base: state.base,
|
|
2658
|
-
revision: state.revision,
|
|
2659
|
-
counts,
|
|
2660
|
-
openTasks,
|
|
2661
|
-
roster: (state.roster ?? []).length,
|
|
2662
|
-
messages: (state.messages ?? []).length,
|
|
2663
|
-
unreadTower: this.unreadFor(state, 'tower').length,
|
|
2664
|
-
findings: (state.findings ?? []).length,
|
|
2665
|
-
findingsByStatus,
|
|
2666
|
-
reviews: (state.reviews ?? []).length,
|
|
2667
|
-
config: {
|
|
2668
|
-
path: this.config.configPath ?? null,
|
|
2669
|
-
repoConfigPresent: this.config.repoConfigPresent === true,
|
|
2670
|
-
spawn: {
|
|
2671
|
-
workerPreset: this.config.spawn?.workerPreset ?? null,
|
|
2672
|
-
reviewerPreset: this.config.spawn?.reviewerPreset ?? null,
|
|
2673
|
-
},
|
|
2674
|
-
},
|
|
2675
|
-
recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
|
|
2676
|
-
id: finding.id,
|
|
2677
|
-
type: finding.type,
|
|
2678
|
-
severity: finding.severity,
|
|
2679
|
-
status: finding.status ?? 'open',
|
|
2680
|
-
title: finding.title,
|
|
2681
|
-
missionId: finding.missionId,
|
|
2682
|
-
reportedBy: finding.reportedBy,
|
|
2683
|
-
at: finding.at,
|
|
2684
|
-
})),
|
|
2685
|
-
missions: await this.enrichedMissions(state, input.missionIds),
|
|
2686
|
-
activity,
|
|
2687
|
-
}
|
|
2688
|
-
}
|
|
2689
|
-
}
|