dsh-tower 2.0.4 → 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 +101 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +40 -3
- package/README.md +38 -3
- package/SECURITY.md +27 -0
- 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 +3 -1
- package/docs/architecture/overview.md +111 -12
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +8 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +4 -1
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +1 -1
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +6 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/config.md +4 -1
- package/docs/zh/reference/errors.md +3 -1
- package/docs/zh/reference/tools.md +1 -1
- 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 +63 -20
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +97 -4
- package/lib/git.js +60 -16
- 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 +11 -43
- 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 +54 -137
- package/lib/tests.js +11 -10
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +5 -4
- 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 +203 -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 +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/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,25 @@
|
|
|
1
|
+
// Shared TypeBox helpers for tool input schemas (#45, #56 P3c).
|
|
2
|
+
import { Type } from '@sinclair/typebox'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A string enum that serializes to the same JSON Schema shape as the old
|
|
6
|
+
* inline schemas (type/enum) rather than TypeBox's anyOf union form.
|
|
7
|
+
* @template {string} T
|
|
8
|
+
* @param {readonly T[]} values
|
|
9
|
+
* @param {Record<string, unknown>} [options]
|
|
10
|
+
* @returns {import('@sinclair/typebox').TUnsafe<T>}
|
|
11
|
+
*/
|
|
12
|
+
export function enumOf(values, options = {}) {
|
|
13
|
+
return Type.Unsafe(/** @type {any} */ ({ type: 'string', enum: [...values], ...options }))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Untyped JSON object entries (checks, receiptHashes) must keep their exact
|
|
18
|
+
* `{ type: 'object' }` shape; Type.Object({}) would add a `properties: {}` key.
|
|
19
|
+
* @param {string} [description]
|
|
20
|
+
*/
|
|
21
|
+
export function untypedObject(description) {
|
|
22
|
+
return Type.Unsafe(/** @type {any} */ (description === undefined ? { type: 'object' } : { type: 'object', description }))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { Type }
|
package/domain/errors.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Stable tool error codes.
|
|
2
|
+
//
|
|
3
|
+
// Everything a Tower tool throws reaches the model as
|
|
4
|
+
// [tower:<code>] <original message>
|
|
5
|
+
// so callers (and tests) can branch on the code instead of matching prose.
|
|
6
|
+
// The catalog is documented in docs/reference/errors.md; scripts/check-docs.mjs
|
|
7
|
+
// keeps the two in sync.
|
|
8
|
+
|
|
9
|
+
/** Neutral error type shared by the git adapter and the domain classifier. */
|
|
10
|
+
export class GitError extends Error {
|
|
11
|
+
constructor(args, detail, code, meta = {}) {
|
|
12
|
+
super(`git ${args.join(' ')} failed: ${detail}`)
|
|
13
|
+
this.name = 'GitError'
|
|
14
|
+
this.code = code
|
|
15
|
+
this.timedOut = meta.timedOut === true
|
|
16
|
+
this.bufferOverflow = meta.bufferOverflow === true
|
|
17
|
+
this.stdout = meta.stdout ?? ''
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ERROR_CODES = {
|
|
22
|
+
'invalid-argument': 'A tool argument is missing, malformed, or not allowed.',
|
|
23
|
+
'repo-invalid': 'repoRoot is missing, relative, or not inside a Git repository.',
|
|
24
|
+
'not-initialized': 'Tower is not initialized for this repository; run tower_init.',
|
|
25
|
+
'mission-not-found': 'No mission with that id exists.',
|
|
26
|
+
'finding-not-found': 'No finding with that id exists.',
|
|
27
|
+
'invalid-transition': 'The requested state transition is not allowed.',
|
|
28
|
+
'stale-base': 'The repository state changed while the operation was being prepared; retry.',
|
|
29
|
+
'state-locked': 'Another Tower writer holds the state lock.',
|
|
30
|
+
'state-corrupt': 'The Tower state file is unreadable or invalid.',
|
|
31
|
+
'config-invalid': 'Tower configuration is invalid.',
|
|
32
|
+
'config-unknown-key': 'Tower configuration has an unknown top-level key.',
|
|
33
|
+
'git-failed': 'A git command failed.',
|
|
34
|
+
'git-timeout': 'A git command timed out or exceeded its output buffer.',
|
|
35
|
+
'worktree-dirty': 'The mission worktree has uncommitted changes and cannot complete.',
|
|
36
|
+
'spawn-unavailable': 'The subagents service is unavailable.',
|
|
37
|
+
'provider-missing': 'No subagent provider is registered.',
|
|
38
|
+
'provider-cooldown': 'The provider is throttled; spawn is in a short cooldown.',
|
|
39
|
+
'preset-missing': 'The configured agent preset is not registered.',
|
|
40
|
+
'reviewer-prerequisite': 'The reviewer spawn prerequisite is not met.',
|
|
41
|
+
'not-authorized': 'The calling agent role may not perform this action.',
|
|
42
|
+
'workspace-busy': 'Another Tower session owns this repository workspace lock.',
|
|
43
|
+
'worker-limit': 'The repository reached spawn.maxLiveWorkers live workers.',
|
|
44
|
+
'rate-limited': 'The tower_send per-minute rate limit was reached.',
|
|
45
|
+
'takeover-refused': 'The mission already has a live worker that was not stopped first.',
|
|
46
|
+
'reload-unavailable': 'The DSH HMR service is not available in this profile.',
|
|
47
|
+
'reload-failed': 'The HMR reload failed; check the DSH log and restart if needed.',
|
|
48
|
+
'tests-failed': 'Declared test discovery failed or did not meet its admission requirements.',
|
|
49
|
+
'tests-timeout': 'Test discovery exceeded its time or output budget.',
|
|
50
|
+
'lease-busy': 'Another holder owns the requested advisory lease.',
|
|
51
|
+
'lease-token': 'The lease release token does not match the current holder.',
|
|
52
|
+
'batch-invalid': 'The batch request or its close evidence is invalid.',
|
|
53
|
+
'legacy-invalid': 'The legacy snapshot or mapping is invalid.',
|
|
54
|
+
'tool-internal': 'An unexpected failure; read the message and the activity log.',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
|
|
58
|
+
export function isErrorCode(value) {
|
|
59
|
+
return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const RETRYABLE_CODES = new Set(['state-locked', 'rate-limited', 'provider-cooldown', 'git-timeout', 'workspace-busy', 'merge-intent-pending', 'stale-base'])
|
|
63
|
+
|
|
64
|
+
export class TowerToolError extends Error {
|
|
65
|
+
constructor(code, message, options = {}) {
|
|
66
|
+
super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
|
|
67
|
+
this.name = 'TowerToolError'
|
|
68
|
+
this.code = code
|
|
69
|
+
this.retryable = options.retryable ?? RETRYABLE_CODES.has(code)
|
|
70
|
+
this.nextAction = options.nextAction ?? null
|
|
71
|
+
this.details = options.details ?? null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function originalMessage(error) {
|
|
76
|
+
return String(error?.message ?? error)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Wrap any thrown value into a TowerToolError with a stable code. */
|
|
80
|
+
export function classifyError(error) {
|
|
81
|
+
if (error instanceof TowerToolError) return error
|
|
82
|
+
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
|
83
|
+
return new TowerToolError(error.code, originalMessage(error), {
|
|
84
|
+
cause: error,
|
|
85
|
+
retryable: error.retryable === true,
|
|
86
|
+
nextAction: error.nextAction ?? null,
|
|
87
|
+
details: error.details ?? null,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof GitError || error?.name === 'GitError') {
|
|
91
|
+
const message = originalMessage(error)
|
|
92
|
+
const code = error?.timedOut === true || error?.bufferOverflow === true ? 'git-timeout' : 'git-failed'
|
|
93
|
+
return new TowerToolError(code, message, { cause: error })
|
|
94
|
+
}
|
|
95
|
+
return new TowerToolError('tool-internal', originalMessage(error), { cause: error })
|
|
96
|
+
}
|
package/domain/gate.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Pure completion gate checks (#49). No repository I/O.
|
|
2
|
+
// @ts-check
|
|
3
|
+
import { isTerminal } from './mission.js'
|
|
4
|
+
import { isReviewExempt, reviewExemptionInvalid } from './integration/review.js'
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../types/index.js').Blocker} Blocker */
|
|
7
|
+
/** @typedef {import('../types/index.js').MergeGateOptions} MergeGateOptions */
|
|
8
|
+
/** @typedef {import('../types/index.js').Mission} Mission */
|
|
9
|
+
/** @typedef {import('../types/index.js').Review} Review */
|
|
10
|
+
/** @typedef {import('../types/index.js').TowerConfig} TowerConfig */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Mission} mission
|
|
14
|
+
* @param {TowerConfig} [config]
|
|
15
|
+
* @returns {Blocker[]}
|
|
16
|
+
*/
|
|
17
|
+
export function completionBlockers(mission, config = {}) {
|
|
18
|
+
/** @type {Blocker[]} */
|
|
19
|
+
const blockers = []
|
|
20
|
+
/** @type {(code: string, detail: string) => void} */
|
|
21
|
+
const push = (code, detail) => {
|
|
22
|
+
blockers.push({ code, detail })
|
|
23
|
+
}
|
|
24
|
+
if (isTerminal(mission.status)) {
|
|
25
|
+
push(
|
|
26
|
+
'branch-owned-by-closed-missions',
|
|
27
|
+
`mission ${mission.id} is ${mission.status}; a merge never flips a closed mission — re-plan the work under a new title`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
if (mission.status !== 'completed') {
|
|
31
|
+
push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion is required`)
|
|
32
|
+
}
|
|
33
|
+
const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
34
|
+
if (openTasks.length > 0) {
|
|
35
|
+
push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
36
|
+
}
|
|
37
|
+
const presentBlockers = mission.blockers ?? []
|
|
38
|
+
if (presentBlockers.length > 0) push('blockers-present', presentBlockers.join('; '))
|
|
39
|
+
if (config.gates?.requireChecks === true) {
|
|
40
|
+
const checks = mission.completionReport?.checks ?? []
|
|
41
|
+
if (checks.length === 0) {
|
|
42
|
+
push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return blockers
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Review, roster, and test-evidence blockers; all inputs are snapshots.
|
|
50
|
+
* @param {MergeGateOptions} options
|
|
51
|
+
* @returns {Blocker[]}
|
|
52
|
+
*/
|
|
53
|
+
export function mergeGateBlockers({
|
|
54
|
+
mission,
|
|
55
|
+
roster = [],
|
|
56
|
+
branch = null,
|
|
57
|
+
branchTip = null,
|
|
58
|
+
review = null,
|
|
59
|
+
config = {},
|
|
60
|
+
activity = {},
|
|
61
|
+
parentStatus = null,
|
|
62
|
+
inactiveStates = [],
|
|
63
|
+
rebaseWaived = false,
|
|
64
|
+
}) {
|
|
65
|
+
/** @type {Blocker[]} */
|
|
66
|
+
const blockers = []
|
|
67
|
+
/** @type {(code: string, detail: string) => void} */
|
|
68
|
+
const push = (code, detail) => {
|
|
69
|
+
blockers.push({ code, detail })
|
|
70
|
+
}
|
|
71
|
+
const inactive = new Set(inactiveStates)
|
|
72
|
+
const requireReview = config.review?.requireReview !== false
|
|
73
|
+
const exempt = isReviewExempt(mission, config)
|
|
74
|
+
if (reviewExemptionInvalid(mission, config)) {
|
|
75
|
+
push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
|
|
76
|
+
}
|
|
77
|
+
if (requireReview && !exempt && !review) {
|
|
78
|
+
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
|
|
79
|
+
} else if (requireReview && !exempt) {
|
|
80
|
+
const latest = /** @type {Review} */ (review)
|
|
81
|
+
if (latest.missionId !== mission.id) push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
|
|
82
|
+
if (latest.status !== 'clean') push('not-clean', `latest review round ${latest.round} is "${latest.status}"`)
|
|
83
|
+
if (latest.mergeVerdict === 'hold') {
|
|
84
|
+
push('review-hold', 'latest review recommends hold — a later clean review must supersede it')
|
|
85
|
+
} else if (latest.status === 'clean' && latest.mergeVerdict !== 'merge') {
|
|
86
|
+
push('review-not-approved', `latest clean review recommends "${latest.mergeVerdict}" — only "merge" approves`)
|
|
87
|
+
}
|
|
88
|
+
if (branchTip && latest.reviewedCommit !== branchTip && !rebaseWaived) {
|
|
89
|
+
push('tip-moved', `reviewed ${latest.reviewedCommit.slice(0, 7)} but tip is ${branchTip.slice(0, 7)}; re-review required`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const entry of (roster ?? []).filter((row) => row.missionId === mission.id && !inactive.has(row.state))) {
|
|
93
|
+
// Reserved rows can lack an agent id; indexing with undefined is a miss.
|
|
94
|
+
const agentId = /** @type {string} */ (entry.agentId)
|
|
95
|
+
if (activity[agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
|
|
96
|
+
else if (parentStatus && entry.parentSessionId && parentStatus[entry.parentSessionId] !== 'ok') {
|
|
97
|
+
push('worker-status-unknown', `cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const staleWorkers = (roster ?? []).filter(
|
|
101
|
+
(row) => row.missionId === mission.id && (row.state === 'stale' || (row.stale === true && !inactive.has(row.state))),
|
|
102
|
+
)
|
|
103
|
+
if (staleWorkers.length > 0) {
|
|
104
|
+
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
105
|
+
}
|
|
106
|
+
if (config.gates?.requireTests === true) {
|
|
107
|
+
const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
|
|
108
|
+
if (runs.length === 0) {
|
|
109
|
+
push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return blockers
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A conflict-free rebase after the reviewed tip waives the stale-tip blocker.
|
|
117
|
+
* @param {Mission} mission
|
|
118
|
+
* @param {Review} review
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function rebaseWaived(mission, review) {
|
|
122
|
+
const rebase = mission.rebase
|
|
123
|
+
if (!rebase) return false
|
|
124
|
+
return review.at <= rebase.at && review.reviewedCommit === rebase.oldTip
|
|
125
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Pure preflight/gate blocker computation from state snapshots plus the Git
|
|
2
|
+
// facts the app already read (tips, changed files, checkout branch). The app
|
|
3
|
+
// performs every read and owns the result assembly.
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { fileInScope } from '../scope.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Blocker} Blocker */
|
|
8
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
9
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} missionId
|
|
13
|
+
* @returns {object}
|
|
14
|
+
*/
|
|
15
|
+
export function missingMissionGateResult(missionId) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
blockers: [{ code: 'mission-missing', detail: `unknown mission "${missionId}"` }],
|
|
19
|
+
missionId,
|
|
20
|
+
branchTip: null,
|
|
21
|
+
baseTip: null,
|
|
22
|
+
changedFiles: [],
|
|
23
|
+
review: null,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ missionId: string, startedAt?: number }} mergeIntent
|
|
29
|
+
* @returns {Blocker}
|
|
30
|
+
*/
|
|
31
|
+
export function mergeIntentPendingBlocker(mergeIntent) {
|
|
32
|
+
return {
|
|
33
|
+
code: 'merge-intent-pending',
|
|
34
|
+
detail: `a merge intent for ${mergeIntent.missionId} was recorded at ${new Date(Number(mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} missionId
|
|
40
|
+
* @returns {Blocker}
|
|
41
|
+
*/
|
|
42
|
+
export function noWorktreeBlocker(missionId) {
|
|
43
|
+
return { code: 'branch-missing', detail: `mission ${missionId} has no worktree or branch` }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} input
|
|
48
|
+
* @param {string} input.branch
|
|
49
|
+
* @param {string} input.base
|
|
50
|
+
* @param {string} input.baseTip
|
|
51
|
+
* @returns {Blocker}
|
|
52
|
+
*/
|
|
53
|
+
export function staleBaseBlocker({ branch, base, baseTip }) {
|
|
54
|
+
return {
|
|
55
|
+
code: 'stale-base',
|
|
56
|
+
detail: `${branch} does not contain base "${base}" tip ${baseTip.slice(0, 12)}; run tower_rebase first`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} input
|
|
62
|
+
* @param {string} input.missionId
|
|
63
|
+
* @param {string[]} input.changedFiles
|
|
64
|
+
* @returns {Blocker | null}
|
|
65
|
+
*/
|
|
66
|
+
export function surveyChangedFilesBlocker({ missionId, changedFiles }) {
|
|
67
|
+
return changedFiles.length > 0
|
|
68
|
+
? {
|
|
69
|
+
code: 'read-only-survey',
|
|
70
|
+
detail: `survey ${missionId} is read-only but ${changedFiles.length} file(s) changed: ${changedFiles.slice(0, 5).join(', ')}`,
|
|
71
|
+
}
|
|
72
|
+
: null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} input
|
|
77
|
+
* @param {Mission} input.mission
|
|
78
|
+
* @param {string[]} input.changedFiles
|
|
79
|
+
* @returns {Blocker | null}
|
|
80
|
+
*/
|
|
81
|
+
export function outOfScopeBlocker({ mission, changedFiles }) {
|
|
82
|
+
const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file))
|
|
83
|
+
return outOfScope.length > 0
|
|
84
|
+
? {
|
|
85
|
+
code: 'out-of-scope',
|
|
86
|
+
detail: `changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`,
|
|
87
|
+
}
|
|
88
|
+
: null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string[]} changedFiles
|
|
93
|
+
* @param {string[]} dirty
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function overwrittenPaths(changedFiles, dirty) {
|
|
97
|
+
return changedFiles.filter((file) => dirty.includes(file))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string[]} wouldOverwrite
|
|
102
|
+
* @returns {Blocker | null}
|
|
103
|
+
*/
|
|
104
|
+
export function baseDirtyBlocker(wouldOverwrite) {
|
|
105
|
+
return wouldOverwrite.length > 0
|
|
106
|
+
? {
|
|
107
|
+
code: 'base-dirty',
|
|
108
|
+
detail: `main checkout has uncommitted changes this merge would overwrite: ${wouldOverwrite.slice(0, 8).join(', ')}`,
|
|
109
|
+
}
|
|
110
|
+
: null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {object} input
|
|
115
|
+
* @param {string[]} input.deps
|
|
116
|
+
* @param {TowerState} input.state
|
|
117
|
+
* @returns {Blocker[]}
|
|
118
|
+
*/
|
|
119
|
+
export function dependencyBlockers({ deps = [], state }) {
|
|
120
|
+
/** @type {Blocker[]} */
|
|
121
|
+
const blockers = []
|
|
122
|
+
for (const depId of deps) {
|
|
123
|
+
const dep = state.missions.find((entry) => entry.id === depId)
|
|
124
|
+
if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
|
|
125
|
+
blockers.push({ code: 'deps-unmerged', detail: `${depId} is ${dep ? dep.status : 'missing'}` })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return blockers
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {object} input
|
|
133
|
+
* @param {string | null} input.checkoutBranch
|
|
134
|
+
* @param {string} input.base
|
|
135
|
+
* @returns {Blocker | null}
|
|
136
|
+
*/
|
|
137
|
+
export function baseWrongBranchBlocker({ checkoutBranch, base }) {
|
|
138
|
+
return checkoutBranch !== base
|
|
139
|
+
? { code: 'base-wrong-branch', detail: `main checkout is on ${checkoutBranch ?? 'detached HEAD'}, expected "${base}"` }
|
|
140
|
+
: null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Append every blocker produced by a decision helper.
|
|
145
|
+
* @param {Blocker[]} blockers
|
|
146
|
+
* @param {Blocker | null} blocker
|
|
147
|
+
* @returns {Blocker[]}
|
|
148
|
+
*/
|
|
149
|
+
export function pushBlocker(blockers, blocker) {
|
|
150
|
+
if (blocker) blockers.push(blocker)
|
|
151
|
+
return blockers
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {object} input
|
|
156
|
+
* @param {object} input.mission full mission projection
|
|
157
|
+
* @param {object | null} input.worktree
|
|
158
|
+
* @param {string | null} input.branchTip
|
|
159
|
+
* @param {string | null} input.baseTip
|
|
160
|
+
* @param {string[]} input.changedFiles
|
|
161
|
+
* @param {object | null} input.review
|
|
162
|
+
* @param {Blocker[]} input.blockers
|
|
163
|
+
* @returns {object}
|
|
164
|
+
*/
|
|
165
|
+
export function gateResult({ mission, worktree, branchTip, baseTip, changedFiles, review, blockers }) {
|
|
166
|
+
return {
|
|
167
|
+
ok: blockers.length === 0,
|
|
168
|
+
blockers,
|
|
169
|
+
mission,
|
|
170
|
+
worktree,
|
|
171
|
+
branchTip,
|
|
172
|
+
baseTip,
|
|
173
|
+
changedFiles,
|
|
174
|
+
review,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Pure init decisions: explicit/fallback base selection, ownership and reanchor
|
|
2
|
+
// policy, snapshot decisions, and the initial/existing result projections. All
|
|
3
|
+
// fs/git work and state persistence stay in app/init.js.
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { SCHEMA_VERSION, TERMINAL_STATUSES } from '../mission.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
9
|
+
|
|
10
|
+
export const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} commit
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function snapshotRefFor(commit) {
|
|
17
|
+
return `${SNAPSHOT_REF_PREFIX}${commit}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {string | null}
|
|
23
|
+
*/
|
|
24
|
+
export function explicitBase(value) {
|
|
25
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An explicit base must be a local branch name, never a raw ref or remote ref.
|
|
30
|
+
* @param {string} base
|
|
31
|
+
* @returns {string | null} the failure message, or null when valid
|
|
32
|
+
*/
|
|
33
|
+
export function invalidBaseMessage(base) {
|
|
34
|
+
return /^refs\//.test(base) || base.startsWith('origin/') ? `base must be a local branch name, not ${base}` : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Without an explicit base, use the checked-out branch and fall back to `main`
|
|
39
|
+
* only for an unborn repository.
|
|
40
|
+
* @param {object} input
|
|
41
|
+
* @param {string | null} [input.branch]
|
|
42
|
+
* @param {boolean} input.committed
|
|
43
|
+
* @returns {{ base: string } | { error: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function fallbackBase({ branch, committed }) {
|
|
46
|
+
let base = branch
|
|
47
|
+
if (!base && !committed) base = 'main'
|
|
48
|
+
if (!base) return { error: 'repository is in detached HEAD; pass an explicit local base branch' }
|
|
49
|
+
return { base }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string | null} owner
|
|
54
|
+
* @param {string | null} caller
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function ownedByOther(owner, caller) {
|
|
58
|
+
return Boolean(owner && caller && owner !== caller)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string | null} owner
|
|
63
|
+
* @param {string | null} caller
|
|
64
|
+
* @returns {string | null} the caller that claims an unowned state
|
|
65
|
+
*/
|
|
66
|
+
export function claimCaller(owner, caller) {
|
|
67
|
+
return !owner && caller ? caller : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {Mission[]} missions
|
|
72
|
+
* @returns {string | null} refusal message, or null when reanchor may proceed
|
|
73
|
+
*/
|
|
74
|
+
export function reanchorBlockedMessage(missions) {
|
|
75
|
+
const active = missions.filter((mission) => !TERMINAL_STATUSES.has(mission.status))
|
|
76
|
+
return active.length > 0 ? `cannot reanchor base while ${active.length} mission(s) are not merged or abandoned` : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {object} input
|
|
81
|
+
* @param {boolean} input.reanchor
|
|
82
|
+
* @param {string | null} input.reanchorBase
|
|
83
|
+
* @param {string} input.currentBase
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function shouldReanchor({ reanchor, reanchorBase, currentBase }) {
|
|
87
|
+
return reanchor === true && reanchorBase !== currentBase
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} input
|
|
92
|
+
* @param {boolean} input.snapshotDirty
|
|
93
|
+
* @param {boolean} input.dirty
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function shouldSnapshot({ snapshotDirty, dirty }) {
|
|
97
|
+
return snapshotDirty === true && dirty
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} input
|
|
102
|
+
* @param {TowerState} input.state
|
|
103
|
+
* @param {string} input.statePath
|
|
104
|
+
* @param {boolean} input.dirty
|
|
105
|
+
* @returns {object}
|
|
106
|
+
*/
|
|
107
|
+
export function existingInitResult({ state, statePath, dirty }) {
|
|
108
|
+
return {
|
|
109
|
+
created: false,
|
|
110
|
+
repoRoot: state.repoRoot,
|
|
111
|
+
commonDir: state.commonDir,
|
|
112
|
+
statePath,
|
|
113
|
+
base: state.base,
|
|
114
|
+
revision: state.revision,
|
|
115
|
+
missions: state.missions.length,
|
|
116
|
+
reanchored: false,
|
|
117
|
+
dirty,
|
|
118
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} input
|
|
124
|
+
* @param {string} input.repoRoot
|
|
125
|
+
* @param {string} input.commonDir
|
|
126
|
+
* @param {string} input.base
|
|
127
|
+
* @param {boolean} input.dirty
|
|
128
|
+
* @param {string | null} [input.towerAgentId]
|
|
129
|
+
* @param {string | null} [input.snapshotCommit]
|
|
130
|
+
* @param {string | null} [input.snapshotRef]
|
|
131
|
+
* @param {number} input.now
|
|
132
|
+
* @returns {TowerState}
|
|
133
|
+
*/
|
|
134
|
+
export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId = null, snapshotCommit = null, snapshotRef = null, now }) {
|
|
135
|
+
return {
|
|
136
|
+
schemaVersion: SCHEMA_VERSION,
|
|
137
|
+
repoRoot,
|
|
138
|
+
commonDir,
|
|
139
|
+
base,
|
|
140
|
+
revision: 0,
|
|
141
|
+
createdAt: now,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
dirtyBase: dirty,
|
|
144
|
+
...(towerAgentId ? { towerAgentId: String(towerAgentId) } : {}),
|
|
145
|
+
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
146
|
+
missions: [],
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {object} input
|
|
152
|
+
* @param {string} input.repoRoot
|
|
153
|
+
* @param {string} input.commonDir
|
|
154
|
+
* @param {string} input.statePath
|
|
155
|
+
* @param {string} input.base
|
|
156
|
+
* @param {boolean} input.dirty
|
|
157
|
+
* @param {string | null} input.snapshotCommit
|
|
158
|
+
* @param {string | null} input.snapshotRef
|
|
159
|
+
* @returns {object}
|
|
160
|
+
*/
|
|
161
|
+
export function createdInitResult({ repoRoot, commonDir, statePath, base, dirty, snapshotCommit, snapshotRef }) {
|
|
162
|
+
return {
|
|
163
|
+
created: true,
|
|
164
|
+
repoRoot,
|
|
165
|
+
commonDir,
|
|
166
|
+
statePath,
|
|
167
|
+
base,
|
|
168
|
+
revision: 0,
|
|
169
|
+
missions: 0,
|
|
170
|
+
reanchored: false,
|
|
171
|
+
dirty,
|
|
172
|
+
snapshotCommit,
|
|
173
|
+
snapshotRef,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {object} input
|
|
179
|
+
* @param {TowerState} input.state
|
|
180
|
+
* @param {string} input.statePath
|
|
181
|
+
* @param {boolean} input.dirty
|
|
182
|
+
* @param {boolean} input.reanchored
|
|
183
|
+
* @returns {object}
|
|
184
|
+
*/
|
|
185
|
+
export function existingInitWriteResult({ state, statePath, dirty, reanchored }) {
|
|
186
|
+
return {
|
|
187
|
+
created: false,
|
|
188
|
+
repoRoot: state.repoRoot,
|
|
189
|
+
commonDir: state.commonDir,
|
|
190
|
+
statePath,
|
|
191
|
+
base: state.base,
|
|
192
|
+
revision: state.revision,
|
|
193
|
+
missions: state.missions.length,
|
|
194
|
+
reanchored,
|
|
195
|
+
dirty,
|
|
196
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {object} input
|
|
202
|
+
* @param {string} input.base
|
|
203
|
+
* @param {string | null} [input.snapshotCommit]
|
|
204
|
+
* @param {boolean} input.dirty
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
export function initActivityText({ base, snapshotCommit = null, dirty }) {
|
|
208
|
+
return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} caller
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function ownerClaimActivityText(caller) {
|
|
216
|
+
return `tower owner claimed by ${caller}`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string} base
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
export function reanchorActivityText(base) {
|
|
224
|
+
return `base reanchored to ${base}`
|
|
225
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pure roster-liveness decisions shared by rebase/teardown guards. A live
|
|
2
|
+
// worker is a roster row for the mission that is neither inactive nor finished
|
|
3
|
+
// and whose observed activity is "running".
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { ROSTER_INACTIVE } from '../roster.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
8
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {TowerState} state
|
|
12
|
+
* @param {string} missionId
|
|
13
|
+
* @param {Record<string, string | undefined>} activity
|
|
14
|
+
* @returns {RosterEntry | undefined}
|
|
15
|
+
*/
|
|
16
|
+
export function liveAgentFor(state, missionId, activity) {
|
|
17
|
+
return (state.roster ?? []).find(
|
|
18
|
+
(entry) =>
|
|
19
|
+
entry.missionId === missionId && !ROSTER_INACTIVE.has(entry.state) && activity[String(entry.agentId)] === 'running',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {TowerState} state
|
|
25
|
+
* @param {string} missionId
|
|
26
|
+
* @param {Record<string, string | undefined>} activity
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function hasLiveAgent(state, missionId, activity) {
|
|
30
|
+
return liveAgentFor(state, missionId, activity) !== undefined
|
|
31
|
+
}
|