dsh-tower 2.0.3 → 2.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
package/domain/scope.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { TowerToolError } from './errors.js'
|
|
2
|
+
// Pure scope helpers shared by planning and gates.
|
|
3
|
+
// @ts-check
|
|
4
|
+
const GLOB_META = /[*?[{]/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @returns {never}
|
|
9
|
+
*/
|
|
10
|
+
function fail(message) {
|
|
11
|
+
throw new TowerToolError('invalid-argument', message)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} pattern
|
|
16
|
+
* @returns {RegExp}
|
|
17
|
+
*/
|
|
18
|
+
function globToRegExp(pattern) {
|
|
19
|
+
let out = '^'
|
|
20
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
21
|
+
const char = pattern[index]
|
|
22
|
+
if (char === '*') {
|
|
23
|
+
if (pattern[index + 1] === '*') {
|
|
24
|
+
out += '.*'
|
|
25
|
+
index += 1
|
|
26
|
+
} else {
|
|
27
|
+
out += '[^/]*'
|
|
28
|
+
}
|
|
29
|
+
} else if (char === '?') {
|
|
30
|
+
out += '[^/]'
|
|
31
|
+
} else {
|
|
32
|
+
out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`${out}$`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string[] | null | undefined} scope
|
|
40
|
+
* @param {string} file
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function fileInScope(scope, file) {
|
|
44
|
+
return (scope ?? []).some((pattern) => globToRegExp(pattern).test(file))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} title
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function validateTitle(title) {
|
|
52
|
+
if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
|
|
53
|
+
if (!/^[\x20-\x7E]+$/.test(title)) fail(`mission title must be printable ASCII: ${JSON.stringify(title)}`)
|
|
54
|
+
return title.trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {unknown} scope
|
|
59
|
+
* @param {{ allowEmpty?: boolean }} [options]
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeScope(scope, { allowEmpty = false } = {}) {
|
|
63
|
+
if (!Array.isArray(scope)) fail('mission scope must be an array of relative globs')
|
|
64
|
+
if (scope.length === 0 && !allowEmpty) fail('mission scope must not be empty for a build mission')
|
|
65
|
+
/** @type {string[]} */
|
|
66
|
+
const out = []
|
|
67
|
+
for (const raw of scope) {
|
|
68
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) fail('scope entries must be non-empty strings')
|
|
69
|
+
const value = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').trim()
|
|
70
|
+
if (value.length === 0) fail('scope entries must be non-empty strings')
|
|
71
|
+
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) fail(`scope must be relative: ${raw}`)
|
|
72
|
+
if (value.split('/').some((part) => part === '..')) fail(`scope must not contain "..": ${raw}`)
|
|
73
|
+
if (!out.includes(value)) out.push(value)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} pattern
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function scopePrefix(pattern) {
|
|
83
|
+
const index = pattern.search(GLOB_META)
|
|
84
|
+
const literal = index < 0 ? pattern : pattern.slice(0, index)
|
|
85
|
+
return literal.replace(/\/+$/, '')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string[]} a
|
|
90
|
+
* @param {string[]} b
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function scopesOverlap(a, b) {
|
|
94
|
+
for (const pa of a) {
|
|
95
|
+
for (const pb of b) {
|
|
96
|
+
if (pa === pb) return true
|
|
97
|
+
const x = scopePrefix(pa)
|
|
98
|
+
const y = scopePrefix(pb)
|
|
99
|
+
if (!x || !y) return true
|
|
100
|
+
if (x === y) return true
|
|
101
|
+
if (x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
towerAgentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
80
|
+
snapshotCommit: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
81
|
+
snapshotRef: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
82
|
+
missions: Type.Array(MissionSchema),
|
|
83
|
+
roster: Type.Optional(Type.Array(RosterEntrySchema)),
|
|
84
|
+
reviews: Type.Optional(Type.Array(ReviewSchema)),
|
|
85
|
+
findings: Type.Optional(Type.Array(FindingSchema)),
|
|
86
|
+
messages: Type.Optional(Type.Array(MessageSchema)),
|
|
87
|
+
batches: Type.Optional(Type.Array(Type.Unknown())),
|
|
88
|
+
legacySnapshots: Type.Optional(Type.Array(Type.Unknown())),
|
|
89
|
+
legacyMaps: Type.Optional(Type.Array(Type.Unknown())),
|
|
90
|
+
deliveries: Type.Optional(Type.Array(Type.Unknown())),
|
|
91
|
+
mergeIntent: Type.Optional(Type.Union([Type.Object({}, optional), Type.Null()])),
|
|
92
|
+
rebaseIntent: Type.Optional(Type.Union([Type.Object({}, optional), Type.Null()])),
|
|
93
|
+
messageSeq: Type.Optional(NonNegativeInteger),
|
|
94
|
+
reviewSeq: Type.Optional(NonNegativeInteger),
|
|
95
|
+
findingSeq: Type.Optional(NonNegativeInteger),
|
|
96
|
+
batchSeq: Type.Optional(NonNegativeInteger),
|
|
97
|
+
deliverySeq: Type.Optional(NonNegativeInteger),
|
|
98
|
+
},
|
|
99
|
+
optional,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {unknown} value
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
function isPlainObject(value) {
|
|
107
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @type {readonly string[]} */
|
|
111
|
+
const ARRAY_KEYS = ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']
|
|
112
|
+
/** @type {readonly string[]} */
|
|
113
|
+
const SEQUENCE_KEYS = ['messageSeq', 'reviewSeq', 'findingSeq', 'batchSeq', 'deliverySeq']
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reproduce the historical `[tower:state-corrupt]` diagnostics for the
|
|
117
|
+
* structural failures the schema rejects. Keeping them in the state vocabulary
|
|
118
|
+
* (rather than exposing TypeBox paths) keeps operator-facing messages stable.
|
|
119
|
+
* @param {Record<string, any>} state
|
|
120
|
+
* @param {string} where
|
|
121
|
+
* @returns {string | null}
|
|
122
|
+
*/
|
|
123
|
+
function firstShapeMessage(state, where) {
|
|
124
|
+
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
125
|
+
return `unsupported tower state schemaVersion ${state.schemaVersion}${where}`
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(state.missions)) return `tower state has no missions array${where}`
|
|
128
|
+
for (const mission of state.missions) {
|
|
129
|
+
if (!isPlainObject(mission) || typeof mission.id !== 'string') return `mission entry without id${where}`
|
|
130
|
+
const id = mission.id
|
|
131
|
+
if (!Array.isArray(mission.tasks)) return `mission ${id} has no tasks array${where}`
|
|
132
|
+
if (!Array.isArray(mission.deps)) return `mission ${id} has no deps array${where}`
|
|
133
|
+
if (mission.references !== undefined && !Array.isArray(mission.references)) {
|
|
134
|
+
return `mission ${id} has no references array${where}`
|
|
135
|
+
}
|
|
136
|
+
if (mission.reviewPolicy !== undefined && typeof mission.reviewPolicy !== 'string') {
|
|
137
|
+
return `mission ${id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`
|
|
138
|
+
}
|
|
139
|
+
if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
|
|
140
|
+
return `mission ${id} has no testRuns array${where}`
|
|
141
|
+
}
|
|
142
|
+
if (mission.worktree !== undefined && mission.worktree !== null && !isPlainObject(mission.worktree)) {
|
|
143
|
+
return `mission ${id} has an invalid worktree record${where}`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const key of ['base', 'repoRoot', 'commonDir']) {
|
|
147
|
+
if (state[key] !== undefined && typeof state[key] !== 'string') return `tower state ${key} is not a string${where}`
|
|
148
|
+
}
|
|
149
|
+
for (const key of ['createdAt', 'updatedAt']) {
|
|
150
|
+
if (state[key] !== undefined && typeof state[key] !== 'number') {
|
|
151
|
+
return `tower state ${key} is not a number${where}`
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (state.dirtyBase !== undefined && typeof state.dirtyBase !== 'boolean') {
|
|
155
|
+
return `tower state dirtyBase is not a boolean${where}`
|
|
156
|
+
}
|
|
157
|
+
for (const key of ['towerAgentId', 'snapshotCommit', 'snapshotRef']) {
|
|
158
|
+
if (state[key] !== undefined && state[key] !== null && typeof state[key] !== 'string') {
|
|
159
|
+
return `tower state ${key} is not a string or null${where}`
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (state.revision !== undefined && typeof state.revision !== 'number') {
|
|
163
|
+
return `tower state revision is not a non-negative integer${where}`
|
|
164
|
+
}
|
|
165
|
+
for (const key of ARRAY_KEYS) {
|
|
166
|
+
if (state[key] !== undefined && !Array.isArray(state[key])) return `tower state ${key} is not an array${where}`
|
|
167
|
+
}
|
|
168
|
+
for (const key of SEQUENCE_KEYS) {
|
|
169
|
+
if (state[key] !== undefined && !(Number.isInteger(state[key]) && state[key] >= 0)) {
|
|
170
|
+
return `tower state ${key} is not a non-negative integer${where}`
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
|
|
174
|
+
return `tower state mergeIntent is not an object${where}`
|
|
175
|
+
}
|
|
176
|
+
if (state.rebaseIntent !== undefined && state.rebaseIntent !== null && !isPlainObject(state.rebaseIntent)) {
|
|
177
|
+
return `tower state rebaseIntent is not an object${where}`
|
|
178
|
+
}
|
|
179
|
+
if (state.roster !== undefined) {
|
|
180
|
+
if (!Array.isArray(state.roster)) return `tower state roster is not an array${where}`
|
|
181
|
+
for (const entry of state.roster) {
|
|
182
|
+
if (!isPlainObject(entry) || typeof entry.name !== 'string') return `invalid roster entry${where}`
|
|
183
|
+
if (entry.agentId !== undefined && entry.agentId !== null && typeof entry.agentId !== 'string') {
|
|
184
|
+
return `invalid roster entry${where}`
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (state.messages !== undefined) {
|
|
189
|
+
if (!Array.isArray(state.messages)) return `tower state messages is not an array${where}`
|
|
190
|
+
for (const message of state.messages) {
|
|
191
|
+
if (!isPlainObject(message) || typeof message.id !== 'string' || typeof message.to !== 'string') {
|
|
192
|
+
return `invalid message entry${where}`
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(message.readBy)) return `message ${message.id} has no readBy array${where}`
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (state.findings !== undefined) {
|
|
198
|
+
if (!Array.isArray(state.findings)) return `tower state findings is not an array${where}`
|
|
199
|
+
for (const finding of state.findings) {
|
|
200
|
+
if (!isPlainObject(finding) || typeof finding.id !== 'string') return `invalid finding entry${where}`
|
|
201
|
+
if (finding.status !== undefined && typeof finding.status !== 'string') {
|
|
202
|
+
return `finding ${finding.id} has unknown status ${finding.status}${where}`
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (state.reviews !== undefined) {
|
|
207
|
+
if (!Array.isArray(state.reviews)) return `tower state reviews is not an array${where}`
|
|
208
|
+
for (const review of state.reviews) {
|
|
209
|
+
if (!isPlainObject(review) || typeof review.id !== 'string') return `invalid review entry${where}`
|
|
210
|
+
if (review.status !== undefined && typeof review.status !== 'string') {
|
|
211
|
+
return `review ${review.id} has unknown status ${review.status}${where}`
|
|
212
|
+
}
|
|
213
|
+
if (review.mergeVerdict !== undefined && typeof review.mergeVerdict !== 'string') {
|
|
214
|
+
return `review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Pure structural check. Returns the legacy diagnostic, or null when the state
|
|
223
|
+
* satisfies `StateSchema`. `where` is the already-formatted " at <path>" suffix.
|
|
224
|
+
* @param {unknown} state
|
|
225
|
+
* @param {string} [where]
|
|
226
|
+
* @returns {string | null}
|
|
227
|
+
*/
|
|
228
|
+
export function stateShapeError(state, where = '') {
|
|
229
|
+
if (!isPlainObject(state)) return `tower state is not an object${where}`
|
|
230
|
+
if (Value.Check(StateSchema, state)) return null
|
|
231
|
+
const record = /** @type {Record<string, any>} */ (state)
|
|
232
|
+
return firstShapeMessage(record, where) ?? `tower state does not match the state schema${where}`
|
|
233
|
+
}
|
|
@@ -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
|
+
}
|
package/domain/state.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pure state-file validation (#56 P4).
|
|
2
|
+
//
|
|
3
|
+
// The structural contract lives in `state/schema.js` (TypeBox) and the policy
|
|
4
|
+
// checks in `state/semantics.js`. This module stays the public entry point so
|
|
5
|
+
// every app/tool import of `domain/state.js` is unchanged.
|
|
6
|
+
// @ts-check
|
|
7
|
+
import { TowerToolError } from './errors.js'
|
|
8
|
+
import { stateShapeError } from './state/schema.js'
|
|
9
|
+
import { assertStateSemantics } from './state/semantics.js'
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../types/index.js').TowerState} TowerState */
|
|
12
|
+
|
|
13
|
+
export { StateSchema, stateShapeError } from './state/schema.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} message
|
|
17
|
+
* @returns {never}
|
|
18
|
+
*/
|
|
19
|
+
function fail(message) {
|
|
20
|
+
throw new TowerToolError('state-corrupt', message)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} state
|
|
25
|
+
* @param {string} [statePath]
|
|
26
|
+
* @returns {TowerState}
|
|
27
|
+
*/
|
|
28
|
+
export function validateState(state, statePath) {
|
|
29
|
+
const where = statePath ? ` at ${statePath}` : ''
|
|
30
|
+
const shapeError = stateShapeError(state, where)
|
|
31
|
+
if (shapeError) fail(shapeError)
|
|
32
|
+
return assertStateSemantics(state, where)
|
|
33
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Pure mission/finding summaries.
|
|
2
|
+
// @ts-check
|
|
3
|
+
import { FINDING_STATUSES } from './mission.js'
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('../types/index.js').Finding} Finding */
|
|
6
|
+
/** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
|
|
7
|
+
/** @typedef {import('../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../types/index.js').MissionDetail} MissionDetail */
|
|
9
|
+
/** @typedef {import('../types/index.js').MissionSummary} MissionSummary */
|
|
10
|
+
/** @typedef {import('../types/index.js').MissionTask} MissionTask */
|
|
11
|
+
/** @typedef {import('../types/index.js').TaskCounts} TaskCounts */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {MissionTask[]} tasks
|
|
15
|
+
* @returns {TaskCounts}
|
|
16
|
+
*/
|
|
17
|
+
function taskCounts(tasks) {
|
|
18
|
+
/** @type {TaskCounts} */
|
|
19
|
+
const counts = { total: tasks.length, done: 0, open: 0, dropped: 0 }
|
|
20
|
+
for (const task of tasks) {
|
|
21
|
+
if (task.status === 'done') counts.done += 1
|
|
22
|
+
else if (task.status === 'dropped') counts.dropped += 1
|
|
23
|
+
else counts.open += 1
|
|
24
|
+
}
|
|
25
|
+
return counts
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Mission} mission
|
|
30
|
+
* @returns {MissionSummary}
|
|
31
|
+
*/
|
|
32
|
+
export function missionSummary(mission) {
|
|
33
|
+
return {
|
|
34
|
+
id: mission.id,
|
|
35
|
+
title: mission.title,
|
|
36
|
+
kind: mission.kind,
|
|
37
|
+
status: mission.status,
|
|
38
|
+
scope: mission.scope,
|
|
39
|
+
deps: mission.deps,
|
|
40
|
+
owner: mission.owner ?? null,
|
|
41
|
+
worktree: mission.worktree
|
|
42
|
+
? {
|
|
43
|
+
path: mission.worktree.path,
|
|
44
|
+
branch: mission.worktree.branch,
|
|
45
|
+
baseCommit: mission.worktree.baseCommit,
|
|
46
|
+
}
|
|
47
|
+
: null,
|
|
48
|
+
blockers: mission.blockers ?? [],
|
|
49
|
+
tasks: taskCounts(mission.tasks ?? []),
|
|
50
|
+
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
51
|
+
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
52
|
+
pauseCause: mission.pauseCause ?? null,
|
|
53
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
54
|
+
mergedCommit: mission.mergedCommit ?? null,
|
|
55
|
+
mergedAt: mission.mergedAt ?? null,
|
|
56
|
+
updatedAt: mission.updatedAt,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Mission} mission
|
|
62
|
+
* @returns {MissionDetail}
|
|
63
|
+
*/
|
|
64
|
+
function fullMission(mission) {
|
|
65
|
+
return {
|
|
66
|
+
...missionSummary(mission),
|
|
67
|
+
tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
|
|
68
|
+
notes: mission.notes ?? [],
|
|
69
|
+
completionReport: mission.completionReport ?? null,
|
|
70
|
+
authorizationRef: mission.authorizationRef ?? null,
|
|
71
|
+
references: mission.references ?? [],
|
|
72
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
73
|
+
reviewExemptionReason: mission.reviewExemptionReason ?? '',
|
|
74
|
+
testRuns: mission.testRuns ?? [],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {Finding} finding
|
|
80
|
+
* @returns {FindingSummary}
|
|
81
|
+
*/
|
|
82
|
+
function findingSummary(finding) {
|
|
83
|
+
return {
|
|
84
|
+
id: finding.id,
|
|
85
|
+
seq: finding.seq ?? null,
|
|
86
|
+
type: finding.type,
|
|
87
|
+
title: finding.title,
|
|
88
|
+
severity: finding.severity ?? null,
|
|
89
|
+
status: finding.status ?? 'open',
|
|
90
|
+
missionId: finding.missionId ?? null,
|
|
91
|
+
branch: finding.branch ?? null,
|
|
92
|
+
reportedBy: finding.reportedBy ?? null,
|
|
93
|
+
at: finding.at ?? null,
|
|
94
|
+
updatedAt: finding.updatedAt ?? null,
|
|
95
|
+
closedAt: finding.closedAt ?? null,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Finding[]} findings
|
|
101
|
+
* @returns {Record<string, number>}
|
|
102
|
+
*/
|
|
103
|
+
function countFindings(findings) {
|
|
104
|
+
/** @type {Record<string, number>} */
|
|
105
|
+
const counts = {}
|
|
106
|
+
for (const status of FINDING_STATUSES) counts[status] = 0
|
|
107
|
+
for (const finding of findings) {
|
|
108
|
+
const status = finding.status ?? 'open'
|
|
109
|
+
counts[status] = (counts[status] ?? 0) + 1
|
|
110
|
+
}
|
|
111
|
+
return counts
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { taskCounts, fullMission, findingSummary, countFindings }
|