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/app/plan.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Mission planning use case.
|
|
2
|
+
import { nextMissionNumber, addAutoDeps, assertAcyclic } from '../domain/planning.js'
|
|
3
|
+
import {
|
|
4
|
+
normalizePlanMissions,
|
|
5
|
+
assignMissionIds,
|
|
6
|
+
assertKnownDependencies,
|
|
7
|
+
newMission,
|
|
8
|
+
} from '../domain/missions/plan.js'
|
|
9
|
+
import { validateState } from '../domain/state.js'
|
|
10
|
+
import { fullMission } from '../domain/summary.js'
|
|
11
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
12
|
+
|
|
13
|
+
export async function plan(tower, input = {}) {
|
|
14
|
+
const prepared = normalizePlanMissions(input)
|
|
15
|
+
return withLock(tower.paths, async () => {
|
|
16
|
+
const state = await tower.load()
|
|
17
|
+
const now = Date.now()
|
|
18
|
+
const assigned = assignMissionIds(prepared, nextMissionNumber(state.missions))
|
|
19
|
+
|
|
20
|
+
assertKnownDependencies(assigned, state.missions)
|
|
21
|
+
|
|
22
|
+
addAutoDeps(assigned, state.missions)
|
|
23
|
+
assertAcyclic([...state.missions, ...assigned])
|
|
24
|
+
|
|
25
|
+
const created = assigned.map((mission) => newMission(mission.id, mission, now))
|
|
26
|
+
state.missions.push(...created)
|
|
27
|
+
state.revision += 1
|
|
28
|
+
state.updatedAt = now
|
|
29
|
+
|
|
30
|
+
validateState(state, tower.paths.state)
|
|
31
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
32
|
+
await appendJsonl(tower.paths.activity, {
|
|
33
|
+
at: now,
|
|
34
|
+
type: 'plan',
|
|
35
|
+
text: `created ${created.map((mission) => mission.id).join(', ')}`,
|
|
36
|
+
})
|
|
37
|
+
return {
|
|
38
|
+
revision: state.revision,
|
|
39
|
+
missions: created.map((mission) => fullMission(mission)),
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
package/app/provision.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `tower_provision` use case, split into three phases (#51):
|
|
2
|
+
//
|
|
3
|
+
// prepare — mission/base commit/worktree identity, all lock-free.
|
|
4
|
+
// act — revalidate the prepared revision, then `git worktree add` (and
|
|
5
|
+
// its supporting exclude/canonical reads) under the git mutex.
|
|
6
|
+
// commit — one short state lock revalidates the revision and that the
|
|
7
|
+
// mission still has no worktree, then records it. A rejected
|
|
8
|
+
// commit rolls the created worktree/branch back outside the lock.
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl, shortHash } from '../lib/store.js'
|
|
11
|
+
import { validateState } from '../domain/state.js'
|
|
12
|
+
import { canonicalPath } from '../lib/worktreeOwnership.js'
|
|
13
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
14
|
+
import {
|
|
15
|
+
baseCommitChoice,
|
|
16
|
+
createdWorktreeResult,
|
|
17
|
+
existingWorktreeResult,
|
|
18
|
+
hasMissionId,
|
|
19
|
+
provisionActivityText,
|
|
20
|
+
worktreeBranch,
|
|
21
|
+
worktreePath,
|
|
22
|
+
worktreeRecord,
|
|
23
|
+
worktreeRoot,
|
|
24
|
+
} from '../domain/integration/provision.js'
|
|
25
|
+
|
|
26
|
+
function fail(message, code = 'invalid-argument') {
|
|
27
|
+
throw new TowerToolError(code, message)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Phase 1 of `Tower.provision` (#51). An existing worktree returns here. */
|
|
31
|
+
async function prepareProvision(tower, input) {
|
|
32
|
+
const state = await tower.load()
|
|
33
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
34
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
35
|
+
if (mission.worktree) {
|
|
36
|
+
await tower.assertWorktree(mission.worktree)
|
|
37
|
+
return { early: existingWorktreeResult(mission, mission.worktree) }
|
|
38
|
+
}
|
|
39
|
+
const needsSnapshot = !input.baseCommit && input.includeDirtyBase !== false
|
|
40
|
+
const snapshotCommitValid =
|
|
41
|
+
needsSnapshot && state.snapshotCommit
|
|
42
|
+
? (await tower.tryGit(tower.root, ['cat-file', '-e', `${state.snapshotCommit}^{commit}`])) !== null
|
|
43
|
+
: false
|
|
44
|
+
const choice = baseCommitChoice({
|
|
45
|
+
baseCommit: input.baseCommit,
|
|
46
|
+
includeDirtyBase: input.includeDirtyBase,
|
|
47
|
+
snapshotCommit: state.snapshotCommit,
|
|
48
|
+
snapshotCommitValid,
|
|
49
|
+
})
|
|
50
|
+
const baseCommit = 'commit' in choice ? choice.commit : await tower.revParse(tower.root, state.base)
|
|
51
|
+
const branch = worktreeBranch(mission)
|
|
52
|
+
const root = worktreeRoot({
|
|
53
|
+
worktreeRoot: input.worktreeRoot,
|
|
54
|
+
configuredRoot: tower.config.worktrees.root,
|
|
55
|
+
repoRoot: tower.root,
|
|
56
|
+
commonDirHash: shortHash(tower.commonDir),
|
|
57
|
+
})
|
|
58
|
+
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
59
|
+
tower.assertOutside(root)
|
|
60
|
+
await ensureDir(root)
|
|
61
|
+
const wtPath = worktreePath(root, mission.id)
|
|
62
|
+
if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
63
|
+
if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null) {
|
|
64
|
+
fail(`branch already exists: ${branch}`)
|
|
65
|
+
}
|
|
66
|
+
return { stateRevision: state.revision, missionId: mission.id, baseCommit, branch, wtPath }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
|
|
70
|
+
async function actProvision(tower, prepared) {
|
|
71
|
+
return withGitLock(tower.paths, async () => {
|
|
72
|
+
let fresh
|
|
73
|
+
try {
|
|
74
|
+
fresh = await tower.load()
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { phase: 'stale', detail: String(error?.message ?? error) }
|
|
77
|
+
}
|
|
78
|
+
if (fresh.revision !== prepared.stateRevision) {
|
|
79
|
+
return { phase: 'stale', detail: `state revision changed from ${prepared.stateRevision} to ${fresh.revision}` }
|
|
80
|
+
}
|
|
81
|
+
const mission = fresh.missions.find((entry) => entry.id === prepared.missionId)
|
|
82
|
+
if (!mission) return { phase: 'missing' }
|
|
83
|
+
if (mission.worktree) {
|
|
84
|
+
await tower.assertWorktree(mission.worktree)
|
|
85
|
+
return { phase: 'existing', mission, worktree: mission.worktree }
|
|
86
|
+
}
|
|
87
|
+
if (await exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
|
|
88
|
+
if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${prepared.branch}`])) !== null) {
|
|
89
|
+
fail(`branch already exists: ${prepared.branch}`)
|
|
90
|
+
}
|
|
91
|
+
await tower.git(tower.root, ['worktree', 'add', '-b', prepared.branch, prepared.wtPath, prepared.baseCommit])
|
|
92
|
+
await tower.ensureExcluded(prepared.wtPath)
|
|
93
|
+
const worktree = worktreeRecord({
|
|
94
|
+
path: prepared.wtPath,
|
|
95
|
+
branch: prepared.branch,
|
|
96
|
+
baseCommit: prepared.baseCommit,
|
|
97
|
+
createdAt: Date.now(),
|
|
98
|
+
realPath: await canonicalPath(prepared.wtPath),
|
|
99
|
+
})
|
|
100
|
+
return { phase: 'created', worktree }
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
|
|
105
|
+
async function commitProvision(tower, prepared, acted) {
|
|
106
|
+
return withLock(tower.paths, async () => {
|
|
107
|
+
const state = await tower.load()
|
|
108
|
+
const mission = state.missions.find((entry) => entry.id === prepared.missionId)
|
|
109
|
+
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
110
|
+
// Revision revalidation: the plan was built from prepared.stateRevision.
|
|
111
|
+
if (state.revision !== prepared.stateRevision) return { phase: 'stale', revision: state.revision }
|
|
112
|
+
// Never overwrite a worktree another writer recorded while git ran.
|
|
113
|
+
if (mission.worktree) return { phase: 'existing', mission, worktree: mission.worktree }
|
|
114
|
+
const now = acted.worktree.createdAt
|
|
115
|
+
mission.worktree = acted.worktree
|
|
116
|
+
mission.baseCommit = prepared.baseCommit
|
|
117
|
+
mission.updatedAt = now
|
|
118
|
+
state.revision += 1
|
|
119
|
+
state.updatedAt = now
|
|
120
|
+
try {
|
|
121
|
+
validateState(state, tower.paths.state)
|
|
122
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { phase: 'persist-error', error }
|
|
125
|
+
}
|
|
126
|
+
await appendJsonl(tower.paths.activity, {
|
|
127
|
+
at: now,
|
|
128
|
+
type: 'worktree',
|
|
129
|
+
missionId: prepared.missionId,
|
|
130
|
+
text: provisionActivityText(prepared.branch, prepared.wtPath),
|
|
131
|
+
}).catch(() => {})
|
|
132
|
+
return { phase: 'committed', mission, worktree: acted.worktree }
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Undo a created worktree/branch outside the state lock (git mutex only). */
|
|
137
|
+
async function rollbackProvision(tower, prepared) {
|
|
138
|
+
await withGitLock(tower.paths, async () => {
|
|
139
|
+
await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
|
|
140
|
+
await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** #51 two-phase provision: prepare/act/commit with rollback on rejection. */
|
|
145
|
+
export async function provision(tower, input = {}) {
|
|
146
|
+
const missionId = input.missionId
|
|
147
|
+
if (!hasMissionId(missionId)) {
|
|
148
|
+
fail('tower_spawn requires missionId in this version')
|
|
149
|
+
}
|
|
150
|
+
const prepared = await prepareProvision(tower, input)
|
|
151
|
+
if (prepared.early) return prepared.early
|
|
152
|
+
|
|
153
|
+
const acted = await actProvision(tower, prepared)
|
|
154
|
+
if (acted.phase === 'existing') return existingWorktreeResult(acted.mission, acted.worktree)
|
|
155
|
+
if (acted.phase === 'missing') fail(`unknown mission "${prepared.missionId}"`, 'mission-not-found')
|
|
156
|
+
if (acted.phase === 'stale') {
|
|
157
|
+
throw new TowerToolError(
|
|
158
|
+
'stale-base',
|
|
159
|
+
`tower state changed while the worktree was being prepared (${acted.detail}); no worktree was created — retry`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let committed
|
|
164
|
+
try {
|
|
165
|
+
committed = await commitProvision(tower, prepared, acted)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// The worktree already exists. A commit that cannot even take the state
|
|
168
|
+
// lock must not leave the mission wedged with an unrecorded worktree.
|
|
169
|
+
await rollbackProvision(tower, prepared)
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
if (committed.phase === 'stale') {
|
|
173
|
+
await rollbackProvision(tower, prepared)
|
|
174
|
+
throw new TowerToolError(
|
|
175
|
+
'stale-base',
|
|
176
|
+
`tower state changed while the worktree was being created (${prepared.stateRevision} -> ${committed.revision}); the new worktree was rolled back — retry`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
if (committed.phase === 'existing') {
|
|
180
|
+
// Another writer recorded a worktree for this mission while git ran; the
|
|
181
|
+
// one created here is orphaned, so remove it and report the recorded one.
|
|
182
|
+
await rollbackProvision(tower, prepared)
|
|
183
|
+
await tower.assertWorktree(committed.worktree)
|
|
184
|
+
return existingWorktreeResult(committed.mission, committed.worktree)
|
|
185
|
+
}
|
|
186
|
+
if (committed.phase === 'persist-error') {
|
|
187
|
+
// Preserve the single-phase rollback: a failed state write must not leave
|
|
188
|
+
// an untracked worktree/branch behind.
|
|
189
|
+
await rollbackProvision(tower, prepared)
|
|
190
|
+
throw committed.error
|
|
191
|
+
}
|
|
192
|
+
return createdWorktreeResult(committed.mission, committed.worktree)
|
|
193
|
+
}
|
package/app/read.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Read-only dashboard/detail use cases.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
|
|
4
|
+
import { readActivityTail } from '../lib/store.js'
|
|
5
|
+
|
|
6
|
+
function fail(message, code = 'invalid-argument') {
|
|
7
|
+
throw new TowerToolError(code, message)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function missionDetail(tower, missionId) {
|
|
11
|
+
const state = await tower.load()
|
|
12
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
13
|
+
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
14
|
+
const activity = await readActivityTail(tower.paths.activity, 200)
|
|
15
|
+
return missionDetailView({ state, mission, activity })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function status(tower, input = {}) {
|
|
19
|
+
const state = await tower.load()
|
|
20
|
+
const activity = await readActivityTail(tower.paths.activity, 12)
|
|
21
|
+
return statusView({
|
|
22
|
+
state,
|
|
23
|
+
statePath: tower.paths.state,
|
|
24
|
+
activity,
|
|
25
|
+
unreadTower: tower.unreadFor(state, 'tower').length,
|
|
26
|
+
config: tower.config,
|
|
27
|
+
missions: await tower.enrichedMissions(state, input.missionIds),
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function diff(tower, input = {}) {
|
|
32
|
+
const state = await tower.load()
|
|
33
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
34
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
35
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
|
|
36
|
+
await tower.assertWorktree(mission.worktree)
|
|
37
|
+
const baseTip = await tower.revParse(tower.root, state.base)
|
|
38
|
+
const branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
|
|
39
|
+
const mergeBase = (await tower.git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
40
|
+
const files = (await tower.git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
const commits = (await tower.git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
const fullPatch = await tower.git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
|
|
47
|
+
return diffView({
|
|
48
|
+
mission,
|
|
49
|
+
baseTip,
|
|
50
|
+
branchTip,
|
|
51
|
+
mergeBase,
|
|
52
|
+
files,
|
|
53
|
+
commits,
|
|
54
|
+
fullPatch,
|
|
55
|
+
maxBytes: input.maxBytes,
|
|
56
|
+
})
|
|
57
|
+
}
|
package/app/rebase.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Rebase use case.
|
|
2
|
+
//
|
|
3
|
+
// #51 two-phase: long git work runs outside the state lock.
|
|
4
|
+
// prepare (lock-free reads) -> intent (short state lock) -> act (git lock)
|
|
5
|
+
// -> commit (short state lock with revision revalidation).
|
|
6
|
+
// The durable `state.rebaseIntent` lets a crash between act and commit be
|
|
7
|
+
// reconciled by the next tower_rebase, and `mission.rebase` keeps the
|
|
8
|
+
// reviewed-tip waiver semantics the merge gate depends on.
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
11
|
+
import { validateState } from '../domain/state.js'
|
|
12
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
13
|
+
import { liveAgentFor } from '../domain/integration/liveness.js'
|
|
14
|
+
import {
|
|
15
|
+
alreadyMergedRebaseResult,
|
|
16
|
+
alreadyUpToDateResult,
|
|
17
|
+
ambiguousRebaseIntentMessage,
|
|
18
|
+
buildRebaseIntent,
|
|
19
|
+
classifyRebaseIntent,
|
|
20
|
+
clearedRebaseIntentActivityText,
|
|
21
|
+
dirtyWorktreeResult,
|
|
22
|
+
malformedRebaseIntent,
|
|
23
|
+
rebaseActivityText,
|
|
24
|
+
rebaseConflictNote,
|
|
25
|
+
rebaseConflictResult,
|
|
26
|
+
rebaseReconcileActivityText,
|
|
27
|
+
rebaseReconcileNote,
|
|
28
|
+
rebaseSuccessNote,
|
|
29
|
+
rebaseSuccessResult,
|
|
30
|
+
rebaseTipsMoved,
|
|
31
|
+
rebaseTipsMovedDetail,
|
|
32
|
+
STALE_ACT,
|
|
33
|
+
STALE_PREPARE,
|
|
34
|
+
workerBusyResult,
|
|
35
|
+
} from '../domain/integration/rebase.js'
|
|
36
|
+
|
|
37
|
+
const RECONCILE_ATTEMPTS = 5
|
|
38
|
+
|
|
39
|
+
function fail(message, code = 'invalid-transition') {
|
|
40
|
+
throw new TowerToolError(code, message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stale(message) {
|
|
44
|
+
return new TowerToolError('stale-base', message)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findMission(state, missionId) {
|
|
48
|
+
return state.missions.find((entry) => entry.id === missionId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Phase 1 (#51): every read needed to decide the rebase — roster liveness,
|
|
53
|
+
* worktree ownership, cleanliness, and both tips — happens without any lock.
|
|
54
|
+
* Returns the snapshot the intent and the commit revalidate against.
|
|
55
|
+
*/
|
|
56
|
+
async function prepareRebase(tower, input) {
|
|
57
|
+
const state = await tower.load()
|
|
58
|
+
const mission = findMission(state, input.missionId)
|
|
59
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
60
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
61
|
+
const activity = input.activity ?? {}
|
|
62
|
+
const busy = liveAgentFor(state, mission.id, activity)
|
|
63
|
+
if (busy) {
|
|
64
|
+
return { early: workerBusyResult(busy) }
|
|
65
|
+
}
|
|
66
|
+
await tower.assertWorktree(mission.worktree)
|
|
67
|
+
if (!(await tower.isClean(mission.worktree.path))) {
|
|
68
|
+
return { early: dirtyWorktreeResult(mission.worktree.path) }
|
|
69
|
+
}
|
|
70
|
+
const oldTip = await tower.revParse(mission.worktree.path, 'HEAD')
|
|
71
|
+
const baseTip = await tower.revParse(tower.root, state.base)
|
|
72
|
+
if (await tower.isAncestor(tower.root, oldTip, baseTip)) {
|
|
73
|
+
// The branch is already contained in the base (manual merge, crashed
|
|
74
|
+
// merge, earlier fast-forward). Rewriting the branch tip to the base
|
|
75
|
+
// would destroy the reviewed tip and the ability to record the merge.
|
|
76
|
+
return { early: alreadyMergedRebaseResult({ oldTip, baseTip, base: state.base }) }
|
|
77
|
+
}
|
|
78
|
+
if (await tower.isAncestor(tower.root, baseTip, oldTip)) {
|
|
79
|
+
return { early: alreadyUpToDateResult({ oldTip, baseTip }) }
|
|
80
|
+
}
|
|
81
|
+
// Count the commits a successful rebase will replay. The count is recorded
|
|
82
|
+
// with the intent so reconciliation can prove a crashed rebase finished
|
|
83
|
+
// instead of guessing from a moved tip.
|
|
84
|
+
let commits = null
|
|
85
|
+
try {
|
|
86
|
+
const count = Number.parseInt(String(await tower.git(mission.worktree.path, ['rev-list', '--count', `${baseTip}..${oldTip}`])).trim(), 10)
|
|
87
|
+
commits = Number.isInteger(count) ? count : null
|
|
88
|
+
} catch {
|
|
89
|
+
commits = null
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
stateRevision: state.revision,
|
|
93
|
+
missionId: mission.id,
|
|
94
|
+
worktree: { path: mission.worktree.path, branch: mission.worktree.branch },
|
|
95
|
+
base: state.base,
|
|
96
|
+
oldTip,
|
|
97
|
+
baseTip,
|
|
98
|
+
commits,
|
|
99
|
+
early: null,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inspect a durable intent with lock-free git reads. Never mutates. */
|
|
104
|
+
async function inspectRebaseIntent(tower, state, intent) {
|
|
105
|
+
const mission = findMission(state, intent.missionId)
|
|
106
|
+
const worktreePath =
|
|
107
|
+
mission && mission.worktree && typeof mission.worktree.path === 'string' ? mission.worktree.path : null
|
|
108
|
+
if (!worktreePath) {
|
|
109
|
+
return classifyRebaseIntent({ intent, mission, tipReadable: false, baseIsAncestor: false })
|
|
110
|
+
}
|
|
111
|
+
let tip
|
|
112
|
+
try {
|
|
113
|
+
tip = await tower.revParse(worktreePath, 'HEAD')
|
|
114
|
+
} catch {
|
|
115
|
+
return classifyRebaseIntent({ intent, mission, tipReadable: false, baseIsAncestor: false })
|
|
116
|
+
}
|
|
117
|
+
if (tip === intent.oldTip) {
|
|
118
|
+
return classifyRebaseIntent({ intent, mission, tip, tipReadable: true, baseIsAncestor: true })
|
|
119
|
+
}
|
|
120
|
+
if (!(await tower.isAncestor(tower.root, intent.baseTip, tip))) {
|
|
121
|
+
return classifyRebaseIntent({ intent, mission, tip, tipReadable: true, baseIsAncestor: false })
|
|
122
|
+
}
|
|
123
|
+
let actualCommits = null
|
|
124
|
+
if (intent.commits !== null && intent.commits !== undefined) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = await tower.git(worktreePath, ['rev-list', '--count', `${intent.baseTip}..${tip}`])
|
|
127
|
+
actualCommits = Number.parseInt(String(raw).trim(), 10)
|
|
128
|
+
} catch {
|
|
129
|
+
actualCommits = null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return classifyRebaseIntent({ intent, mission, tip, tipReadable: true, baseIsAncestor: true, actualCommits })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reconcile a rebase intent left behind by a crash between act and commit.
|
|
137
|
+
* The two unambiguous outcomes (the branch never moved, or the rebase landed
|
|
138
|
+
* with the expected commit count) are committed; anything else keeps the
|
|
139
|
+
* marker and refuses rather than guessing.
|
|
140
|
+
*/
|
|
141
|
+
async function reconcileRebaseIntent(tower) {
|
|
142
|
+
for (let attempt = 0; attempt < RECONCILE_ATTEMPTS; attempt += 1) {
|
|
143
|
+
const snapshot = await tower.load()
|
|
144
|
+
const intent = snapshot.rebaseIntent
|
|
145
|
+
if (!intent) return
|
|
146
|
+
if (malformedRebaseIntent(intent)) {
|
|
147
|
+
throw stale('a rebase intent in tower state is malformed; inspect it and retry')
|
|
148
|
+
}
|
|
149
|
+
const inspection = await inspectRebaseIntent(tower, snapshot, intent)
|
|
150
|
+
if (inspection.action === 'ambiguous') {
|
|
151
|
+
throw stale(ambiguousRebaseIntentMessage(intent, inspection.tip))
|
|
152
|
+
}
|
|
153
|
+
const applied = await withLock(tower.paths, async () => {
|
|
154
|
+
const state = await tower.load()
|
|
155
|
+
if (state.revision !== snapshot.revision) return false
|
|
156
|
+
const current = state.rebaseIntent
|
|
157
|
+
if (!current || current.token !== intent.token) return true
|
|
158
|
+
const now = Date.now()
|
|
159
|
+
const mission = findMission(state, intent.missionId)
|
|
160
|
+
delete state.rebaseIntent
|
|
161
|
+
state.revision += 1
|
|
162
|
+
state.updatedAt = now
|
|
163
|
+
if (inspection.action === 'record' && mission) {
|
|
164
|
+
const tip = /** @type {string} */ (inspection.tip)
|
|
165
|
+
mission.rebase = { at: now, oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }
|
|
166
|
+
if (mission.status === 'blocked') mission.status = 'active'
|
|
167
|
+
mission.notes.push(rebaseReconcileNote({ oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }))
|
|
168
|
+
mission.updatedAt = now
|
|
169
|
+
}
|
|
170
|
+
validateState(state, tower.paths.state)
|
|
171
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
172
|
+
await appendJsonl(tower.paths.activity, {
|
|
173
|
+
at: now,
|
|
174
|
+
type: 'rebase',
|
|
175
|
+
missionId: intent.missionId,
|
|
176
|
+
text:
|
|
177
|
+
inspection.action === 'record'
|
|
178
|
+
? rebaseReconcileActivityText({
|
|
179
|
+
oldTip: intent.oldTip,
|
|
180
|
+
newTip: /** @type {string} */ (inspection.tip),
|
|
181
|
+
baseTip: intent.baseTip,
|
|
182
|
+
})
|
|
183
|
+
: clearedRebaseIntentActivityText(inspection.reason),
|
|
184
|
+
})
|
|
185
|
+
return true
|
|
186
|
+
})
|
|
187
|
+
if (applied) return
|
|
188
|
+
}
|
|
189
|
+
throw stale('tower state kept changing while reconciling a pending rebase intent; retry')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function rebase(tower, input) {
|
|
193
|
+
await reconcileRebaseIntent(tower)
|
|
194
|
+
const prepared = await prepareRebase(tower, input)
|
|
195
|
+
if (prepared.early) return prepared.early
|
|
196
|
+
const worktree = /** @type {{ path: string, branch: string }} */ (prepared.worktree)
|
|
197
|
+
|
|
198
|
+
// Record the intent before touching Git: a crash between the rebase and the
|
|
199
|
+
// commit leaves a durable marker instead of an unexplained moved branch.
|
|
200
|
+
const token = crypto.randomUUID()
|
|
201
|
+
const startedAt = Date.now()
|
|
202
|
+
const intentRevision = await withLock(tower.paths, async () => {
|
|
203
|
+
const state = await tower.load()
|
|
204
|
+
if (state.revision !== prepared.stateRevision) throw stale(STALE_PREPARE)
|
|
205
|
+
const mission = findMission(state, prepared.missionId)
|
|
206
|
+
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
207
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
208
|
+
state.rebaseIntent = buildRebaseIntent({
|
|
209
|
+
token,
|
|
210
|
+
missionId: mission.id,
|
|
211
|
+
base: prepared.base,
|
|
212
|
+
oldTip: prepared.oldTip,
|
|
213
|
+
baseTip: prepared.baseTip,
|
|
214
|
+
commits: prepared.commits ?? null,
|
|
215
|
+
startedAt,
|
|
216
|
+
})
|
|
217
|
+
state.revision += 1
|
|
218
|
+
state.updatedAt = startedAt
|
|
219
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
220
|
+
return state.revision
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Phase 2 (#51): the long git work runs under the git lock and never under
|
|
224
|
+
// the state lock. Re-verify the snapshot right before rewriting the branch.
|
|
225
|
+
const outcome = await withGitLock(tower.paths, async () => {
|
|
226
|
+
const currentTip = await tower.revParse(worktree.path, 'HEAD')
|
|
227
|
+
const currentBase = await tower.revParse(tower.root, prepared.base)
|
|
228
|
+
if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
|
|
229
|
+
return {
|
|
230
|
+
phase: 'stale',
|
|
231
|
+
detail: rebaseTipsMovedDetail({
|
|
232
|
+
oldTip: prepared.oldTip,
|
|
233
|
+
baseTip: prepared.baseTip,
|
|
234
|
+
currentTip,
|
|
235
|
+
currentBase,
|
|
236
|
+
}),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
await tower.git(worktree.path, ['rebase', prepared.baseTip])
|
|
241
|
+
} catch (error) {
|
|
242
|
+
await tower.git(worktree.path, ['rebase', '--abort']).catch(() => {})
|
|
243
|
+
return { phase: 'conflict', detail: String(error?.message ?? error) }
|
|
244
|
+
}
|
|
245
|
+
const newTip = await tower.revParse(worktree.path, 'HEAD')
|
|
246
|
+
return { phase: 'done', newTip }
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// Phase 3 (#51): re-read state and revalidate the revision computed by the
|
|
250
|
+
// intent write; the lock only holds the state mutation, never a subprocess.
|
|
251
|
+
return withLock(tower.paths, async () => {
|
|
252
|
+
const state = await tower.load()
|
|
253
|
+
if (state.revision !== intentRevision) throw stale(STALE_ACT)
|
|
254
|
+
const mission = findMission(state, prepared.missionId)
|
|
255
|
+
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
256
|
+
const intent = state.rebaseIntent
|
|
257
|
+
if (!intent || intent.token !== token) throw stale(STALE_ACT)
|
|
258
|
+
const now = Date.now()
|
|
259
|
+
|
|
260
|
+
if (outcome.phase === 'stale') {
|
|
261
|
+
delete state.rebaseIntent
|
|
262
|
+
state.revision += 1
|
|
263
|
+
state.updatedAt = now
|
|
264
|
+
validateState(state, tower.paths.state)
|
|
265
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
266
|
+
throw stale(outcome.detail)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (outcome.phase === 'conflict') {
|
|
270
|
+
delete state.rebaseIntent
|
|
271
|
+
mission.status = 'blocked'
|
|
272
|
+
mission.blockers.push(rebaseConflictNote(prepared.base, prepared.baseTip))
|
|
273
|
+
mission.updatedAt = now
|
|
274
|
+
state.revision += 1
|
|
275
|
+
state.updatedAt = now
|
|
276
|
+
validateState(state, tower.paths.state)
|
|
277
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
278
|
+
await appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
|
|
279
|
+
return rebaseConflictResult(outcome.detail)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const { newTip } = outcome
|
|
283
|
+
delete state.rebaseIntent
|
|
284
|
+
mission.rebase = { at: now, oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }
|
|
285
|
+
if (mission.status === 'blocked') mission.status = 'active'
|
|
286
|
+
mission.notes.push(
|
|
287
|
+
rebaseSuccessNote({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
|
|
288
|
+
)
|
|
289
|
+
mission.updatedAt = now
|
|
290
|
+
state.revision += 1
|
|
291
|
+
state.updatedAt = now
|
|
292
|
+
validateState(state, tower.paths.state)
|
|
293
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
294
|
+
await appendJsonl(tower.paths.activity, {
|
|
295
|
+
at: now,
|
|
296
|
+
type: 'rebase',
|
|
297
|
+
missionId: mission.id,
|
|
298
|
+
text: rebaseActivityText({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
|
|
299
|
+
})
|
|
300
|
+
return rebaseSuccessResult({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip })
|
|
301
|
+
})
|
|
302
|
+
}
|
package/app/recording.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// State recording use cases for test receipts and imported legacy snapshots.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { validateState } from '../domain/state.js'
|
|
4
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
5
|
+
|
|
6
|
+
function fail(message, code = 'invalid-argument') {
|
|
7
|
+
throw new TowerToolError(code, message)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function recordTestRun(tower, input = {}) {
|
|
11
|
+
return withLock(tower.paths, async () => {
|
|
12
|
+
const state = await tower.load()
|
|
13
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
14
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
15
|
+
if (!mission.testRuns) mission.testRuns = []
|
|
16
|
+
const rawReceipt = { ...input.receipt, recordedAt: Date.now() }
|
|
17
|
+
// Keep state bounded even when a repository reports a huge suite.
|
|
18
|
+
const tests = Array.isArray(rawReceipt.tests) ? rawReceipt.tests : []
|
|
19
|
+
const receipt = tests.length > 100
|
|
20
|
+
? { ...rawReceipt, tests: tests.slice(0, 100), testsTotal: rawReceipt.count ?? tests.length, testsTruncated: true }
|
|
21
|
+
: rawReceipt
|
|
22
|
+
mission.testRuns.push(receipt)
|
|
23
|
+
if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
|
|
24
|
+
const MAX_RUNS_BYTES = 512 * 1024
|
|
25
|
+
while (mission.testRuns.length > 1 && Buffer.byteLength(JSON.stringify(mission.testRuns), 'utf8') > MAX_RUNS_BYTES) {
|
|
26
|
+
mission.testRuns.shift()
|
|
27
|
+
}
|
|
28
|
+
state.revision += 1
|
|
29
|
+
state.updatedAt = Date.now()
|
|
30
|
+
validateState(state, tower.paths.state)
|
|
31
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
32
|
+
await appendJsonl(tower.paths.activity, {
|
|
33
|
+
at: Date.now(),
|
|
34
|
+
type: 'test',
|
|
35
|
+
missionId: mission.id,
|
|
36
|
+
text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
|
|
37
|
+
})
|
|
38
|
+
return receipt
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function recordLegacySnapshot(tower, snapshot) {
|
|
43
|
+
return withLock(tower.paths, async () => {
|
|
44
|
+
const state = await tower.load()
|
|
45
|
+
if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
|
|
46
|
+
const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
|
|
47
|
+
if (existing) return { snapshot: existing, duplicate: true }
|
|
48
|
+
state.legacySnapshots.push(snapshot)
|
|
49
|
+
state.revision += 1
|
|
50
|
+
state.updatedAt = Date.now()
|
|
51
|
+
validateState(state, tower.paths.state)
|
|
52
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
53
|
+
return { snapshot, duplicate: false }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function recordLegacyMap(tower, map) {
|
|
58
|
+
return withLock(tower.paths, async () => {
|
|
59
|
+
const state = await tower.load()
|
|
60
|
+
if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
|
|
61
|
+
const existing = state.legacyMaps.find(
|
|
62
|
+
(entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
|
|
63
|
+
)
|
|
64
|
+
if (existing) return { map: existing, duplicate: true }
|
|
65
|
+
state.legacyMaps.push(map)
|
|
66
|
+
state.revision += 1
|
|
67
|
+
state.updatedAt = Date.now()
|
|
68
|
+
validateState(state, tower.paths.state)
|
|
69
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
70
|
+
return { map, duplicate: false }
|
|
71
|
+
})
|
|
72
|
+
}
|