dsh-tower 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,145 @@
1
+ // State-schema migrations (#72).
2
+ //
3
+ // The state file is a durable contract, so a version move is a migration, never
4
+ // a corruption. This module is the single place a future version is added: bump
5
+ // `SCHEMA_VERSION` in `../mission.js`, append one `{ from, to, migrate }` entry
6
+ // here, and every load path picks it up.
7
+ //
8
+ // Each `migrate` is a pure function: it receives a parsed document and returns a
9
+ // new one. It must not read the filesystem, mutate its argument, read the clock,
10
+ // or throw for data it can normalise. A step that throws becomes
11
+ // `[tower:state-migration-failed]` and the original file is left byte-identical.
12
+ // @ts-check
13
+ import { TowerToolError } from '../errors.js'
14
+ import { SCHEMA_VERSION } from '../mission.js'
15
+
16
+ /** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>) => Record<string, any> }} StateMigration */
17
+
18
+ /**
19
+ * @param {unknown} value
20
+ * @returns {boolean}
21
+ */
22
+ function isPlainObject(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ }
25
+
26
+ /**
27
+ * v1 -> v2. Documents written before the current schema can omit `revision` and
28
+ * a mission's `tasks`/`deps` arrays; v2's structural contract requires them.
29
+ * The step only backfills absent values and never drops data.
30
+ * @param {Record<string, any>} state
31
+ * @returns {Record<string, any>}
32
+ */
33
+ export function migrateV1ToV2(state) {
34
+ const next = { ...state }
35
+ if (!Number.isInteger(next.revision) || next.revision < 0) next.revision = 0
36
+ next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
37
+ if (!isPlainObject(mission)) return mission
38
+ return {
39
+ ...mission,
40
+ tasks: Array.isArray(mission.tasks) ? mission.tasks : [],
41
+ deps: Array.isArray(mission.deps) ? mission.deps : [],
42
+ }
43
+ })
44
+ return next
45
+ }
46
+
47
+ /**
48
+ * v2 -> v3. Scope matching gained a detected case mode (#90). Older documents
49
+ * never recorded one, and a migration must not read the filesystem, so the
50
+ * field is backfilled as `null` ("not detected"). Comparison then keeps the
51
+ * safe exact default until the next `tower_init` probes the checkout.
52
+ * @param {Record<string, any>} state
53
+ * @returns {Record<string, any>}
54
+ */
55
+ export function migrateV2ToV3(state) {
56
+ if (state.caseInsensitive !== undefined) return state
57
+ return { ...state, caseInsensitive: null }
58
+ }
59
+
60
+ /** Ordered registry: entry 0 moves 1 -> 2, entry 1 moves 2 -> 3, and so on. */
61
+ export const STATE_MIGRATIONS = Object.freeze([
62
+ Object.freeze({ from: 1, to: 2, migrate: migrateV1ToV2 }),
63
+ Object.freeze({ from: 2, to: 3, migrate: migrateV2ToV3 }),
64
+ ])
65
+
66
+ /**
67
+ * The ordered steps needed to move `from` to `to`, or null when the gap cannot
68
+ * be crossed. `[]` means the document is already at `to`; a downgrade is never
69
+ * expressed as a chain.
70
+ *
71
+ * @param {unknown} from
72
+ * @param {number} [to]
73
+ * @param {readonly StateMigration[]} [migrations]
74
+ * @returns {StateMigration[] | null}
75
+ */
76
+ export function migrationChain(from, to = SCHEMA_VERSION, migrations = STATE_MIGRATIONS) {
77
+ if (!Number.isInteger(from) || /** @type {number} */ (from) < 1) return null
78
+ if (!Number.isInteger(to) || to < 1) return null
79
+ if (from === to) return []
80
+ if (/** @type {number} */ (from) > to) return null
81
+ const chain = []
82
+ let version = /** @type {number} */ (from)
83
+ while (version < to) {
84
+ const step = migrations.find((entry) => entry.from === version)
85
+ if (!step || !Number.isInteger(step.to) || step.to <= version) return null
86
+ chain.push(step)
87
+ version = step.to
88
+ }
89
+ return version === to ? chain : null
90
+ }
91
+
92
+ /**
93
+ * Apply the registry to a parsed document. Pure: the input object is never
94
+ * touched and the returned document has the target `schemaVersion`.
95
+ *
96
+ * @param {Record<string, any>} state
97
+ * @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] }} [options]
98
+ * @returns {{ state: Record<string, any>, from: number, to: number, steps: Array<{ from: number, to: number }> }}
99
+ */
100
+ export function migrateState(state, options = {}) {
101
+ const where = options.statePath ? ` at ${options.statePath}` : ''
102
+ const to = options.to ?? SCHEMA_VERSION
103
+ const migrations = options.migrations ?? STATE_MIGRATIONS
104
+ const from = state?.schemaVersion
105
+ const chain = migrationChain(from, to, migrations)
106
+ if (!chain || chain.length === 0) {
107
+ throw new TowerToolError(
108
+ 'state-version-unsupported',
109
+ `tower state schemaVersion ${String(from)}${where} cannot be migrated to ${to}`,
110
+ {
111
+ nextAction: `upgrade dsh-tower when the state was written by a newer release; otherwise restore a state.json this build can read (schemaVersion ${SCHEMA_VERSION})`,
112
+ },
113
+ )
114
+ }
115
+ /** @type {Record<string, any>} */
116
+ let current = state
117
+ /** @type {Array<{ from: number, to: number }>} */
118
+ const steps = []
119
+ for (const step of chain) {
120
+ /** @type {Record<string, any>} */
121
+ let next
122
+ try {
123
+ next = step.migrate(current)
124
+ } catch (error) {
125
+ throw new TowerToolError(
126
+ 'state-migration-failed',
127
+ `state migration v${step.from} -> v${step.to} failed${where}: ${String(error?.message ?? error)}`,
128
+ {
129
+ cause: error,
130
+ nextAction: 'the original state.json was not modified; inspect the pre-migration backup state.json.v<N>.bak and report this failure',
131
+ },
132
+ )
133
+ }
134
+ if (!isPlainObject(next)) {
135
+ throw new TowerToolError(
136
+ 'state-migration-failed',
137
+ `state migration v${step.from} -> v${step.to} returned ${next === null ? 'null' : typeof next}${where}`,
138
+ { nextAction: 'the original state.json was not modified; report this migration bug' },
139
+ )
140
+ }
141
+ current = { ...next, schemaVersion: step.to }
142
+ steps.push({ from: step.from, to: step.to })
143
+ }
144
+ return { state: current, from: /** @type {number} */ (from), to, steps }
145
+ }
@@ -0,0 +1,239 @@
1
+ // Declarative state-file schema (#56 P4).
2
+ //
3
+ // `StateSchema` describes the structural contract of
4
+ // `<git-common-dir>/dsh-tower/state.json`. Object schemas allow additional
5
+ // properties on purpose: state files are forward/backward compatible and legacy
6
+ // rows carry extra fields. Semantic policy (uniqueness, status/verdict
7
+ // vocabularies, review exemption, worktree records and the revision counter)
8
+ // lives in `./semantics.js`, so this module remains a pure declaration.
9
+ // @ts-check
10
+ import { Type } from '@sinclair/typebox'
11
+ import { Value } from '@sinclair/typebox/value'
12
+ import { SCHEMA_VERSION } from '../mission.js'
13
+
14
+ const optional = { additionalProperties: true }
15
+
16
+ const NonNegativeInteger = Type.Integer({ minimum: 0 })
17
+
18
+ const WorktreeSchema = Type.Object({}, optional)
19
+
20
+ const MissionSchema = Type.Object(
21
+ {
22
+ id: Type.String(),
23
+ status: Type.Optional(Type.String()),
24
+ tasks: Type.Array(Type.Unknown()),
25
+ deps: Type.Array(Type.Unknown()),
26
+ references: Type.Optional(Type.Array(Type.Unknown())),
27
+ reviewPolicy: Type.Optional(Type.String()),
28
+ testRuns: Type.Optional(Type.Array(Type.Unknown())),
29
+ worktree: Type.Optional(Type.Union([WorktreeSchema, Type.Null()])),
30
+ },
31
+ optional,
32
+ )
33
+
34
+ const RosterEntrySchema = Type.Object(
35
+ {
36
+ name: Type.String(),
37
+ agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
38
+ },
39
+ optional,
40
+ )
41
+
42
+ const ReviewSchema = Type.Object(
43
+ {
44
+ id: Type.String(),
45
+ status: Type.Optional(Type.String()),
46
+ mergeVerdict: Type.Optional(Type.String()),
47
+ },
48
+ optional,
49
+ )
50
+
51
+ const FindingSchema = Type.Object(
52
+ {
53
+ id: Type.String(),
54
+ status: Type.Optional(Type.String()),
55
+ },
56
+ optional,
57
+ )
58
+
59
+ const MessageSchema = Type.Object(
60
+ {
61
+ id: Type.String(),
62
+ to: Type.String(),
63
+ readBy: Type.Array(Type.Unknown()),
64
+ },
65
+ optional,
66
+ )
67
+
68
+ /** Structural contract for one persisted Tower state document. */
69
+ export const StateSchema = Type.Object(
70
+ {
71
+ schemaVersion: Type.Literal(SCHEMA_VERSION),
72
+ revision: Type.Optional(Type.Number()),
73
+ base: Type.Optional(Type.String()),
74
+ repoRoot: Type.Optional(Type.String()),
75
+ commonDir: Type.Optional(Type.String()),
76
+ createdAt: Type.Optional(Type.Number()),
77
+ updatedAt: Type.Optional(Type.Number()),
78
+ dirtyBase: Type.Optional(Type.Boolean()),
79
+ // Detected once at tower_init (#90). `null` means "not detected yet": the
80
+ // v2 -> v3 migration backfills it without touching the filesystem.
81
+ caseInsensitive: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
82
+ towerAgentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
83
+ snapshotCommit: Type.Optional(Type.Union([Type.String(), Type.Null()])),
84
+ snapshotRef: Type.Optional(Type.Union([Type.String(), Type.Null()])),
85
+ missions: Type.Array(MissionSchema),
86
+ roster: Type.Optional(Type.Array(RosterEntrySchema)),
87
+ reviews: Type.Optional(Type.Array(ReviewSchema)),
88
+ findings: Type.Optional(Type.Array(FindingSchema)),
89
+ messages: Type.Optional(Type.Array(MessageSchema)),
90
+ batches: Type.Optional(Type.Array(Type.Unknown())),
91
+ legacySnapshots: Type.Optional(Type.Array(Type.Unknown())),
92
+ legacyMaps: Type.Optional(Type.Array(Type.Unknown())),
93
+ deliveries: Type.Optional(Type.Array(Type.Unknown())),
94
+ mergeIntent: Type.Optional(Type.Union([Type.Object({}, optional), Type.Null()])),
95
+ rebaseIntent: Type.Optional(Type.Union([Type.Object({}, optional), Type.Null()])),
96
+ messageSeq: Type.Optional(NonNegativeInteger),
97
+ reviewSeq: Type.Optional(NonNegativeInteger),
98
+ findingSeq: Type.Optional(NonNegativeInteger),
99
+ batchSeq: Type.Optional(NonNegativeInteger),
100
+ deliverySeq: Type.Optional(NonNegativeInteger),
101
+ },
102
+ optional,
103
+ )
104
+
105
+ /**
106
+ * @param {unknown} value
107
+ * @returns {boolean}
108
+ */
109
+ function isPlainObject(value) {
110
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
111
+ }
112
+
113
+ /** @type {readonly string[]} */
114
+ const ARRAY_KEYS = ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']
115
+ /** @type {readonly string[]} */
116
+ const SEQUENCE_KEYS = ['messageSeq', 'reviewSeq', 'findingSeq', 'batchSeq', 'deliverySeq']
117
+
118
+ /**
119
+ * Reproduce the historical `[tower:state-corrupt]` diagnostics for the
120
+ * structural failures the schema rejects. Keeping them in the state vocabulary
121
+ * (rather than exposing TypeBox paths) keeps operator-facing messages stable.
122
+ * @param {Record<string, any>} state
123
+ * @param {string} where
124
+ * @returns {string | null}
125
+ */
126
+ function firstShapeMessage(state, where) {
127
+ if (state.schemaVersion !== SCHEMA_VERSION) {
128
+ return `unsupported tower state schemaVersion ${state.schemaVersion}${where}`
129
+ }
130
+ if (!Array.isArray(state.missions)) return `tower state has no missions array${where}`
131
+ for (const mission of state.missions) {
132
+ if (!isPlainObject(mission) || typeof mission.id !== 'string') return `mission entry without id${where}`
133
+ const id = mission.id
134
+ if (!Array.isArray(mission.tasks)) return `mission ${id} has no tasks array${where}`
135
+ if (!Array.isArray(mission.deps)) return `mission ${id} has no deps array${where}`
136
+ if (mission.references !== undefined && !Array.isArray(mission.references)) {
137
+ return `mission ${id} has no references array${where}`
138
+ }
139
+ if (mission.reviewPolicy !== undefined && typeof mission.reviewPolicy !== 'string') {
140
+ return `mission ${id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`
141
+ }
142
+ if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
143
+ return `mission ${id} has no testRuns array${where}`
144
+ }
145
+ if (mission.worktree !== undefined && mission.worktree !== null && !isPlainObject(mission.worktree)) {
146
+ return `mission ${id} has an invalid worktree record${where}`
147
+ }
148
+ }
149
+ for (const key of ['base', 'repoRoot', 'commonDir']) {
150
+ if (state[key] !== undefined && typeof state[key] !== 'string') return `tower state ${key} is not a string${where}`
151
+ }
152
+ for (const key of ['createdAt', 'updatedAt']) {
153
+ if (state[key] !== undefined && typeof state[key] !== 'number') {
154
+ return `tower state ${key} is not a number${where}`
155
+ }
156
+ }
157
+ if (state.dirtyBase !== undefined && typeof state.dirtyBase !== 'boolean') {
158
+ return `tower state dirtyBase is not a boolean${where}`
159
+ }
160
+ if (state.caseInsensitive !== undefined && state.caseInsensitive !== null && typeof state.caseInsensitive !== 'boolean') {
161
+ return `tower state caseInsensitive is not a boolean${where}`
162
+ }
163
+ for (const key of ['towerAgentId', 'snapshotCommit', 'snapshotRef']) {
164
+ if (state[key] !== undefined && state[key] !== null && typeof state[key] !== 'string') {
165
+ return `tower state ${key} is not a string or null${where}`
166
+ }
167
+ }
168
+ if (state.revision !== undefined && typeof state.revision !== 'number') {
169
+ return `tower state revision is not a non-negative integer${where}`
170
+ }
171
+ for (const key of ARRAY_KEYS) {
172
+ if (state[key] !== undefined && !Array.isArray(state[key])) return `tower state ${key} is not an array${where}`
173
+ }
174
+ for (const key of SEQUENCE_KEYS) {
175
+ if (state[key] !== undefined && !(Number.isInteger(state[key]) && state[key] >= 0)) {
176
+ return `tower state ${key} is not a non-negative integer${where}`
177
+ }
178
+ }
179
+ if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
180
+ return `tower state mergeIntent is not an object${where}`
181
+ }
182
+ if (state.rebaseIntent !== undefined && state.rebaseIntent !== null && !isPlainObject(state.rebaseIntent)) {
183
+ return `tower state rebaseIntent is not an object${where}`
184
+ }
185
+ if (state.roster !== undefined) {
186
+ if (!Array.isArray(state.roster)) return `tower state roster is not an array${where}`
187
+ for (const entry of state.roster) {
188
+ if (!isPlainObject(entry) || typeof entry.name !== 'string') return `invalid roster entry${where}`
189
+ if (entry.agentId !== undefined && entry.agentId !== null && typeof entry.agentId !== 'string') {
190
+ return `invalid roster entry${where}`
191
+ }
192
+ }
193
+ }
194
+ if (state.messages !== undefined) {
195
+ if (!Array.isArray(state.messages)) return `tower state messages is not an array${where}`
196
+ for (const message of state.messages) {
197
+ if (!isPlainObject(message) || typeof message.id !== 'string' || typeof message.to !== 'string') {
198
+ return `invalid message entry${where}`
199
+ }
200
+ if (!Array.isArray(message.readBy)) return `message ${message.id} has no readBy array${where}`
201
+ }
202
+ }
203
+ if (state.findings !== undefined) {
204
+ if (!Array.isArray(state.findings)) return `tower state findings is not an array${where}`
205
+ for (const finding of state.findings) {
206
+ if (!isPlainObject(finding) || typeof finding.id !== 'string') return `invalid finding entry${where}`
207
+ if (finding.status !== undefined && typeof finding.status !== 'string') {
208
+ return `finding ${finding.id} has unknown status ${finding.status}${where}`
209
+ }
210
+ }
211
+ }
212
+ if (state.reviews !== undefined) {
213
+ if (!Array.isArray(state.reviews)) return `tower state reviews is not an array${where}`
214
+ for (const review of state.reviews) {
215
+ if (!isPlainObject(review) || typeof review.id !== 'string') return `invalid review entry${where}`
216
+ if (review.status !== undefined && typeof review.status !== 'string') {
217
+ return `review ${review.id} has unknown status ${review.status}${where}`
218
+ }
219
+ if (review.mergeVerdict !== undefined && typeof review.mergeVerdict !== 'string') {
220
+ return `review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`
221
+ }
222
+ }
223
+ }
224
+ return null
225
+ }
226
+
227
+ /**
228
+ * Pure structural check. Returns the legacy diagnostic, or null when the state
229
+ * satisfies `StateSchema`. `where` is the already-formatted " at <path>" suffix.
230
+ * @param {unknown} state
231
+ * @param {string} [where]
232
+ * @returns {string | null}
233
+ */
234
+ export function stateShapeError(state, where = '') {
235
+ if (!isPlainObject(state)) return `tower state is not an object${where}`
236
+ if (Value.Check(StateSchema, state)) return null
237
+ const record = /** @type {Record<string, any>} */ (state)
238
+ return firstShapeMessage(record, where) ?? `tower state does not match the state schema${where}`
239
+ }
@@ -0,0 +1,117 @@
1
+ // Semantic state-file checks (#56 P4).
2
+ //
3
+ // `stateShapeError` proves the structural contract; this module owns the policy
4
+ // the shape cannot express: unique ids, status/verdict vocabularies, the review
5
+ // exemption, roster/agent uniqueness, worktree records and the revision
6
+ // counter. Messages keep the historical `[tower:state-corrupt]` wording.
7
+ // @ts-check
8
+ import { TowerToolError } from '../errors.js'
9
+ import { FINDING_STATUSES, MERGE_VERDICTS, MISSION_STATUSES, REVIEW_STATUS_RE } from '../mission.js'
10
+
11
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
+
13
+ /**
14
+ * @param {string} message
15
+ * @returns {never}
16
+ */
17
+ function fail(message) {
18
+ throw new TowerToolError('state-corrupt', message)
19
+ }
20
+
21
+ /**
22
+ * @param {unknown} value
23
+ * @returns {boolean}
24
+ */
25
+ function isPlainObject(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
27
+ }
28
+
29
+ /**
30
+ * A worktree record may omit legacy fields, but every field it carries must
31
+ * keep its documented type.
32
+ * @param {Record<string, any>} mission
33
+ * @param {string} where
34
+ * @returns {void}
35
+ */
36
+ function assertWorktreeRecord(mission, where) {
37
+ const worktree = mission.worktree
38
+ if (worktree === undefined || worktree === null) return
39
+ const invalid = !isPlainObject(worktree) || ['path', 'branch', 'baseCommit', 'realPath'].some(
40
+ (key) => worktree[key] !== undefined && typeof worktree[key] !== 'string',
41
+ ) || (worktree.createdAt !== undefined && typeof worktree.createdAt !== 'number')
42
+ if (invalid) fail(`mission ${mission.id} has an invalid worktree record${where}`)
43
+ }
44
+
45
+ /**
46
+ * @param {unknown} state
47
+ * @param {string} where
48
+ * @returns {TowerState}
49
+ */
50
+ export function assertStateSemantics(state, where) {
51
+ const record = /** @type {Record<string, any>} */ (state)
52
+ if (record.revision !== undefined && (!Number.isInteger(record.revision) || record.revision < 0)) {
53
+ fail(`tower state revision is not a non-negative integer${where}`)
54
+ }
55
+ const missionIds = new Set()
56
+ for (const mission of record.missions) {
57
+ if (missionIds.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
58
+ missionIds.add(mission.id)
59
+ if (!MISSION_STATUSES.includes(mission.status)) {
60
+ fail(`mission ${mission.id} has unknown status ${mission.status}${where}`)
61
+ }
62
+ if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
63
+ fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
64
+ }
65
+ if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
66
+ fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
67
+ }
68
+ assertWorktreeRecord(mission, where)
69
+ }
70
+ if (record.roster !== undefined) {
71
+ const names = new Set()
72
+ const agents = new Set()
73
+ for (const entry of record.roster) {
74
+ const reserved = entry?.state === 'reserved'
75
+ if (!entry || typeof entry.name !== 'string' || (!reserved && typeof entry.agentId !== 'string')) {
76
+ fail(`invalid roster entry${where}`)
77
+ }
78
+ if (names.has(entry.name)) fail(`duplicate roster name ${entry.name}${where}`)
79
+ if (typeof entry.agentId === 'string') {
80
+ if (agents.has(entry.agentId)) fail(`duplicate roster agent ${entry.agentId}${where}`)
81
+ agents.add(entry.agentId)
82
+ }
83
+ names.add(entry.name)
84
+ }
85
+ }
86
+ if (record.messages !== undefined) {
87
+ const ids = new Set()
88
+ for (const message of record.messages) {
89
+ if (ids.has(message.id)) fail(`duplicate message id ${message.id}${where}`)
90
+ ids.add(message.id)
91
+ }
92
+ }
93
+ if (record.findings !== undefined) {
94
+ const ids = new Set()
95
+ for (const finding of record.findings) {
96
+ if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
97
+ ids.add(finding.id)
98
+ if (finding.status !== undefined && !FINDING_STATUSES.includes(finding.status)) {
99
+ fail(`finding ${finding.id} has unknown status ${finding.status}${where}`)
100
+ }
101
+ }
102
+ }
103
+ if (record.reviews !== undefined) {
104
+ const ids = new Set()
105
+ for (const review of record.reviews) {
106
+ if (ids.has(review.id)) fail(`duplicate review id ${review.id}${where}`)
107
+ ids.add(review.id)
108
+ if (!REVIEW_STATUS_RE.test(String(review.status))) {
109
+ fail(`review ${review.id} has unknown status ${review.status}${where}`)
110
+ }
111
+ if (!MERGE_VERDICTS.includes(review.mergeVerdict)) {
112
+ fail(`review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`)
113
+ }
114
+ }
115
+ }
116
+ return /** @type {TowerState} */ (record)
117
+ }
@@ -0,0 +1,183 @@
1
+ // Pure state-file validation and migration decisions (#56 P4, #72).
2
+ //
3
+ // The structural contract lives in `state/schema.js` (TypeBox), the policy
4
+ // checks in `state/semantics.js`, and the version registry in
5
+ // `state/migrations.js`. This module stays the public entry point so every
6
+ // app/tool import of `domain/state.js` is unchanged.
7
+ //
8
+ // `validateState` proves a document matches the *current* schema.
9
+ // `prepareState` is the load-path decision: it inspects the version, applies the
10
+ // ordered migration registry, then validates the target schema. Both are pure;
11
+ // the side effects (backup, write, activity) live in `lib/stateFile.js`.
12
+ // @ts-check
13
+ import { TowerToolError } from './errors.js'
14
+ import { SCHEMA_VERSION } from './mission.js'
15
+ import { migrationChain, migrateState } from './state/migrations.js'
16
+ import { stateShapeError } from './state/schema.js'
17
+ import { assertStateSemantics } from './state/semantics.js'
18
+
19
+ /** @typedef {import('../types/index.js').TowerState} TowerState */
20
+ /** @typedef {{ from: number, to: number }} MigrationStep */
21
+ /** @typedef {import('./state/migrations.js').StateMigration} StateMigration */
22
+
23
+ export { StateSchema, stateShapeError } from './state/schema.js'
24
+
25
+ /**
26
+ * @param {unknown} value
27
+ * @returns {boolean}
28
+ */
29
+ function isPlainObject(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
31
+ }
32
+
33
+ /**
34
+ * Classify a document's `schemaVersion` without throwing. This is the read path
35
+ * doctor uses, so it must stay usable on a repository that cannot load.
36
+ * @param {unknown} state
37
+ * @param {string} [statePath]
38
+ * @param {readonly StateMigration[]} [migrations] registry to classify against
39
+ * @returns {{
40
+ * status: 'current'|'migratable'|'newer'|'unmigratable'|'invalid',
41
+ * currentVersion: number|null,
42
+ * supportedVersion: number,
43
+ * migrations: MigrationStep[],
44
+ * message?: string,
45
+ * }}
46
+ */
47
+ export function inspectStateVersion(state, statePath, migrations) {
48
+ const where = statePath ? ` at ${statePath}` : ''
49
+ if (!isPlainObject(state)) {
50
+ return {
51
+ status: 'invalid',
52
+ currentVersion: null,
53
+ supportedVersion: SCHEMA_VERSION,
54
+ migrations: [],
55
+ message: `tower state is not an object${where}`,
56
+ }
57
+ }
58
+ const version = /** @type {Record<string, any>} */ (state).schemaVersion
59
+ if (!Number.isInteger(version) || version < 1) {
60
+ return {
61
+ status: 'invalid',
62
+ currentVersion: null,
63
+ supportedVersion: SCHEMA_VERSION,
64
+ migrations: [],
65
+ message: `tower state has no usable schemaVersion (${JSON.stringify(version)})${where}`,
66
+ }
67
+ }
68
+ if (version === SCHEMA_VERSION) {
69
+ return { status: 'current', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
70
+ }
71
+ if (version > SCHEMA_VERSION) {
72
+ return { status: 'newer', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
73
+ }
74
+ const chain = migrationChain(version, SCHEMA_VERSION, migrations)
75
+ if (!chain) {
76
+ return { status: 'unmigratable', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
77
+ }
78
+ return {
79
+ status: 'migratable',
80
+ currentVersion: version,
81
+ supportedVersion: SCHEMA_VERSION,
82
+ migrations: chain.map((step) => ({ from: step.from, to: step.to })),
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {{ status: string, currentVersion: number|null, supportedVersion: number, message?: string }} info
88
+ * @param {string} [statePath]
89
+ * @returns {TowerToolError}
90
+ */
91
+ function versionError(info, statePath) {
92
+ const where = statePath ? ` at ${statePath}` : ''
93
+ if (info.status === 'newer') {
94
+ return new TowerToolError(
95
+ 'state-version-unsupported',
96
+ `tower state${where} has schemaVersion ${info.currentVersion}, but this build supports ${info.supportedVersion}: it was written by a newer dsh-tower and will not be downgraded`,
97
+ {
98
+ nextAction: `upgrade dsh-tower to a release that supports state schemaVersion ${info.currentVersion}; the file was left untouched`,
99
+ },
100
+ )
101
+ }
102
+ if (info.status === 'migratable') {
103
+ return new TowerToolError(
104
+ 'state-version-unsupported',
105
+ `tower state${where} has schemaVersion ${info.currentVersion}; load it through the state loader to migrate it to ${info.supportedVersion}`,
106
+ { nextAction: 'call any tower tool; mutations apply the migration under the state lock' },
107
+ )
108
+ }
109
+ if (info.status === 'unmigratable') {
110
+ return new TowerToolError(
111
+ 'state-version-unsupported',
112
+ `tower state${where} has schemaVersion ${info.currentVersion}, which has no migration to ${info.supportedVersion}`,
113
+ {
114
+ nextAction: `restore a state.json written by dsh-tower ${info.supportedVersion}; this build will not guess at an unknown schema`,
115
+ },
116
+ )
117
+ }
118
+ return new TowerToolError('state-corrupt', info.message ?? `tower state is not usable${where}`)
119
+ }
120
+
121
+ /**
122
+ * @param {string} message
123
+ * @returns {never}
124
+ */
125
+ function fail(message) {
126
+ throw new TowerToolError('state-corrupt', message)
127
+ }
128
+
129
+ /**
130
+ * Prove a document matches the current schema. Any other version is rejected
131
+ * with a version error rather than corruption; the load path migrates first.
132
+ * @param {unknown} state
133
+ * @param {string} [statePath]
134
+ * @returns {TowerState}
135
+ */
136
+ export function validateState(state, statePath) {
137
+ const info = inspectStateVersion(state, statePath)
138
+ if (info.status !== 'current') throw versionError(info, statePath)
139
+ const where = statePath ? ` at ${statePath}` : ''
140
+ const shapeError = stateShapeError(state, where)
141
+ if (shapeError) fail(shapeError)
142
+ return assertStateSemantics(state, where)
143
+ }
144
+
145
+ /**
146
+ * The pure load-path decision: migrate an older document through the registry,
147
+ * validate the result against the target schema, then report what happened. The
148
+ * returned state is the validated migrated document; the caller decides whether
149
+ * to persist it (only while holding the state lock).
150
+ *
151
+ * @param {unknown} state
152
+ * @param {string} [statePath]
153
+ * @param {{ migrations?: readonly StateMigration[] }} [options]
154
+ * @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
155
+ */
156
+ export function prepareState(state, statePath, options = {}) {
157
+ const info = inspectStateVersion(state, statePath, options.migrations)
158
+ if (info.status === 'current') {
159
+ return {
160
+ state: validateState(state, statePath),
161
+ from: SCHEMA_VERSION,
162
+ to: SCHEMA_VERSION,
163
+ migrated: [],
164
+ changed: false,
165
+ }
166
+ }
167
+ if (info.status !== 'migratable') throw versionError(info, statePath)
168
+ const outcome = migrateState(/** @type {Record<string, any>} */ (state), {
169
+ statePath,
170
+ to: info.supportedVersion,
171
+ migrations: options.migrations,
172
+ })
173
+ // Validation runs on the *target* document: a shape that was invalid under
174
+ // the old schema (a missing `tasks` array, say) may be valid after a backfill,
175
+ // and validating the target is what proves the migration did its job.
176
+ return {
177
+ state: validateState(outcome.state, statePath),
178
+ from: outcome.from,
179
+ to: outcome.to,
180
+ migrated: outcome.steps,
181
+ changed: true,
182
+ }
183
+ }