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.
- package/CHANGELOG.md +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- 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/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- 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 +427 -0
- package/domain/contracts/outputs.js +305 -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 +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -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 +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -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 +9 -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 +212 -0
- package/scripts/release-check.mjs +303 -0
- 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 +45 -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 +102 -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 +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Pure worker-completion policy and check normalization.
|
|
2
|
+
//
|
|
3
|
+
// app/checkCompletion.js still loads state, verifies the worktree/commits with
|
|
4
|
+
// Git, and persists the transition; the rules below are repository-free.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
9
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hard cap for `tower_complete.report`, in UTF-8 bytes (32 KiB). The value is
|
|
13
|
+
* the default of `gates.maxReportBytes`; a repository may lower or raise it.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_MAX_REPORT_BYTES = 32 * 1024
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* UTF-8 byte length, so multi-byte text is not silently favoured by a
|
|
19
|
+
* character-count bound.
|
|
20
|
+
* @param {string} value
|
|
21
|
+
* @returns {number}
|
|
22
|
+
*/
|
|
23
|
+
export function utf8ByteLength(value) {
|
|
24
|
+
return Buffer.byteLength(String(value), 'utf8')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reject a report over the cap instead of truncating it: a silent cut loses
|
|
29
|
+
* evidence and cannot be recovered by the tower. The writer is told to keep the
|
|
30
|
+
* full text beside the branch and reference it.
|
|
31
|
+
* @param {string} report
|
|
32
|
+
* @param {number} [maxBytes]
|
|
33
|
+
* @returns {number} accepted UTF-8 byte length
|
|
34
|
+
*/
|
|
35
|
+
export function assertReportSize(report, maxBytes = DEFAULT_MAX_REPORT_BYTES) {
|
|
36
|
+
const bytes = utf8ByteLength(report)
|
|
37
|
+
if (bytes > maxBytes) {
|
|
38
|
+
throw new TowerToolError(
|
|
39
|
+
'invalid-argument',
|
|
40
|
+
`report is ${bytes} bytes (UTF-8), over the ${maxBytes}-byte cap; reports are rejected, not truncated — write the full text to a file in the mission worktree and reference the path in the report`,
|
|
41
|
+
{
|
|
42
|
+
nextAction: 'write the full text to a file in the mission worktree and reference the path in the report',
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return bytes
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Byte size of a stored completion report. Older documents have no
|
|
51
|
+
* `reportBytes`, so the body is measured on read; the field is never required.
|
|
52
|
+
* @param {any} completionReport
|
|
53
|
+
* @returns {number | null}
|
|
54
|
+
*/
|
|
55
|
+
export function completionReportBytes(completionReport) {
|
|
56
|
+
if (!isPlainObject(completionReport)) return null
|
|
57
|
+
const stored = completionReport.reportBytes
|
|
58
|
+
if (Number.isInteger(stored) && stored >= 0) return stored
|
|
59
|
+
return typeof completionReport.report === 'string' ? utf8ByteLength(completionReport.report) : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} message
|
|
64
|
+
* @param {string} [code]
|
|
65
|
+
* @returns {never}
|
|
66
|
+
*/
|
|
67
|
+
function fail(message, code = 'invalid-argument') {
|
|
68
|
+
throw new TowerToolError(code, message)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {unknown} value
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
function isPlainObject(value) {
|
|
76
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalize `tower_complete` check evidence.
|
|
81
|
+
* @param {any} checks
|
|
82
|
+
* @returns {any[]}
|
|
83
|
+
*/
|
|
84
|
+
export function normalizeChecks(checks) {
|
|
85
|
+
if (checks === undefined) return []
|
|
86
|
+
if (!Array.isArray(checks)) fail('checks must be an array')
|
|
87
|
+
if (checks.length > 20) fail('checks may contain at most 20 entries')
|
|
88
|
+
return checks.map((check, index) => {
|
|
89
|
+
if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
|
|
90
|
+
const name = typeof check.name === 'string' ? check.name.trim() : ''
|
|
91
|
+
if (!name) fail(`checks[${index}].name must be a non-empty string`)
|
|
92
|
+
/** @type {Record<string, any>} */
|
|
93
|
+
const entry = { name }
|
|
94
|
+
if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
|
|
95
|
+
if (check.exitCode !== undefined && check.exitCode !== null) {
|
|
96
|
+
if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
|
|
97
|
+
entry.exitCode = check.exitCode
|
|
98
|
+
}
|
|
99
|
+
if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
|
|
100
|
+
return entry
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve and validate the reporting worker and the mission's completion
|
|
106
|
+
* preconditions that need no repository access.
|
|
107
|
+
* @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any, maxReportBytes?: number }} params
|
|
108
|
+
* @returns {RosterEntry}
|
|
109
|
+
*/
|
|
110
|
+
export function assertCompletionRequest({ mission, roster, input, maxReportBytes }) {
|
|
111
|
+
const worker = (roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
112
|
+
if (!worker || worker.missionId !== mission.id) {
|
|
113
|
+
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
114
|
+
}
|
|
115
|
+
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
116
|
+
if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
|
|
117
|
+
fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
|
|
118
|
+
}
|
|
119
|
+
if (mission.owner && mission.owner !== worker.name) {
|
|
120
|
+
fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
|
|
121
|
+
}
|
|
122
|
+
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
123
|
+
if (!['active', 'planned'].includes(mission.status)) {
|
|
124
|
+
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
125
|
+
}
|
|
126
|
+
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
127
|
+
fail('tower_complete requires a non-empty report')
|
|
128
|
+
}
|
|
129
|
+
assertReportSize(input.report.trim(), maxReportBytes)
|
|
130
|
+
const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
131
|
+
if (openTasks.length > 0) {
|
|
132
|
+
fail(
|
|
133
|
+
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
134
|
+
.map((task) => task.text)
|
|
135
|
+
.join('; ')}`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
const blockers = mission.blockers ?? []
|
|
139
|
+
if (blockers.length > 0) {
|
|
140
|
+
fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}`)
|
|
141
|
+
}
|
|
142
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
143
|
+
return worker
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Unread inbox messages block completion.
|
|
148
|
+
* @param {{ mission: Mission, worker: RosterEntry, unread: unknown[] }} params
|
|
149
|
+
* @returns {void}
|
|
150
|
+
*/
|
|
151
|
+
export function assertCompletionInbox({ mission, worker, unread }) {
|
|
152
|
+
if (unread.length > 0) {
|
|
153
|
+
fail(
|
|
154
|
+
`mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Project the completion report stored on the mission.
|
|
161
|
+
* @param {{ report: string, worker: RosterEntry, checks: any[], now: number, maxReportBytes?: number }} params
|
|
162
|
+
* @returns {any}
|
|
163
|
+
*/
|
|
164
|
+
export function completionReport({ report, worker, checks, now, maxReportBytes }) {
|
|
165
|
+
const text = report.trim()
|
|
166
|
+
return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
|
|
167
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Pure finding normalization, filtering, and transition policy.
|
|
2
|
+
//
|
|
3
|
+
// app/findings.js owns loading state, the writer lock, sequence assignment,
|
|
4
|
+
// persistence, and the activity entry.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
import { FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS, FINDING_TYPES } from '../mission.js'
|
|
8
|
+
import { countFindings, findingSummary } from '../summary.js'
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
11
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {string} [code]
|
|
16
|
+
* @returns {never}
|
|
17
|
+
*/
|
|
18
|
+
function fail(message, code = 'invalid-argument') {
|
|
19
|
+
throw new TowerToolError(code, message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isNonEmpty(value) {
|
|
27
|
+
return value !== undefined && value !== null && value !== ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate and project the user-supplied fields of a new finding.
|
|
32
|
+
* @param {any} input
|
|
33
|
+
* @param {Mission[]} missions
|
|
34
|
+
* @returns {any}
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeFindingInput(input, missions) {
|
|
37
|
+
if (!FINDING_TYPES.includes(input.type)) {
|
|
38
|
+
fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
39
|
+
}
|
|
40
|
+
const title = String(input.title ?? '').trim()
|
|
41
|
+
if (!title) fail('finding title is required')
|
|
42
|
+
const summary = String(input.summary ?? '').trim()
|
|
43
|
+
if (!summary) fail('finding summary is required')
|
|
44
|
+
const details = String(input.details ?? '').trim()
|
|
45
|
+
if (!details) fail('finding details are required')
|
|
46
|
+
const suggestedFix = String(input.suggested_fix ?? '').trim()
|
|
47
|
+
if (!suggestedFix) fail('finding suggested_fix is required')
|
|
48
|
+
const severity = input.severity ?? null
|
|
49
|
+
if (severity !== null && !FINDING_SEVERITIES.includes(severity)) {
|
|
50
|
+
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
51
|
+
}
|
|
52
|
+
let mission = null
|
|
53
|
+
if (isNonEmpty(input.missionId)) {
|
|
54
|
+
mission = missions.find((entry) => entry.id === input.missionId) ?? null
|
|
55
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
type: input.type,
|
|
59
|
+
title,
|
|
60
|
+
severity,
|
|
61
|
+
summary,
|
|
62
|
+
location: typeof input.location === 'string' && input.location.trim() ? input.location.trim() : null,
|
|
63
|
+
details,
|
|
64
|
+
suggested_fix: suggestedFix,
|
|
65
|
+
missionId: mission ? mission.id : null,
|
|
66
|
+
branch: mission && mission.worktree ? mission.worktree.branch : null,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Project the complete finding record that gets persisted.
|
|
72
|
+
* @param {any} fields
|
|
73
|
+
* @param {{ seq: number, now: number, reportedBy: unknown }} params
|
|
74
|
+
* @returns {Finding}
|
|
75
|
+
*/
|
|
76
|
+
export function newFinding(fields, { seq, now, reportedBy }) {
|
|
77
|
+
return {
|
|
78
|
+
id: `F${seq}`,
|
|
79
|
+
seq,
|
|
80
|
+
...fields,
|
|
81
|
+
reportedBy,
|
|
82
|
+
at: now,
|
|
83
|
+
status: 'open',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {any} actor
|
|
89
|
+
* @returns {{ name: string, role: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeFindingActor(actor) {
|
|
92
|
+
const resolved = actor ?? { name: 'tower', role: 'tower' }
|
|
93
|
+
if (typeof resolved.role !== 'string') fail('finding actor role must be a string')
|
|
94
|
+
return resolved
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {any} input
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function wantsFindingUpdate(input) {
|
|
102
|
+
const hasId = isNonEmpty(input.id)
|
|
103
|
+
return hasId && (input.status !== undefined || input.note !== undefined)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Pure read/list projection. Throws on an unknown id or an invalid filter.
|
|
108
|
+
* @param {Finding[]} findings
|
|
109
|
+
* @param {any} input
|
|
110
|
+
* @returns {any}
|
|
111
|
+
*/
|
|
112
|
+
export function readFindingsView(findings, input) {
|
|
113
|
+
const counts = countFindings(findings)
|
|
114
|
+
if (isNonEmpty(input.id)) {
|
|
115
|
+
const finding = findings.find((entry) => entry.id === input.id)
|
|
116
|
+
if (!finding) fail(`unknown finding "${input.id}"`, 'finding-not-found')
|
|
117
|
+
return { finding: { ...finding }, counts, total: findings.length, filtered: 1, action: 'read' }
|
|
118
|
+
}
|
|
119
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
120
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
121
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let visible = findings
|
|
125
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
126
|
+
visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
|
|
127
|
+
}
|
|
128
|
+
if (input.type !== undefined) {
|
|
129
|
+
if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
130
|
+
visible = visible.filter((finding) => finding.type === input.type)
|
|
131
|
+
}
|
|
132
|
+
if (input.severity !== undefined) {
|
|
133
|
+
if (!FINDING_SEVERITIES.includes(input.severity)) {
|
|
134
|
+
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
135
|
+
}
|
|
136
|
+
visible = visible.filter((finding) => finding.severity === input.severity)
|
|
137
|
+
}
|
|
138
|
+
if (isNonEmpty(input.missionId)) {
|
|
139
|
+
visible = visible.filter((finding) => finding.missionId === input.missionId)
|
|
140
|
+
}
|
|
141
|
+
if (input.open === true) {
|
|
142
|
+
visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
143
|
+
}
|
|
144
|
+
const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
|
|
145
|
+
const page = visible.slice(-limit).reverse()
|
|
146
|
+
return {
|
|
147
|
+
findings: page.map((finding) => findingSummary(finding)),
|
|
148
|
+
counts,
|
|
149
|
+
total: findings.length,
|
|
150
|
+
filtered: visible.length,
|
|
151
|
+
action: 'list',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Apply one finding lifecycle transition to the draft finding.
|
|
157
|
+
* @param {{ finding: Finding, input: any, actor: { name: string, role: string }, now: number }} params
|
|
158
|
+
* @returns {{ noteAdded: boolean }}
|
|
159
|
+
*/
|
|
160
|
+
export function applyFindingUpdate({ finding, input, actor, now }) {
|
|
161
|
+
if (actor.role === 'reviewer') {
|
|
162
|
+
fail('reviewers may list and read findings, but only the tower or a worker may change finding status', 'not-authorized')
|
|
163
|
+
}
|
|
164
|
+
let noteAdded = false
|
|
165
|
+
if (input.status !== undefined) {
|
|
166
|
+
if (input.status === 'all') {
|
|
167
|
+
fail('finding status "all" is only a list filter; pass a concrete status when updating')
|
|
168
|
+
}
|
|
169
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
170
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
171
|
+
}
|
|
172
|
+
const current = finding.status ?? 'open'
|
|
173
|
+
const allowed = FINDING_TRANSITIONS[current] ?? []
|
|
174
|
+
if (!allowed.includes(input.status)) {
|
|
175
|
+
fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`, 'invalid-transition')
|
|
176
|
+
}
|
|
177
|
+
finding.status = input.status
|
|
178
|
+
if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
|
|
179
|
+
else delete finding.closedAt
|
|
180
|
+
}
|
|
181
|
+
if (input.note !== undefined) {
|
|
182
|
+
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
183
|
+
fail('finding note must be a non-empty string')
|
|
184
|
+
}
|
|
185
|
+
const record = /** @type {any} */ (finding)
|
|
186
|
+
if (!Array.isArray(record.notes)) record.notes = []
|
|
187
|
+
record.notes.push({ by: actor.name, at: now, text: input.note.trim() })
|
|
188
|
+
noteAdded = true
|
|
189
|
+
}
|
|
190
|
+
finding.updatedAt = now
|
|
191
|
+
return { noteAdded }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {Finding} finding
|
|
196
|
+
* @param {Finding[]} all
|
|
197
|
+
* @returns {any}
|
|
198
|
+
*/
|
|
199
|
+
export function findingUpdateView(finding, all) {
|
|
200
|
+
return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
|
|
201
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Pure mission lifecycle transition policy.
|
|
2
|
+
//
|
|
3
|
+
// app/mission.js supplies the loaded mission/state and persists the result;
|
|
4
|
+
// this module owns the authorization and transition rules and mutates only the
|
|
5
|
+
// in-memory mission draft it is given.
|
|
6
|
+
// @ts-check
|
|
7
|
+
import { TowerToolError } from '../errors.js'
|
|
8
|
+
import { MISSION_STATUSES, TERMINAL_STATUSES, canTransition, pauseCauseError } from '../mission.js'
|
|
9
|
+
import { normalizeScope, scopesOverlap } from '../scope.js'
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
12
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
13
|
+
|
|
14
|
+
const TERMINAL = TERMINAL_STATUSES
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} message
|
|
18
|
+
* @param {string} [code]
|
|
19
|
+
* @returns {never}
|
|
20
|
+
*/
|
|
21
|
+
function fail(message, code = 'invalid-argument') {
|
|
22
|
+
throw new TowerToolError(code, message)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizePauseCause(value) {
|
|
30
|
+
const error = pauseCauseError(/** @type {string | null | undefined} */ (value))
|
|
31
|
+
if (error) fail(error)
|
|
32
|
+
return value === undefined || value === null || value === '' ? 'user' : /** @type {string} */ (value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reject actor/input combinations a worker may not perform. The tower owns
|
|
37
|
+
* status, scope, blockers, and pause metadata; a worker only reports work.
|
|
38
|
+
* @param {Mission} mission
|
|
39
|
+
* @param {{ name?: string, role?: string }} actor
|
|
40
|
+
* @param {any} input
|
|
41
|
+
* @returns {void}
|
|
42
|
+
*/
|
|
43
|
+
export function assertMissionActor(mission, actor, input) {
|
|
44
|
+
const workerRequest = actor.role === 'worker'
|
|
45
|
+
if (actor.role === 'reviewer') {
|
|
46
|
+
fail('only the tower or a worker may update a mission; reviewers submit tower_review', 'not-authorized')
|
|
47
|
+
}
|
|
48
|
+
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
49
|
+
fail(`worker ${actor.name} does not own mission ${mission.id} (owner: ${mission.owner})`, 'not-authorized')
|
|
50
|
+
}
|
|
51
|
+
if (workerRequest) {
|
|
52
|
+
if (input.status !== undefined) {
|
|
53
|
+
fail('only the tower can change mission status directly; workers report completion with tower_complete', 'not-authorized')
|
|
54
|
+
}
|
|
55
|
+
if (input.scope !== undefined) fail('only the tower can change mission scope', 'not-authorized')
|
|
56
|
+
if (input.clear_blockers === true) fail('only the tower can clear blockers', 'not-authorized')
|
|
57
|
+
if (input.pause_cause !== undefined || input.authorization !== undefined) {
|
|
58
|
+
fail('only the tower can record pause/authorization metadata', 'not-authorized')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply one validated `tower_mission` patch to the mission draft and return
|
|
65
|
+
* the human-readable change list used by the activity log.
|
|
66
|
+
* @param {{ mission: Mission, state: TowerState, input: any, config: any }} params
|
|
67
|
+
* @returns {{ changes: string[] }}
|
|
68
|
+
*/
|
|
69
|
+
export function applyMissionUpdate({ mission, state, input, config }) {
|
|
70
|
+
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
71
|
+
assertMissionActor(mission, actor, input)
|
|
72
|
+
if (input.pause_cause !== undefined && input.status !== 'paused') {
|
|
73
|
+
fail('pause_cause is only valid together with status "paused"')
|
|
74
|
+
}
|
|
75
|
+
if (input.status === 'abandoned' && (typeof input.note !== 'string' || input.note.trim().length === 0)) {
|
|
76
|
+
fail('abandoning a mission requires a non-empty note (reason)')
|
|
77
|
+
}
|
|
78
|
+
/** @type {string[]} */
|
|
79
|
+
const changes = []
|
|
80
|
+
|
|
81
|
+
if (input.status !== undefined) {
|
|
82
|
+
if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
|
|
83
|
+
fail(`unknown mission status "${input.status}"`)
|
|
84
|
+
}
|
|
85
|
+
if (input.status === 'merged') {
|
|
86
|
+
fail('merged is recorded only by tower_merge, not by a generic mission patch', 'invalid-transition')
|
|
87
|
+
}
|
|
88
|
+
if (!canTransition(mission.status, input.status)) {
|
|
89
|
+
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`, 'invalid-transition')
|
|
90
|
+
}
|
|
91
|
+
if (input.status === 'paused') {
|
|
92
|
+
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
93
|
+
}
|
|
94
|
+
if (mission.status === 'paused' && input.status === 'active') {
|
|
95
|
+
const requireAuthorization = config.mode.requireResumeAuthorization === true
|
|
96
|
+
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
97
|
+
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
98
|
+
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
102
|
+
mission.authorizationRef = input.authorization.trim()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
mission.status = input.status
|
|
106
|
+
changes.push(`status=${input.status}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (input.task_done !== undefined) {
|
|
110
|
+
const needle = String(input.task_done)
|
|
111
|
+
const tasks = /** @type {any[]} */ (mission.tasks)
|
|
112
|
+
const task = tasks.find(
|
|
113
|
+
(entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
|
|
114
|
+
)
|
|
115
|
+
if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
|
|
116
|
+
task.status = 'done'
|
|
117
|
+
changes.push(`task_done="${task.text}"`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (input.task_drop !== undefined) {
|
|
121
|
+
if (typeof input.task_drop_reason !== 'string' || input.task_drop_reason.trim().length === 0) {
|
|
122
|
+
fail('task_drop requires task_drop_reason')
|
|
123
|
+
}
|
|
124
|
+
const needle = String(input.task_drop)
|
|
125
|
+
const tasks = /** @type {any[]} */ (mission.tasks)
|
|
126
|
+
const task = tasks.find(
|
|
127
|
+
(entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
|
|
128
|
+
)
|
|
129
|
+
if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
|
|
130
|
+
task.status = 'dropped'
|
|
131
|
+
task.note = input.task_drop_reason.trim()
|
|
132
|
+
const notes = /** @type {string[]} */ (mission.notes)
|
|
133
|
+
notes.push(`task dropped: ${task.text} — ${task.note}`)
|
|
134
|
+
changes.push(`task_drop="${task.text}"`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (input.blocker !== undefined) {
|
|
138
|
+
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
139
|
+
fail('blocker must be a non-empty string')
|
|
140
|
+
}
|
|
141
|
+
if (TERMINAL.has(mission.status)) {
|
|
142
|
+
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
143
|
+
}
|
|
144
|
+
const blockers = /** @type {string[]} */ (mission.blockers)
|
|
145
|
+
blockers.push(input.blocker.trim())
|
|
146
|
+
mission.status = 'blocked'
|
|
147
|
+
changes.push('blocker added')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (input.clear_blockers === true) {
|
|
151
|
+
mission.blockers = []
|
|
152
|
+
changes.push('blockers cleared')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (input.note !== undefined) {
|
|
156
|
+
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
157
|
+
fail('note must be a non-empty string')
|
|
158
|
+
}
|
|
159
|
+
const notes = /** @type {string[]} */ (mission.notes)
|
|
160
|
+
notes.push(input.note.trim())
|
|
161
|
+
changes.push('note added')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (input.scope !== undefined) {
|
|
165
|
+
if (!['active', 'completed'].includes(mission.status)) {
|
|
166
|
+
fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
|
|
167
|
+
}
|
|
168
|
+
const scope = normalizeScope(input.scope, { allowEmpty: mission.kind === 'survey' })
|
|
169
|
+
const others = state.missions.filter(
|
|
170
|
+
(entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
|
|
171
|
+
)
|
|
172
|
+
const caseInsensitive = state.caseInsensitive === true
|
|
173
|
+
for (const other of others) {
|
|
174
|
+
if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope, { caseInsensitive })) {
|
|
175
|
+
fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
mission.scope = scope
|
|
179
|
+
mission.scopeGeneration = (mission.scopeGeneration ?? 1) + 1
|
|
180
|
+
mission.status = 'active'
|
|
181
|
+
changes.push('scope widened')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (changes.length === 0) fail('tower_mission requires at least one change')
|
|
185
|
+
return { changes }
|
|
186
|
+
}
|