dsh-tower 2.1.0-beta.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +28 -8
- package/app/tower.js +121 -39
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +64 -19
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +3 -4
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +4 -7
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +7 -6
- package/tools/preflight.js +3 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /package/{lib → host}/presets.js +0 -0
|
@@ -116,6 +116,7 @@ export function existingInitResult({ state, statePath, dirty }) {
|
|
|
116
116
|
reanchored: false,
|
|
117
117
|
dirty,
|
|
118
118
|
snapshotCommit: state.snapshotCommit ?? null,
|
|
119
|
+
caseInsensitive: state.caseInsensitive ?? null,
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -128,10 +129,21 @@ export function existingInitResult({ state, statePath, dirty }) {
|
|
|
128
129
|
* @param {string | null} [input.towerAgentId]
|
|
129
130
|
* @param {string | null} [input.snapshotCommit]
|
|
130
131
|
* @param {string | null} [input.snapshotRef]
|
|
132
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
131
133
|
* @param {number} input.now
|
|
132
134
|
* @returns {TowerState}
|
|
133
135
|
*/
|
|
134
|
-
export function initialState({
|
|
136
|
+
export function initialState({
|
|
137
|
+
repoRoot,
|
|
138
|
+
commonDir,
|
|
139
|
+
base,
|
|
140
|
+
dirty,
|
|
141
|
+
towerAgentId = null,
|
|
142
|
+
snapshotCommit = null,
|
|
143
|
+
snapshotRef = null,
|
|
144
|
+
caseInsensitive = null,
|
|
145
|
+
now,
|
|
146
|
+
}) {
|
|
135
147
|
return {
|
|
136
148
|
schemaVersion: SCHEMA_VERSION,
|
|
137
149
|
repoRoot,
|
|
@@ -141,6 +153,7 @@ export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId =
|
|
|
141
153
|
createdAt: now,
|
|
142
154
|
updatedAt: now,
|
|
143
155
|
dirtyBase: dirty,
|
|
156
|
+
caseInsensitive,
|
|
144
157
|
...(towerAgentId ? { towerAgentId: String(towerAgentId) } : {}),
|
|
145
158
|
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
146
159
|
missions: [],
|
|
@@ -156,9 +169,19 @@ export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId =
|
|
|
156
169
|
* @param {boolean} input.dirty
|
|
157
170
|
* @param {string | null} input.snapshotCommit
|
|
158
171
|
* @param {string | null} input.snapshotRef
|
|
172
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
159
173
|
* @returns {object}
|
|
160
174
|
*/
|
|
161
|
-
export function createdInitResult({
|
|
175
|
+
export function createdInitResult({
|
|
176
|
+
repoRoot,
|
|
177
|
+
commonDir,
|
|
178
|
+
statePath,
|
|
179
|
+
base,
|
|
180
|
+
dirty,
|
|
181
|
+
snapshotCommit,
|
|
182
|
+
snapshotRef,
|
|
183
|
+
caseInsensitive = null,
|
|
184
|
+
}) {
|
|
162
185
|
return {
|
|
163
186
|
created: true,
|
|
164
187
|
repoRoot,
|
|
@@ -171,6 +194,7 @@ export function createdInitResult({ repoRoot, commonDir, statePath, base, dirty,
|
|
|
171
194
|
dirty,
|
|
172
195
|
snapshotCommit,
|
|
173
196
|
snapshotRef,
|
|
197
|
+
caseInsensitive,
|
|
174
198
|
}
|
|
175
199
|
}
|
|
176
200
|
|
|
@@ -194,6 +218,7 @@ export function existingInitWriteResult({ state, statePath, dirty, reanchored })
|
|
|
194
218
|
reanchored,
|
|
195
219
|
dirty,
|
|
196
220
|
snapshotCommit: state.snapshotCommit ?? null,
|
|
221
|
+
caseInsensitive: state.caseInsensitive ?? null,
|
|
197
222
|
}
|
|
198
223
|
}
|
|
199
224
|
|
|
@@ -202,10 +227,21 @@ export function existingInitWriteResult({ state, statePath, dirty, reanchored })
|
|
|
202
227
|
* @param {string} input.base
|
|
203
228
|
* @param {string | null} [input.snapshotCommit]
|
|
204
229
|
* @param {boolean} input.dirty
|
|
230
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
205
231
|
* @returns {string}
|
|
206
232
|
*/
|
|
207
|
-
export function initActivityText({ base, snapshotCommit = null, dirty }) {
|
|
208
|
-
|
|
233
|
+
export function initActivityText({ base, snapshotCommit = null, dirty, caseInsensitive = null }) {
|
|
234
|
+
const mode = caseInsensitive === null ? '' : ` (${caseInsensitive ? 'case-insensitive' : 'case-sensitive'} filesystem)`
|
|
235
|
+
return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}${mode}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Activity line for a probe recorded on an existing state.
|
|
240
|
+
* @param {boolean} caseInsensitive
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function caseProbeActivityText(caseInsensitive) {
|
|
244
|
+
return `case sensitivity detected: ${caseInsensitive ? 'case-insensitive' : 'case-sensitive'}`
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
/**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// writes, the state lock, and persistence stay in app/merge.js.
|
|
4
4
|
// @ts-check
|
|
5
5
|
import { isReviewExempt, latestReview, reviewsForBranch } from './review.js'
|
|
6
|
+
import { mergedEvent } from '../events.js'
|
|
6
7
|
|
|
7
8
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
9
|
/** @typedef {import('../../types/index.js').Review} Review */
|
|
@@ -182,6 +183,40 @@ export function mergeSuccessNote(branchTip, base, newTip) {
|
|
|
182
183
|
return `merged ${branchTip.slice(0, 7)} into ${base} as ${newTip.slice(0, 7)}`
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Apply a successful two-phase merge to the mission draft and describe the
|
|
188
|
+
* domain fact, including the commit that landed. Persistence stays in
|
|
189
|
+
* app/merge.js at the commit phase.
|
|
190
|
+
* @param {{ mission: Mission, branchTip: string, base: string, newTip: string, now: number }} params
|
|
191
|
+
* @returns {{ state: Mission, events: Array<{ type: string, missionId: string, commit: string }> }}
|
|
192
|
+
*/
|
|
193
|
+
export function applyMergeSuccess({ mission, branchTip, base, newTip, now }) {
|
|
194
|
+
mission.status = 'merged'
|
|
195
|
+
mission.mergedAt = now
|
|
196
|
+
mission.mergedCommit = newTip
|
|
197
|
+
mission.updatedAt = now
|
|
198
|
+
mission.notes = mission.notes ?? []
|
|
199
|
+
mission.notes.push(mergeSuccessNote(branchTip, base, newTip))
|
|
200
|
+
return { state: mission, events: [mergedEvent(mission, newTip)] }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Apply an already-landed merge reconciliation to the mission draft. `note` is
|
|
205
|
+
* the evidence line the caller built from the observed commit and parents.
|
|
206
|
+
* @param {{ mission: Mission, mergedCommit: string, note: string, now: number }} params
|
|
207
|
+
* @returns {{ state: Mission, events: Array<{ type: string, missionId: string, commit: string }> }}
|
|
208
|
+
*/
|
|
209
|
+
export function applyLandedMerge({ mission, mergedCommit, note, now }) {
|
|
210
|
+
mission.status = 'merged'
|
|
211
|
+
mission.mergedAt = now
|
|
212
|
+
mission.mergedCommit = mergedCommit
|
|
213
|
+
mission.blockers = []
|
|
214
|
+
mission.notes = mission.notes ?? []
|
|
215
|
+
mission.notes.push(note)
|
|
216
|
+
mission.updatedAt = now
|
|
217
|
+
return { state: mission, events: [mergedEvent(mission, mergedCommit)] }
|
|
218
|
+
}
|
|
219
|
+
|
|
185
220
|
/**
|
|
186
221
|
* @param {string} detail
|
|
187
222
|
* @returns {string}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Git-mutex budget policy (#93), shared by the app layer and the store
|
|
2
|
+
// infrastructure (#69).
|
|
3
|
+
//
|
|
4
|
+
// The waiter behind the repo-scoped git mutex must outlast a merge that is
|
|
5
|
+
// entitled to `mergeTimeoutMs`: a merge legally holds the mutex for its whole
|
|
6
|
+
// plan.timeoutMs and then runs a few cheap evidence/cleanup git calls before
|
|
7
|
+
// releasing it (#93). The budget is pure policy over the resolved Tower config,
|
|
8
|
+
// so it lives in the domain rather than in an adapter; app/tower.js and
|
|
9
|
+
// adapters/store.js both compute it from here.
|
|
10
|
+
export const GIT_LOCK_MARGIN_MS = 60000
|
|
11
|
+
|
|
12
|
+
/** The default merge timeout when a caller passes a config without `merge`. */
|
|
13
|
+
export const DEFAULT_MERGE_TIMEOUT_MS = 900000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ merge?: { timeoutMs?: number } } | null | undefined} config
|
|
17
|
+
* @param {number} [mergeTimeoutMs] the caller's resolved merge timeout
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export function gitLockTimeoutMs(config, mergeTimeoutMs) {
|
|
21
|
+
const explicit = Number(mergeTimeoutMs)
|
|
22
|
+
const configured = Number(config?.merge?.timeoutMs)
|
|
23
|
+
const base =
|
|
24
|
+
Number.isInteger(explicit) && explicit > 0
|
|
25
|
+
? explicit
|
|
26
|
+
: Number.isInteger(configured) && configured > 0
|
|
27
|
+
? configured
|
|
28
|
+
: DEFAULT_MERGE_TIMEOUT_MS
|
|
29
|
+
return base + GIT_LOCK_MARGIN_MS
|
|
30
|
+
}
|
package/domain/mission.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
/** @type {readonly MissionStatus[]} */
|
|
11
11
|
export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
|
|
12
|
-
export const SCHEMA_VERSION =
|
|
12
|
+
export const SCHEMA_VERSION = 3
|
|
13
13
|
/** @type {readonly MergeVerdict[]} */
|
|
14
14
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
15
|
export const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
@@ -4,10 +4,62 @@
|
|
|
4
4
|
// Git, and persists the transition; the rules below are repository-free.
|
|
5
5
|
// @ts-check
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
|
+
import { completedEvent } from '../events.js'
|
|
7
8
|
|
|
8
9
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
9
10
|
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Hard cap for `tower_complete.report`, in UTF-8 bytes (32 KiB). The value is
|
|
14
|
+
* the default of `gates.maxReportBytes`; a repository may lower or raise it.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_MAX_REPORT_BYTES = 32 * 1024
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* UTF-8 byte length, so multi-byte text is not silently favoured by a
|
|
20
|
+
* character-count bound.
|
|
21
|
+
* @param {string} value
|
|
22
|
+
* @returns {number}
|
|
23
|
+
*/
|
|
24
|
+
export function utf8ByteLength(value) {
|
|
25
|
+
return Buffer.byteLength(String(value), 'utf8')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reject a report over the cap instead of truncating it: a silent cut loses
|
|
30
|
+
* evidence and cannot be recovered by the tower. The writer is told to keep the
|
|
31
|
+
* full text beside the branch and reference it.
|
|
32
|
+
* @param {string} report
|
|
33
|
+
* @param {number} [maxBytes]
|
|
34
|
+
* @returns {number} accepted UTF-8 byte length
|
|
35
|
+
*/
|
|
36
|
+
export function assertReportSize(report, maxBytes = DEFAULT_MAX_REPORT_BYTES) {
|
|
37
|
+
const bytes = utf8ByteLength(report)
|
|
38
|
+
if (bytes > maxBytes) {
|
|
39
|
+
throw new TowerToolError(
|
|
40
|
+
'invalid-argument',
|
|
41
|
+
`report is ${bytes} bytes (UTF-8), over the ${maxBytes}-byte cap; reports are rejected, not truncated — write the full text to a file in the mission worktree and reference the path in the report`,
|
|
42
|
+
{
|
|
43
|
+
nextAction: 'write the full text to a file in the mission worktree and reference the path in the report',
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return bytes
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Byte size of a stored completion report. Older documents have no
|
|
52
|
+
* `reportBytes`, so the body is measured on read; the field is never required.
|
|
53
|
+
* @param {any} completionReport
|
|
54
|
+
* @returns {number | null}
|
|
55
|
+
*/
|
|
56
|
+
export function completionReportBytes(completionReport) {
|
|
57
|
+
if (!isPlainObject(completionReport)) return null
|
|
58
|
+
const stored = completionReport.reportBytes
|
|
59
|
+
if (Number.isInteger(stored) && stored >= 0) return stored
|
|
60
|
+
return typeof completionReport.report === 'string' ? utf8ByteLength(completionReport.report) : null
|
|
61
|
+
}
|
|
62
|
+
|
|
11
63
|
/**
|
|
12
64
|
* @param {string} message
|
|
13
65
|
* @param {string} [code]
|
|
@@ -53,10 +105,10 @@ export function normalizeChecks(checks) {
|
|
|
53
105
|
/**
|
|
54
106
|
* Resolve and validate the reporting worker and the mission's completion
|
|
55
107
|
* preconditions that need no repository access.
|
|
56
|
-
* @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any }} params
|
|
108
|
+
* @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any, maxReportBytes?: number }} params
|
|
57
109
|
* @returns {RosterEntry}
|
|
58
110
|
*/
|
|
59
|
-
export function assertCompletionRequest({ mission, roster, input }) {
|
|
111
|
+
export function assertCompletionRequest({ mission, roster, input, maxReportBytes }) {
|
|
60
112
|
const worker = (roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
61
113
|
if (!worker || worker.missionId !== mission.id) {
|
|
62
114
|
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
@@ -75,6 +127,7 @@ export function assertCompletionRequest({ mission, roster, input }) {
|
|
|
75
127
|
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
76
128
|
fail('tower_complete requires a non-empty report')
|
|
77
129
|
}
|
|
130
|
+
assertReportSize(input.report.trim(), maxReportBytes)
|
|
78
131
|
const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
79
132
|
if (openTasks.length > 0) {
|
|
80
133
|
fail(
|
|
@@ -106,9 +159,23 @@ export function assertCompletionInbox({ mission, worker, unread }) {
|
|
|
106
159
|
|
|
107
160
|
/**
|
|
108
161
|
* Project the completion report stored on the mission.
|
|
109
|
-
* @param {{ report: string, worker: RosterEntry, checks: any[], now: number }} params
|
|
162
|
+
* @param {{ report: string, worker: RosterEntry, checks: any[], now: number, maxReportBytes?: number }} params
|
|
110
163
|
* @returns {any}
|
|
111
164
|
*/
|
|
112
|
-
export function completionReport({ report, worker, checks, now }) {
|
|
113
|
-
|
|
165
|
+
export function completionReport({ report, worker, checks, now, maxReportBytes }) {
|
|
166
|
+
const text = report.trim()
|
|
167
|
+
return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Apply the completion transition to the mission draft and describe the domain
|
|
172
|
+
* fact. Persistence stays in app/checkCompletion.js.
|
|
173
|
+
* @param {{ mission: Mission, report: any, now: number }} params
|
|
174
|
+
* @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
|
|
175
|
+
*/
|
|
176
|
+
export function applyCompletion({ mission, report, now }) {
|
|
177
|
+
mission.status = 'completed'
|
|
178
|
+
mission.completionReport = report
|
|
179
|
+
mission.updatedAt = now
|
|
180
|
+
return { state: mission, events: [completedEvent(mission)] }
|
|
114
181
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// in-memory mission draft it is given.
|
|
6
6
|
// @ts-check
|
|
7
7
|
import { TowerToolError } from '../errors.js'
|
|
8
|
+
import { abandonedEvent, completedEvent } from '../events.js'
|
|
8
9
|
import { MISSION_STATUSES, TERMINAL_STATUSES, canTransition, pauseCauseError } from '../mission.js'
|
|
9
10
|
import { normalizeScope, scopesOverlap } from '../scope.js'
|
|
10
11
|
|
|
@@ -62,9 +63,10 @@ export function assertMissionActor(mission, actor, input) {
|
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* Apply one validated `tower_mission` patch to the mission draft and return
|
|
65
|
-
* the human-readable change list used by the activity log
|
|
66
|
+
* the human-readable change list used by the activity log plus the domain
|
|
67
|
+
* events the transition describes.
|
|
66
68
|
* @param {{ mission: Mission, state: TowerState, input: any, config: any }} params
|
|
67
|
-
* @returns {{ changes: string[] }}
|
|
69
|
+
* @returns {{ state: Mission, changes: string[], events: Array<{ type: string, missionId: string }> }}
|
|
68
70
|
*/
|
|
69
71
|
export function applyMissionUpdate({ mission, state, input, config }) {
|
|
70
72
|
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
@@ -77,6 +79,8 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
77
79
|
}
|
|
78
80
|
/** @type {string[]} */
|
|
79
81
|
const changes = []
|
|
82
|
+
/** @type {Array<{ type: string, missionId: string }>} */
|
|
83
|
+
const events = []
|
|
80
84
|
|
|
81
85
|
if (input.status !== undefined) {
|
|
82
86
|
if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
|
|
@@ -104,6 +108,8 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
104
108
|
}
|
|
105
109
|
mission.status = input.status
|
|
106
110
|
changes.push(`status=${input.status}`)
|
|
111
|
+
if (input.status === 'completed') events.push(completedEvent(mission))
|
|
112
|
+
if (input.status === 'abandoned') events.push(abandonedEvent(mission))
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
if (input.task_done !== undefined) {
|
|
@@ -169,8 +175,9 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
169
175
|
const others = state.missions.filter(
|
|
170
176
|
(entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
|
|
171
177
|
)
|
|
178
|
+
const caseInsensitive = state.caseInsensitive === true
|
|
172
179
|
for (const other of others) {
|
|
173
|
-
if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope)) {
|
|
180
|
+
if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope, { caseInsensitive })) {
|
|
174
181
|
fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
|
|
175
182
|
}
|
|
176
183
|
}
|
|
@@ -181,5 +188,5 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
if (changes.length === 0) fail('tower_mission requires at least one change')
|
|
184
|
-
return { changes }
|
|
191
|
+
return { state: mission, changes, events }
|
|
185
192
|
}
|
package/domain/missions/plan.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// activity entry; everything here is deterministic and repository-free.
|
|
5
5
|
// @ts-check
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
|
+
import { plannedEvents } from '../events.js'
|
|
7
8
|
import { normalizeScope, validateTitle } from '../scope.js'
|
|
8
9
|
|
|
9
10
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
11
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* @param {string} message
|
|
@@ -147,3 +149,15 @@ export function newMission(id, input, now) {
|
|
|
147
149
|
updatedAt: now,
|
|
148
150
|
}))
|
|
149
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Append the freshly projected missions to the state document and describe the
|
|
155
|
+
* resulting domain facts. Persistence and revision accounting stay in app/plan.
|
|
156
|
+
* @param {TowerState} state
|
|
157
|
+
* @param {Mission[]} created
|
|
158
|
+
* @returns {{ state: TowerState, events: Array<{ type: string, missionId: string }> }}
|
|
159
|
+
*/
|
|
160
|
+
export function applyPlan(state, created) {
|
|
161
|
+
state.missions.push(...created)
|
|
162
|
+
return { state, events: plannedEvents(created) }
|
|
163
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// persistence. Everything here is deterministic and repository-free.
|
|
6
6
|
// @ts-check
|
|
7
7
|
import { TowerToolError } from '../errors.js'
|
|
8
|
+
import { reviewedEvent } from '../events.js'
|
|
8
9
|
import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
|
|
9
10
|
|
|
10
11
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
@@ -109,11 +110,67 @@ export function assertReviewerSpawnTip(caller, tip) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
113
|
+
* The latest review of one branch. Higher rounds win; routes that share a round
|
|
114
|
+
* are ordered by submission sequence, so "latest" is the most recently recorded
|
|
115
|
+
* verdict (#108).
|
|
116
|
+
* @param {Review[]} reviews
|
|
117
|
+
* @returns {Review | null}
|
|
118
|
+
*/
|
|
119
|
+
function latestBranchReview(reviews) {
|
|
120
|
+
/** @type {Review | null} */
|
|
121
|
+
let latest = null
|
|
122
|
+
for (const review of reviews) {
|
|
123
|
+
if (!latest) {
|
|
124
|
+
latest = review
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const round = Number(review.round) || 0
|
|
128
|
+
const latestRound = Number(latest.round) || 0
|
|
129
|
+
if (round > latestRound) latest = review
|
|
130
|
+
else if (round === latestRound && (Number(review.seq) || 0) > (Number(latest.seq) || 0)) latest = review
|
|
131
|
+
}
|
|
132
|
+
return latest
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Decide whether this submission starts a new review round or appends to the
|
|
137
|
+
* current one (#108). A completed mission always starts a new round. A mission
|
|
138
|
+
* that is `active` accepts a later route for the tip that its current round
|
|
139
|
+
* reviewed: the first non-clean verdict already returned it to `active`, and the
|
|
140
|
+
* remaining routes spawned against the same tip must still record their
|
|
141
|
+
* verdicts. A verdict for a tip that moved is rejected with an explanation.
|
|
142
|
+
* @param {{ mission: Mission, reviews: Review[] | undefined, branch: string, tip: string }} params
|
|
143
|
+
* @returns {{ appended: boolean, round: number | null }}
|
|
144
|
+
*/
|
|
145
|
+
export function reviewAdmission({ mission, reviews, branch, tip }) {
|
|
146
|
+
if (mission.status === 'completed') return { appended: false, round: null }
|
|
147
|
+
if (mission.status !== 'active') {
|
|
148
|
+
fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
|
|
149
|
+
}
|
|
150
|
+
const latest = latestBranchReview((reviews ?? []).filter((review) => review.branch === branch))
|
|
151
|
+
if (!latest) {
|
|
152
|
+
fail(`mission ${mission.id} is active and has no review round to append to; only a completed mission can be reviewed`)
|
|
153
|
+
}
|
|
154
|
+
if (latest.reviewedCommit !== tip) {
|
|
155
|
+
const cause =
|
|
156
|
+
latest.status === 'clean'
|
|
157
|
+
? 'the reviewed tip moved'
|
|
158
|
+
: `review round ${latest.round} (${latest.status}) returned it to active and the reviewed tip moved`
|
|
159
|
+
fail(
|
|
160
|
+
`mission ${mission.id} is active: ${cause} (${String(latest.reviewedCommit).slice(0, 7)} -> ${tip.slice(0, 7)}); only review routes for the same reviewed tip can append to round ${latest.round} — return the mission to completed before starting a new round`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
return { appended: true, round: latest.round }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Compute the review round and enforce the per-reviewer cap. `round` joins the
|
|
168
|
+
* round `reviewAdmission` selected for an appended route; omitted, the
|
|
169
|
+
* submission opens the next round.
|
|
170
|
+
* @param {{ reviews: Review[] | undefined, branch: string, reviewerName: string, maxRoundsPerReviewer: unknown, round?: number | null }} params
|
|
114
171
|
* @returns {{ reviewerRounds: number, round: number }}
|
|
115
172
|
*/
|
|
116
|
-
export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewer }) {
|
|
173
|
+
export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewer, round: sharedRound }) {
|
|
117
174
|
const prior = (reviews ?? []).filter((review) => review.branch === branch)
|
|
118
175
|
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
119
176
|
const maxReviewRounds = Number(maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
@@ -122,17 +179,28 @@ export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewe
|
|
|
122
179
|
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
123
180
|
)
|
|
124
181
|
}
|
|
125
|
-
const round =
|
|
182
|
+
const round = Number.isInteger(sharedRound)
|
|
183
|
+
? /** @type {number} */ (sharedRound)
|
|
184
|
+
: prior.length === 0
|
|
185
|
+
? 1
|
|
186
|
+
: Math.max(...prior.map((review) => review.round)) + 1
|
|
126
187
|
return { reviewerRounds, round }
|
|
127
188
|
}
|
|
128
189
|
|
|
129
190
|
/**
|
|
130
191
|
* A non-clean verdict returns the mission to active and records the note; a
|
|
131
|
-
* clean verdict leaves the status untouched.
|
|
132
|
-
*
|
|
192
|
+
* clean verdict leaves the status untouched. An appended route records the
|
|
193
|
+
* verdict without flipping the status again (#108).
|
|
194
|
+
* @param {{ status: string, merge: string, round: number, reviewerName: string, appended?: boolean }} params
|
|
133
195
|
* @returns {{ returnToActive: boolean, note: string }}
|
|
134
196
|
*/
|
|
135
|
-
export function reviewOutcome({ status, merge, round, reviewerName }) {
|
|
197
|
+
export function reviewOutcome({ status, merge, round, reviewerName, appended = false }) {
|
|
198
|
+
if (appended) {
|
|
199
|
+
return {
|
|
200
|
+
returnToActive: false,
|
|
201
|
+
note: `review round ${round} by ${reviewerName}: ${status} (${merge}) — appended to the same round; the mission stays active until it is re-completed`,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
136
204
|
if (status !== 'clean') {
|
|
137
205
|
return {
|
|
138
206
|
returnToActive: true,
|
|
@@ -142,6 +210,21 @@ export function reviewOutcome({ status, merge, round, reviewerName }) {
|
|
|
142
210
|
return { returnToActive: false, note: `review round ${round} by ${reviewerName}: clean (${merge})` }
|
|
143
211
|
}
|
|
144
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Apply a recorded review's mission-level effect to the draft and describe the
|
|
215
|
+
* domain fact. Persistence, the review record and the round accounting stay in
|
|
216
|
+
* app/review.js.
|
|
217
|
+
* @param {{ mission: Mission, outcome: { returnToActive: boolean, note: string }, now: number }} params
|
|
218
|
+
* @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
|
|
219
|
+
*/
|
|
220
|
+
export function applyReviewOutcome({ mission, outcome, now }) {
|
|
221
|
+
if (outcome.returnToActive) mission.status = 'active'
|
|
222
|
+
mission.notes = mission.notes ?? []
|
|
223
|
+
mission.notes.push(outcome.note)
|
|
224
|
+
mission.updatedAt = now
|
|
225
|
+
return { state: mission, events: [reviewedEvent(mission)] }
|
|
226
|
+
}
|
|
227
|
+
|
|
145
228
|
/**
|
|
146
229
|
* Project the persisted review record.
|
|
147
230
|
* @param {{ seq: number, missionId: string, branch: string, tip: string, verdict: { status: string, merge: string, findings: string, checks: string[], decision: string }, reviewerName: string, reviewerRole: string, round: number, now: number, breakGlassReason?: string }} params
|
package/domain/planning.js
CHANGED
|
@@ -85,11 +85,14 @@ function assertAcyclic(missions) {
|
|
|
85
85
|
/**
|
|
86
86
|
* Kimi-style heuristic: overlapping build scopes are serialized by an
|
|
87
87
|
* auto-added dependency. It is conservative, not a proof of disjointness.
|
|
88
|
+
* `caseInsensitive` is the checkout's detected mode (#90); on such a filesystem
|
|
89
|
+
* `src/Api/**` and `src/api/**` are the same real files and must serialise.
|
|
88
90
|
* @param {Mission[]} newMissions
|
|
89
91
|
* @param {Mission[]} existingMissions
|
|
92
|
+
* @param {{ caseInsensitive?: boolean }} [options]
|
|
90
93
|
* @returns {void}
|
|
91
94
|
*/
|
|
92
|
-
function addAutoDeps(newMissions, existingMissions) {
|
|
95
|
+
function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
|
|
93
96
|
const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
|
|
94
97
|
const all = [...existingMissions, ...newMissions]
|
|
95
98
|
/** @type {Map<string, Mission>} */
|
|
@@ -100,7 +103,7 @@ function addAutoDeps(newMissions, existingMissions) {
|
|
|
100
103
|
const earlier = newMissions[i]
|
|
101
104
|
const later = newMissions[j]
|
|
102
105
|
if (earlier.kind === 'survey' || later.kind === 'survey') continue
|
|
103
|
-
if (!scopesOverlap(earlier.scope, later.scope)) continue
|
|
106
|
+
if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
|
|
104
107
|
if (hasDependencyPath(byId, later.id, earlier.id)) continue
|
|
105
108
|
addDependency(later, earlier.id)
|
|
106
109
|
}
|
|
@@ -108,7 +111,7 @@ function addAutoDeps(newMissions, existingMissions) {
|
|
|
108
111
|
if (mission.kind === 'survey') continue
|
|
109
112
|
for (const other of openExisting) {
|
|
110
113
|
if (other.kind === 'survey') continue
|
|
111
|
-
if (!scopesOverlap(mission.scope, other.scope)) continue
|
|
114
|
+
if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
|
|
112
115
|
if (hasDependencyPath(byId, mission.id, other.id)) continue
|
|
113
116
|
addDependency(mission, other.id)
|
|
114
117
|
}
|
package/domain/read/views.js
CHANGED
|
@@ -38,9 +38,22 @@ export function missionDetailView({ state, mission, activity }) {
|
|
|
38
38
|
* @param {number} input.unreadTower
|
|
39
39
|
* @param {object} input.config
|
|
40
40
|
* @param {object[]} input.missions enriched mission views
|
|
41
|
+
* @param {number | null} [input.archivedMissions] archived mission count, or null when unreadable
|
|
42
|
+
* @param {object} [input.archive] archive index view/diagnostic
|
|
43
|
+
* @param {{ held: boolean, pid?: number | null, phase?: string | null, heldMs?: number | null }} [input.gitLock]
|
|
41
44
|
* @returns {object}
|
|
42
45
|
*/
|
|
43
|
-
export function statusView({
|
|
46
|
+
export function statusView({
|
|
47
|
+
state,
|
|
48
|
+
statePath,
|
|
49
|
+
activity,
|
|
50
|
+
unreadTower,
|
|
51
|
+
config = {},
|
|
52
|
+
missions,
|
|
53
|
+
archivedMissions = 0,
|
|
54
|
+
archive = null,
|
|
55
|
+
gitLock = { held: false },
|
|
56
|
+
}) {
|
|
44
57
|
/** @type {Record<string, number>} */
|
|
45
58
|
const counts = {}
|
|
46
59
|
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
@@ -88,6 +101,9 @@ export function statusView({ state, statePath, activity, unreadTower, config = {
|
|
|
88
101
|
at: finding.at,
|
|
89
102
|
})),
|
|
90
103
|
missions,
|
|
104
|
+
archivedMissions,
|
|
105
|
+
gitLock,
|
|
106
|
+
...(archive ? { archive } : {}),
|
|
91
107
|
activity,
|
|
92
108
|
}
|
|
93
109
|
}
|
package/domain/report.js
CHANGED
|
@@ -7,9 +7,10 @@ import { taskCounts } from './summary.js'
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @param {TowerState} state
|
|
10
|
+
* @param {{ count?: number, unreadable?: boolean, message?: string } | null} [archive]
|
|
10
11
|
* @returns {string}
|
|
11
12
|
*/
|
|
12
|
-
export function boardMarkdown(state) {
|
|
13
|
+
export function boardMarkdown(state, archive = null) {
|
|
13
14
|
const lines = []
|
|
14
15
|
lines.push(`# Tower missions — ${state.repoRoot}`)
|
|
15
16
|
lines.push('')
|
|
@@ -23,6 +24,13 @@ export function boardMarkdown(state) {
|
|
|
23
24
|
`Missions: ${state.missions.length} · ` +
|
|
24
25
|
MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
|
|
25
26
|
)
|
|
27
|
+
if (archive) {
|
|
28
|
+
lines.push(
|
|
29
|
+
archive.unreadable === true
|
|
30
|
+
? `Archived: unreadable (${archive.message ?? 'archive index could not be read'})`
|
|
31
|
+
: `Archived: ${archive.count ?? 0} terminal mission(s) in archive/ (not listed; use tower_status includeArchived)`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
26
34
|
const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
27
35
|
lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
|
|
28
36
|
lines.push('')
|