dsh-tower 2.1.0-beta.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +28 -8
- package/app/tower.js +121 -39
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +64 -19
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +3 -4
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +4 -7
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +7 -6
- package/tools/preflight.js +3 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /package/{lib → host}/presets.js +0 -0
package/app/checkCompletion.js
CHANGED
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
assertCompletionRequest,
|
|
6
6
|
assertCompletionInbox,
|
|
7
7
|
completionReport,
|
|
8
|
+
applyCompletion,
|
|
8
9
|
} from '../domain/missions/completion.js'
|
|
9
10
|
import { fullMission } from '../domain/summary.js'
|
|
10
11
|
import { validateState } from '../domain/state.js'
|
|
11
|
-
import {
|
|
12
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
12
|
+
import { sweepArchive } from './archive.js'
|
|
13
13
|
|
|
14
14
|
export { normalizeChecks }
|
|
15
15
|
|
|
@@ -19,16 +19,21 @@ export async function checkCompletion(tower, input) {
|
|
|
19
19
|
if (!mission) {
|
|
20
20
|
throw new TowerToolError('invalid-argument', `unknown mission "${input.missionId}"`)
|
|
21
21
|
}
|
|
22
|
-
const worker = assertCompletionRequest({
|
|
22
|
+
const worker = assertCompletionRequest({
|
|
23
|
+
mission,
|
|
24
|
+
roster: state.roster,
|
|
25
|
+
input,
|
|
26
|
+
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
27
|
+
})
|
|
23
28
|
await tower.assertWorktree(mission.worktree)
|
|
24
|
-
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
29
|
+
const commits = await tower.deps.vcs.countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
25
30
|
if (commits < 1) {
|
|
26
31
|
throw new TowerToolError(
|
|
27
32
|
'invalid-argument',
|
|
28
33
|
`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
|
|
29
34
|
)
|
|
30
35
|
}
|
|
31
|
-
if (!(await isClean(mission.worktree.path))) {
|
|
36
|
+
if (!(await tower.deps.vcs.isClean(mission.worktree.path))) {
|
|
32
37
|
const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
|
|
33
38
|
.split('\n')
|
|
34
39
|
.filter(Boolean)
|
|
@@ -47,7 +52,7 @@ export async function complete(tower, input) {
|
|
|
47
52
|
// Two-phase pilot (#51): expensive git/state checks run before the lock;
|
|
48
53
|
// the lock only revalidates the state revision and commits the transition.
|
|
49
54
|
const prepared = await tower.checkCompletion(input)
|
|
50
|
-
return withLock(tower.paths, async () => {
|
|
55
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
51
56
|
const state = await tower.load()
|
|
52
57
|
if (state.revision !== prepared.state.revision) {
|
|
53
58
|
throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
|
|
@@ -57,20 +62,27 @@ export async function complete(tower, input) {
|
|
|
57
62
|
const { worker, commits } = prepared
|
|
58
63
|
const checks = normalizeChecks(input.checks)
|
|
59
64
|
const now = Date.now()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const storedReport = completionReport({
|
|
66
|
+
report: input.report,
|
|
67
|
+
worker,
|
|
68
|
+
checks,
|
|
69
|
+
now,
|
|
70
|
+
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
71
|
+
})
|
|
72
|
+
const applied = applyCompletion({ mission, report: storedReport, now })
|
|
73
|
+
await sweepArchive(tower, state, { now })
|
|
63
74
|
state.revision += 1
|
|
64
75
|
state.updatedAt = now
|
|
65
76
|
|
|
66
77
|
validateState(state, tower.paths.state)
|
|
67
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
68
|
-
await appendJsonl(tower.paths.activity, {
|
|
78
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
79
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
69
80
|
at: now,
|
|
70
81
|
type: 'complete',
|
|
71
82
|
missionId: mission.id,
|
|
72
83
|
text: `${worker.name} reported completion (${commits} commit(s))`,
|
|
73
84
|
})
|
|
85
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
74
86
|
return { mission: fullMission(mission), commits, unread: 0 }
|
|
75
87
|
})
|
|
76
88
|
}
|
package/app/enrichedMissions.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// Dashboard mission enrichment use case.
|
|
2
|
-
import { exists } from '../lib/store.js'
|
|
3
|
-
import { revParse } from '../lib/git.js'
|
|
4
2
|
import {
|
|
5
3
|
enrichedMissionView,
|
|
6
4
|
latestReviewForMission,
|
|
@@ -12,9 +10,9 @@ export async function enrichedMissions(tower, state, onlyIds) {
|
|
|
12
10
|
for (const mission of missionsToEnrich(state.missions, onlyIds)) {
|
|
13
11
|
const latest = latestReviewForMission(state, mission)
|
|
14
12
|
let tipMoved = false
|
|
15
|
-
if (latest && mission.worktree && (await exists(mission.worktree.path))) {
|
|
13
|
+
if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
|
|
16
14
|
try {
|
|
17
|
-
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
15
|
+
const tip = await tower.deps.vcs.revParse(mission.worktree.path, 'HEAD')
|
|
18
16
|
tipMoved = tip !== latest.reviewedCommit
|
|
19
17
|
} catch {
|
|
20
18
|
tipMoved = false
|
package/app/findings.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Findings use cases.
|
|
2
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
2
|
import { validateState } from '../domain/state.js'
|
|
4
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
5
4
|
import {
|
|
@@ -17,7 +16,7 @@ function fail(message, code = 'invalid-argument') {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export async function addFinding(tower, input) {
|
|
20
|
-
return withLock(tower.paths, async () => {
|
|
19
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
21
20
|
const state = await tower.load()
|
|
22
21
|
if (!Array.isArray(state.findings)) state.findings = []
|
|
23
22
|
const fields = normalizeFindingInput(input, state.missions)
|
|
@@ -30,8 +29,8 @@ export async function addFinding(tower, input) {
|
|
|
30
29
|
state.updatedAt = now
|
|
31
30
|
|
|
32
31
|
validateState(state, tower.paths.state)
|
|
33
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
34
|
-
await appendJsonl(tower.paths.activity, {
|
|
32
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
33
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
35
34
|
at: now,
|
|
36
35
|
type: 'finding',
|
|
37
36
|
missionId: finding.missionId,
|
|
@@ -55,7 +54,7 @@ export async function addFinding(tower, input) {
|
|
|
55
54
|
const actor = normalizeFindingActor(input.actor)
|
|
56
55
|
if (!wantsFindingUpdate(input)) return tower.readFindings(input)
|
|
57
56
|
|
|
58
|
-
return withLock(tower.paths, async () => {
|
|
57
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
59
58
|
const state = await tower.load()
|
|
60
59
|
const all = state.findings ?? []
|
|
61
60
|
const finding = all.find((entry) => entry.id === input.id)
|
|
@@ -65,8 +64,8 @@ export async function addFinding(tower, input) {
|
|
|
65
64
|
state.revision += 1
|
|
66
65
|
state.updatedAt = now
|
|
67
66
|
validateState(state, tower.paths.state)
|
|
68
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
69
|
-
await appendJsonl(tower.paths.activity, {
|
|
67
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
68
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
70
69
|
at: now,
|
|
71
70
|
type: 'finding',
|
|
72
71
|
missionId: finding.missionId,
|
package/app/gate.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Merge/preflight gate evaluation.
|
|
2
|
-
import { currentBranch } from '../lib/git.js'
|
|
3
2
|
import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
4
3
|
import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
|
|
5
4
|
import { fullMission } from '../domain/summary.js'
|
|
@@ -20,8 +19,23 @@ import {
|
|
|
20
19
|
|
|
21
20
|
export async function evaluateGate(tower, input) {
|
|
22
21
|
const state = await tower.load()
|
|
23
|
-
|
|
22
|
+
let mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
23
|
+
let archivedEntry = null
|
|
24
|
+
if (!mission) {
|
|
25
|
+
// A completed mission may have been archived by policy; preflight and
|
|
26
|
+
// merge must behave the same for it as before the move (#73).
|
|
27
|
+
const archived = await tower.archivedMission(input.missionId)
|
|
28
|
+
if (archived.ok && archived.entry) {
|
|
29
|
+
archivedEntry = archived.entry
|
|
30
|
+
mission = archived.entry.mission
|
|
31
|
+
}
|
|
32
|
+
}
|
|
24
33
|
if (!mission) return missingMissionGateResult(input.missionId)
|
|
34
|
+
const archiveIndex = await tower.archiveIndex()
|
|
35
|
+
const archivedMissions = new Map(
|
|
36
|
+
(archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
|
|
37
|
+
)
|
|
38
|
+
const reviews = archivedEntry ? [...(state.reviews ?? []), ...(archivedEntry.reviews ?? [])] : state.reviews
|
|
25
39
|
const activity = input.activity ?? {}
|
|
26
40
|
const blockers = []
|
|
27
41
|
const push = (code, detail) => blockers.push({ code, detail })
|
|
@@ -57,7 +71,7 @@ export async function evaluateGate(tower, input) {
|
|
|
57
71
|
if (blocker) push(blocker.code, blocker.detail)
|
|
58
72
|
}
|
|
59
73
|
if (mission.kind !== 'survey') {
|
|
60
|
-
const blocker = outOfScopeBlocker({ mission, changedFiles })
|
|
74
|
+
const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
|
|
61
75
|
if (blocker) push(blocker.code, blocker.detail)
|
|
62
76
|
}
|
|
63
77
|
const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
|
|
@@ -71,12 +85,12 @@ export async function evaluateGate(tower, input) {
|
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state })) {
|
|
88
|
+
for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state, archived: archivedMissions })) {
|
|
75
89
|
push(blocker.code, blocker.detail)
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
const branch = worktree ? worktree.branch : null
|
|
79
|
-
const latest = latestReview(reviewsForBranch(
|
|
93
|
+
const latest = latestReview(reviewsForBranch(reviews, branch))
|
|
80
94
|
for (const blocker of mergeGateBlockers({
|
|
81
95
|
mission,
|
|
82
96
|
roster: state.roster ?? [],
|
|
@@ -90,19 +104,22 @@ export async function evaluateGate(tower, input) {
|
|
|
90
104
|
rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
|
|
91
105
|
})) push(blocker.code, blocker.detail)
|
|
92
106
|
|
|
93
|
-
const checkoutBranch = await currentBranch(tower.root)
|
|
107
|
+
const checkoutBranch = await tower.deps.vcs.currentBranch(tower.root)
|
|
94
108
|
const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
|
|
95
109
|
if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
|
|
96
110
|
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
return {
|
|
112
|
+
...gateResult({
|
|
113
|
+
mission: fullMission(mission),
|
|
114
|
+
worktree,
|
|
115
|
+
branchTip,
|
|
116
|
+
baseTip,
|
|
117
|
+
changedFiles,
|
|
118
|
+
review: latest,
|
|
119
|
+
blockers,
|
|
120
|
+
}),
|
|
121
|
+
...(archivedEntry ? { archived: true } : {}),
|
|
122
|
+
}
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
/** Read-only mission diff for reviewers: branch changes against the merge base. */
|
package/app/init.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// the repo-scoped git mutex, never under the state lock.
|
|
7
7
|
// commit — one short state lock writes/claims/reanchors, revalidating the
|
|
8
8
|
// state revision captured by prepare.
|
|
9
|
-
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
10
9
|
import { validateState } from '../domain/state.js'
|
|
11
10
|
import { TowerToolError } from '../domain/errors.js'
|
|
12
11
|
import {
|
|
13
12
|
SNAPSHOT_REF_PREFIX,
|
|
13
|
+
caseProbeActivityText,
|
|
14
14
|
claimCaller,
|
|
15
15
|
createdInitResult,
|
|
16
16
|
existingInitResult,
|
|
@@ -33,6 +33,16 @@ function fail(message, code = 'invalid-argument') {
|
|
|
33
33
|
throw new TowerToolError(code, message)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Probe the checkout's own filesystem through the injected case-sensitivity
|
|
38
|
+
* port. tower_init probes once and records the answer in state (#90).
|
|
39
|
+
* @param {any} tower
|
|
40
|
+
* @returns {Promise<boolean | null>}
|
|
41
|
+
*/
|
|
42
|
+
function detectCaseMode(tower) {
|
|
43
|
+
return tower.deps.caseSensitivity.detect(tower.root)
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
/** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
|
|
37
47
|
async function prepareInit(tower, input) {
|
|
38
48
|
const dirty = !(await tower.isClean(tower.root))
|
|
@@ -53,13 +63,17 @@ async function prepareInit(tower, input) {
|
|
|
53
63
|
return /** @type {{ base: string }} */ (fallback).base
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
if (await exists(tower.paths.state)) {
|
|
66
|
+
if (await tower.deps.store.exists(tower.paths.state)) {
|
|
57
67
|
const state = await tower.load()
|
|
58
68
|
const owner = state.towerAgentId ? String(state.towerAgentId) : null
|
|
59
69
|
const caller = input.towerAgentId ? String(input.towerAgentId) : null
|
|
60
70
|
if (ownedByOther(owner, caller)) {
|
|
61
71
|
fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
|
|
62
72
|
}
|
|
73
|
+
// "Detect once": a state that already carries a boolean is never re-probed,
|
|
74
|
+
// so a transient probe failure cannot silently flip the comparison mode.
|
|
75
|
+
const known = state.caseInsensitive === true || state.caseInsensitive === false
|
|
76
|
+
const caseInsensitive = known ? null : await detectCaseMode(tower)
|
|
63
77
|
let reanchorBase = null
|
|
64
78
|
if (input.reanchor === true) {
|
|
65
79
|
const refusal = reanchorBlockedMessage(state.missions)
|
|
@@ -68,10 +82,10 @@ async function prepareInit(tower, input) {
|
|
|
68
82
|
}
|
|
69
83
|
const claim = claimCaller(owner, caller) !== null
|
|
70
84
|
const reanchor = shouldReanchor({ reanchor: input.reanchor, reanchorBase, currentBase: state.base })
|
|
71
|
-
if (!claim && !reanchor) {
|
|
85
|
+
if (!claim && !reanchor && caseInsensitive === null) {
|
|
72
86
|
return { early: existingInitResult({ state, statePath: tower.paths.state, dirty }) }
|
|
73
87
|
}
|
|
74
|
-
return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty }
|
|
88
|
+
return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty, caseInsensitive }
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
return {
|
|
@@ -79,6 +93,7 @@ async function prepareInit(tower, input) {
|
|
|
79
93
|
base: await resolveBase(),
|
|
80
94
|
snapshot: shouldSnapshot({ snapshotDirty: input.snapshotDirty, dirty }),
|
|
81
95
|
dirty,
|
|
96
|
+
caseInsensitive: await detectCaseMode(tower),
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
|
@@ -88,8 +103,8 @@ async function prepareInit(tower, input) {
|
|
|
88
103
|
* revalidated first so a concurrent init cannot leave an unreferenced snapshot.
|
|
89
104
|
*/
|
|
90
105
|
async function actInitCreate(tower, prepared) {
|
|
91
|
-
return withGitLock(tower.paths, async () => {
|
|
92
|
-
if (await exists(tower.paths.state)) return { stale: true }
|
|
106
|
+
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
107
|
+
if (await tower.deps.store.exists(tower.paths.state)) return { stale: true }
|
|
93
108
|
if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
|
|
94
109
|
const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
|
|
95
110
|
if (!snapshotCommit) {
|
|
@@ -100,12 +115,12 @@ async function actInitCreate(tower, prepared) {
|
|
|
100
115
|
const snapshotRef = snapshotRefFor(snapshotCommit)
|
|
101
116
|
await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
|
|
102
117
|
return { snapshotCommit, snapshotRef }
|
|
103
|
-
})
|
|
118
|
+
}, tower.gitMutexOptions({ phase: 'init' }))
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
|
|
107
122
|
async function actInitReanchor(tower, prepared) {
|
|
108
|
-
return withGitLock(tower.paths, async () => {
|
|
123
|
+
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
109
124
|
const state = await tower.load()
|
|
110
125
|
if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
|
|
111
126
|
if (state.base === prepared.base) return { deleted: [] }
|
|
@@ -123,26 +138,26 @@ async function actInitReanchor(tower, prepared) {
|
|
|
123
138
|
}
|
|
124
139
|
}
|
|
125
140
|
return { deleted }
|
|
126
|
-
})
|
|
141
|
+
}, tower.gitMutexOptions({ phase: 'init' }))
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
/** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
|
|
130
145
|
async function restoreInitRefs(tower, deleted) {
|
|
131
146
|
const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
|
|
132
147
|
if (entries.length === 0) return
|
|
133
|
-
await withGitLock(tower.paths, async () => {
|
|
148
|
+
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
134
149
|
for (const entry of entries) {
|
|
135
150
|
await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
|
|
136
151
|
}
|
|
137
|
-
})
|
|
152
|
+
}, tower.gitMutexOptions({ phase: 'init' }))
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
/** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
|
|
141
156
|
async function commitInitCreate(tower, prepared, acted, input) {
|
|
142
|
-
return withLock(tower.paths, async () => {
|
|
157
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
143
158
|
// Revision revalidation for the create path: prepare saw "absent", so a
|
|
144
159
|
// state file that appeared meanwhile must never be overwritten.
|
|
145
|
-
if (await exists(tower.paths.state)) {
|
|
160
|
+
if (await tower.deps.store.exists(tower.paths.state)) {
|
|
146
161
|
throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
|
|
147
162
|
}
|
|
148
163
|
const now = Date.now()
|
|
@@ -154,14 +169,20 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
154
169
|
towerAgentId: input.towerAgentId ? String(input.towerAgentId) : null,
|
|
155
170
|
snapshotCommit: acted.snapshotCommit,
|
|
156
171
|
snapshotRef: acted.snapshotRef,
|
|
172
|
+
caseInsensitive: prepared.caseInsensitive,
|
|
157
173
|
now,
|
|
158
174
|
})
|
|
159
175
|
validateState(state, tower.paths.state)
|
|
160
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
161
|
-
await appendJsonl(tower.paths.activity, {
|
|
176
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
177
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
162
178
|
at: now,
|
|
163
179
|
type: 'init',
|
|
164
|
-
text: initActivityText({
|
|
180
|
+
text: initActivityText({
|
|
181
|
+
base: prepared.base,
|
|
182
|
+
snapshotCommit: acted.snapshotCommit,
|
|
183
|
+
dirty: prepared.dirty,
|
|
184
|
+
caseInsensitive: prepared.caseInsensitive,
|
|
185
|
+
}),
|
|
165
186
|
})
|
|
166
187
|
return createdInitResult({
|
|
167
188
|
repoRoot: tower.root,
|
|
@@ -171,13 +192,14 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
171
192
|
dirty: prepared.dirty,
|
|
172
193
|
snapshotCommit: acted.snapshotCommit,
|
|
173
194
|
snapshotRef: acted.snapshotRef,
|
|
195
|
+
caseInsensitive: prepared.caseInsensitive,
|
|
174
196
|
})
|
|
175
197
|
})
|
|
176
198
|
}
|
|
177
199
|
|
|
178
200
|
/** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
|
|
179
201
|
async function commitInitExisting(tower, prepared, input) {
|
|
180
|
-
return withLock(tower.paths, async () => {
|
|
202
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
181
203
|
const state = await tower.load()
|
|
182
204
|
if (state.revision !== prepared.stateRevision) {
|
|
183
205
|
throw new TowerToolError(
|
|
@@ -197,14 +219,29 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
197
219
|
state.revision += 1
|
|
198
220
|
state.updatedAt = now
|
|
199
221
|
validateState(state, tower.paths.state)
|
|
200
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
201
|
-
await appendJsonl(tower.paths.activity, {
|
|
222
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
223
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
202
224
|
at: now,
|
|
203
225
|
type: 'init',
|
|
204
226
|
text: ownerClaimActivityText(claimant),
|
|
205
227
|
})
|
|
206
228
|
}
|
|
207
229
|
let reanchored = false
|
|
230
|
+
// Record the one-time case-sensitivity probe (#90). An unknown result is
|
|
231
|
+
// not persisted, so a later init can retry it without bumping the revision
|
|
232
|
+
// on every call.
|
|
233
|
+
if (prepared.caseInsensitive !== null && state.caseInsensitive !== prepared.caseInsensitive) {
|
|
234
|
+
state.caseInsensitive = prepared.caseInsensitive
|
|
235
|
+
state.revision += 1
|
|
236
|
+
state.updatedAt = now
|
|
237
|
+
validateState(state, tower.paths.state)
|
|
238
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
239
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
240
|
+
at: now,
|
|
241
|
+
type: 'init',
|
|
242
|
+
text: caseProbeActivityText(prepared.caseInsensitive),
|
|
243
|
+
})
|
|
244
|
+
}
|
|
208
245
|
if (prepared.reanchor && state.base !== prepared.base) {
|
|
209
246
|
state.base = prepared.base
|
|
210
247
|
state.baseChangedAt = now
|
|
@@ -214,8 +251,8 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
214
251
|
state.revision += 1
|
|
215
252
|
state.updatedAt = now
|
|
216
253
|
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) })
|
|
254
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
255
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
|
|
219
256
|
}
|
|
220
257
|
return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
|
|
221
258
|
})
|
|
@@ -223,7 +260,7 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
223
260
|
|
|
224
261
|
/** #51 two-phase init: prepare/act/commit with stale-state retries. */
|
|
225
262
|
export async function init(tower, input = {}) {
|
|
226
|
-
await ensureDir(tower.paths.dir)
|
|
263
|
+
await tower.deps.store.ensureDir(tower.paths.dir)
|
|
227
264
|
const prepared = await prepareInit(tower, input)
|
|
228
265
|
if (prepared.early) return prepared.early
|
|
229
266
|
if (prepared.create) {
|