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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Pure batch lifecycle decisions and status projection.
|
|
2
|
+
//
|
|
3
|
+
// `domain/batch.js` owns the admission/close evidence validation; this module
|
|
4
|
+
// owns the id assignment, close/abandon record transitions, and status payload
|
|
5
|
+
// that app/batch.js used to keep inline. Receipt file reads stay in the tool
|
|
6
|
+
// layer.
|
|
7
|
+
// @ts-check
|
|
8
|
+
import { TowerToolError } from '../errors.js'
|
|
9
|
+
import { summarize } from '../batch.js'
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {string} [code]
|
|
16
|
+
* @returns {never}
|
|
17
|
+
*/
|
|
18
|
+
function fail(message, code = 'batch-invalid') {
|
|
19
|
+
throw new TowerToolError(code, message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Next free `B<n>` id.
|
|
24
|
+
* @param {any[]} batches
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function nextBatchId(batches) {
|
|
28
|
+
const max = batches.reduce((value, batch) => {
|
|
29
|
+
const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
|
|
30
|
+
return match ? Math.max(value, Number(match[1])) : value
|
|
31
|
+
}, 0)
|
|
32
|
+
return `B${max + 1}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Project the persisted batch record from normalized create input.
|
|
37
|
+
* @param {string} id
|
|
38
|
+
* @param {any} normalized
|
|
39
|
+
* @param {string} actor
|
|
40
|
+
* @param {number} now
|
|
41
|
+
* @returns {any}
|
|
42
|
+
*/
|
|
43
|
+
export function createBatchRecord(id, normalized, actor, now) {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
status: 'open',
|
|
47
|
+
...normalized,
|
|
48
|
+
createdBy: actor,
|
|
49
|
+
createdAt: now,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mark a validated open batch closed with its evidence snapshot.
|
|
55
|
+
* @param {any} batch
|
|
56
|
+
* @param {{ checks?: unknown, treeTip?: unknown, receiptHashes?: unknown }} options
|
|
57
|
+
* @param {string} actor
|
|
58
|
+
* @param {number} now
|
|
59
|
+
* @returns {any}
|
|
60
|
+
*/
|
|
61
|
+
export function closeBatchRecord(batch, options, actor, now) {
|
|
62
|
+
batch.status = 'closed'
|
|
63
|
+
batch.closedAt = now
|
|
64
|
+
batch.closedBy = actor
|
|
65
|
+
batch.close = {
|
|
66
|
+
checks: options.checks ?? [],
|
|
67
|
+
treeTip: options.treeTip ?? null,
|
|
68
|
+
receiptHashes: options.receiptHashes ?? {},
|
|
69
|
+
at: now,
|
|
70
|
+
}
|
|
71
|
+
return batch
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A closed batch cannot be abandoned; an already-abandoned one is a no-op.
|
|
76
|
+
* @param {any} batch
|
|
77
|
+
* @param {string} batchId
|
|
78
|
+
* @returns {{ alreadyAbandoned: boolean }}
|
|
79
|
+
*/
|
|
80
|
+
export function abandonBatchCheck(batch, batchId) {
|
|
81
|
+
if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
|
|
82
|
+
return { alreadyAbandoned: batch.status === 'abandoned' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply an abandonment to the draft batch.
|
|
87
|
+
* @param {{ batch: any, actor: string, reason?: unknown, now: number }} params
|
|
88
|
+
* @returns {any}
|
|
89
|
+
*/
|
|
90
|
+
export function applyBatchAbandon({ batch, actor, reason, now }) {
|
|
91
|
+
batch.status = 'abandoned'
|
|
92
|
+
batch.abandonedAt = now
|
|
93
|
+
batch.abandonedBy = actor
|
|
94
|
+
batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
|
|
95
|
+
return batch
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read-only `tower_batch status` payload.
|
|
100
|
+
* @param {TowerState} state
|
|
101
|
+
* @param {string | undefined} batchId
|
|
102
|
+
* @returns {any}
|
|
103
|
+
*/
|
|
104
|
+
export function batchStatusPayload(state, batchId) {
|
|
105
|
+
const batches = state.batches ?? []
|
|
106
|
+
const batch = batchId ? batches.find((entry) => entry.id === batchId) : null
|
|
107
|
+
if (batchId && !batch) fail(`unknown batch "${batchId}"`)
|
|
108
|
+
return {
|
|
109
|
+
batches: batches.map((entry) => summarize(state, entry)),
|
|
110
|
+
...(batch ? { batch: summarize(state, batch) } : {}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Pure message routing, read tracking, and inbox projections.
|
|
2
|
+
//
|
|
3
|
+
// app/messages.js owns the state lock, load, persistence, and activity log;
|
|
4
|
+
// app/roster.js's unreadFor delegates to `unreadMessages` here.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
9
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message
|
|
13
|
+
* @returns {never}
|
|
14
|
+
*/
|
|
15
|
+
function fail(message) {
|
|
16
|
+
throw new TowerToolError('invalid-argument', message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate the recipient against the roster plus the synthetic `tower` name.
|
|
21
|
+
* @param {{ roster?: RosterEntry[], to: string, from: string }} params
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
24
|
+
export function assertMessageRecipient({ roster = [], to, from }) {
|
|
25
|
+
const names = new Set(roster.map((entry) => entry.name))
|
|
26
|
+
names.add('tower')
|
|
27
|
+
if (to !== 'all' && !names.has(to)) {
|
|
28
|
+
fail(`unknown message recipient "${to}" (known: ${[...names].join(', ')}, all)`)
|
|
29
|
+
}
|
|
30
|
+
if (to === from) fail('refusing to send a message to yourself')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Project the persisted message record.
|
|
35
|
+
* @param {any} input
|
|
36
|
+
* @param {number} seq
|
|
37
|
+
* @param {number} now
|
|
38
|
+
* @returns {any}
|
|
39
|
+
*/
|
|
40
|
+
export function createMessage(input, seq, now) {
|
|
41
|
+
return {
|
|
42
|
+
id: `msg-${seq}`,
|
|
43
|
+
seq,
|
|
44
|
+
from: input.from,
|
|
45
|
+
to: input.to,
|
|
46
|
+
subject: input.subject,
|
|
47
|
+
body: input.body,
|
|
48
|
+
scope: input.scope ?? null,
|
|
49
|
+
consentRef: input.consentRef ?? null,
|
|
50
|
+
at: now,
|
|
51
|
+
readBy: [],
|
|
52
|
+
delivery: [],
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Append delivery results to a message, preserving earlier entries.
|
|
58
|
+
* @param {{ delivery?: unknown[] }} message
|
|
59
|
+
* @param {unknown[]} results
|
|
60
|
+
* @returns {unknown[]}
|
|
61
|
+
*/
|
|
62
|
+
export function addDelivery(message, results) {
|
|
63
|
+
message.delivery = [...(message.delivery ?? []), ...results]
|
|
64
|
+
return message.delivery
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Inbox page plus read-marking mutation. Returns `changed` so the use case can
|
|
69
|
+
* decide whether the state file needs a write.
|
|
70
|
+
* @param {any[]} messages
|
|
71
|
+
* @param {{ reader: string, limit?: number, includeRead?: boolean }} input
|
|
72
|
+
* @param {number} now
|
|
73
|
+
* @returns {{ payload: any, changed: boolean }}
|
|
74
|
+
*/
|
|
75
|
+
export function inboxView(messages, input, now) {
|
|
76
|
+
const reader = input.reader
|
|
77
|
+
const limit = Math.max(1, Math.min(100, input.limit ?? 20))
|
|
78
|
+
const includeRead = input.includeRead === true
|
|
79
|
+
const visible = [...messages]
|
|
80
|
+
.reverse()
|
|
81
|
+
.filter((message) => (message.to === reader || message.to === 'all') && message.from !== reader)
|
|
82
|
+
const unread = visible.filter((message) => !(message.readBy ?? []).includes(reader))
|
|
83
|
+
const page = (includeRead ? visible : unread).slice(0, limit)
|
|
84
|
+
|
|
85
|
+
let changed = false
|
|
86
|
+
for (const message of page) {
|
|
87
|
+
if (!(message.readBy ?? []).includes(reader)) {
|
|
88
|
+
message.readBy = [...(message.readBy ?? []), reader]
|
|
89
|
+
message.readAt = { ...(message.readAt ?? {}), [reader]: now }
|
|
90
|
+
changed = true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const unreadRemaining = visible.filter((message) => !(message.readBy ?? []).includes(reader)).length
|
|
94
|
+
return {
|
|
95
|
+
payload: {
|
|
96
|
+
reader,
|
|
97
|
+
messages: page.map((message) => ({
|
|
98
|
+
id: message.id,
|
|
99
|
+
from: message.from,
|
|
100
|
+
to: message.to,
|
|
101
|
+
subject: message.subject,
|
|
102
|
+
body: message.body,
|
|
103
|
+
scope: message.scope ?? null,
|
|
104
|
+
at: message.at,
|
|
105
|
+
delivery: message.delivery ?? [],
|
|
106
|
+
ackAt: message.readAt?.[reader] ?? null,
|
|
107
|
+
})),
|
|
108
|
+
pageSize: page.length,
|
|
109
|
+
unreadRemaining,
|
|
110
|
+
},
|
|
111
|
+
changed,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Unread inbox rows for one recipient (messages from self never count).
|
|
117
|
+
* @param {TowerState} state
|
|
118
|
+
* @param {string} name
|
|
119
|
+
* @returns {any[]}
|
|
120
|
+
*/
|
|
121
|
+
export function unreadMessages(state, name) {
|
|
122
|
+
return (state.messages ?? []).filter((message) => {
|
|
123
|
+
if (message.from === name) return false
|
|
124
|
+
if (message.to !== name && message.to !== 'all') return false
|
|
125
|
+
return !(/** @type {any[]} */ (message.readBy ?? [])).includes(name)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Pure roster observation and stale/inactive policy.
|
|
2
|
+
//
|
|
3
|
+
// app/roster.js owns the state lock, load, persistence, and activity log; the
|
|
4
|
+
// transitions below mutate only the in-memory roster rows they are given.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { ROSTER_INACTIVE } from '../roster.js'
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {RosterEntry} entry
|
|
12
|
+
* @returns {void}
|
|
13
|
+
*/
|
|
14
|
+
export function clearStale(entry) {
|
|
15
|
+
delete entry.stale
|
|
16
|
+
delete entry.staleAt
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Count repeated absence observations and list rows ready to adopt.
|
|
21
|
+
* @param {RosterEntry[]} roster
|
|
22
|
+
* @param {{ present?: unknown[], absent?: unknown[], confirmations?: unknown }} observations
|
|
23
|
+
* @param {number} now
|
|
24
|
+
* @returns {{ candidates: string[], changed: boolean }}
|
|
25
|
+
*/
|
|
26
|
+
export function recordRosterObservations(roster, { present = [], absent = [], confirmations = 2 }, now) {
|
|
27
|
+
const presentIds = new Set(present)
|
|
28
|
+
const absentIds = new Set(absent)
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
const candidates = []
|
|
31
|
+
let changed = false
|
|
32
|
+
for (const entry of roster) {
|
|
33
|
+
if (presentIds.has(entry.agentId)) {
|
|
34
|
+
if (entry.absentObservations !== undefined) {
|
|
35
|
+
delete entry.absentObservations
|
|
36
|
+
delete entry.absentSince
|
|
37
|
+
changed = true
|
|
38
|
+
}
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (!absentIds.has(entry.agentId)) continue
|
|
42
|
+
entry.absentObservations = (/** @type {number} */ (entry.absentObservations ?? 0)) + 1
|
|
43
|
+
entry.absentSince ??= now
|
|
44
|
+
changed = true
|
|
45
|
+
const observations = /** @type {number} */ (entry.absentObservations)
|
|
46
|
+
if (observations >= Math.max(2, Number(confirmations) || 2)) {
|
|
47
|
+
candidates.push(/** @type {string} */ (entry.agentId))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { candidates, changed }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Release live slots whose catalog activity is explicitly inactive.
|
|
55
|
+
* @param {RosterEntry[]} roster
|
|
56
|
+
* @param {Record<string, string>} activity
|
|
57
|
+
* @param {number} now
|
|
58
|
+
* @returns {{ released: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
export function reconcileObservedInactive(roster, activity = {}, now) {
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
const released = []
|
|
63
|
+
for (const entry of roster) {
|
|
64
|
+
if (entry.state === 'reserved' || ROSTER_INACTIVE.has(entry.state)) continue
|
|
65
|
+
const agentId = /** @type {string} */ (entry.agentId)
|
|
66
|
+
if (activity[agentId] !== 'inactive') continue
|
|
67
|
+
entry.state = 'inactive'
|
|
68
|
+
entry.outcome = 'catalog reported inactive'
|
|
69
|
+
entry.outcomeAt = now
|
|
70
|
+
clearStale(entry)
|
|
71
|
+
released.push(entry.name)
|
|
72
|
+
}
|
|
73
|
+
return { released }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Retire entries by agent id, skipping rows already retired.
|
|
78
|
+
* @param {RosterEntry[]} roster
|
|
79
|
+
* @param {unknown[]} agentIds
|
|
80
|
+
* @param {number} now
|
|
81
|
+
* @returns {{ retired: string[] }}
|
|
82
|
+
*/
|
|
83
|
+
export function retireEntries(roster, agentIds, now) {
|
|
84
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
85
|
+
/** @type {string[]} */
|
|
86
|
+
const retired = []
|
|
87
|
+
for (const entry of roster) {
|
|
88
|
+
if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
|
|
89
|
+
entry.state = 'retired'
|
|
90
|
+
entry.retiredAt = now
|
|
91
|
+
clearStale(entry)
|
|
92
|
+
retired.push(/** @type {string} */ (entry.agentId))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { retired }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mark entries missing unless they are already inactive.
|
|
100
|
+
* @param {RosterEntry[]} roster
|
|
101
|
+
* @param {unknown[]} agentIds
|
|
102
|
+
* @param {number} now
|
|
103
|
+
* @returns {{ missing: string[] }}
|
|
104
|
+
*/
|
|
105
|
+
export function markMissingEntries(roster, agentIds, now) {
|
|
106
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
107
|
+
/** @type {string[]} */
|
|
108
|
+
const missing = []
|
|
109
|
+
for (const entry of roster) {
|
|
110
|
+
if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
|
|
111
|
+
entry.state = 'missing'
|
|
112
|
+
entry.missingSince = now
|
|
113
|
+
clearStale(entry)
|
|
114
|
+
missing.push(/** @type {string} */ (entry.agentId))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { missing }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Watchdog sweep: catalog-inactive rows become inactive; rows absent from an
|
|
122
|
+
* authoritative catalog and older than `staleMs` become stale. Unknown catalog
|
|
123
|
+
* state is never treated as stopped.
|
|
124
|
+
* @param {RosterEntry[]} roster
|
|
125
|
+
* @param {{ activity?: Record<string, string>, staleMs?: unknown, authoritative?: unknown }} input
|
|
126
|
+
* @param {number} now
|
|
127
|
+
* @returns {{ marked: string[], idled: string[] }}
|
|
128
|
+
*/
|
|
129
|
+
export function sweepRosterState(roster, input = {}, now) {
|
|
130
|
+
const staleMs = Number(input.staleMs) || 0
|
|
131
|
+
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
132
|
+
const activity = input.activity ?? {}
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const marked = []
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const idled = []
|
|
137
|
+
for (const entry of roster) {
|
|
138
|
+
if (ROSTER_INACTIVE.has(entry.state)) continue
|
|
139
|
+
const observed = activity[/** @type {string} */ (entry.agentId)]
|
|
140
|
+
if (observed === 'inactive') {
|
|
141
|
+
entry.state = 'inactive'
|
|
142
|
+
entry.outcome = entry.outcome ?? 'idle (catalog)'
|
|
143
|
+
entry.outcomeAt = now
|
|
144
|
+
idled.push(entry.name)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (observed === 'running') continue
|
|
148
|
+
if (observed !== undefined) continue
|
|
149
|
+
const age = now - Number(entry.spawnedAt ?? now)
|
|
150
|
+
if (age > staleMs) {
|
|
151
|
+
entry.state = 'stale'
|
|
152
|
+
entry.stale = true
|
|
153
|
+
entry.staleAt = now
|
|
154
|
+
marked.push(entry.name)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { marked, idled }
|
|
158
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Pure spawn reservation/outcome policy: roster entry projections, role and
|
|
2
|
+
// live-worker limit decisions.
|
|
3
|
+
//
|
|
4
|
+
// app/spawn.js owns the state lock, load, Git spawn-tip capture, persistence,
|
|
5
|
+
// and activity log. Preset/provider selection happens in tools/spawn.js and is
|
|
6
|
+
// intentionally not duplicated here.
|
|
7
|
+
// @ts-check
|
|
8
|
+
import { TowerToolError } from '../errors.js'
|
|
9
|
+
import { ROSTER_INACTIVE, countLiveWorkers, workerEntries } from '../roster.js'
|
|
10
|
+
import { clearStale } from './roster.js'
|
|
11
|
+
|
|
12
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
13
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} message
|
|
17
|
+
* @returns {never}
|
|
18
|
+
*/
|
|
19
|
+
function fail(message) {
|
|
20
|
+
throw new TowerToolError('invalid-argument', message)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} role
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isWorkerRole(role) {
|
|
28
|
+
return role === 'worker'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The configured live-worker ceiling, defaulting to four.
|
|
33
|
+
* @param {unknown} maxLiveWorkers
|
|
34
|
+
* @returns {number}
|
|
35
|
+
*/
|
|
36
|
+
export function liveWorkerLimit(maxLiveWorkers) {
|
|
37
|
+
return Number(maxLiveWorkers) || 4
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Record that a spawned agent failed or went idle.
|
|
42
|
+
* @param {RosterEntry} entry
|
|
43
|
+
* @param {{ state?: string, detail?: unknown }} input
|
|
44
|
+
* @param {number} now
|
|
45
|
+
* @returns {RosterEntry}
|
|
46
|
+
*/
|
|
47
|
+
export function applySpawnOutcome(entry, input, now) {
|
|
48
|
+
entry.state = input.state ?? 'failed'
|
|
49
|
+
entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
|
|
50
|
+
entry.outcomeAt = now
|
|
51
|
+
clearStale(entry)
|
|
52
|
+
return entry
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record that a live agent was interrupted by the tower.
|
|
57
|
+
* @param {RosterEntry} entry
|
|
58
|
+
* @param {{ reason?: unknown }} input
|
|
59
|
+
* @param {number} now
|
|
60
|
+
* @returns {RosterEntry}
|
|
61
|
+
*/
|
|
62
|
+
export function applyInterrupt(entry, input, now) {
|
|
63
|
+
entry.state = 'interrupted'
|
|
64
|
+
entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
|
|
65
|
+
entry.outcomeAt = now
|
|
66
|
+
clearStale(entry)
|
|
67
|
+
return entry
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A reserved roster row: the name is claimed before activation so a failed
|
|
72
|
+
* spawn remains recoverable.
|
|
73
|
+
* @param {any} input
|
|
74
|
+
* @param {number} now
|
|
75
|
+
* @returns {RosterEntry}
|
|
76
|
+
*/
|
|
77
|
+
export function reservedRosterEntry(input, now) {
|
|
78
|
+
return {
|
|
79
|
+
name: input.name,
|
|
80
|
+
role: input.role,
|
|
81
|
+
agentId: null,
|
|
82
|
+
sessionId: null,
|
|
83
|
+
parentSessionId: input.parentSessionId,
|
|
84
|
+
missionId: input.missionId,
|
|
85
|
+
cwd: input.cwd ?? null,
|
|
86
|
+
branch: input.branch ?? null,
|
|
87
|
+
spawnTip: null,
|
|
88
|
+
state: 'reserved',
|
|
89
|
+
reservedAt: now,
|
|
90
|
+
spawnedAt: now,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a reservation request and project its row.
|
|
96
|
+
* @param {{ roster: RosterEntry[], input: any, now: number }} params
|
|
97
|
+
* @returns {RosterEntry}
|
|
98
|
+
*/
|
|
99
|
+
export function reserveRosterEntry({ roster, input, now }) {
|
|
100
|
+
if (roster.some((entry) => entry.name === input.name)) fail(`roster name already used: ${input.name}`)
|
|
101
|
+
return reservedRosterEntry(input, now)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate and apply a spawn record. Mutates the roster (retiring prior worker
|
|
106
|
+
* rows and inserting/reusing the entry) and the mission owner/status/gen.
|
|
107
|
+
* @param {{ roster: RosterEntry[], mission: Mission, input: any, spawnTip: string | null, maxLiveWorkers: unknown, now: number }} params
|
|
108
|
+
* @returns {RosterEntry}
|
|
109
|
+
*/
|
|
110
|
+
export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWorkers, now }) {
|
|
111
|
+
const reserved = roster.find((entry) => entry.name === input.name && entry.state === 'reserved' && !entry.agentId)
|
|
112
|
+
if (!reserved && roster.some((entry) => entry.name === input.name)) {
|
|
113
|
+
fail(`roster name already used: ${input.name}`)
|
|
114
|
+
}
|
|
115
|
+
if (roster.some((entry) => entry.agentId && entry.agentId === input.agentId)) {
|
|
116
|
+
fail(`agent ${input.agentId} is already in the roster`)
|
|
117
|
+
}
|
|
118
|
+
/** @type {RosterEntry} */
|
|
119
|
+
const entry = {
|
|
120
|
+
name: input.name,
|
|
121
|
+
role: input.role,
|
|
122
|
+
agentId: input.agentId,
|
|
123
|
+
sessionId: input.sessionId ?? input.agentId,
|
|
124
|
+
parentSessionId: input.parentSessionId,
|
|
125
|
+
missionId: input.missionId,
|
|
126
|
+
cwd: input.cwd ?? null,
|
|
127
|
+
branch: input.branch ?? null,
|
|
128
|
+
spawnTip,
|
|
129
|
+
state: 'active',
|
|
130
|
+
spawnedAt: now,
|
|
131
|
+
}
|
|
132
|
+
if (isWorkerRole(input.role)) {
|
|
133
|
+
const snapshot = /** @type {any} */ ({ roster })
|
|
134
|
+
const previous = workerEntries(snapshot, mission.id)
|
|
135
|
+
const live = previous.find((row) => !ROSTER_INACTIVE.has(row.state) && row.state !== 'reserved')
|
|
136
|
+
if (live && !input.takeover) {
|
|
137
|
+
fail(
|
|
138
|
+
`mission ${mission.id} already has a live worker "${live.name}"; stop it with tower_interrupt, or pass takeover:true after it is no longer running`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
// A new owner always retires the mission's older worker entries, including
|
|
142
|
+
// idle `inactive` ones; otherwise the next spawn would see several entries
|
|
143
|
+
// for the same mission and the old idle worker would block forever.
|
|
144
|
+
for (const old of previous) {
|
|
145
|
+
if (old.state === 'retired' || old.state === 'reserved') continue
|
|
146
|
+
old.state = 'retired'
|
|
147
|
+
old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
|
|
148
|
+
old.outcomeAt = now
|
|
149
|
+
clearStale(old)
|
|
150
|
+
}
|
|
151
|
+
const max = liveWorkerLimit(maxLiveWorkers)
|
|
152
|
+
if (countLiveWorkers(snapshot) >= max) {
|
|
153
|
+
fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
|
|
154
|
+
}
|
|
155
|
+
const generation = (mission.ownerGeneration ?? 0) + 1
|
|
156
|
+
entry.generation = generation
|
|
157
|
+
mission.ownerGeneration = generation
|
|
158
|
+
mission.owner = input.name
|
|
159
|
+
if (mission.status === 'planned') mission.status = 'active'
|
|
160
|
+
}
|
|
161
|
+
if (reserved) Object.assign(reserved, entry)
|
|
162
|
+
else roster.push(entry)
|
|
163
|
+
return reserved ?? entry
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Pure read projection for `tower_roster`.
|
|
168
|
+
* @param {import('../../types/index.js').TowerState} state
|
|
169
|
+
* @returns {any}
|
|
170
|
+
*/
|
|
171
|
+
export function rosterProjection(state) {
|
|
172
|
+
return {
|
|
173
|
+
revision: state.revision,
|
|
174
|
+
roster: (state.roster ?? []).map((entry) => ({ ...entry })),
|
|
175
|
+
missions: state.missions.map((mission) => ({
|
|
176
|
+
id: mission.id,
|
|
177
|
+
status: mission.status,
|
|
178
|
+
owner: mission.owner ?? null,
|
|
179
|
+
worktree: mission.worktree
|
|
180
|
+
? {
|
|
181
|
+
path: mission.worktree.path,
|
|
182
|
+
branch: mission.worktree.branch,
|
|
183
|
+
baseCommit: mission.worktree.baseCommit,
|
|
184
|
+
}
|
|
185
|
+
: null,
|
|
186
|
+
})),
|
|
187
|
+
}
|
|
188
|
+
}
|