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/gate.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Merge/preflight gate evaluation.
|
|
2
|
+
import { currentBranch } from '../lib/git.js'
|
|
3
|
+
import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
4
|
+
import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
|
|
5
|
+
import { fullMission } from '../domain/summary.js'
|
|
6
|
+
import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
|
|
7
|
+
import {
|
|
8
|
+
baseDirtyBlocker,
|
|
9
|
+
baseWrongBranchBlocker,
|
|
10
|
+
dependencyBlockers,
|
|
11
|
+
gateResult,
|
|
12
|
+
mergeIntentPendingBlocker,
|
|
13
|
+
missingMissionGateResult,
|
|
14
|
+
noWorktreeBlocker,
|
|
15
|
+
outOfScopeBlocker,
|
|
16
|
+
overwrittenPaths,
|
|
17
|
+
staleBaseBlocker,
|
|
18
|
+
surveyChangedFilesBlocker,
|
|
19
|
+
} from '../domain/integration/gate.js'
|
|
20
|
+
|
|
21
|
+
export async function evaluateGate(tower, input) {
|
|
22
|
+
const state = await tower.load()
|
|
23
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
24
|
+
if (!mission) return missingMissionGateResult(input.missionId)
|
|
25
|
+
const activity = input.activity ?? {}
|
|
26
|
+
const blockers = []
|
|
27
|
+
const push = (code, detail) => blockers.push({ code, detail })
|
|
28
|
+
|
|
29
|
+
for (const blocker of completionBlockers(mission, tower.config)) push(blocker.code, blocker.detail)
|
|
30
|
+
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
31
|
+
const blocker = mergeIntentPendingBlocker(state.mergeIntent)
|
|
32
|
+
push(blocker.code, blocker.detail)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const worktree = mission.worktree ?? null
|
|
36
|
+
let branchTip = null
|
|
37
|
+
let baseTip = null
|
|
38
|
+
let changedFiles = []
|
|
39
|
+
if (!worktree) {
|
|
40
|
+
const blocker = noWorktreeBlocker(mission.id)
|
|
41
|
+
push(blocker.code, blocker.detail)
|
|
42
|
+
} else {
|
|
43
|
+
try {
|
|
44
|
+
await tower.assertWorktree(worktree)
|
|
45
|
+
branchTip = await tower.revParse(worktree.path, 'HEAD')
|
|
46
|
+
baseTip = await tower.revParse(tower.root, state.base)
|
|
47
|
+
const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
48
|
+
changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
if (!(await tower.isAncestor(tower.root, baseTip, branchTip))) {
|
|
52
|
+
const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
|
|
53
|
+
push(blocker.code, blocker.detail)
|
|
54
|
+
}
|
|
55
|
+
if (mission.kind === 'survey') {
|
|
56
|
+
const blocker = surveyChangedFilesBlocker({ missionId: mission.id, changedFiles })
|
|
57
|
+
if (blocker) push(blocker.code, blocker.detail)
|
|
58
|
+
}
|
|
59
|
+
if (mission.kind !== 'survey') {
|
|
60
|
+
const blocker = outOfScopeBlocker({ mission, changedFiles })
|
|
61
|
+
if (blocker) push(blocker.code, blocker.detail)
|
|
62
|
+
}
|
|
63
|
+
const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
|
|
64
|
+
.split('\n')
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map((line) => line.slice(3).trim())
|
|
67
|
+
const blocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
|
|
68
|
+
if (blocker) push(blocker.code, blocker.detail)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
push('branch-missing', String(error?.message ?? error))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state })) {
|
|
75
|
+
push(blocker.code, blocker.detail)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const branch = worktree ? worktree.branch : null
|
|
79
|
+
const latest = latestReview(reviewsForBranch(state.reviews, branch))
|
|
80
|
+
for (const blocker of mergeGateBlockers({
|
|
81
|
+
mission,
|
|
82
|
+
roster: state.roster ?? [],
|
|
83
|
+
branch,
|
|
84
|
+
branchTip,
|
|
85
|
+
review: latest,
|
|
86
|
+
config: tower.config,
|
|
87
|
+
activity,
|
|
88
|
+
parentStatus: input.parentStatus,
|
|
89
|
+
inactiveStates: ROSTER_INACTIVE_STATES,
|
|
90
|
+
rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
|
|
91
|
+
})) push(blocker.code, blocker.detail)
|
|
92
|
+
|
|
93
|
+
const checkoutBranch = await currentBranch(tower.root)
|
|
94
|
+
const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
|
|
95
|
+
if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
|
|
96
|
+
|
|
97
|
+
return gateResult({
|
|
98
|
+
mission: fullMission(mission),
|
|
99
|
+
worktree,
|
|
100
|
+
branchTip,
|
|
101
|
+
baseTip,
|
|
102
|
+
changedFiles,
|
|
103
|
+
review: latest,
|
|
104
|
+
blockers,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read-only mission diff for reviewers: branch changes against the merge base. */
|
package/app/init.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// `tower_init` use case, split into three phases (#51):
|
|
2
|
+
//
|
|
3
|
+
// prepare — dirty/committed/base/snapshot decisions, all lock-free (no state
|
|
4
|
+
// lock and no git lock; git reads only).
|
|
5
|
+
// act — the only Git mutations (stash/update-ref/for-each-ref) run under
|
|
6
|
+
// the repo-scoped git mutex, never under the state lock.
|
|
7
|
+
// commit — one short state lock writes/claims/reanchors, revalidating the
|
|
8
|
+
// state revision captured by prepare.
|
|
9
|
+
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
10
|
+
import { validateState } from '../domain/state.js'
|
|
11
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
12
|
+
import {
|
|
13
|
+
SNAPSHOT_REF_PREFIX,
|
|
14
|
+
claimCaller,
|
|
15
|
+
createdInitResult,
|
|
16
|
+
existingInitResult,
|
|
17
|
+
existingInitWriteResult,
|
|
18
|
+
explicitBase,
|
|
19
|
+
fallbackBase,
|
|
20
|
+
initActivityText,
|
|
21
|
+
initialState,
|
|
22
|
+
invalidBaseMessage,
|
|
23
|
+
ownedByOther,
|
|
24
|
+
ownerClaimActivityText,
|
|
25
|
+
reanchorActivityText,
|
|
26
|
+
reanchorBlockedMessage,
|
|
27
|
+
shouldReanchor,
|
|
28
|
+
shouldSnapshot,
|
|
29
|
+
snapshotRefFor,
|
|
30
|
+
} from '../domain/integration/init.js'
|
|
31
|
+
|
|
32
|
+
function fail(message, code = 'invalid-argument') {
|
|
33
|
+
throw new TowerToolError(code, message)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
|
|
37
|
+
async function prepareInit(tower, input) {
|
|
38
|
+
const dirty = !(await tower.isClean(tower.root))
|
|
39
|
+
const committed = ((await tower.tryGit(tower.root, ['rev-list', '-n', '1', '--all'])) ?? '').trim().length > 0
|
|
40
|
+
|
|
41
|
+
const resolveBase = async () => {
|
|
42
|
+
const explicit = explicitBase(input.base)
|
|
43
|
+
if (explicit) {
|
|
44
|
+
const message = invalidBaseMessage(explicit)
|
|
45
|
+
if (message) fail(message)
|
|
46
|
+
if (committed && (await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${explicit}`])) === null) {
|
|
47
|
+
fail(`base branch "${explicit}" does not exist locally`)
|
|
48
|
+
}
|
|
49
|
+
return explicit
|
|
50
|
+
}
|
|
51
|
+
const fallback = fallbackBase({ branch: tower.branch, committed })
|
|
52
|
+
if ('error' in fallback) fail(fallback.error)
|
|
53
|
+
return /** @type {{ base: string }} */ (fallback).base
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (await exists(tower.paths.state)) {
|
|
57
|
+
const state = await tower.load()
|
|
58
|
+
const owner = state.towerAgentId ? String(state.towerAgentId) : null
|
|
59
|
+
const caller = input.towerAgentId ? String(input.towerAgentId) : null
|
|
60
|
+
if (ownedByOther(owner, caller)) {
|
|
61
|
+
fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
|
|
62
|
+
}
|
|
63
|
+
let reanchorBase = null
|
|
64
|
+
if (input.reanchor === true) {
|
|
65
|
+
const refusal = reanchorBlockedMessage(state.missions)
|
|
66
|
+
if (refusal) fail(refusal, 'invalid-transition')
|
|
67
|
+
reanchorBase = await resolveBase()
|
|
68
|
+
}
|
|
69
|
+
const claim = claimCaller(owner, caller) !== null
|
|
70
|
+
const reanchor = shouldReanchor({ reanchor: input.reanchor, reanchorBase, currentBase: state.base })
|
|
71
|
+
if (!claim && !reanchor) {
|
|
72
|
+
return { early: existingInitResult({ state, statePath: tower.paths.state, dirty }) }
|
|
73
|
+
}
|
|
74
|
+
return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
create: true,
|
|
79
|
+
base: await resolveBase(),
|
|
80
|
+
snapshot: shouldSnapshot({ snapshotDirty: input.snapshotDirty, dirty }),
|
|
81
|
+
dirty,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Phase 2a of `Tower.init` (#51): create the dirty snapshot and its keeping ref
|
|
87
|
+
* under the git mutex. The prepared "no state file yet" decision is
|
|
88
|
+
* revalidated first so a concurrent init cannot leave an unreferenced snapshot.
|
|
89
|
+
*/
|
|
90
|
+
async function actInitCreate(tower, prepared) {
|
|
91
|
+
return withGitLock(tower.paths, async () => {
|
|
92
|
+
if (await exists(tower.paths.state)) return { stale: true }
|
|
93
|
+
if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
|
|
94
|
+
const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
|
|
95
|
+
if (!snapshotCommit) {
|
|
96
|
+
fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
|
|
97
|
+
}
|
|
98
|
+
// A dangling commit can be pruned by git gc; keep a ref so the mission
|
|
99
|
+
// baseline stays reachable for the life of the state.
|
|
100
|
+
const snapshotRef = snapshotRefFor(snapshotCommit)
|
|
101
|
+
await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
|
|
102
|
+
return { snapshotCommit, snapshotRef }
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
|
|
107
|
+
async function actInitReanchor(tower, prepared) {
|
|
108
|
+
return withGitLock(tower.paths, async () => {
|
|
109
|
+
const state = await tower.load()
|
|
110
|
+
if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
|
|
111
|
+
if (state.base === prepared.base) return { deleted: [] }
|
|
112
|
+
const refs =
|
|
113
|
+
(await tower.tryGit(tower.root, ['for-each-ref', '--format=%(refname) %(objectname)', SNAPSHOT_REF_PREFIX])) ?? ''
|
|
114
|
+
const deleted = []
|
|
115
|
+
for (const line of refs.split('\n').map((entry) => entry.trim()).filter(Boolean)) {
|
|
116
|
+
const [ref, sha] = line.split(' ')
|
|
117
|
+
if (!ref) continue
|
|
118
|
+
try {
|
|
119
|
+
await tower.git(tower.root, ['update-ref', '-d', ref])
|
|
120
|
+
deleted.push({ ref, sha })
|
|
121
|
+
} catch {
|
|
122
|
+
// best effort: the single-phase code never failed init on a stale snapshot ref
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { deleted }
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
|
|
130
|
+
async function restoreInitRefs(tower, deleted) {
|
|
131
|
+
const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
|
|
132
|
+
if (entries.length === 0) return
|
|
133
|
+
await withGitLock(tower.paths, async () => {
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
|
|
141
|
+
async function commitInitCreate(tower, prepared, acted, input) {
|
|
142
|
+
return withLock(tower.paths, async () => {
|
|
143
|
+
// Revision revalidation for the create path: prepare saw "absent", so a
|
|
144
|
+
// state file that appeared meanwhile must never be overwritten.
|
|
145
|
+
if (await exists(tower.paths.state)) {
|
|
146
|
+
throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
|
|
147
|
+
}
|
|
148
|
+
const now = Date.now()
|
|
149
|
+
const state = initialState({
|
|
150
|
+
repoRoot: tower.root,
|
|
151
|
+
commonDir: tower.commonDir,
|
|
152
|
+
base: prepared.base,
|
|
153
|
+
dirty: prepared.dirty,
|
|
154
|
+
towerAgentId: input.towerAgentId ? String(input.towerAgentId) : null,
|
|
155
|
+
snapshotCommit: acted.snapshotCommit,
|
|
156
|
+
snapshotRef: acted.snapshotRef,
|
|
157
|
+
now,
|
|
158
|
+
})
|
|
159
|
+
validateState(state, tower.paths.state)
|
|
160
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
161
|
+
await appendJsonl(tower.paths.activity, {
|
|
162
|
+
at: now,
|
|
163
|
+
type: 'init',
|
|
164
|
+
text: initActivityText({ base: prepared.base, snapshotCommit: acted.snapshotCommit, dirty: prepared.dirty }),
|
|
165
|
+
})
|
|
166
|
+
return createdInitResult({
|
|
167
|
+
repoRoot: tower.root,
|
|
168
|
+
commonDir: tower.commonDir,
|
|
169
|
+
statePath: tower.paths.state,
|
|
170
|
+
base: prepared.base,
|
|
171
|
+
dirty: prepared.dirty,
|
|
172
|
+
snapshotCommit: acted.snapshotCommit,
|
|
173
|
+
snapshotRef: acted.snapshotRef,
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
|
|
179
|
+
async function commitInitExisting(tower, prepared, input) {
|
|
180
|
+
return withLock(tower.paths, async () => {
|
|
181
|
+
const state = await tower.load()
|
|
182
|
+
if (state.revision !== prepared.stateRevision) {
|
|
183
|
+
throw new TowerToolError(
|
|
184
|
+
'stale-base',
|
|
185
|
+
`tower state changed while init was being prepared (${prepared.stateRevision} -> ${state.revision}); retry`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
const now = Date.now()
|
|
189
|
+
const owner = state.towerAgentId ? String(state.towerAgentId) : null
|
|
190
|
+
const caller = input.towerAgentId ? String(input.towerAgentId) : null
|
|
191
|
+
if (ownedByOther(owner, caller)) {
|
|
192
|
+
fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
|
|
193
|
+
}
|
|
194
|
+
const claimant = claimCaller(owner, caller)
|
|
195
|
+
if (claimant) {
|
|
196
|
+
state.towerAgentId = claimant
|
|
197
|
+
state.revision += 1
|
|
198
|
+
state.updatedAt = now
|
|
199
|
+
validateState(state, tower.paths.state)
|
|
200
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
201
|
+
await appendJsonl(tower.paths.activity, {
|
|
202
|
+
at: now,
|
|
203
|
+
type: 'init',
|
|
204
|
+
text: ownerClaimActivityText(claimant),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
let reanchored = false
|
|
208
|
+
if (prepared.reanchor && state.base !== prepared.base) {
|
|
209
|
+
state.base = prepared.base
|
|
210
|
+
state.baseChangedAt = now
|
|
211
|
+
delete state.snapshotCommit
|
|
212
|
+
delete state.snapshotRef
|
|
213
|
+
reanchored = true
|
|
214
|
+
state.revision += 1
|
|
215
|
+
state.updatedAt = now
|
|
216
|
+
validateState(state, tower.paths.state)
|
|
217
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
218
|
+
await appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
|
|
219
|
+
}
|
|
220
|
+
return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** #51 two-phase init: prepare/act/commit with stale-state retries. */
|
|
225
|
+
export async function init(tower, input = {}) {
|
|
226
|
+
await ensureDir(tower.paths.dir)
|
|
227
|
+
const prepared = await prepareInit(tower, input)
|
|
228
|
+
if (prepared.early) return prepared.early
|
|
229
|
+
if (prepared.create) {
|
|
230
|
+
const acted = await actInitCreate(tower, prepared)
|
|
231
|
+
if (acted.stale) {
|
|
232
|
+
throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
|
|
233
|
+
}
|
|
234
|
+
return commitInitCreate(tower, prepared, acted, input)
|
|
235
|
+
}
|
|
236
|
+
const acted = prepared.reanchor ? await actInitReanchor(tower, prepared) : { deleted: [] }
|
|
237
|
+
if (acted.staleRevision !== undefined) {
|
|
238
|
+
throw new TowerToolError(
|
|
239
|
+
'stale-base',
|
|
240
|
+
`tower state changed while init was being prepared (${prepared.stateRevision} -> ${acted.staleRevision}); retry`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return await commitInitExisting(tower, prepared, input)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (acted.deleted.length > 0) await restoreInitRefs(tower, acted.deleted)
|
|
247
|
+
throw error
|
|
248
|
+
}
|
|
249
|
+
}
|