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
package/domain/scope.js
CHANGED
|
@@ -11,16 +11,32 @@ function fail(message) {
|
|
|
11
11
|
throw new TowerToolError('invalid-argument', message)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** @typedef {{ caseInsensitive?: boolean }} MatchOptions */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Case folding is a comparison decision, not a normalisation: the stored scope
|
|
18
|
+
* text keeps the operator's casing, and callers supply the checkout's detected
|
|
19
|
+
* mode (#90).
|
|
20
|
+
* @param {string} value
|
|
21
|
+
* @param {boolean} caseInsensitive
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function foldCase(value, caseInsensitive) {
|
|
25
|
+
return caseInsensitive ? value.toLowerCase() : value
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
/**
|
|
15
29
|
* @param {string} pattern
|
|
30
|
+
* @param {MatchOptions} [options]
|
|
16
31
|
* @returns {RegExp}
|
|
17
32
|
*/
|
|
18
|
-
function globToRegExp(pattern) {
|
|
33
|
+
function globToRegExp(pattern, { caseInsensitive = false } = {}) {
|
|
34
|
+
const source = foldCase(pattern, caseInsensitive)
|
|
19
35
|
let out = '^'
|
|
20
|
-
for (let index = 0; index <
|
|
21
|
-
const char =
|
|
36
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
37
|
+
const char = source[index]
|
|
22
38
|
if (char === '*') {
|
|
23
|
-
if (
|
|
39
|
+
if (source[index + 1] === '*') {
|
|
24
40
|
out += '.*'
|
|
25
41
|
index += 1
|
|
26
42
|
} else {
|
|
@@ -38,20 +54,73 @@ function globToRegExp(pattern) {
|
|
|
38
54
|
/**
|
|
39
55
|
* @param {string[] | null | undefined} scope
|
|
40
56
|
* @param {string} file
|
|
57
|
+
* @param {MatchOptions} [options]
|
|
41
58
|
* @returns {boolean}
|
|
42
59
|
*/
|
|
43
|
-
export function fileInScope(scope, file) {
|
|
44
|
-
|
|
60
|
+
export function fileInScope(scope, file, { caseInsensitive = false } = {}) {
|
|
61
|
+
const target = foldCase(file, caseInsensitive)
|
|
62
|
+
return (scope ?? []).some((pattern) => globToRegExp(pattern, { caseInsensitive }).test(target))
|
|
45
63
|
}
|
|
46
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The first scope entry that would match `file` if case were folded, or null.
|
|
67
|
+
* Only used to explain a rejection on a case-sensitive checkout; it never
|
|
68
|
+
* widens the acceptance test (#90).
|
|
69
|
+
* @param {string[] | null | undefined} scope
|
|
70
|
+
* @param {string} file
|
|
71
|
+
* @returns {string | null}
|
|
72
|
+
*/
|
|
73
|
+
export function caseInsensitiveScopeMatch(scope, file) {
|
|
74
|
+
const target = file.toLowerCase()
|
|
75
|
+
return (scope ?? []).find((pattern) => globToRegExp(pattern, { caseInsensitive: true }).test(target)) ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The comparison the helpers use for a detected mode. `null`/`undefined` (a
|
|
80
|
+
* legacy state that predates detection) keeps the safe, exact default.
|
|
81
|
+
* @param {unknown} caseInsensitive
|
|
82
|
+
* @returns {'case-insensitive' | 'case-sensitive'}
|
|
83
|
+
*/
|
|
84
|
+
export function comparisonMode(caseInsensitive) {
|
|
85
|
+
return caseInsensitive === true ? 'case-insensitive' : 'case-sensitive'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Mission titles are printable Unicode, not printable ASCII (#88). The hazards
|
|
89
|
+
// the old ASCII range stood in for are made explicit: control/format/surrogate
|
|
90
|
+
// code points, bidi overrides, zero-width characters, and unbounded length.
|
|
91
|
+
const TITLE_MAX_LENGTH = 120
|
|
92
|
+
// U+202A..U+202E (embedding/override) and U+2066..U+2069 (isolate/override).
|
|
93
|
+
const BIDI_OVERRIDE = /[\u202A-\u202E\u2066-\u2069]/
|
|
94
|
+
// U+200B..U+200D (zero-width space/non-joiner/joiner) and U+FEFF (BOM/ZWNBSP).
|
|
95
|
+
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/
|
|
96
|
+
// Unicode "Other": controls (Cc), format (Cf), unassigned (Cn), private use
|
|
97
|
+
// (Co) and surrogates (Cs). With the `u` flag a lone surrogate matches Cs,
|
|
98
|
+
// while a valid surrogate pair is one printable code point.
|
|
99
|
+
const CONTROL_OR_FORMAT = /\p{C}/u
|
|
100
|
+
|
|
47
101
|
/**
|
|
48
102
|
* @param {unknown} title
|
|
49
103
|
* @returns {string}
|
|
50
104
|
*/
|
|
51
105
|
export function validateTitle(title) {
|
|
52
106
|
if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
|
|
53
|
-
if (
|
|
54
|
-
|
|
107
|
+
if (BIDI_OVERRIDE.test(title)) {
|
|
108
|
+
fail(`mission title must not contain bidi override characters (U+202A-U+202E, U+2066-U+2069): ${JSON.stringify(title)}`)
|
|
109
|
+
}
|
|
110
|
+
if (ZERO_WIDTH.test(title)) {
|
|
111
|
+
fail(`mission title must not contain zero-width characters (U+200B-U+200D, U+FEFF): ${JSON.stringify(title)}`)
|
|
112
|
+
}
|
|
113
|
+
if (CONTROL_OR_FORMAT.test(title)) {
|
|
114
|
+
fail(`mission title must not contain control, format, or surrogate characters: ${JSON.stringify(title)}`)
|
|
115
|
+
}
|
|
116
|
+
const normalized = title.trim().normalize('NFC')
|
|
117
|
+
// Count Unicode code points, not UTF-16 units, so astral characters (emoji)
|
|
118
|
+
// count as one character and the bound is script-independent.
|
|
119
|
+
const length = [...normalized].length
|
|
120
|
+
if (length > TITLE_MAX_LENGTH) {
|
|
121
|
+
fail(`mission title must be at most ${TITLE_MAX_LENGTH} characters after NFC normalization (got ${length})`)
|
|
122
|
+
}
|
|
123
|
+
return normalized
|
|
55
124
|
}
|
|
56
125
|
|
|
57
126
|
/**
|
|
@@ -88,11 +157,14 @@ function scopePrefix(pattern) {
|
|
|
88
157
|
/**
|
|
89
158
|
* @param {string[]} a
|
|
90
159
|
* @param {string[]} b
|
|
160
|
+
* @param {MatchOptions} [options]
|
|
91
161
|
* @returns {boolean}
|
|
92
162
|
*/
|
|
93
|
-
export function scopesOverlap(a, b) {
|
|
94
|
-
for (const
|
|
95
|
-
for (const
|
|
163
|
+
export function scopesOverlap(a, b, { caseInsensitive = false } = {}) {
|
|
164
|
+
for (const rawA of a) {
|
|
165
|
+
for (const rawB of b) {
|
|
166
|
+
const pa = foldCase(rawA, caseInsensitive)
|
|
167
|
+
const pb = foldCase(rawB, caseInsensitive)
|
|
96
168
|
if (pa === pb) return true
|
|
97
169
|
const x = scopePrefix(pa)
|
|
98
170
|
const y = scopePrefix(pb)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Terminal-mission archive decisions (#73).
|
|
2
|
+
//
|
|
3
|
+
// A finished mission keeps its tasks, reviews, findings and completion report,
|
|
4
|
+
// but every mutation rewrites the whole state document. Archiving moves that
|
|
5
|
+
// history into `<git-common-dir>/dsh-tower/archive/` and leaves only an index
|
|
6
|
+
// entry behind, so state.json grows with active work rather than with the
|
|
7
|
+
// repository's total history.
|
|
8
|
+
//
|
|
9
|
+
// This module is pure: it declares the archive document contract, selects what
|
|
10
|
+
// is eligible under the configured policy, and builds the records. Reading and
|
|
11
|
+
// writing the files, and the lock that makes them atomic with state.json, live
|
|
12
|
+
// in `adapters/archive.js` and `app/archive.js`.
|
|
13
|
+
// @ts-check
|
|
14
|
+
import { isTerminal } from '../mission.js'
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
17
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
18
|
+
/** @typedef {import('../../types/index.js').Review} Review */
|
|
19
|
+
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
20
|
+
|
|
21
|
+
/** The archive document version. It is independent of `SCHEMA_VERSION`. */
|
|
22
|
+
export const ARCHIVE_VERSION = 1
|
|
23
|
+
|
|
24
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ archivedAt: number, archivedYear: number }} ArchiveMeta */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A worktree is still live until teardown records its removal. Archiving a
|
|
38
|
+
* mission whose worktree exists would lose the only record of where it is.
|
|
39
|
+
* @param {Mission} mission
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function hasLiveWorktree(mission) {
|
|
43
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
44
|
+
return Boolean(record.worktree) && !record.worktreeRemovedAt
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* When the mission became terminal. Missing timestamps are treated as ancient
|
|
49
|
+
* so a legacy terminal mission does not stay hot forever; the threshold is
|
|
50
|
+
* still explicit in config.
|
|
51
|
+
* @param {Mission} mission
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
export function terminalTimestamp(mission) {
|
|
55
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
56
|
+
for (const value of [record.mergedAt, record.abandonedAt, record.updatedAt]) {
|
|
57
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Select the terminal missions the policy wants moved out of state.json.
|
|
64
|
+
*
|
|
65
|
+
* Eligibility:
|
|
66
|
+
* - status is `merged` or `abandoned` (terminal);
|
|
67
|
+
* - the worktree is gone (never provisioned, or removed by teardown);
|
|
68
|
+
* - the caller did not protect it (open batch members, live agents).
|
|
69
|
+
*
|
|
70
|
+
* Triggers:
|
|
71
|
+
* - `force` archives every eligible mission (tower_teardown);
|
|
72
|
+
* - otherwise a mission older than `afterDays` is archived, and when the hot
|
|
73
|
+
* terminal count exceeds `maxMissions`, the oldest eligible missions are
|
|
74
|
+
* archived until the count fits the cap.
|
|
75
|
+
*
|
|
76
|
+
* @param {Mission[]} missions
|
|
77
|
+
* @param {Partial<TowerConfig['archive']>} [policy]
|
|
78
|
+
* @param {{ now?: number, force?: boolean, protectedIds?: Iterable<string> }} [options]
|
|
79
|
+
* @returns {Mission[]}
|
|
80
|
+
*/
|
|
81
|
+
export function selectForArchive(missions, policy = {}, options = {}) {
|
|
82
|
+
if (policy?.enabled === false) return []
|
|
83
|
+
const now = Number.isFinite(options.now) ? /** @type {number} */ (options.now) : Date.now()
|
|
84
|
+
const protectedIds = new Set(options.protectedIds ?? [])
|
|
85
|
+
const afterDays = Number.isFinite(policy?.afterDays) ? /** @type {number} */ (policy.afterDays) : 30
|
|
86
|
+
const maxMissions = Number.isFinite(policy?.maxMissions) ? /** @type {number} */ (policy.maxMissions) : 100
|
|
87
|
+
const eligible = missions.filter(
|
|
88
|
+
(mission) => isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
|
|
89
|
+
)
|
|
90
|
+
if (eligible.length === 0) return []
|
|
91
|
+
if (options.force === true) return eligible
|
|
92
|
+
|
|
93
|
+
const selected = new Set()
|
|
94
|
+
const cutoff = now - Math.max(0, afterDays) * DAY_MS
|
|
95
|
+
for (const mission of eligible) {
|
|
96
|
+
if (terminalTimestamp(mission) <= cutoff) selected.add(mission.id)
|
|
97
|
+
}
|
|
98
|
+
if (eligible.length > maxMissions) {
|
|
99
|
+
const oldest = [...eligible].sort((left, right) => terminalTimestamp(left) - terminalTimestamp(right))
|
|
100
|
+
for (let index = 0; index < oldest.length - maxMissions; index += 1) selected.add(oldest[index].id)
|
|
101
|
+
}
|
|
102
|
+
return missions.filter((mission) => selected.has(mission.id))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Review records that belong to one mission, by id or by its branch.
|
|
107
|
+
* @param {Mission} mission
|
|
108
|
+
* @param {Review[] | null | undefined} reviews
|
|
109
|
+
* @returns {Review[]}
|
|
110
|
+
*/
|
|
111
|
+
export function linkedReviews(mission, reviews) {
|
|
112
|
+
const branch = /** @type {Record<string, any>} */ (mission).worktree?.branch ?? null
|
|
113
|
+
return (reviews ?? []).filter(
|
|
114
|
+
(review) => review.missionId === mission.id || (branch !== null && review.branch === branch),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Mission} mission
|
|
120
|
+
* @param {Finding[] | null | undefined} findings
|
|
121
|
+
* @returns {Finding[]}
|
|
122
|
+
*/
|
|
123
|
+
export function linkedFindings(mission, findings) {
|
|
124
|
+
return (findings ?? []).filter((finding) => finding.missionId === mission.id)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Closed findings are historical; open/triaged findings stay in state.json so
|
|
129
|
+
* `tower_findings` keeps showing actionable work even after its mission is
|
|
130
|
+
* archived. The archive entry still carries a snapshot of every linked finding.
|
|
131
|
+
* @param {Finding} finding
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
export function isClosedFinding(finding) {
|
|
135
|
+
return finding.status === 'fixed' || finding.status === 'wontfix'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** UTC year used for `archive/<year>.json`. @param {number} now @returns {number} */
|
|
139
|
+
export function archiveYearFor(now) {
|
|
140
|
+
return new Date(now).getUTCFullYear()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The compact entry written to `archive/index.json` (and referenced by
|
|
145
|
+
* `tower_status`). It carries enough fields to keep ordering, dependency
|
|
146
|
+
* lookups and mission-id allocation working without loading a year file.
|
|
147
|
+
* @param {Mission} mission
|
|
148
|
+
* @param {ArchiveMeta} meta
|
|
149
|
+
* @returns {Record<string, any>}
|
|
150
|
+
*/
|
|
151
|
+
export function archiveIndexEntry(mission, meta) {
|
|
152
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
153
|
+
const branch = record.worktree?.branch ?? record.branch ?? null
|
|
154
|
+
return {
|
|
155
|
+
id: mission.id,
|
|
156
|
+
title: String(record.title ?? mission.id),
|
|
157
|
+
status: mission.status,
|
|
158
|
+
kind: record.kind ?? 'build',
|
|
159
|
+
scope: Array.isArray(record.scope) ? record.scope : [],
|
|
160
|
+
deps: Array.isArray(record.deps) ? record.deps : [],
|
|
161
|
+
owner: record.owner ?? null,
|
|
162
|
+
branch,
|
|
163
|
+
mergedCommit: record.mergedCommit ?? null,
|
|
164
|
+
mergedAt: record.mergedAt ?? null,
|
|
165
|
+
updatedAt: record.updatedAt ?? null,
|
|
166
|
+
archivedAt: meta.archivedAt,
|
|
167
|
+
archivedYear: meta.archivedYear,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A full archived record. It nests the untouched mission and its linked
|
|
173
|
+
* reviews/findings so `tower_status missionId` can reproduce the detail view.
|
|
174
|
+
* @param {Mission} mission
|
|
175
|
+
* @param {ArchiveMeta & { reviews?: Review[], findings?: Finding[] }} meta
|
|
176
|
+
* @returns {Record<string, any>}
|
|
177
|
+
*/
|
|
178
|
+
export function archivedEntry(mission, meta) {
|
|
179
|
+
return {
|
|
180
|
+
...archiveIndexEntry(mission, meta),
|
|
181
|
+
mission,
|
|
182
|
+
reviews: meta.reviews ?? [],
|
|
183
|
+
findings: meta.findings ?? [],
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Strip the full payload before writing the index.
|
|
189
|
+
* @param {Record<string, any>} entry
|
|
190
|
+
* @returns {Record<string, any>}
|
|
191
|
+
*/
|
|
192
|
+
export function indexEntryFromArchived(entry) {
|
|
193
|
+
const { mission: _mission, reviews: _reviews, findings: _findings, ...index } = entry
|
|
194
|
+
return index
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Dedupe entries by id, keeping the first occurrence. Archiving is idempotent:
|
|
199
|
+
* a crash between writing a year file and writing state.json is retried on the
|
|
200
|
+
* next mutation and must not create a second record.
|
|
201
|
+
* @template {{ id?: unknown }} T
|
|
202
|
+
* @param {T[]} entries
|
|
203
|
+
* @returns {T[]}
|
|
204
|
+
*/
|
|
205
|
+
export function dedupeById(entries) {
|
|
206
|
+
const seen = new Set()
|
|
207
|
+
const out = []
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const id = entry?.id
|
|
210
|
+
if (typeof id !== 'string' || seen.has(id)) continue
|
|
211
|
+
seen.add(id)
|
|
212
|
+
out.push(entry)
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate an archive document. Returns the diagnostic string, or null when the
|
|
219
|
+
* document is structurally usable. A truncated or foreign archive must degrade
|
|
220
|
+
* to a diagnostic in status/doctor, never to a thrown state-corrupt error.
|
|
221
|
+
* @param {unknown} document
|
|
222
|
+
* @param {'index'|'year'} kind
|
|
223
|
+
* @param {string} [where]
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
export function archiveDocumentError(document, kind, where = '') {
|
|
227
|
+
if (!isPlainObject(document)) return `tower archive ${kind} is not an object${where}`
|
|
228
|
+
const record = /** @type {Record<string, any>} */ (document)
|
|
229
|
+
if (record.archiveVersion !== ARCHIVE_VERSION) {
|
|
230
|
+
return `tower archive ${kind} has unsupported archiveVersion ${JSON.stringify(record.archiveVersion)}${where}`
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(record.missions)) return `tower archive ${kind} has no missions array${where}`
|
|
233
|
+
const ids = new Set()
|
|
234
|
+
for (const entry of record.missions) {
|
|
235
|
+
if (!isPlainObject(entry) || typeof entry.id !== 'string') {
|
|
236
|
+
return `tower archive ${kind} has an entry without id${where}`
|
|
237
|
+
}
|
|
238
|
+
if (ids.has(entry.id)) return `tower archive ${kind} has duplicate mission ${entry.id}${where}`
|
|
239
|
+
ids.add(entry.id)
|
|
240
|
+
if (typeof entry.status !== 'string') return `tower archive ${kind} entry ${entry.id} has no status${where}`
|
|
241
|
+
if (kind === 'year') {
|
|
242
|
+
if (!isPlainObject(entry.mission) || entry.mission.id !== entry.id) {
|
|
243
|
+
return `tower archive ${kind} entry ${entry.id} has no full mission${where}`
|
|
244
|
+
}
|
|
245
|
+
if (entry.reviews !== undefined && !Array.isArray(entry.reviews)) {
|
|
246
|
+
return `tower archive ${kind} entry ${entry.id} has no reviews array${where}`
|
|
247
|
+
}
|
|
248
|
+
if (entry.findings !== undefined && !Array.isArray(entry.findings)) {
|
|
249
|
+
return `tower archive ${kind} entry ${entry.id} has no findings array${where}`
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (kind === 'index' && (!Number.isInteger(entry.archivedYear) || entry.archivedYear < 1970)) {
|
|
253
|
+
return `tower archive ${kind} entry ${entry.id} has no archivedYear${where}`
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
@@ -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
|
+
}
|
package/domain/state/schema.js
CHANGED
|
@@ -76,6 +76,9 @@ export const StateSchema = Type.Object(
|
|
|
76
76
|
createdAt: Type.Optional(Type.Number()),
|
|
77
77
|
updatedAt: Type.Optional(Type.Number()),
|
|
78
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()])),
|
|
79
82
|
towerAgentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
80
83
|
snapshotCommit: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
81
84
|
snapshotRef: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
@@ -154,6 +157,9 @@ function firstShapeMessage(state, where) {
|
|
|
154
157
|
if (state.dirtyBase !== undefined && typeof state.dirtyBase !== 'boolean') {
|
|
155
158
|
return `tower state dirtyBase is not a boolean${where}`
|
|
156
159
|
}
|
|
160
|
+
if (state.caseInsensitive !== undefined && state.caseInsensitive !== null && typeof state.caseInsensitive !== 'boolean') {
|
|
161
|
+
return `tower state caseInsensitive is not a boolean${where}`
|
|
162
|
+
}
|
|
157
163
|
for (const key of ['towerAgentId', 'snapshotCommit', 'snapshotRef']) {
|
|
158
164
|
if (state[key] !== undefined && state[key] !== null && typeof state[key] !== 'string') {
|
|
159
165
|
return `tower state ${key} is not a string or null${where}`
|