dsh-tower 2.5.0 → 2.7.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 +114 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +18 -16
- package/README.md +18 -11
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +104 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +31 -20
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +34 -6
- package/client.js +321 -0
- package/cordis.patch.yml +55 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +18 -10
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +58 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/runbook.md +1 -1
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +20 -11
- package/docs/reference/component-surface.md +20 -8
- package/docs/reference/config.generated.md +8 -5
- package/docs/reference/config.md +30 -5
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +53 -4
- package/docs/reference/testing.md +5 -3
- package/docs/reference/tools.generated.md +7 -7
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +13 -9
- package/docs/zh/reference/config.md +22 -5
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +5 -4
- package/docs/zh/reference/tools.md +3 -3
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/components.js +12 -4
- package/domain/config/schema.js +87 -22
- package/domain/contracts/outputs.js +2 -1
- package/domain/contracts/tool-inputs/missions.js +10 -2
- package/domain/errors.js +6 -0
- package/domain/evidence.js +283 -0
- package/domain/gate.js +17 -34
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/integration/review.js +203 -9
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/config.js +40 -3
- package/host/doctor.js +20 -8
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +17 -11
- package/host/tool-registry.js +23 -13
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +19 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +5 -0
- package/scripts/component-surface.json +83 -29
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +22 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +75 -4
- package/scripts/types-coverage.mjs +7 -0
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/review.js +1 -1
- package/tools/spawn.js +48 -50
- package/tools/status.js +8 -1
- package/tools/test.js +2 -1
- package/types/index.d.ts +76 -5
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Review evidence checkouts (#149 W3).
|
|
2
|
+
//
|
|
3
|
+
// A review verdict is stamped with a commit, not with whatever a live worktree
|
|
4
|
+
// HEAD happens to be when the reviewer submits. When a reviewer is spawned the
|
|
5
|
+
// tower creates a disposable detached checkout at the mission branch tip and
|
|
6
|
+
// records that commit on the roster row. The reviewer reads (and tower_diff
|
|
7
|
+
// shows) that commit; the verdict uses it; the merge gate compares it to the
|
|
8
|
+
// current branch tip with the existing `tip-moved` blocker. The checkout is
|
|
9
|
+
// removed as soon as the verdict is recorded, so it can never silently drift.
|
|
10
|
+
//
|
|
11
|
+
// This module owns only the checkout lifecycle. The commit stamp and the
|
|
12
|
+
// admission rules live in `domain/missions/review.js` and `app/review.js`.
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
15
|
+
import { validateState } from '../domain/state.js'
|
|
16
|
+
import { removeSandbox } from './sandbox.js'
|
|
17
|
+
import { resolveCheckoutRoot } from './provision.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} message
|
|
21
|
+
* @param {string} [code]
|
|
22
|
+
* @returns {never}
|
|
23
|
+
*/
|
|
24
|
+
function fail(message, code = 'invalid-argument') {
|
|
25
|
+
throw new TowerToolError(code, message)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Deterministic per-reviewer checkout path. `-review-` cannot collide with the
|
|
30
|
+
* `-sandbox` survey suffix or a mission worktree directory.
|
|
31
|
+
* @param {string} root
|
|
32
|
+
* @param {string} missionId
|
|
33
|
+
* @param {string} reviewerName
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function reviewCheckoutPath(root, missionId, reviewerName) {
|
|
37
|
+
const safeName = String(reviewerName).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 64)
|
|
38
|
+
return path.join(root, `${missionId}-review-${safeName}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Provision the detached review checkout for a completed mission at its current
|
|
43
|
+
* branch tip. Returns the mission, the checkout path and the commit the reviewer
|
|
44
|
+
* is bound to.
|
|
45
|
+
* @param {any} tower
|
|
46
|
+
* @param {{ missionId?: unknown, name?: unknown, worktreeRoot?: string }} input
|
|
47
|
+
* @returns {Promise<{ mission: any, path: string, commit: string }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function provisionReviewCheckout(tower, input = {}) {
|
|
50
|
+
const missionId = input.missionId
|
|
51
|
+
if (typeof missionId !== 'string' || missionId.trim().length === 0) fail('tower_spawn requires missionId in this version')
|
|
52
|
+
if (typeof input.name !== 'string' || input.name.trim().length === 0) fail('a review checkout needs the reviewer name')
|
|
53
|
+
const state = await tower.load()
|
|
54
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
55
|
+
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
56
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no branch to review`, 'reviewer-prerequisite')
|
|
57
|
+
if (mission.status !== 'completed') {
|
|
58
|
+
fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`, 'reviewer-prerequisite')
|
|
59
|
+
}
|
|
60
|
+
const commit = await tower.branchTip(mission)
|
|
61
|
+
const root = resolveCheckoutRoot(tower, input)
|
|
62
|
+
await tower.deps.store.ensureDir(root)
|
|
63
|
+
const checkout = reviewCheckoutPath(root, mission.id, input.name.trim())
|
|
64
|
+
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
65
|
+
if (await tower.deps.store.exists(checkout)) {
|
|
66
|
+
const registered = (await tower.deps.vcs.listWorktrees(tower.root)).some(
|
|
67
|
+
(entry) => path.resolve(entry.path) === path.resolve(checkout),
|
|
68
|
+
)
|
|
69
|
+
if (!registered) {
|
|
70
|
+
fail(`review checkout path already exists and is not a git worktree: ${checkout}; remove it and retry`)
|
|
71
|
+
}
|
|
72
|
+
// A leftover checkout from a failed spawn is disposable evidence for a
|
|
73
|
+
// review that never happened; recreate it at the recorded tip.
|
|
74
|
+
await tower.git(tower.root, ['worktree', 'remove', '--force', checkout]).catch(() => {})
|
|
75
|
+
}
|
|
76
|
+
await tower.git(tower.root, ['worktree', 'add', '--detach', checkout, commit])
|
|
77
|
+
await tower.ensureExcluded(checkout)
|
|
78
|
+
}, tower.gitMutexOptions({ phase: 'review' }))
|
|
79
|
+
const head = await tower.revParse(checkout, 'HEAD')
|
|
80
|
+
if (head !== commit) {
|
|
81
|
+
await removeSandbox(tower, checkout)
|
|
82
|
+
fail(`review checkout ${checkout} was created at ${String(head).slice(0, 12)} instead of ${commit.slice(0, 12)}; no reviewer was started`)
|
|
83
|
+
}
|
|
84
|
+
return { mission, path: checkout, commit }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Remove one review checkout. Advisory and bounded: a failed cleanup never
|
|
89
|
+
* blocks the verdict, and `tower_teardown`/`tower_recover` retry it later.
|
|
90
|
+
* @param {any} tower
|
|
91
|
+
* @param {string} checkout
|
|
92
|
+
* @returns {Promise<{ ok: boolean, removed: boolean, attempts: number, error?: string }>}
|
|
93
|
+
*/
|
|
94
|
+
export async function removeReviewCheckout(tower, checkout) {
|
|
95
|
+
return removeSandbox(tower, checkout)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Remove a reviewer's checkout and clear the recorded path on its roster row.
|
|
100
|
+
* The recorded commit stays: it is the evidence the review was based on.
|
|
101
|
+
* @param {any} tower
|
|
102
|
+
* @param {{ name: string, missionId?: string | null }} input
|
|
103
|
+
* @returns {Promise<{ entry: any, removal: { ok: boolean, removed: boolean, attempts: number, error?: string } } | null>}
|
|
104
|
+
*/
|
|
105
|
+
export async function cleanupReviewCheckout(tower, input) {
|
|
106
|
+
const removal = await tower.deps.store.withLock(tower.paths, async () => {
|
|
107
|
+
const state = await tower.load()
|
|
108
|
+
const entry = (state.roster ?? []).find(
|
|
109
|
+
(row) =>
|
|
110
|
+
row.name === input.name &&
|
|
111
|
+
(input.missionId === undefined || input.missionId === null || row.missionId === input.missionId),
|
|
112
|
+
)
|
|
113
|
+
if (!entry || typeof entry.reviewCheckout !== 'string' || entry.reviewCheckout.length === 0) return null
|
|
114
|
+
return { checkout: entry.reviewCheckout, entryName: entry.name, missionId: entry.missionId }
|
|
115
|
+
})
|
|
116
|
+
if (!removal) return null
|
|
117
|
+
const result = await removeSandbox(tower, removal.checkout)
|
|
118
|
+
// A failed removal keeps the path on the row so teardown can retry it; the
|
|
119
|
+
// verdict itself never depends on this advisory cleanup.
|
|
120
|
+
if (!result.ok) return { entry: null, removal: result }
|
|
121
|
+
const written = await tower.deps.store.withLock(tower.paths, async () => {
|
|
122
|
+
const state = await tower.load()
|
|
123
|
+
const entry = (state.roster ?? []).find((row) => row.name === removal.entryName)
|
|
124
|
+
if (!entry || entry.reviewCheckout !== removal.checkout) return entry ?? null
|
|
125
|
+
delete entry.reviewCheckout
|
|
126
|
+
state.revision += 1
|
|
127
|
+
state.updatedAt = Date.now()
|
|
128
|
+
validateState(state, tower.paths.state)
|
|
129
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
130
|
+
return entry
|
|
131
|
+
})
|
|
132
|
+
return { entry: written, removal: result }
|
|
133
|
+
}
|
package/app/sandbox.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Disposable detached survey sandboxes (#148 W2).
|
|
2
|
+
//
|
|
3
|
+
// A `survey.sandbox: 'detached'` survey reads in a detached git worktree with no
|
|
4
|
+
// branch. The checkout is disposable: completion, abandon and teardown remove it
|
|
5
|
+
// with a bounded retry. Cleanup is advisory — a removal failure leaves the
|
|
6
|
+
// recorded path and a visible diagnostic in state, and never fails or corrupts
|
|
7
|
+
// the transition that triggered it.
|
|
8
|
+
// @ts-check
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { validateState } from '../domain/state.js'
|
|
11
|
+
|
|
12
|
+
/** Bounded cleanup attempts: a transient git lock or busy file is retried. */
|
|
13
|
+
export const SANDBOX_CLEANUP_ATTEMPTS = 3
|
|
14
|
+
export const SANDBOX_RETRY_DELAY_MS = 50
|
|
15
|
+
|
|
16
|
+
/** @param {number} ms */
|
|
17
|
+
function delay(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {any} tower
|
|
23
|
+
* @param {string} sandbox
|
|
24
|
+
* @returns {Promise<boolean>}
|
|
25
|
+
*/
|
|
26
|
+
async function registered(tower, sandbox) {
|
|
27
|
+
try {
|
|
28
|
+
const entries = await tower.deps.vcs.listWorktrees(tower.root)
|
|
29
|
+
return entries.some((entry) => entry.path && path.resolve(entry.path) === path.resolve(sandbox))
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Remove one detached sandbox worktree, retrying a bounded number of times.
|
|
37
|
+
* Never throws: the result is the diagnostic. A path git no longer registers is
|
|
38
|
+
* treated as already removed, so a half-finished cleanup is not an error.
|
|
39
|
+
* @param {any} tower
|
|
40
|
+
* @param {string} sandbox
|
|
41
|
+
* @param {{ attempts?: number, delayMs?: number }} [options]
|
|
42
|
+
* @returns {Promise<{ ok: boolean, removed: boolean, attempts: number, error?: string }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function removeSandbox(tower, sandbox, options = {}) {
|
|
45
|
+
const attempts = Math.max(1, options.attempts ?? SANDBOX_CLEANUP_ATTEMPTS)
|
|
46
|
+
const delayMs = options.delayMs ?? SANDBOX_RETRY_DELAY_MS
|
|
47
|
+
/** @type {unknown} */
|
|
48
|
+
let lastError = null
|
|
49
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
50
|
+
try {
|
|
51
|
+
if (!(await registered(tower, sandbox))) return { ok: true, removed: false, attempts: attempt }
|
|
52
|
+
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
53
|
+
await tower.git(tower.root, ['worktree', 'remove', '--force', sandbox])
|
|
54
|
+
}, tower.gitMutexOptions({ phase: 'sandbox-cleanup' }))
|
|
55
|
+
// Removing the registered worktree also removes its directory. Verify
|
|
56
|
+
// both, so a git that reports success but leaves the entry is retried.
|
|
57
|
+
if (!(await tower.deps.store.exists(sandbox)) && !(await registered(tower, sandbox))) {
|
|
58
|
+
return { ok: true, removed: true, attempts: attempt }
|
|
59
|
+
}
|
|
60
|
+
lastError = new Error(`git still reports ${sandbox} after worktree remove`)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
lastError = error
|
|
63
|
+
if (!(await registered(tower, sandbox))) return { ok: true, removed: false, attempts: attempt }
|
|
64
|
+
}
|
|
65
|
+
if (attempt < attempts && delayMs > 0) await delay(delayMs)
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
removed: false,
|
|
70
|
+
attempts,
|
|
71
|
+
error: String(/** @type {{ message?: unknown }} */ (lastError)?.message ?? lastError ?? 'sandbox cleanup failed').slice(0, 400),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove a mission's recorded sandbox and persist the outcome under a short
|
|
77
|
+
* state lock. Success clears `mission.sandbox`; failure keeps it and records an
|
|
78
|
+
* actionable `sandboxCleanupError` plus an activity entry, so the tower can see
|
|
79
|
+
* the retained path and retry without the state contradicting the filesystem.
|
|
80
|
+
*
|
|
81
|
+
* Returns null when the mission has no sandbox (or no longer exists).
|
|
82
|
+
* @param {any} tower
|
|
83
|
+
* @param {string} missionId
|
|
84
|
+
* @param {{ attempts?: number, delayMs?: number }} [options]
|
|
85
|
+
* @returns {Promise<{ ok: boolean, removed: boolean, attempts: number, path: string, error?: string } | null>}
|
|
86
|
+
*/
|
|
87
|
+
export async function cleanupSandbox(tower, missionId, options = {}) {
|
|
88
|
+
const state = await tower.load()
|
|
89
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
90
|
+
const sandbox = mission && typeof /** @type {any} */ (mission).sandbox === 'string' ? /** @type {any} */ (mission).sandbox : null
|
|
91
|
+
if (!sandbox) return null
|
|
92
|
+
const removal = await removeSandbox(tower, sandbox, options)
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
await tower.deps.store.withLock(tower.paths, async () => {
|
|
95
|
+
const fresh = await tower.load()
|
|
96
|
+
const current = fresh.missions.find((entry) => entry.id === missionId)
|
|
97
|
+
if (!current || /** @type {any} */ (current).sandbox !== sandbox) return
|
|
98
|
+
const record = /** @type {any} */ (current)
|
|
99
|
+
if (removal.ok) {
|
|
100
|
+
delete record.sandbox
|
|
101
|
+
delete record.sandboxCleanupError
|
|
102
|
+
record.sandboxRemovedAt = now
|
|
103
|
+
} else {
|
|
104
|
+
record.sandboxCleanupError = removal.error
|
|
105
|
+
}
|
|
106
|
+
current.updatedAt = now
|
|
107
|
+
fresh.revision += 1
|
|
108
|
+
fresh.updatedAt = now
|
|
109
|
+
validateState(fresh, tower.paths.state)
|
|
110
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, fresh)
|
|
111
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
112
|
+
at: now,
|
|
113
|
+
type: 'sandbox',
|
|
114
|
+
missionId,
|
|
115
|
+
text: removal.ok
|
|
116
|
+
? `removed detached sandbox at ${sandbox}`
|
|
117
|
+
: `sandbox cleanup failed after ${removal.attempts} attempt(s): ${removal.error}; path retained at ${sandbox} — remove it with "git worktree remove --force ${sandbox}" and call tower_teardown to retry the record`,
|
|
118
|
+
}).catch(() => {})
|
|
119
|
+
})
|
|
120
|
+
return { ...removal, path: sandbox }
|
|
121
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Shared-checkout helpers for the survey shape (#148).
|
|
2
|
+
//
|
|
3
|
+
// A survey reads one commit in the shared checkout. The fingerprint recorded at
|
|
4
|
+
// plan time is the set of *tracked* changed paths; untracked files (node_modules,
|
|
5
|
+
// tmp output) are intentionally ignored so a read-only survey is not blocked by
|
|
6
|
+
// build artefacts it never touched.
|
|
7
|
+
// @ts-check
|
|
8
|
+
|
|
9
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} porcelain `git status --porcelain` output
|
|
13
|
+
* @returns {string[]} sorted tracked changed paths
|
|
14
|
+
*/
|
|
15
|
+
export function trackedFingerprint(porcelain) {
|
|
16
|
+
/** @type {string[]} */
|
|
17
|
+
const paths = []
|
|
18
|
+
for (const line of String(porcelain ?? '').split(/\r?\n/)) {
|
|
19
|
+
if (line.length < 4) continue
|
|
20
|
+
const status = line.slice(0, 2)
|
|
21
|
+
if (status === '??' || status === '!!') continue
|
|
22
|
+
let path = line.slice(3)
|
|
23
|
+
const rename = path.indexOf(' -> ')
|
|
24
|
+
if (rename >= 0) path = path.slice(rename + 4)
|
|
25
|
+
paths.push(path.replace(/^"|"$/g, ''))
|
|
26
|
+
}
|
|
27
|
+
return paths.sort()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the commit a survey reads and fingerprint the tracked status at the
|
|
32
|
+
* same moment.
|
|
33
|
+
* @param {{ revParse(cwd: string, ref: string): Promise<string>, git(cwd: string, args: string[]): Promise<string>, root: string }} tower
|
|
34
|
+
* @param {string} ref
|
|
35
|
+
* @returns {Promise<{ commit: string, dirty: string[] }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function readFacts(tower, ref) {
|
|
38
|
+
const commit = await tower.revParse(tower.root, ref)
|
|
39
|
+
// A survey reads the shared checkout as it stands, so the checkout must be at
|
|
40
|
+
// the base it records. Otherwise every later completion check would compare
|
|
41
|
+
// against a commit the worker never saw (or block it for the wrong reason).
|
|
42
|
+
const head = await tower.revParse(tower.root, 'HEAD')
|
|
43
|
+
if (head !== commit) {
|
|
44
|
+
throw new TowerToolError(
|
|
45
|
+
'invalid-argument',
|
|
46
|
+
`the shared checkout is not at ${ref}: HEAD is ${head.slice(0, 12)} but ${ref} reads ${commit.slice(0, 12)}`,
|
|
47
|
+
{ nextAction: `check out ${ref} in the shared checkout and plan the survey again` },
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const porcelain = await tower.git(tower.root, ['status', '--porcelain'])
|
|
51
|
+
return { commit, dirty: trackedFingerprint(porcelain) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load-time resolver for a legacy survey that recorded no base at all (#149).
|
|
56
|
+
* A 2.6.0 survey planned but never provisioned is a valid document with no
|
|
57
|
+
* `baseCommit` and no worktree; the v3 -> v4 migration must anchor it at the
|
|
58
|
+
* state's base ref as that ref resolves *now*, together with the tracked-status
|
|
59
|
+
* fingerprint at the same moment (never a fabricated empty set).
|
|
60
|
+
*
|
|
61
|
+
* HEAD is deliberately not compared with the base here: a migration must load
|
|
62
|
+
* even when the shared checkout is parked on another branch. The plan-time
|
|
63
|
+
* guard in `readFacts` is what enforces HEAD === the recorded commit for a new
|
|
64
|
+
* survey.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when the document records no base ref at all; the migration then
|
|
67
|
+
* fails with its own clear message instead of inventing a commit.
|
|
68
|
+
* @param {{ revParse(cwd: string, ref: string): Promise<string>, git(cwd: string, args: string[]): Promise<string>, root: string }} tower
|
|
69
|
+
* @param {Record<string, any>} rawState
|
|
70
|
+
* @returns {Promise<{ readCommit: string, readDirty: string[] } | null>}
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveLegacySurveyRead(tower, rawState) {
|
|
73
|
+
const base = rawState?.base
|
|
74
|
+
if (typeof base !== 'string' || base.length === 0) return null
|
|
75
|
+
let readCommit
|
|
76
|
+
try {
|
|
77
|
+
readCommit = await tower.revParse(tower.root, base)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new TowerToolError(
|
|
80
|
+
'state-migration-failed',
|
|
81
|
+
`cannot anchor a planned-but-never-provisioned legacy survey: the state base "${base}" does not resolve (${String(error?.message ?? error)})`,
|
|
82
|
+
{
|
|
83
|
+
cause: error,
|
|
84
|
+
nextAction: `restore or check out the base ref "${base}" in the shared checkout, then load tower again; state.json was not modified`,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
const porcelain = await tower.git(tower.root, ['status', '--porcelain'])
|
|
89
|
+
return { readCommit, readDirty: trackedFingerprint(porcelain) }
|
|
90
|
+
}
|
package/app/spawn-prechecks.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// service/provider/lease validation. Split out of tools/spawn.js; no I/O.
|
|
3
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
4
4
|
import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
5
|
+
import { reviewUnavailable } from '../domain/integration/review.js'
|
|
6
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
7
|
+
import { roleCountsTowardBudget, roleMissionBlockers, roleTracksMission } from '../domain/roles.js'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Pick a unique roster name before any activation.
|
|
@@ -27,23 +30,21 @@ export function resolveRosterName({ role, missionId, requested, roster }) {
|
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Role/mission preconditions checked before any provisioning.
|
|
30
|
-
*
|
|
33
|
+
* `config` is the repository-resolved Tower config (#M39): with
|
|
34
|
+
* `review.mode:off` there is no review to dispatch, so the reviewer role is
|
|
35
|
+
* refused before a worktree, preset or activation is touched.
|
|
36
|
+
* @param {{ role: string, context: any, config?: any }} input
|
|
31
37
|
*/
|
|
32
|
-
export function assertSpawnableMission({ role, context }) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
if (
|
|
42
|
-
throw new TowerToolError(
|
|
43
|
-
'invalid-transition',
|
|
44
|
-
`mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
|
|
45
|
-
)
|
|
46
|
-
}
|
|
38
|
+
export function assertSpawnableMission({ role, context, config }) {
|
|
39
|
+
const shape = shapeFor(context.mission.kind)
|
|
40
|
+
// #149 W4: the role contract owns the prerequisites; the shape only supplies
|
|
41
|
+
// facts (can it merge? does it have a worktree? which statuses accept a spawn).
|
|
42
|
+
const [blocker] = roleMissionBlockers(role, {
|
|
43
|
+
shape,
|
|
44
|
+
mission: context.mission,
|
|
45
|
+
reviewAvailable: !reviewUnavailable(config),
|
|
46
|
+
})
|
|
47
|
+
if (blocker) throw new TowerToolError(blocker.code, blocker.message)
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -73,10 +74,16 @@ export async function precheckWorkerOwnership({
|
|
|
73
74
|
observeActivity,
|
|
74
75
|
}) {
|
|
75
76
|
let effectiveTakeover = takeover === true
|
|
76
|
-
if (role
|
|
77
|
+
if (!roleTracksMission(role)) return effectiveTakeover
|
|
78
|
+
// #148: a read-only survey worker must not consume a build worker slot, but
|
|
79
|
+
// the single-owner rule still applies to every worker entry.
|
|
80
|
+
const state = await tower.load()
|
|
81
|
+
const kinds = new Map(state.missions.map((entry) => [entry.id, entry.kind]))
|
|
82
|
+
const budgeted = (entry) => roleCountsTowardBudget(entry.role, shapeFor(kinds.get(entry.missionId)))
|
|
77
83
|
const liveWorkers = view.roster.filter(
|
|
78
|
-
(entry) => entry.role
|
|
84
|
+
(entry) => roleTracksMission(entry.role) && !ROSTER_INACTIVE_STATES.includes(entry.state),
|
|
79
85
|
)
|
|
86
|
+
const budgetWorkers = liveWorkers.filter(budgeted)
|
|
80
87
|
const sameMission = liveWorkers.filter((entry) => entry.missionId === missionId)
|
|
81
88
|
if (sameMission.length > 1) {
|
|
82
89
|
throw new TowerToolError(
|
|
@@ -118,9 +125,13 @@ export async function precheckWorkerOwnership({
|
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
const replacing = effectiveTakeover ? sameMission : []
|
|
121
|
-
const remaining =
|
|
128
|
+
const remaining = budgetWorkers.filter((entry) => !replacing.includes(entry))
|
|
122
129
|
const max = Math.min(Number(maxLiveWorkers) || 4, budget)
|
|
123
|
-
|
|
130
|
+
// The new mission's shape decides whether this spawn consumes a build slot: a
|
|
131
|
+
// survey does not, so it is allowed while every build slot is busy (#148).
|
|
132
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
133
|
+
const countsTowardBudget = roleCountsTowardBudget(role, shapeFor(mission?.kind))
|
|
134
|
+
if (countsTowardBudget && remaining.length >= max) {
|
|
124
135
|
throw new TowerToolError(
|
|
125
136
|
'worker-limit',
|
|
126
137
|
'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
|
package/app/spawn-support.js
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
1
|
// tower_spawn support (#56 P3c): briefing assembly, agent options, and the
|
|
2
2
|
// wait/result settling path. Split out of tools/spawn.js; no I/O of its own.
|
|
3
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
4
|
+
import { roleBriefingKind, rolePlacement } from '../domain/roles.js'
|
|
3
5
|
import { workerBriefing, reviewerBriefing } from './briefing.js'
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Resolve where a spawn runs (#149 W3). A worker gets the shape's provisioning
|
|
9
|
+
* (branch worktree, shared checkout, or detached survey sandbox); a reviewer
|
|
10
|
+
* gets a disposable detached checkout at the recorded branch tip, so the
|
|
11
|
+
* verdict is stamped with a commit instead of a live worktree HEAD.
|
|
12
|
+
* @param {any} tower
|
|
13
|
+
* @param {{ role: string, context: any, missionId: string, name: string, worktreeRoot?: string, includeDirtyBase?: boolean, instructions?: string }} input
|
|
14
|
+
*/
|
|
15
|
+
export async function provisionSpawnPlacement(tower, { role, context, missionId, name, worktreeRoot, includeDirtyBase, instructions }) {
|
|
16
|
+
// #149 W4: the role contract decides the placement; the shape supplies the
|
|
17
|
+
// mission provisioning facts it does not own.
|
|
18
|
+
const reviewCheckout = rolePlacement(role) === 'review-checkout'
|
|
19
|
+
const shape = shapeFor(context.mission.kind)
|
|
20
|
+
const provisioned = reviewCheckout
|
|
21
|
+
? null
|
|
22
|
+
: await tower.provision({ missionId, worktreeRoot, includeDirtyBase: includeDirtyBase !== false })
|
|
23
|
+
const review = reviewCheckout
|
|
24
|
+
? await tower.provisionReviewCheckout({ missionId, name, worktreeRoot })
|
|
25
|
+
: null
|
|
26
|
+
const mission = review ? review.mission : provisioned.mission
|
|
27
|
+
const worktree = review ? mission.worktree : provisioned.worktree
|
|
28
|
+
const cwd = review ? review.path : (provisioned.cwd ?? worktree.path)
|
|
29
|
+
const branch = worktree?.branch ?? null
|
|
30
|
+
// The briefing must describe the checkout that actually exists: a detached
|
|
31
|
+
// sandbox is recorded only after provision, so use the provisioned mission.
|
|
32
|
+
const briefing = buildSpawnBriefing({
|
|
33
|
+
role,
|
|
34
|
+
towerRoot: tower.root,
|
|
35
|
+
context: /** @type {any} */ ({ ...context, mission }),
|
|
36
|
+
worktree,
|
|
37
|
+
review,
|
|
38
|
+
instructions,
|
|
39
|
+
})
|
|
40
|
+
return {
|
|
41
|
+
mission,
|
|
42
|
+
worktree,
|
|
43
|
+
cwd,
|
|
44
|
+
branch,
|
|
45
|
+
review,
|
|
46
|
+
briefing,
|
|
47
|
+
provisioning: review ? 'review-checkout' : (provisioned.provisioning ?? shape.provisioning),
|
|
48
|
+
created: review ? true : provisioned.created,
|
|
49
|
+
sandbox: provisioned?.sandbox ?? null,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
5
53
|
/**
|
|
6
54
|
* @param {any} model
|
|
7
55
|
*/
|
|
@@ -14,25 +62,28 @@ export function buildAgentOptions(model) {
|
|
|
14
62
|
}
|
|
15
63
|
|
|
16
64
|
/**
|
|
17
|
-
* @param {{ role: string, towerRoot: string, context: any, worktree: any, instructions: string | undefined }} input
|
|
65
|
+
* @param {{ role: string, towerRoot: string, context: any, worktree: any, review?: any, instructions: string | undefined }} input
|
|
18
66
|
*/
|
|
19
|
-
export function buildSpawnBriefing({ role, towerRoot, context, worktree, instructions }) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
export function buildSpawnBriefing({ role, towerRoot, context, worktree, review = null, instructions }) {
|
|
68
|
+
// Only a shape that owns a worktree gets the git checkout projection; a survey
|
|
69
|
+
// reads the shared checkout and must not see a worktree record it does not own.
|
|
70
|
+
const shape = shapeFor(context.mission.kind)
|
|
71
|
+
const mission = shape.fields.worktree
|
|
72
|
+
? { ...context.mission, worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit } }
|
|
73
|
+
: { ...context.mission }
|
|
24
74
|
const briefing = {
|
|
25
75
|
repoRoot: towerRoot,
|
|
26
76
|
base: context.base,
|
|
27
77
|
mission,
|
|
28
78
|
deps: context.deps,
|
|
29
|
-
worktree,
|
|
79
|
+
worktree: worktree ?? null,
|
|
80
|
+
review,
|
|
30
81
|
instructions,
|
|
31
82
|
reviews: context.reviews,
|
|
32
83
|
findings: context.findings,
|
|
33
84
|
completionReport: context.completionReport,
|
|
34
85
|
}
|
|
35
|
-
return role === '
|
|
86
|
+
return roleBriefingKind(role) === 'reviewer' ? reviewerBriefing(briefing) : workerBriefing(briefing)
|
|
36
87
|
}
|
|
37
88
|
|
|
38
89
|
/**
|
package/app/spawn.js
CHANGED
|
@@ -96,6 +96,9 @@ export async function recordSpawn(tower, input) {
|
|
|
96
96
|
const now = Date.now()
|
|
97
97
|
const entry = recordSpawnEntry({
|
|
98
98
|
roster: state.roster,
|
|
99
|
+
// The live-worker count resolves each roster row's kind, so the whole
|
|
100
|
+
// mission list is required: a survey row is not a build worker (#148).
|
|
101
|
+
missions: state.missions,
|
|
99
102
|
mission,
|
|
100
103
|
input,
|
|
101
104
|
spawnTip,
|
package/app/teardown.js
CHANGED
|
@@ -21,6 +21,106 @@ import {
|
|
|
21
21
|
teardownRemovedDecision,
|
|
22
22
|
} from '../domain/integration/teardown.js'
|
|
23
23
|
import { sweepArchive } from './archive.js'
|
|
24
|
+
import { cleanupSandbox } from './sandbox.js'
|
|
25
|
+
import { cleanupReviewCheckout } from './reviewCheckout.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Disposable review checkouts (#149 W3). A reviewer works in a detached checkout
|
|
29
|
+
* at its recorded review commit and the checkout is removed when the verdict is
|
|
30
|
+
* recorded; a failed spawn or an interrupted reviewer leaves it behind, so
|
|
31
|
+
* teardown retries the bounded removal and clears the recorded path. A checkout
|
|
32
|
+
* whose reviewer is still active is kept. This runs before the worktree phase so
|
|
33
|
+
* its state revisions never invalidate the worktree claims.
|
|
34
|
+
* @param {any} tower
|
|
35
|
+
* @param {any} input
|
|
36
|
+
* @returns {Promise<{ decisions: any[], removed: string[] }>}
|
|
37
|
+
*/
|
|
38
|
+
async function teardownReviewCheckouts(tower, input) {
|
|
39
|
+
const state = await tower.load()
|
|
40
|
+
const exclude = new Set(input.exclude ?? [])
|
|
41
|
+
const decisions = []
|
|
42
|
+
const removed = []
|
|
43
|
+
for (const entry of state.roster ?? []) {
|
|
44
|
+
const checkout = typeof entry.reviewCheckout === 'string' ? entry.reviewCheckout : null
|
|
45
|
+
if (!checkout) continue
|
|
46
|
+
const row = { name: entry.name, missionId: entry.missionId ?? null, checkout }
|
|
47
|
+
if (exclude.has(entry.missionId)) {
|
|
48
|
+
decisions.push({ ...row, action: 'keep', reason: 'excluded' })
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (entry.state === 'active') {
|
|
52
|
+
decisions.push({ ...row, action: 'keep', reason: 'reviewer is active' })
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (input.dryRun === true) {
|
|
56
|
+
decisions.push({ ...row, action: 'remove', reason: 'eligible' })
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const outcome = await cleanupReviewCheckout(tower, { name: entry.name, missionId: entry.missionId })
|
|
60
|
+
if (!outcome || outcome.removal.ok) {
|
|
61
|
+
decisions.push({ ...row, action: 'removed', reason: outcome ? 'checkout was already gone' : 'checkout and record were already gone' })
|
|
62
|
+
removed.push(entry.name)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
decisions.push({
|
|
66
|
+
...row,
|
|
67
|
+
action: 'keep',
|
|
68
|
+
reason: `checkout cleanup failed after ${outcome.removal.attempts} attempt(s): ${outcome.removal.error}`,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return { decisions, removed }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Disposable detached survey sandboxes (#148 W2). A terminal survey may still
|
|
76
|
+
* carry a sandbox after a failed cleanup; teardown retries with the same bounded
|
|
77
|
+
* removal as completion. Run before the worktree phase so its state revisions
|
|
78
|
+
* never invalidate the worktree claims.
|
|
79
|
+
* @param {any} tower
|
|
80
|
+
* @param {any} input
|
|
81
|
+
* @returns {Promise<{ decisions: any[], removed: string[] }>}
|
|
82
|
+
*/
|
|
83
|
+
async function teardownSandboxes(tower, input) {
|
|
84
|
+
const state = await tower.load()
|
|
85
|
+
const activity = input.activity ?? {}
|
|
86
|
+
const exclude = new Set(input.exclude ?? [])
|
|
87
|
+
const decisions = []
|
|
88
|
+
const removed = []
|
|
89
|
+
for (const mission of state.missions) {
|
|
90
|
+
const sandbox = typeof /** @type {any} */ (mission).sandbox === 'string' ? /** @type {any} */ (mission).sandbox : null
|
|
91
|
+
if (!sandbox) continue
|
|
92
|
+
if (exclude.has(mission.id)) {
|
|
93
|
+
decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: 'excluded' })
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (hasLiveAgent(state, mission.id, activity)) {
|
|
97
|
+
decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: 'live agent' })
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (!['completed', 'abandoned'].includes(mission.status)) {
|
|
101
|
+
decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: `mission is ${mission.status}` })
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (input.dryRun === true) {
|
|
105
|
+
decisions.push({ missionId: mission.id, action: 'remove', sandbox, reason: 'eligible' })
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const outcome = await cleanupSandbox(tower, mission.id)
|
|
109
|
+
if (!outcome) {
|
|
110
|
+
decisions.push({ missionId: mission.id, action: 'removed', sandbox, reason: 'sandbox was already gone' })
|
|
111
|
+
removed.push(mission.id)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (outcome.ok) removed.push(mission.id)
|
|
115
|
+
decisions.push({
|
|
116
|
+
missionId: mission.id,
|
|
117
|
+
action: outcome.ok ? 'removed' : 'keep',
|
|
118
|
+
sandbox,
|
|
119
|
+
...(outcome.ok ? {} : { reason: `sandbox cleanup failed after ${outcome.attempts} attempt(s): ${outcome.error}` }),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return { decisions, removed }
|
|
123
|
+
}
|
|
24
124
|
|
|
25
125
|
/**
|
|
26
126
|
* Phase 1 (#51): decide every keep/remove outcome without holding the state
|
|
@@ -87,8 +187,23 @@ async function claimRemoval(tower, candidate, expectedRevision, input) {
|
|
|
87
187
|
}
|
|
88
188
|
|
|
89
189
|
export async function teardown(tower, input) {
|
|
190
|
+
// Sandbox cleanup runs first: it owns its own short locks and revisions, so
|
|
191
|
+
// the worktree prepare/act/commit phases below see a stable revision.
|
|
192
|
+
// Review checkout cleanup first: it owns its own short locks and revisions, so
|
|
193
|
+
// the worktree prepare/act/commit phases below see a stable revision.
|
|
194
|
+
const reviews = await teardownReviewCheckouts(tower, input)
|
|
195
|
+
const sandboxes = await teardownSandboxes(tower, input)
|
|
90
196
|
const prepared = await prepareTeardown(tower, input)
|
|
91
|
-
if (prepared.dryRun)
|
|
197
|
+
if (prepared.dryRun) {
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
dryRun: true,
|
|
201
|
+
removed: [],
|
|
202
|
+
decisions: [...reviews.decisions, ...sandboxes.decisions, ...prepared.decisions],
|
|
203
|
+
reviewCheckoutsRemoved: [],
|
|
204
|
+
sandboxesRemoved: [],
|
|
205
|
+
}
|
|
206
|
+
}
|
|
92
207
|
|
|
93
208
|
const removed = []
|
|
94
209
|
let expectedRevision = prepared.stateRevision
|
|
@@ -197,7 +312,9 @@ export async function teardown(tower, input) {
|
|
|
197
312
|
ok: true,
|
|
198
313
|
dryRun: false,
|
|
199
314
|
removed,
|
|
200
|
-
decisions: prepared.decisions,
|
|
315
|
+
decisions: [...reviews.decisions, ...sandboxes.decisions, ...prepared.decisions],
|
|
316
|
+
reviewCheckoutsRemoved: reviews.removed,
|
|
317
|
+
sandboxesRemoved: sandboxes.removed,
|
|
201
318
|
archived: archive.archived,
|
|
202
319
|
...(archive.error ? { archiveError: archive.error } : {}),
|
|
203
320
|
}
|