dsh-tower 2.1.0 → 2.4.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 +137 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +78 -5
- package/PRINCIPLES.md +163 -0
- package/README.en.md +35 -19
- package/README.md +28 -18
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -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 +8 -9
- package/app/tower.js +89 -35
- package/cordis.patch.yml +56 -28
- package/docs/README.md +7 -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 +70 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +52 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +192 -43
- package/docs/guide/hot-reload.md +5 -5
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/operations/security.md +1 -1
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -9
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +53 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +157 -0
- package/docs/reference/tools.generated.md +8 -5
- package/docs/reference/tools.md +10 -7
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +4 -4
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +10 -8
- package/domain/batch.js +2 -2
- package/domain/components.js +641 -0
- package/domain/config/schema.js +62 -28
- package/domain/contracts/outputs.js +16 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +308 -43
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +12 -1
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +347 -0
- package/host/capability-batch.js +15 -0
- package/host/capability-hmr.js +30 -0
- package/host/capability-import.js +11 -0
- package/host/capability-leases.js +11 -0
- package/host/capability-registry.js +149 -0
- package/host/capability-tests.js +16 -0
- package/{lib → host}/doctor.js +157 -18
- package/{lib → host}/hmr.js +2 -4
- package/{lib → host}/modeStore.js +1 -1
- package/host/presets.js +77 -0
- package/{lib → host}/tool-registry.js +235 -44
- package/host/tower.js +32 -0
- package/index.js +40 -25
- package/locale/en.json +31 -11
- package/locale/zh.json +30 -10
- package/package.json +24 -4
- 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-components.mjs +235 -0
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-component-surface.mjs +59 -0
- 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 +18 -25
- package/scripts/generate-types.mjs +127 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/lib/tool-surface.mjs +85 -0
- package/scripts/mount-smoke/fixtures.mjs +3 -3
- package/scripts/mount-smoke/surface.mjs +96 -27
- package/scripts/mount-smoke.mjs +3 -1
- package/scripts/pack-manifest.txt +75 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +52 -0
- package/scripts/surface-baseline.mjs +118 -0
- package/scripts/types-coverage.mjs +184 -0
- package/scripts/types-strict-manifest.txt +10 -0
- package/scripts/types-strict.mjs +141 -0
- package/skills.js +8 -1
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- 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 +9 -8
- package/tools/init.js +3 -6
- 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 +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +36 -40
- 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 +11 -4
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +29 -13
- 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/persistence.js +0 -356
- package/lib/presets.js +0 -35
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /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}/modeState.js +0 -0
package/domain/events.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Stable domain events (#75).
|
|
2
|
+
//
|
|
3
|
+
// The domain layer *describes* what changed; `app/` persists the state and only
|
|
4
|
+
// then emits the event through the injected event port. `state.json` remains the
|
|
5
|
+
// authoritative record — events are a process-local observation channel, not an
|
|
6
|
+
// event-sourced log, and are never replayed.
|
|
7
|
+
//
|
|
8
|
+
// The table below is the single source of truth for the generated reference
|
|
9
|
+
// (`docs/reference/events.md`), so a rename cannot drift from the runtime.
|
|
10
|
+
// Every domain event carries `{ missionId, revision, at }`; `merged` also
|
|
11
|
+
// carries the merge `commit`. Names are dotted (`tower.mission.*`); the older
|
|
12
|
+
// tool-shaped `tower/<tool>` aliases stay for one minor release and are marked
|
|
13
|
+
// deprecated in the generated reference.
|
|
14
|
+
// @ts-check
|
|
15
|
+
|
|
16
|
+
/** @typedef {'planned'|'completed'|'reviewed'|'merged'|'abandoned'} MissionEventType */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} MissionEventEntry
|
|
20
|
+
* @property {MissionEventType} type
|
|
21
|
+
* @property {string} name
|
|
22
|
+
* @property {string[]} payload
|
|
23
|
+
* @property {string} description
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Stable domain-fact events (`tower.mission.*`). */
|
|
27
|
+
export const MISSION_EVENTS = Object.freeze({
|
|
28
|
+
planned: {
|
|
29
|
+
type: 'planned',
|
|
30
|
+
name: 'tower.mission.planned',
|
|
31
|
+
payload: ['missionId', 'revision', 'at'],
|
|
32
|
+
description: 'tower_plan persisted one or more new missions',
|
|
33
|
+
},
|
|
34
|
+
completed: {
|
|
35
|
+
type: 'completed',
|
|
36
|
+
name: 'tower.mission.completed',
|
|
37
|
+
payload: ['missionId', 'revision', 'at'],
|
|
38
|
+
description: 'tower_complete recorded a completion report (or tower_mission set status "completed")',
|
|
39
|
+
},
|
|
40
|
+
reviewed: {
|
|
41
|
+
type: 'reviewed',
|
|
42
|
+
name: 'tower.mission.reviewed',
|
|
43
|
+
payload: ['missionId', 'revision', 'at'],
|
|
44
|
+
description: 'tower_review recorded a verdict for the mission branch',
|
|
45
|
+
},
|
|
46
|
+
merged: {
|
|
47
|
+
type: 'merged',
|
|
48
|
+
name: 'tower.mission.merged',
|
|
49
|
+
payload: ['missionId', 'revision', 'at', 'commit'],
|
|
50
|
+
description: 'tower_merge recorded the merge commit that landed the mission branch',
|
|
51
|
+
},
|
|
52
|
+
abandoned: {
|
|
53
|
+
type: 'abandoned',
|
|
54
|
+
name: 'tower.mission.abandoned',
|
|
55
|
+
payload: ['missionId', 'revision', 'at'],
|
|
56
|
+
description: 'a mission transitioned to "abandoned"',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** @type {readonly MissionEventType[]} */
|
|
61
|
+
export const MISSION_EVENT_TYPES = Object.freeze(['planned', 'completed', 'reviewed', 'merged', 'abandoned'])
|
|
62
|
+
|
|
63
|
+
/** @type {readonly MissionEventEntry[]} */
|
|
64
|
+
export const MISSION_EVENT_TABLE = Object.freeze(
|
|
65
|
+
MISSION_EVENT_TYPES.map((type) => /** @type {MissionEventEntry} */ (MISSION_EVENTS[type])),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/** Tool-shaped alias events, kept for one minor release (#75). */
|
|
69
|
+
export const LEGACY_TOOL_EVENT_TABLE = Object.freeze([
|
|
70
|
+
{ name: 'tower/init', description: '`tower_init` created or re-anchored state' },
|
|
71
|
+
{ name: 'tower/plan', description: '`tower_plan` created missions' },
|
|
72
|
+
{ name: 'tower/mission', description: '`tower_mission` changed one mission' },
|
|
73
|
+
{ name: 'tower/spawn', description: '`tower_spawn` recorded a worker/reviewer' },
|
|
74
|
+
{ name: 'tower/complete', description: '`tower_complete` recorded a completion report' },
|
|
75
|
+
{ name: 'tower/review', description: '`tower_review` recorded a verdict' },
|
|
76
|
+
{ name: 'tower/merge', description: '`tower_merge` returned (including gate failures; inspect `value.ok`)' },
|
|
77
|
+
{ name: 'tower/rebase', description: '`tower_rebase` returned' },
|
|
78
|
+
{ name: 'tower/teardown', description: '`tower_teardown` removed worktrees' },
|
|
79
|
+
{ name: 'tower/finding', description: '`tower_finding` created one finding' },
|
|
80
|
+
{ name: 'tower/findings', description: '`tower_findings` read/listed/updated findings' },
|
|
81
|
+
{ name: 'tower/send', description: '`tower_send` persisted a message' },
|
|
82
|
+
{ name: 'tower/inbox', description: '`tower_inbox` acknowledged messages' },
|
|
83
|
+
{ name: 'tower/interrupt', description: '`tower_interrupt` stopped a live agent' },
|
|
84
|
+
{ name: 'tower/diff', description: '`tower_diff` read a mission diff' },
|
|
85
|
+
{ name: 'tower/adopt', description: '`tower_adopt` reconciled the roster' },
|
|
86
|
+
{ name: 'tower/roster', description: '`tower_roster` read the roster' },
|
|
87
|
+
{ name: 'tower/status', description: '`tower_status` read the dashboard' },
|
|
88
|
+
{ name: 'tower/preflight', description: '`tower_preflight` evaluated the gate' },
|
|
89
|
+
{ name: 'tower/doctor', description: '`tower_doctor` completed its read-only checks' },
|
|
90
|
+
{ name: 'tower/recover', description: '`tower_recover` inspected or cleared a stale state lock' },
|
|
91
|
+
{ name: 'tower/test', description: '`tower_test` ran discovery' },
|
|
92
|
+
{ name: 'tower/batch', description: '`tower_batch` created, inspected or closed a batch' },
|
|
93
|
+
{ name: 'tower/resource', description: '`tower_resource` acquired/released/inspected leases' },
|
|
94
|
+
{ name: 'tower/import_legacy', description: '`tower_import_legacy` stored a legacy snapshot' },
|
|
95
|
+
{ name: 'tower/legacy_map', description: '`tower_legacy_map` mapped or listed legacy ids' },
|
|
96
|
+
{ name: 'tower/mode', description: '`tower_mode` read the session stance (`value.mode`, `allowAgentControl`, `deniedWhileActive`)' },
|
|
97
|
+
{ name: 'tower/control', description: '`tower_control` entered/left the mode (`value.mode`, actor/reason in `value`)' },
|
|
98
|
+
{ name: 'tower/reload', description: '`tower_reload` returned a reload schedule/result' },
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
/** @type {readonly string[]} */
|
|
102
|
+
export const LEGACY_TOOL_EVENT_NAMES = Object.freeze(LEGACY_TOOL_EVENT_TABLE.map((entry) => entry.name))
|
|
103
|
+
|
|
104
|
+
/** Total number of process-local events described by the generated reference. */
|
|
105
|
+
export const EVENT_COUNT = MISSION_EVENT_TABLE.length + LEGACY_TOOL_EVENT_TABLE.length
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The runtime name of a domain event type. Throws on an unknown type so a typo
|
|
109
|
+
* fails in tests instead of emitting an unobservable event.
|
|
110
|
+
* @param {string} type
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function missionEventName(type) {
|
|
114
|
+
const entry = /** @type {Record<string, MissionEventEntry>} */ (MISSION_EVENTS)[type]
|
|
115
|
+
if (!entry) throw new TypeError(`unknown mission event type "${type}"`)
|
|
116
|
+
return entry.name
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A domain transition's event *descriptor*: what changed, before the caller
|
|
121
|
+
* knows the persisted revision and timestamp. App code finalises it with
|
|
122
|
+
* `buildMissionEvent` after the state write.
|
|
123
|
+
* @param {MissionEventType} type
|
|
124
|
+
* @param {string} missionId
|
|
125
|
+
* @param {{ commit?: string }} [extra]
|
|
126
|
+
* @returns {{ type: MissionEventType, missionId: string, commit?: string }}
|
|
127
|
+
*/
|
|
128
|
+
export function missionEventDescriptor(type, missionId, extra = {}) {
|
|
129
|
+
if (!MISSION_EVENT_TYPES.includes(type)) throw new TypeError(`unknown mission event type "${type}"`)
|
|
130
|
+
if (typeof missionId !== 'string' || missionId.length === 0) {
|
|
131
|
+
throw new TypeError('mission events require a non-empty missionId')
|
|
132
|
+
}
|
|
133
|
+
const descriptor = /** @type {{ type: MissionEventType, missionId: string, commit?: string }} */ ({
|
|
134
|
+
type,
|
|
135
|
+
missionId,
|
|
136
|
+
})
|
|
137
|
+
if (type === 'merged') {
|
|
138
|
+
if (typeof extra.commit !== 'string' || extra.commit.length === 0) {
|
|
139
|
+
throw new TypeError('tower.mission.merged requires the merge commit')
|
|
140
|
+
}
|
|
141
|
+
descriptor.commit = extra.commit
|
|
142
|
+
}
|
|
143
|
+
return descriptor
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Finalise one descriptor into `{ name, payload }` for the event port.
|
|
148
|
+
* Payload shapes are fixed here so `docs/reference/events.md` (generated from
|
|
149
|
+
* the same table) cannot describe a different shape than the runtime emits.
|
|
150
|
+
* @param {{ type: MissionEventType, missionId: string, commit?: string }} descriptor
|
|
151
|
+
* @param {{ revision: number, at: number }} context
|
|
152
|
+
* @returns {{ name: string, payload: Record<string, any> }}
|
|
153
|
+
*/
|
|
154
|
+
export function buildMissionEvent(descriptor, { revision, at }) {
|
|
155
|
+
const entry = /** @type {Record<string, MissionEventEntry>} */ (MISSION_EVENTS)[descriptor.type]
|
|
156
|
+
if (!entry) throw new TypeError(`unknown mission event type "${descriptor.type}"`)
|
|
157
|
+
if (!Number.isInteger(revision)) throw new TypeError('mission events require the persisted integer revision')
|
|
158
|
+
if (!Number.isFinite(at)) throw new TypeError('mission events require the transition timestamp')
|
|
159
|
+
/** @type {Record<string, any>} */
|
|
160
|
+
const payload = { missionId: descriptor.missionId, revision, at }
|
|
161
|
+
if (entry.payload.includes('commit')) payload.commit = descriptor.commit
|
|
162
|
+
return { name: entry.name, payload }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Descriptors for a set of freshly persisted missions.
|
|
167
|
+
* @param {Array<{ id: string }>} missions
|
|
168
|
+
* @returns {Array<{ type: MissionEventType, missionId: string }>}
|
|
169
|
+
*/
|
|
170
|
+
export function plannedEvents(missions) {
|
|
171
|
+
return missions.map((mission) => missionEventDescriptor('planned', mission.id))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {{ id: string }} mission
|
|
176
|
+
* @returns {{ type: MissionEventType, missionId: string }}
|
|
177
|
+
*/
|
|
178
|
+
export function completedEvent(mission) {
|
|
179
|
+
return missionEventDescriptor('completed', mission.id)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {{ id: string }} mission
|
|
184
|
+
* @returns {{ type: MissionEventType, missionId: string }}
|
|
185
|
+
*/
|
|
186
|
+
export function reviewedEvent(mission) {
|
|
187
|
+
return missionEventDescriptor('reviewed', mission.id)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {{ id: string }} mission
|
|
192
|
+
* @param {string} commit
|
|
193
|
+
* @returns {{ type: MissionEventType, missionId: string, commit: string }}
|
|
194
|
+
*/
|
|
195
|
+
export function mergedEvent(mission, commit) {
|
|
196
|
+
return /** @type {{ type: MissionEventType, missionId: string, commit: string }} */ (
|
|
197
|
+
missionEventDescriptor('merged', mission.id, { commit })
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{ id: string }} mission
|
|
203
|
+
* @returns {{ type: MissionEventType, missionId: string }}
|
|
204
|
+
*/
|
|
205
|
+
export function abandonedEvent(mission) {
|
|
206
|
+
return missionEventDescriptor('abandoned', mission.id)
|
|
207
|
+
}
|
|
@@ -123,11 +123,26 @@ export function baseDirtyBlocker(wouldOverwrite) {
|
|
|
123
123
|
: null
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Whether a dependency already satisfies the gate. A build mission must be
|
|
128
|
+
* merged (or abandoned), but a survey is terminal-success at `completed`: it is
|
|
129
|
+
* read-only and has no branch to merge, so requiring `merged` would keep the
|
|
130
|
+
* dependent mission blocked forever (#107). Archived entries carry `kind` for
|
|
131
|
+
* the same decision.
|
|
132
|
+
* @param {{ kind?: string, status?: string } | null | undefined} dep
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function dependencySatisfied(dep) {
|
|
136
|
+
if (!dep) return false
|
|
137
|
+
if (dep.status === 'merged' || dep.status === 'abandoned') return true
|
|
138
|
+
return dep.kind === 'survey' && dep.status === 'completed'
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
/**
|
|
127
142
|
* @param {object} input
|
|
128
143
|
* @param {string[]} input.deps
|
|
129
144
|
* @param {TowerState} input.state
|
|
130
|
-
* @param {Map<string, { status: string }>} [input.archived] compact archived missions (#73)
|
|
145
|
+
* @param {Map<string, { status: string, kind?: string }>} [input.archived] compact archived missions (#73)
|
|
131
146
|
* @returns {Blocker[]}
|
|
132
147
|
*/
|
|
133
148
|
export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
|
|
@@ -135,7 +150,7 @@ export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
|
|
|
135
150
|
const blockers = []
|
|
136
151
|
for (const depId of deps) {
|
|
137
152
|
const dep = state.missions.find((entry) => entry.id === depId) ?? archived.get(depId)
|
|
138
|
-
if (!
|
|
153
|
+
if (!dependencySatisfied(dep)) {
|
|
139
154
|
blockers.push({ code: 'deps-unmerged', detail: `${depId} is ${dep ? dep.status : 'missing'}` })
|
|
140
155
|
}
|
|
141
156
|
}
|
|
@@ -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
|
@@ -12,7 +12,18 @@ export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'p
|
|
|
12
12
|
export const SCHEMA_VERSION = 3
|
|
13
13
|
/** @type {readonly MergeVerdict[]} */
|
|
14
14
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
|
-
|
|
15
|
+
// #115: the review status vocabulary is one runtime source. The validator
|
|
16
|
+
// accepts any positive item count (`p1-7items`), while the published
|
|
17
|
+
// `ReviewStatus` type is generated from REVIEW_STATUS_ITEMS, which fixes the
|
|
18
|
+
// documented 1..5 range so a consumer sees a closed union.
|
|
19
|
+
export const REVIEW_STATUS_PRIORITIES = ['p1', 'p2']
|
|
20
|
+
export const REVIEW_STATUS_ITEMS = [1, 2, 3, 4, 5]
|
|
21
|
+
/** @type {readonly string[]} */
|
|
22
|
+
export const REVIEW_STATUSES = [
|
|
23
|
+
'clean',
|
|
24
|
+
...REVIEW_STATUS_PRIORITIES.flatMap((priority) => REVIEW_STATUS_ITEMS.map((count) => `${priority}-${count}items`)),
|
|
25
|
+
]
|
|
26
|
+
export const REVIEW_STATUS_RE = new RegExp(`^(?:clean|(?:${REVIEW_STATUS_PRIORITIES.join('|')})-\\d+items)$`)
|
|
16
27
|
/** @type {ReadonlySet<string>} */
|
|
17
28
|
export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
|
|
18
29
|
/** @type {readonly FindingType[]} */
|
|
@@ -4,6 +4,7 @@
|
|
|
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 */
|
|
@@ -165,3 +166,16 @@ export function completionReport({ report, worker, checks, now, maxReportBytes }
|
|
|
165
166
|
const text = report.trim()
|
|
166
167
|
return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
|
|
167
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)] }
|
|
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) {
|
|
@@ -182,5 +188,5 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
if (changes.length === 0) fail('tower_mission requires at least one change')
|
|
185
|
-
return { changes }
|
|
191
|
+
return { state: mission, changes, events }
|
|
186
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/read/views.js
CHANGED
|
@@ -40,6 +40,7 @@ export function missionDetailView({ state, mission, activity }) {
|
|
|
40
40
|
* @param {object[]} input.missions enriched mission views
|
|
41
41
|
* @param {number | null} [input.archivedMissions] archived mission count, or null when unreadable
|
|
42
42
|
* @param {object} [input.archive] archive index view/diagnostic
|
|
43
|
+
* @param {{ held: boolean, pid?: number | null, phase?: string | null, heldMs?: number | null }} [input.gitLock]
|
|
43
44
|
* @returns {object}
|
|
44
45
|
*/
|
|
45
46
|
export function statusView({
|
|
@@ -51,6 +52,7 @@ export function statusView({
|
|
|
51
52
|
missions,
|
|
52
53
|
archivedMissions = 0,
|
|
53
54
|
archive = null,
|
|
55
|
+
gitLock = { held: false },
|
|
54
56
|
}) {
|
|
55
57
|
/** @type {Record<string, number>} */
|
|
56
58
|
const counts = {}
|
|
@@ -100,6 +102,7 @@ export function statusView({
|
|
|
100
102
|
})),
|
|
101
103
|
missions,
|
|
102
104
|
archivedMissions,
|
|
105
|
+
gitLock,
|
|
103
106
|
...(archive ? { archive } : {}),
|
|
104
107
|
activity,
|
|
105
108
|
}
|
package/domain/state/archive.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// This module is pure: it declares the archive document contract, selects what
|
|
10
10
|
// is eligible under the configured policy, and builds the records. Reading and
|
|
11
11
|
// writing the files, and the lock that makes them atomic with state.json, live
|
|
12
|
-
// in `
|
|
12
|
+
// in `adapters/archive.js` and `app/archive.js`.
|
|
13
13
|
// @ts-check
|
|
14
14
|
import { isTerminal } from '../mission.js'
|
|
15
15
|
|
package/domain/state.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// `validateState` proves a document matches the *current* schema.
|
|
9
9
|
// `prepareState` is the load-path decision: it inspects the version, applies the
|
|
10
10
|
// ordered migration registry, then validates the target schema. Both are pure;
|
|
11
|
-
// the side effects (backup, write, activity) live in `
|
|
11
|
+
// the side effects (backup, write, activity) live in `adapters/stateFile.js`.
|
|
12
12
|
// @ts-check
|
|
13
13
|
import { TowerToolError } from './errors.js'
|
|
14
14
|
import { SCHEMA_VERSION } from './mission.js'
|