dsh-tower 1.3.0 → 2.0.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 +109 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +44 -8
- package/README.md +42 -8
- package/cordis.patch.yml +11 -1
- package/docs/README.md +10 -5
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +45 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/guide/scenarios-and-philosophy.md +309 -0
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +33 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +55 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +60 -0
- package/docs/zh/faq.md +81 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +31 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +50 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +275 -9
- package/lib/doctor.js +171 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1566 -184
- package/lib/tower.js +986 -103
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
package/lib/tower.js
CHANGED
|
@@ -17,18 +17,21 @@ import {
|
|
|
17
17
|
isAncestor,
|
|
18
18
|
tryGit,
|
|
19
19
|
} from './git.js'
|
|
20
|
+
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from './batch.js'
|
|
21
|
+
import { assertOwnership, canonicalPath } from './worktreeOwnership.js'
|
|
20
22
|
import {
|
|
21
23
|
pathsFor,
|
|
22
24
|
exists,
|
|
23
25
|
ensureDir,
|
|
24
26
|
readJson,
|
|
25
27
|
writeJsonAtomic,
|
|
28
|
+
renameWithRetry,
|
|
26
29
|
appendJsonl,
|
|
27
30
|
readActivityTail,
|
|
28
31
|
withLock,
|
|
29
32
|
shortHash,
|
|
30
33
|
} from './store.js'
|
|
31
|
-
import { runtimeConfig } from './config.js'
|
|
34
|
+
import { runtimeConfig, resolveRepoConfig } from './config.js'
|
|
32
35
|
|
|
33
36
|
export const SCHEMA_VERSION = 1
|
|
34
37
|
export const MISSION_STATUSES = [
|
|
@@ -41,6 +44,14 @@ export const MISSION_STATUSES = [
|
|
|
41
44
|
'abandoned',
|
|
42
45
|
]
|
|
43
46
|
const TERMINAL = new Set(['merged', 'abandoned'])
|
|
47
|
+
/** Roster states that no longer represent a live writer. `inactive` is an idle
|
|
48
|
+
* child whose turn finished: it must not block a new owner or consume a worker
|
|
49
|
+
* slot. `unknown` is deliberately NOT included because it cannot be proven
|
|
50
|
+
* stopped. */
|
|
51
|
+
export const ROSTER_INACTIVE_STATES = ['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive']
|
|
52
|
+
const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
|
|
53
|
+
const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
|
|
54
|
+
const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
|
|
44
55
|
const TRANSITIONS = {
|
|
45
56
|
planned: ['active', 'abandoned'],
|
|
46
57
|
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
@@ -63,15 +74,22 @@ function slugify(title) {
|
|
|
63
74
|
|
|
64
75
|
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
65
76
|
export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
|
|
77
|
+
export const FINDING_STATUSES = ['open', 'triaged', 'fixed', 'wontfix']
|
|
78
|
+
export const FINDING_TRANSITIONS = {
|
|
79
|
+
open: ['triaged', 'fixed', 'wontfix'],
|
|
80
|
+
triaged: ['open', 'fixed', 'wontfix'],
|
|
81
|
+
fixed: ['open'],
|
|
82
|
+
wontfix: ['open'],
|
|
83
|
+
}
|
|
66
84
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
67
85
|
const MAX_REVIEW_ROUNDS = 5
|
|
68
86
|
const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
69
87
|
const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
|
|
70
88
|
const MERGE_MAX_BUFFER = 64 * 1024 * 1024
|
|
71
89
|
|
|
72
|
-
function mergeTimeoutMs(value) {
|
|
90
|
+
function mergeTimeoutMs(value, config) {
|
|
73
91
|
if (value === undefined || value === null) {
|
|
74
|
-
const configured = runtimeConfig().merge.timeoutMs
|
|
92
|
+
const configured = (config ?? runtimeConfig()).merge.timeoutMs
|
|
75
93
|
return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
|
|
76
94
|
}
|
|
77
95
|
const ms = Number(value)
|
|
@@ -284,7 +302,26 @@ function normalizeMissionInput(input, index) {
|
|
|
284
302
|
}
|
|
285
303
|
}
|
|
286
304
|
const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
|
|
287
|
-
|
|
305
|
+
const references = []
|
|
306
|
+
if (input.references !== undefined) {
|
|
307
|
+
if (!Array.isArray(input.references)) fail(`missions[${index}].references must be an array of pointers`)
|
|
308
|
+
if (input.references.length > 20) fail(`missions[${index}].references supports at most 20 pointers`)
|
|
309
|
+
for (const reference of input.references) {
|
|
310
|
+
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
311
|
+
fail(`missions[${index}].references entries must be non-empty strings`)
|
|
312
|
+
}
|
|
313
|
+
if (!references.includes(reference.trim())) references.push(reference.trim())
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const reviewPolicy = input.reviewPolicy === undefined ? 'required-clean' : input.reviewPolicy
|
|
317
|
+
if (!['required-clean', 'none'].includes(reviewPolicy)) {
|
|
318
|
+
fail(`missions[${index}].reviewPolicy must be "required-clean" or "none"`)
|
|
319
|
+
}
|
|
320
|
+
const reviewExemptionReason = typeof input.reviewExemptionReason === 'string' ? input.reviewExemptionReason.trim() : ''
|
|
321
|
+
if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
|
|
322
|
+
fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
|
|
323
|
+
}
|
|
324
|
+
return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
function newMission(id, input, now) {
|
|
@@ -296,6 +333,9 @@ function newMission(id, input, now) {
|
|
|
296
333
|
tasks: input.tasks,
|
|
297
334
|
deps: input.deps,
|
|
298
335
|
context: input.context,
|
|
336
|
+
references: input.references ?? [],
|
|
337
|
+
reviewPolicy: input.reviewPolicy ?? 'required-clean',
|
|
338
|
+
reviewExemptionReason: input.reviewExemptionReason ?? '',
|
|
299
339
|
status: 'planned',
|
|
300
340
|
owner: null,
|
|
301
341
|
blockers: [],
|
|
@@ -324,6 +364,24 @@ export function validateState(state, statePath) {
|
|
|
324
364
|
}
|
|
325
365
|
if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
|
|
326
366
|
if (!Array.isArray(mission.deps)) fail(`mission ${mission.id} has no deps array${where}`)
|
|
367
|
+
if (mission.references !== undefined && !Array.isArray(mission.references)) {
|
|
368
|
+
fail(`mission ${mission.id} has no references array${where}`)
|
|
369
|
+
}
|
|
370
|
+
if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
|
|
371
|
+
fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
|
|
372
|
+
}
|
|
373
|
+
if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
|
|
374
|
+
fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
|
|
375
|
+
}
|
|
376
|
+
if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
|
|
377
|
+
fail(`mission ${mission.id} has no testRuns array${where}`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const key of ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']) {
|
|
381
|
+
if (state[key] !== undefined && !Array.isArray(state[key])) fail(`tower state ${key} is not an array${where}`)
|
|
382
|
+
}
|
|
383
|
+
if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
|
|
384
|
+
fail(`tower state mergeIntent is not an object${where}`)
|
|
327
385
|
}
|
|
328
386
|
if (state.roster !== undefined) {
|
|
329
387
|
if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
|
|
@@ -358,6 +416,9 @@ export function validateState(state, statePath) {
|
|
|
358
416
|
if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
|
|
359
417
|
if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
|
|
360
418
|
ids.add(finding.id)
|
|
419
|
+
if (finding.status !== undefined && !FINDING_STATUSES.includes(finding.status)) {
|
|
420
|
+
fail(`finding ${finding.id} has unknown status ${finding.status}${where}`)
|
|
421
|
+
}
|
|
361
422
|
}
|
|
362
423
|
}
|
|
363
424
|
if (state.reviews !== undefined) {
|
|
@@ -407,6 +468,9 @@ export function missionSummary(mission) {
|
|
|
407
468
|
blockers: mission.blockers ?? [],
|
|
408
469
|
tasks: taskCounts(mission.tasks ?? []),
|
|
409
470
|
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
471
|
+
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
472
|
+
pauseCause: mission.pauseCause ?? null,
|
|
473
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
410
474
|
mergedCommit: mission.mergedCommit ?? null,
|
|
411
475
|
mergedAt: mission.mergedAt ?? null,
|
|
412
476
|
updatedAt: mission.updatedAt,
|
|
@@ -418,20 +482,96 @@ function fullMission(mission) {
|
|
|
418
482
|
...missionSummary(mission),
|
|
419
483
|
tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
|
|
420
484
|
notes: mission.notes ?? [],
|
|
485
|
+
completionReport: mission.completionReport ?? null,
|
|
486
|
+
authorizationRef: mission.authorizationRef ?? null,
|
|
487
|
+
references: mission.references ?? [],
|
|
488
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
489
|
+
reviewExemptionReason: mission.reviewExemptionReason ?? '',
|
|
490
|
+
testRuns: mission.testRuns ?? [],
|
|
421
491
|
}
|
|
422
492
|
}
|
|
423
493
|
|
|
494
|
+
function findingSummary(finding) {
|
|
495
|
+
return {
|
|
496
|
+
id: finding.id,
|
|
497
|
+
seq: finding.seq ?? null,
|
|
498
|
+
type: finding.type,
|
|
499
|
+
title: finding.title,
|
|
500
|
+
severity: finding.severity ?? null,
|
|
501
|
+
status: finding.status ?? 'open',
|
|
502
|
+
missionId: finding.missionId ?? null,
|
|
503
|
+
branch: finding.branch ?? null,
|
|
504
|
+
reportedBy: finding.reportedBy ?? null,
|
|
505
|
+
at: finding.at ?? null,
|
|
506
|
+
updatedAt: finding.updatedAt ?? null,
|
|
507
|
+
closedAt: finding.closedAt ?? null,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function countFindings(findings) {
|
|
512
|
+
const counts = {}
|
|
513
|
+
for (const status of FINDING_STATUSES) counts[status] = 0
|
|
514
|
+
for (const finding of findings) {
|
|
515
|
+
const status = finding.status ?? 'open'
|
|
516
|
+
counts[status] = (counts[status] ?? 0) + 1
|
|
517
|
+
}
|
|
518
|
+
return counts
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function liveWorkerEntry(state, missionId) {
|
|
522
|
+
return (state.roster ?? []).find(
|
|
523
|
+
(entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function workerEntries(state, missionId) {
|
|
528
|
+
return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function countLiveWorkers(state) {
|
|
532
|
+
return (state.roster ?? []).filter((entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state)).length
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizePauseCause(value) {
|
|
536
|
+
if (value === undefined || value === null || value === '') return 'user'
|
|
537
|
+
if (!PAUSE_CAUSES.includes(value)) fail(`pause_cause must be one of ${PAUSE_CAUSES.join('|')}`)
|
|
538
|
+
return value
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function normalizeChecks(checks) {
|
|
542
|
+
if (checks === undefined) return []
|
|
543
|
+
if (!Array.isArray(checks)) fail('checks must be an array')
|
|
544
|
+
if (checks.length > 20) fail('checks may contain at most 20 entries')
|
|
545
|
+
return checks.map((check, index) => {
|
|
546
|
+
if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
|
|
547
|
+
const name = typeof check.name === 'string' ? check.name.trim() : ''
|
|
548
|
+
if (!name) fail(`checks[${index}].name must be a non-empty string`)
|
|
549
|
+
const entry = { name }
|
|
550
|
+
if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
|
|
551
|
+
if (check.exitCode !== undefined && check.exitCode !== null) {
|
|
552
|
+
if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
|
|
553
|
+
entry.exitCode = check.exitCode
|
|
554
|
+
}
|
|
555
|
+
if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
|
|
556
|
+
return entry
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
424
560
|
export class Tower {
|
|
425
|
-
constructor(repo) {
|
|
561
|
+
constructor(repo, config) {
|
|
426
562
|
this.root = repo.root
|
|
427
563
|
this.commonDir = repo.commonDir
|
|
428
564
|
this.branch = repo.branch
|
|
429
565
|
this.paths = pathsFor(repo.commonDir)
|
|
566
|
+
// Effective config for this repository: code defaults < profile config <
|
|
567
|
+
// repo config file. Per-call tool arguments are applied by the caller.
|
|
568
|
+
this.config = config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false }
|
|
430
569
|
}
|
|
431
570
|
|
|
432
571
|
static async open(repoRoot) {
|
|
433
572
|
const repo = await resolveRepo(repoRoot)
|
|
434
|
-
|
|
573
|
+
const config = await resolveRepoConfig(repo)
|
|
574
|
+
return new Tower(repo, config)
|
|
435
575
|
}
|
|
436
576
|
|
|
437
577
|
async load() {
|
|
@@ -445,8 +585,52 @@ export class Tower {
|
|
|
445
585
|
async init(input = {}) {
|
|
446
586
|
await ensureDir(this.paths.dir)
|
|
447
587
|
return withLock(this.paths, async () => {
|
|
588
|
+
const now = Date.now()
|
|
589
|
+
const dirty = !(await isClean(this.root))
|
|
590
|
+
const committed = await hasAnyCommit(this.root)
|
|
591
|
+
const resolveBase = async () => {
|
|
592
|
+
let base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
|
|
593
|
+
if (base) {
|
|
594
|
+
if (/^refs\//.test(base) || base.startsWith('origin/')) {
|
|
595
|
+
fail(`base must be a local branch name, not ${base}`)
|
|
596
|
+
}
|
|
597
|
+
if (committed && !(await localBranchExists(this.root, base))) {
|
|
598
|
+
fail(`base branch "${base}" does not exist locally`)
|
|
599
|
+
}
|
|
600
|
+
return base
|
|
601
|
+
}
|
|
602
|
+
base = this.branch
|
|
603
|
+
if (!base && !committed) base = 'main'
|
|
604
|
+
if (!base) fail('repository is in detached HEAD; pass an explicit local base branch')
|
|
605
|
+
return base
|
|
606
|
+
}
|
|
607
|
+
|
|
448
608
|
if (await exists(this.paths.state)) {
|
|
449
609
|
const state = await this.load()
|
|
610
|
+
let reanchored = false
|
|
611
|
+
if (input.reanchor === true) {
|
|
612
|
+
const active = state.missions.filter((mission) => !TERMINAL.has(mission.status))
|
|
613
|
+
if (active.length > 0) {
|
|
614
|
+
fail(`cannot reanchor base while ${active.length} mission(s) are not merged or abandoned`)
|
|
615
|
+
}
|
|
616
|
+
const base = await resolveBase()
|
|
617
|
+
if (base !== state.base) {
|
|
618
|
+
const refs = (await tryGit(this.root, ['for-each-ref', '--format=%(refname)', SNAPSHOT_REF_PREFIX])) ?? ''
|
|
619
|
+
for (const ref of refs.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
620
|
+
await git(this.root, ['update-ref', '-d', ref]).catch(() => {})
|
|
621
|
+
}
|
|
622
|
+
state.base = base
|
|
623
|
+
state.baseChangedAt = now
|
|
624
|
+
delete state.snapshotCommit
|
|
625
|
+
delete state.snapshotRef
|
|
626
|
+
reanchored = true
|
|
627
|
+
state.revision += 1
|
|
628
|
+
state.updatedAt = now
|
|
629
|
+
validateState(state, this.paths.state)
|
|
630
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
631
|
+
await appendJsonl(this.paths.activity, { at: now, type: 'init', text: `base reanchored to ${base}` })
|
|
632
|
+
}
|
|
633
|
+
}
|
|
450
634
|
return {
|
|
451
635
|
created: false,
|
|
452
636
|
repoRoot: state.repoRoot,
|
|
@@ -455,23 +639,24 @@ export class Tower {
|
|
|
455
639
|
base: state.base,
|
|
456
640
|
revision: state.revision,
|
|
457
641
|
missions: state.missions.length,
|
|
642
|
+
reanchored,
|
|
643
|
+
dirty,
|
|
644
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
458
645
|
}
|
|
459
646
|
}
|
|
460
647
|
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
let
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (committed && !(await localBranchExists(this.root, base))) {
|
|
469
|
-
fail(`base branch "${base}" does not exist locally`)
|
|
648
|
+
const base = await resolveBase()
|
|
649
|
+
let snapshotCommit = null
|
|
650
|
+
let snapshotRef = null
|
|
651
|
+
if (input.snapshotDirty === true && dirty) {
|
|
652
|
+
snapshotCommit = ((await tryGit(this.root, ['stash', 'create'])) ?? '').trim() || null
|
|
653
|
+
if (!snapshotCommit) {
|
|
654
|
+
fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
|
|
470
655
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
656
|
+
// A dangling commit can be pruned by git gc; keep a ref so the mission
|
|
657
|
+
// baseline stays reachable for the life of the state.
|
|
658
|
+
snapshotRef = `${SNAPSHOT_REF_PREFIX}${snapshotCommit}`
|
|
659
|
+
await git(this.root, ['update-ref', snapshotRef, snapshotCommit])
|
|
475
660
|
}
|
|
476
661
|
|
|
477
662
|
const state = {
|
|
@@ -482,11 +667,17 @@ export class Tower {
|
|
|
482
667
|
revision: 0,
|
|
483
668
|
createdAt: now,
|
|
484
669
|
updatedAt: now,
|
|
670
|
+
dirtyBase: dirty,
|
|
671
|
+
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
485
672
|
missions: [],
|
|
486
673
|
}
|
|
487
674
|
validateState(state, this.paths.state)
|
|
488
675
|
await writeJsonAtomic(this.paths.state, state)
|
|
489
|
-
await appendJsonl(this.paths.activity, {
|
|
676
|
+
await appendJsonl(this.paths.activity, {
|
|
677
|
+
at: now,
|
|
678
|
+
type: 'init',
|
|
679
|
+
text: `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}`,
|
|
680
|
+
})
|
|
490
681
|
return {
|
|
491
682
|
created: true,
|
|
492
683
|
repoRoot: this.root,
|
|
@@ -495,6 +686,10 @@ export class Tower {
|
|
|
495
686
|
base,
|
|
496
687
|
revision: 0,
|
|
497
688
|
missions: 0,
|
|
689
|
+
reanchored: false,
|
|
690
|
+
dirty,
|
|
691
|
+
snapshotCommit,
|
|
692
|
+
snapshotRef,
|
|
498
693
|
}
|
|
499
694
|
})
|
|
500
695
|
}
|
|
@@ -540,11 +735,36 @@ export class Tower {
|
|
|
540
735
|
}
|
|
541
736
|
|
|
542
737
|
async mission(input = {}) {
|
|
543
|
-
|
|
738
|
+
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
739
|
+
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
544
740
|
return withLock(this.paths, async () => {
|
|
545
741
|
const state = await this.load()
|
|
546
|
-
const mission = state.missions.find((entry) => entry.id ===
|
|
547
|
-
if (!mission) fail(`unknown mission "${
|
|
742
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
743
|
+
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
744
|
+
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
745
|
+
const workerRequest = actor.role === 'worker'
|
|
746
|
+
if (actor.role === 'reviewer') {
|
|
747
|
+
fail('only the tower or a worker may update a mission; reviewers submit tower_review')
|
|
748
|
+
}
|
|
749
|
+
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
750
|
+
fail(`worker ${actor.name} does not own mission ${mission.id} (owner: ${mission.owner})`)
|
|
751
|
+
}
|
|
752
|
+
if (workerRequest) {
|
|
753
|
+
if (input.status !== undefined) {
|
|
754
|
+
fail('only the tower can change mission status directly; workers report completion with tower_complete')
|
|
755
|
+
}
|
|
756
|
+
if (input.scope !== undefined) fail('only the tower can change mission scope')
|
|
757
|
+
if (input.clear_blockers === true) fail('only the tower can clear blockers')
|
|
758
|
+
if (input.pause_cause !== undefined || input.authorization !== undefined) {
|
|
759
|
+
fail('only the tower can record pause/authorization metadata')
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (input.pause_cause !== undefined && input.status !== 'paused') {
|
|
763
|
+
fail('pause_cause is only valid together with status "paused"')
|
|
764
|
+
}
|
|
765
|
+
if (input.status === 'abandoned' && (typeof input.note !== 'string' || input.note.trim().length === 0)) {
|
|
766
|
+
fail('abandoning a mission requires a non-empty note (reason)')
|
|
767
|
+
}
|
|
548
768
|
const now = Date.now()
|
|
549
769
|
const changes = []
|
|
550
770
|
|
|
@@ -557,6 +777,20 @@ export class Tower {
|
|
|
557
777
|
if (!allowed.includes(input.status)) {
|
|
558
778
|
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
|
|
559
779
|
}
|
|
780
|
+
if (input.status === 'paused') {
|
|
781
|
+
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
782
|
+
}
|
|
783
|
+
if (mission.status === 'paused' && input.status === 'active') {
|
|
784
|
+
const requireAuthorization = this.config.mode.requireResumeAuthorization === true
|
|
785
|
+
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
786
|
+
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
787
|
+
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
791
|
+
mission.authorizationRef = input.authorization.trim()
|
|
792
|
+
}
|
|
793
|
+
}
|
|
560
794
|
mission.status = input.status
|
|
561
795
|
changes.push(`status=${input.status}`)
|
|
562
796
|
}
|
|
@@ -590,6 +824,9 @@ export class Tower {
|
|
|
590
824
|
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
591
825
|
fail('blocker must be a non-empty string')
|
|
592
826
|
}
|
|
827
|
+
if (TERMINAL.has(mission.status)) {
|
|
828
|
+
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
829
|
+
}
|
|
593
830
|
mission.blockers.push(input.blocker.trim())
|
|
594
831
|
mission.status = 'blocked'
|
|
595
832
|
changes.push('blocker added')
|
|
@@ -661,20 +898,17 @@ export class Tower {
|
|
|
661
898
|
|
|
662
899
|
async assertWorktree(worktree) {
|
|
663
900
|
if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (branch !== worktree.branch) {
|
|
673
|
-
fail(`worktree ${wtPath} is on ${branch ?? 'detached HEAD'}, expected ${worktree.branch}`)
|
|
674
|
-
}
|
|
901
|
+
const owned = await assertOwnership({
|
|
902
|
+
storedPath: worktree.path,
|
|
903
|
+
storedRealPath: worktree.realPath,
|
|
904
|
+
branch: worktree.branch,
|
|
905
|
+
commonDir: this.commonDir,
|
|
906
|
+
missionId: worktree.missionId,
|
|
907
|
+
})
|
|
908
|
+
worktree.realPath = owned.realPath
|
|
675
909
|
const registered = await listWorktrees(this.root)
|
|
676
|
-
if (!registered.some((entry) => path.resolve(entry.path) ===
|
|
677
|
-
fail(`worktree ${
|
|
910
|
+
if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
|
|
911
|
+
fail(`worktree ${owned.path} is not registered with git`)
|
|
678
912
|
}
|
|
679
913
|
return worktree
|
|
680
914
|
}
|
|
@@ -703,6 +937,34 @@ export class Tower {
|
|
|
703
937
|
})
|
|
704
938
|
}
|
|
705
939
|
|
|
940
|
+
/** Record that a live agent was interrupted by the tower. */
|
|
941
|
+
async recordInterrupt(input) {
|
|
942
|
+
return withLock(this.paths, async () => {
|
|
943
|
+
const state = await this.load()
|
|
944
|
+
const entry = (state.roster ?? []).find(
|
|
945
|
+
(row) =>
|
|
946
|
+
(input.agentId !== undefined && row.agentId === input.agentId) ||
|
|
947
|
+
(input.name !== undefined && row.name === input.name),
|
|
948
|
+
)
|
|
949
|
+
if (!entry) fail(`unknown roster agent "${input.name ?? input.agentId}"`)
|
|
950
|
+
const now = Date.now()
|
|
951
|
+
entry.state = 'interrupted'
|
|
952
|
+
entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
|
|
953
|
+
entry.outcomeAt = now
|
|
954
|
+
state.revision += 1
|
|
955
|
+
state.updatedAt = now
|
|
956
|
+
validateState(state, this.paths.state)
|
|
957
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
958
|
+
await appendJsonl(this.paths.activity, {
|
|
959
|
+
at: now,
|
|
960
|
+
type: 'spawn',
|
|
961
|
+
missionId: entry.missionId,
|
|
962
|
+
text: `${entry.name} interrupted: ${entry.outcome}`,
|
|
963
|
+
})
|
|
964
|
+
return entry
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
|
|
706
968
|
async provision(input = {}) {
|
|
707
969
|
const missionId = input.missionId
|
|
708
970
|
if (typeof missionId !== 'string' || missionId.trim().length === 0) {
|
|
@@ -717,12 +979,19 @@ export class Tower {
|
|
|
717
979
|
return { mission, worktree: mission.worktree, created: false }
|
|
718
980
|
}
|
|
719
981
|
|
|
720
|
-
const baseCommit =
|
|
982
|
+
const baseCommit = input.baseCommit
|
|
983
|
+
? String(input.baseCommit)
|
|
984
|
+
: input.includeDirtyBase === false
|
|
985
|
+
? await revParse(this.root, state.base)
|
|
986
|
+
: state.snapshotCommit && (await tryGit(this.root, ['cat-file', '-e', `${state.snapshotCommit}^{commit}`])) !== null
|
|
987
|
+
? state.snapshotCommit
|
|
988
|
+
: await revParse(this.root, state.base)
|
|
721
989
|
const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
990
|
+
const configuredRoot = this.config.worktrees.root
|
|
722
991
|
const root = input.worktreeRoot
|
|
723
992
|
? path.resolve(input.worktreeRoot)
|
|
724
|
-
:
|
|
725
|
-
? path.resolve(
|
|
993
|
+
: configuredRoot
|
|
994
|
+
? path.resolve(configuredRoot)
|
|
726
995
|
: path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
|
|
727
996
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
728
997
|
this.assertOutside(root)
|
|
@@ -734,7 +1003,7 @@ export class Tower {
|
|
|
734
1003
|
|
|
735
1004
|
await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
|
|
736
1005
|
const now = Date.now()
|
|
737
|
-
const worktree = { path: wtPath, branch, baseCommit, createdAt: now }
|
|
1006
|
+
const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
|
|
738
1007
|
mission.worktree = worktree
|
|
739
1008
|
mission.baseCommit = baseCommit
|
|
740
1009
|
mission.updatedAt = now
|
|
@@ -758,9 +1027,37 @@ export class Tower {
|
|
|
758
1027
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
759
1028
|
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
760
1029
|
const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
|
|
1030
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1031
|
+
const reviews = branch
|
|
1032
|
+
? (state.reviews ?? [])
|
|
1033
|
+
.filter((review) => review.branch === branch)
|
|
1034
|
+
.slice()
|
|
1035
|
+
.sort((a, b) => b.round - a.round)
|
|
1036
|
+
.slice(0, 3)
|
|
1037
|
+
: []
|
|
1038
|
+
const findings = (state.findings ?? [])
|
|
1039
|
+
.filter((finding) => finding.missionId === mission.id)
|
|
1040
|
+
.slice()
|
|
1041
|
+
.sort((a, b) => b.seq - a.seq)
|
|
1042
|
+
.slice(0, 10)
|
|
761
1043
|
return {
|
|
762
1044
|
base: state.base,
|
|
763
1045
|
mission: fullMission(mission),
|
|
1046
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
1047
|
+
completionReport: mission.completionReport ?? null,
|
|
1048
|
+
reviews: reviews.map((review) => ({
|
|
1049
|
+
id: review.id,
|
|
1050
|
+
round: review.round,
|
|
1051
|
+
status: review.status,
|
|
1052
|
+
mergeVerdict: review.mergeVerdict,
|
|
1053
|
+
reviewer: review.reviewer,
|
|
1054
|
+
findings: review.findings,
|
|
1055
|
+
checks: review.checks ?? [],
|
|
1056
|
+
decision: review.decision,
|
|
1057
|
+
reviewedCommit: review.reviewedCommit,
|
|
1058
|
+
at: review.at,
|
|
1059
|
+
})),
|
|
1060
|
+
findings: findings.map((finding) => findingSummary(finding)),
|
|
764
1061
|
deps: (mission.deps ?? []).map((id) => {
|
|
765
1062
|
const dep = byId.get(id)
|
|
766
1063
|
return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
|
|
@@ -795,11 +1092,35 @@ export class Tower {
|
|
|
795
1092
|
state: 'active',
|
|
796
1093
|
spawnedAt: now,
|
|
797
1094
|
}
|
|
798
|
-
state.roster.push(entry)
|
|
799
1095
|
if (input.role === 'worker') {
|
|
1096
|
+
const previous = workerEntries(state, mission.id)
|
|
1097
|
+
const live = previous.find((entry) => !ROSTER_INACTIVE.has(entry.state))
|
|
1098
|
+
if (live && !input.takeover) {
|
|
1099
|
+
fail(
|
|
1100
|
+
`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`,
|
|
1101
|
+
)
|
|
1102
|
+
}
|
|
1103
|
+
// A new owner always retires the mission's older worker entries, including
|
|
1104
|
+
// idle `inactive` ones; otherwise the next spawn would see several entries
|
|
1105
|
+
// for the same mission and the old idle worker would block forever.
|
|
1106
|
+
for (const old of previous) {
|
|
1107
|
+
if (old.state === 'retired') continue
|
|
1108
|
+
old.state = 'retired'
|
|
1109
|
+
old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
|
|
1110
|
+
old.outcomeAt = now
|
|
1111
|
+
}
|
|
1112
|
+
const liveCount = countLiveWorkers(state)
|
|
1113
|
+
const max = Number(this.config.spawn.maxLiveWorkers) || 4
|
|
1114
|
+
if (liveCount >= max) {
|
|
1115
|
+
fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
|
|
1116
|
+
}
|
|
1117
|
+
const generation = (mission.ownerGeneration ?? 0) + 1
|
|
1118
|
+
entry.generation = generation
|
|
1119
|
+
mission.ownerGeneration = generation
|
|
800
1120
|
mission.owner = input.name
|
|
801
1121
|
if (mission.status === 'planned') mission.status = 'active'
|
|
802
1122
|
}
|
|
1123
|
+
state.roster.push(entry)
|
|
803
1124
|
mission.updatedAt = now
|
|
804
1125
|
state.revision += 1
|
|
805
1126
|
state.updatedAt = now
|
|
@@ -836,6 +1157,149 @@ export class Tower {
|
|
|
836
1157
|
}
|
|
837
1158
|
}
|
|
838
1159
|
|
|
1160
|
+
async recordTestRun(input = {}) {
|
|
1161
|
+
return withLock(this.paths, async () => {
|
|
1162
|
+
const state = await this.load()
|
|
1163
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1164
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1165
|
+
if (!mission.testRuns) mission.testRuns = []
|
|
1166
|
+
const receipt = { ...input.receipt, recordedAt: Date.now() }
|
|
1167
|
+
mission.testRuns.push(receipt)
|
|
1168
|
+
if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
|
|
1169
|
+
state.revision += 1
|
|
1170
|
+
state.updatedAt = Date.now()
|
|
1171
|
+
validateState(state, this.paths.state)
|
|
1172
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1173
|
+
await appendJsonl(this.paths.activity, {
|
|
1174
|
+
at: Date.now(),
|
|
1175
|
+
type: 'test',
|
|
1176
|
+
missionId: mission.id,
|
|
1177
|
+
text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
|
|
1178
|
+
})
|
|
1179
|
+
return receipt
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async recordLegacySnapshot(snapshot) {
|
|
1184
|
+
return withLock(this.paths, async () => {
|
|
1185
|
+
const state = await this.load()
|
|
1186
|
+
if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
|
|
1187
|
+
const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
|
|
1188
|
+
if (existing) return { snapshot: existing, duplicate: true }
|
|
1189
|
+
state.legacySnapshots.push(snapshot)
|
|
1190
|
+
state.revision += 1
|
|
1191
|
+
state.updatedAt = Date.now()
|
|
1192
|
+
validateState(state, this.paths.state)
|
|
1193
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1194
|
+
return { snapshot, duplicate: false }
|
|
1195
|
+
})
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async recordLegacyMap(map) {
|
|
1199
|
+
return withLock(this.paths, async () => {
|
|
1200
|
+
const state = await this.load()
|
|
1201
|
+
if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
|
|
1202
|
+
const existing = state.legacyMaps.find(
|
|
1203
|
+
(entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
|
|
1204
|
+
)
|
|
1205
|
+
if (existing) return { map: existing, duplicate: true }
|
|
1206
|
+
state.legacyMaps.push(map)
|
|
1207
|
+
state.revision += 1
|
|
1208
|
+
state.updatedAt = Date.now()
|
|
1209
|
+
validateState(state, this.paths.state)
|
|
1210
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1211
|
+
return { map, duplicate: false }
|
|
1212
|
+
})
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async createBatch(input = {}, actor = 'tower') {
|
|
1216
|
+
return withLock(this.paths, async () => {
|
|
1217
|
+
const state = await this.load()
|
|
1218
|
+
const normalized = validateBatchCreate(state, input)
|
|
1219
|
+
if (!Array.isArray(state.batches)) state.batches = []
|
|
1220
|
+
const max = state.batches.reduce((value, batch) => {
|
|
1221
|
+
const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
|
|
1222
|
+
return match ? Math.max(value, Number(match[1])) : value
|
|
1223
|
+
}, 0)
|
|
1224
|
+
const batch = {
|
|
1225
|
+
id: `B${max + 1}`,
|
|
1226
|
+
status: 'open',
|
|
1227
|
+
...normalized,
|
|
1228
|
+
createdBy: actor,
|
|
1229
|
+
createdAt: Date.now(),
|
|
1230
|
+
}
|
|
1231
|
+
state.batches.push(batch)
|
|
1232
|
+
state.revision += 1
|
|
1233
|
+
state.updatedAt = batch.createdAt
|
|
1234
|
+
validateState(state, this.paths.state)
|
|
1235
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1236
|
+
await appendJsonl(this.paths.activity, {
|
|
1237
|
+
at: batch.createdAt,
|
|
1238
|
+
type: 'batch',
|
|
1239
|
+
text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
|
|
1240
|
+
})
|
|
1241
|
+
return batch
|
|
1242
|
+
})
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async closeBatch(batchId, options = {}, actor = 'tower') {
|
|
1246
|
+
return withLock(this.paths, async () => {
|
|
1247
|
+
const state = await this.load()
|
|
1248
|
+
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1249
|
+
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
1250
|
+
const result = validateBatchClose(state, batch, { ...options, state })
|
|
1251
|
+
const blockers = Array.isArray(result.blockers) ? result.blockers : []
|
|
1252
|
+
if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
|
|
1253
|
+
batch.status = 'closed'
|
|
1254
|
+
batch.closedAt = Date.now()
|
|
1255
|
+
batch.closedBy = actor
|
|
1256
|
+
batch.close = {
|
|
1257
|
+
checks: options.checks ?? [],
|
|
1258
|
+
treeTip: options.treeTip ?? null,
|
|
1259
|
+
receiptHashes: options.receiptHashes ?? {},
|
|
1260
|
+
at: batch.closedAt,
|
|
1261
|
+
}
|
|
1262
|
+
state.revision += 1
|
|
1263
|
+
state.updatedAt = batch.closedAt
|
|
1264
|
+
validateState(state, this.paths.state)
|
|
1265
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1266
|
+
await appendJsonl(this.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
|
|
1267
|
+
return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
|
|
1268
|
+
})
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async abandonBatch(batchId, actor = 'tower', reason = null) {
|
|
1272
|
+
return withLock(this.paths, async () => {
|
|
1273
|
+
const state = await this.load()
|
|
1274
|
+
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1275
|
+
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
1276
|
+
if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
|
|
1277
|
+
if (batch.status === 'abandoned') return { ok: true, batch, alreadyAbandoned: true }
|
|
1278
|
+
batch.status = 'abandoned'
|
|
1279
|
+
batch.abandonedAt = Date.now()
|
|
1280
|
+
batch.abandonedBy = actor
|
|
1281
|
+
batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
|
|
1282
|
+
state.revision += 1
|
|
1283
|
+
state.updatedAt = batch.abandonedAt
|
|
1284
|
+
validateState(state, this.paths.state)
|
|
1285
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1286
|
+
await appendJsonl(this.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
|
|
1287
|
+
return { ok: true, batch, alreadyAbandoned: false }
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
async batchStatus(batchId) {
|
|
1292
|
+
const state = await this.load()
|
|
1293
|
+
const batch = batchId
|
|
1294
|
+
? (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1295
|
+
: null
|
|
1296
|
+
if (batchId && !batch) fail(`unknown batch "${batchId}"`)
|
|
1297
|
+
return {
|
|
1298
|
+
batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
|
|
1299
|
+
...(batch ? { batch: summarizeBatch(state, batch) } : {}),
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
839
1303
|
async markRetired(agentIds) {
|
|
840
1304
|
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
841
1305
|
return withLock(this.paths, async () => {
|
|
@@ -864,6 +1328,77 @@ export class Tower {
|
|
|
864
1328
|
})
|
|
865
1329
|
}
|
|
866
1330
|
|
|
1331
|
+
async markMissing(agentIds) {
|
|
1332
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
1333
|
+
return withLock(this.paths, async () => {
|
|
1334
|
+
const state = await this.load()
|
|
1335
|
+
const missing = []
|
|
1336
|
+
const now = Date.now()
|
|
1337
|
+
for (const entry of state.roster ?? []) {
|
|
1338
|
+
if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
|
|
1339
|
+
entry.state = 'missing'
|
|
1340
|
+
entry.missingSince = now
|
|
1341
|
+
missing.push(entry.agentId)
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (missing.length > 0) {
|
|
1345
|
+
state.revision += 1
|
|
1346
|
+
state.updatedAt = now
|
|
1347
|
+
validateState(state, this.paths.state)
|
|
1348
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1349
|
+
await appendJsonl(this.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
|
|
1350
|
+
}
|
|
1351
|
+
return { missing }
|
|
1352
|
+
})
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Mark live roster entries whose catalog activity disappeared after staleMs.
|
|
1356
|
+
* `authoritative` must be true only when the parent catalog was readable;
|
|
1357
|
+
* unknown state is never treated as stopped. */
|
|
1358
|
+
async sweepRoster(input = {}) {
|
|
1359
|
+
const staleMs = Number(input.staleMs) || 0
|
|
1360
|
+
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
1361
|
+
const activity = input.activity ?? {}
|
|
1362
|
+
return withLock(this.paths, async () => {
|
|
1363
|
+
const state = await this.load()
|
|
1364
|
+
const now = Date.now()
|
|
1365
|
+
const marked = []
|
|
1366
|
+
const idled = []
|
|
1367
|
+
for (const entry of state.roster ?? []) {
|
|
1368
|
+
if (ROSTER_INACTIVE.has(entry.state)) continue
|
|
1369
|
+
const observed = activity[entry.agentId]
|
|
1370
|
+
if (observed === 'inactive') {
|
|
1371
|
+
entry.state = 'inactive'
|
|
1372
|
+
entry.outcome = entry.outcome ?? 'idle (catalog)'
|
|
1373
|
+
entry.outcomeAt = now
|
|
1374
|
+
idled.push(entry.name)
|
|
1375
|
+
continue
|
|
1376
|
+
}
|
|
1377
|
+
if (observed === 'running') continue
|
|
1378
|
+
if (observed !== undefined) continue
|
|
1379
|
+
const age = now - Number(entry.spawnedAt ?? now)
|
|
1380
|
+
if (age > staleMs) {
|
|
1381
|
+
entry.state = 'stale'
|
|
1382
|
+
entry.stale = true
|
|
1383
|
+
entry.staleAt = now
|
|
1384
|
+
marked.push(entry.name)
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (marked.length > 0 || idled.length > 0) {
|
|
1388
|
+
state.revision += 1
|
|
1389
|
+
state.updatedAt = now
|
|
1390
|
+
validateState(state, this.paths.state)
|
|
1391
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1392
|
+
await appendJsonl(this.paths.activity, {
|
|
1393
|
+
at: now,
|
|
1394
|
+
type: 'watchdog',
|
|
1395
|
+
text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
|
|
1396
|
+
})
|
|
1397
|
+
}
|
|
1398
|
+
return { marked, idled }
|
|
1399
|
+
})
|
|
1400
|
+
}
|
|
1401
|
+
|
|
867
1402
|
unreadFor(state, name) {
|
|
868
1403
|
return (state.messages ?? []).filter((message) => {
|
|
869
1404
|
if (message.from === name) return false
|
|
@@ -893,6 +1428,7 @@ export class Tower {
|
|
|
893
1428
|
subject: input.subject,
|
|
894
1429
|
body: input.body,
|
|
895
1430
|
scope: input.scope ?? null,
|
|
1431
|
+
consentRef: input.consentRef ?? null,
|
|
896
1432
|
at: now,
|
|
897
1433
|
readBy: [],
|
|
898
1434
|
delivery: [],
|
|
@@ -941,6 +1477,7 @@ export class Tower {
|
|
|
941
1477
|
for (const message of page) {
|
|
942
1478
|
if (!(message.readBy ?? []).includes(reader)) {
|
|
943
1479
|
message.readBy = [...(message.readBy ?? []), reader]
|
|
1480
|
+
message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
|
|
944
1481
|
changed = true
|
|
945
1482
|
}
|
|
946
1483
|
}
|
|
@@ -961,6 +1498,8 @@ export class Tower {
|
|
|
961
1498
|
body: message.body,
|
|
962
1499
|
scope: message.scope ?? null,
|
|
963
1500
|
at: message.at,
|
|
1501
|
+
delivery: message.delivery ?? [],
|
|
1502
|
+
ackAt: message.readAt?.[reader] ?? null,
|
|
964
1503
|
})),
|
|
965
1504
|
pageSize: page.length,
|
|
966
1505
|
unreadRemaining,
|
|
@@ -968,50 +1507,64 @@ export class Tower {
|
|
|
968
1507
|
})
|
|
969
1508
|
}
|
|
970
1509
|
|
|
1510
|
+
/** Read-only completion preconditions shared by tower_complete and the tool
|
|
1511
|
+
* layer's test-discovery preflight. Throws on the first failed condition. */
|
|
1512
|
+
async checkCompletion(input) {
|
|
1513
|
+
const state = await this.load()
|
|
1514
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1515
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1516
|
+
const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
1517
|
+
if (!worker || worker.missionId !== mission.id) {
|
|
1518
|
+
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
1519
|
+
}
|
|
1520
|
+
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
1521
|
+
if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
|
|
1522
|
+
fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
|
|
1523
|
+
}
|
|
1524
|
+
if (mission.owner && mission.owner !== worker.name) {
|
|
1525
|
+
fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
|
|
1526
|
+
}
|
|
1527
|
+
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
1528
|
+
if (!['active', 'planned'].includes(mission.status)) {
|
|
1529
|
+
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
1530
|
+
}
|
|
1531
|
+
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
1532
|
+
fail('tower_complete requires a non-empty report')
|
|
1533
|
+
}
|
|
1534
|
+
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
1535
|
+
if (openTasks.length > 0) {
|
|
1536
|
+
fail(
|
|
1537
|
+
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
1538
|
+
.map((task) => task.text)
|
|
1539
|
+
.join('; ')}`,
|
|
1540
|
+
)
|
|
1541
|
+
}
|
|
1542
|
+
if ((mission.blockers ?? []).length > 0) {
|
|
1543
|
+
fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
|
|
1544
|
+
}
|
|
1545
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
1546
|
+
await this.assertWorktree(mission.worktree)
|
|
1547
|
+
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
1548
|
+
if (commits < 1) {
|
|
1549
|
+
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
1550
|
+
}
|
|
1551
|
+
const unread = this.unreadFor(state, worker.name)
|
|
1552
|
+
if (unread.length > 0) {
|
|
1553
|
+
fail(
|
|
1554
|
+
`mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
|
|
1555
|
+
)
|
|
1556
|
+
}
|
|
1557
|
+
return { state, mission, worker, commits }
|
|
1558
|
+
}
|
|
1559
|
+
|
|
971
1560
|
async complete(input) {
|
|
972
1561
|
return withLock(this.paths, async () => {
|
|
973
|
-
const state = await this.
|
|
974
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
975
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
976
|
-
const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
977
|
-
if (!worker || worker.missionId !== mission.id) {
|
|
978
|
-
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
979
|
-
}
|
|
980
|
-
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
981
|
-
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
982
|
-
if (!['active', 'planned'].includes(mission.status)) {
|
|
983
|
-
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
984
|
-
}
|
|
985
|
-
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
986
|
-
fail('tower_complete requires a non-empty report')
|
|
987
|
-
}
|
|
988
|
-
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
989
|
-
if (openTasks.length > 0) {
|
|
990
|
-
fail(
|
|
991
|
-
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
992
|
-
.map((task) => task.text)
|
|
993
|
-
.join('; ')}`,
|
|
994
|
-
)
|
|
995
|
-
}
|
|
996
|
-
if ((mission.blockers ?? []).length > 0) {
|
|
997
|
-
fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
|
|
998
|
-
}
|
|
999
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
1000
|
-
await this.assertWorktree(mission.worktree)
|
|
1001
|
-
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
1002
|
-
if (commits < 1) {
|
|
1003
|
-
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
1004
|
-
}
|
|
1005
|
-
const unread = this.unreadFor(state, worker.name)
|
|
1006
|
-
if (unread.length > 0) {
|
|
1007
|
-
fail(
|
|
1008
|
-
`mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
|
|
1009
|
-
)
|
|
1010
|
-
}
|
|
1562
|
+
const { state, mission, worker, commits } = await this.checkCompletion(input)
|
|
1011
1563
|
|
|
1564
|
+
const checks = normalizeChecks(input.checks)
|
|
1012
1565
|
const now = Date.now()
|
|
1013
1566
|
mission.status = 'completed'
|
|
1014
|
-
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now }
|
|
1567
|
+
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
|
|
1015
1568
|
mission.updatedAt = now
|
|
1016
1569
|
state.revision += 1
|
|
1017
1570
|
state.updatedAt = now
|
|
@@ -1087,6 +1640,111 @@ export class Tower {
|
|
|
1087
1640
|
})
|
|
1088
1641
|
}
|
|
1089
1642
|
|
|
1643
|
+
/** List or read findings. Read-only paths never take the writer lock. */
|
|
1644
|
+
async readFindings(input = {}) {
|
|
1645
|
+
const state = await this.load()
|
|
1646
|
+
const all = state.findings ?? []
|
|
1647
|
+
const counts = countFindings(all)
|
|
1648
|
+
if (input.id !== undefined && input.id !== null && input.id !== '') {
|
|
1649
|
+
const finding = all.find((entry) => entry.id === input.id)
|
|
1650
|
+
if (!finding) fail(`unknown finding "${input.id}"`)
|
|
1651
|
+
return { finding: { ...finding }, counts, total: all.length, filtered: 1, action: 'read' }
|
|
1652
|
+
}
|
|
1653
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
1654
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
1655
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
let visible = all
|
|
1659
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
1660
|
+
visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
|
|
1661
|
+
}
|
|
1662
|
+
if (input.type !== undefined) {
|
|
1663
|
+
if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
1664
|
+
visible = visible.filter((finding) => finding.type === input.type)
|
|
1665
|
+
}
|
|
1666
|
+
if (input.severity !== undefined) {
|
|
1667
|
+
if (!FINDING_SEVERITIES.includes(input.severity)) {
|
|
1668
|
+
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
1669
|
+
}
|
|
1670
|
+
visible = visible.filter((finding) => finding.severity === input.severity)
|
|
1671
|
+
}
|
|
1672
|
+
if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
|
|
1673
|
+
visible = visible.filter((finding) => finding.missionId === input.missionId)
|
|
1674
|
+
}
|
|
1675
|
+
if (input.open === true) {
|
|
1676
|
+
visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
1677
|
+
}
|
|
1678
|
+
const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
|
|
1679
|
+
const page = visible.slice(-limit).reverse()
|
|
1680
|
+
return {
|
|
1681
|
+
findings: page.map((finding) => findingSummary(finding)),
|
|
1682
|
+
counts,
|
|
1683
|
+
total: all.length,
|
|
1684
|
+
filtered: visible.length,
|
|
1685
|
+
action: 'list',
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* List, read, or transition findings. Listing and reading are lock-free;
|
|
1691
|
+
* only a transition takes the writer lock. Reviewers may only read.
|
|
1692
|
+
*/
|
|
1693
|
+
async findings(input = {}) {
|
|
1694
|
+
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
1695
|
+
if (typeof actor.role !== 'string') fail('finding actor role must be a string')
|
|
1696
|
+
const hasId = input.id !== undefined && input.id !== null && input.id !== ''
|
|
1697
|
+
const wantsUpdate = hasId && (input.status !== undefined || input.note !== undefined)
|
|
1698
|
+
if (!wantsUpdate) return this.readFindings(input)
|
|
1699
|
+
|
|
1700
|
+
return withLock(this.paths, async () => {
|
|
1701
|
+
const state = await this.load()
|
|
1702
|
+
const all = state.findings ?? []
|
|
1703
|
+
const finding = all.find((entry) => entry.id === input.id)
|
|
1704
|
+
if (!finding) fail(`unknown finding "${input.id}"`)
|
|
1705
|
+
if (actor.role === 'reviewer') {
|
|
1706
|
+
fail('reviewers may list and read findings, but only the tower or a worker may change finding status')
|
|
1707
|
+
}
|
|
1708
|
+
const now = Date.now()
|
|
1709
|
+
if (input.status !== undefined) {
|
|
1710
|
+
if (input.status === 'all') {
|
|
1711
|
+
fail('finding status "all" is only a list filter; pass a concrete status when updating')
|
|
1712
|
+
}
|
|
1713
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
1714
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
1715
|
+
}
|
|
1716
|
+
const current = finding.status ?? 'open'
|
|
1717
|
+
const allowed = FINDING_TRANSITIONS[current] ?? []
|
|
1718
|
+
if (!allowed.includes(input.status)) {
|
|
1719
|
+
fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`)
|
|
1720
|
+
}
|
|
1721
|
+
finding.status = input.status
|
|
1722
|
+
if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
|
|
1723
|
+
else delete finding.closedAt
|
|
1724
|
+
}
|
|
1725
|
+
if (input.note !== undefined) {
|
|
1726
|
+
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
1727
|
+
fail('finding note must be a non-empty string')
|
|
1728
|
+
}
|
|
1729
|
+
if (!Array.isArray(finding.notes)) finding.notes = []
|
|
1730
|
+
finding.notes.push({ by: actor.name, at: now, text: input.note.trim() })
|
|
1731
|
+
}
|
|
1732
|
+
finding.updatedAt = now
|
|
1733
|
+
state.revision += 1
|
|
1734
|
+
state.updatedAt = now
|
|
1735
|
+
validateState(state, this.paths.state)
|
|
1736
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1737
|
+
await appendJsonl(this.paths.activity, {
|
|
1738
|
+
at: now,
|
|
1739
|
+
type: 'finding',
|
|
1740
|
+
missionId: finding.missionId,
|
|
1741
|
+
text: `${finding.id} ${finding.status}${input.note !== undefined ? ' +note' : ''} (${actor.name})`,
|
|
1742
|
+
})
|
|
1743
|
+
// Counts must reflect the transition just recorded.
|
|
1744
|
+
return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
|
|
1745
|
+
})
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1090
1748
|
async submitReview(input) {
|
|
1091
1749
|
return withLock(this.paths, async () => {
|
|
1092
1750
|
const state = await this.load()
|
|
@@ -1150,7 +1808,7 @@ export class Tower {
|
|
|
1150
1808
|
}
|
|
1151
1809
|
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1152
1810
|
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
1153
|
-
const maxReviewRounds = Number(
|
|
1811
|
+
const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
1154
1812
|
if (reviewerRounds >= maxReviewRounds) {
|
|
1155
1813
|
fail(
|
|
1156
1814
|
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
@@ -1211,9 +1869,105 @@ export class Tower {
|
|
|
1211
1869
|
})
|
|
1212
1870
|
}
|
|
1213
1871
|
|
|
1214
|
-
|
|
1872
|
+
/** Full detail for one mission, including reviews, findings and recent activity. */
|
|
1873
|
+
async missionDetail(missionId) {
|
|
1874
|
+
const state = await this.load()
|
|
1875
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
1876
|
+
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
1877
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1878
|
+
const reviews = (state.reviews ?? [])
|
|
1879
|
+
.filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
|
|
1880
|
+
.slice()
|
|
1881
|
+
.sort((a, b) => b.round - a.round)
|
|
1882
|
+
const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
|
|
1883
|
+
const activity = (await readActivityTail(this.paths.activity, 200)).filter(
|
|
1884
|
+
(entry) => entry.missionId === mission.id,
|
|
1885
|
+
)
|
|
1886
|
+
return {
|
|
1887
|
+
mission: fullMission(mission),
|
|
1888
|
+
reviews: reviews.slice(0, 10),
|
|
1889
|
+
findings: findings.slice(-10).map((finding) => findingSummary(finding)),
|
|
1890
|
+
activity: activity.slice(-20),
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** Human-readable mission board (markdown) for the current state. */
|
|
1895
|
+
boardMarkdown(state) {
|
|
1896
|
+
const lines = []
|
|
1897
|
+
lines.push(`# Tower missions — ${state.repoRoot}`)
|
|
1898
|
+
lines.push('')
|
|
1899
|
+
lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
|
|
1900
|
+
const counts = {}
|
|
1901
|
+
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
1902
|
+
for (const mission of state.missions) counts[mission.status] += 1
|
|
1903
|
+
lines.push('')
|
|
1904
|
+
lines.push(
|
|
1905
|
+
`Missions: ${state.missions.length} · ` +
|
|
1906
|
+
MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
|
|
1907
|
+
)
|
|
1908
|
+
const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
1909
|
+
lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
|
|
1910
|
+
lines.push('')
|
|
1911
|
+
lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
|
|
1912
|
+
lines.push('|---|---|---|---|---|---|---|---|')
|
|
1913
|
+
for (const mission of state.missions) {
|
|
1914
|
+
const tasks = taskCounts(mission.tasks ?? [])
|
|
1915
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1916
|
+
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1917
|
+
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1918
|
+
const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
|
|
1919
|
+
lines.push(
|
|
1920
|
+
`| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
|
|
1921
|
+
mission.owner ?? '—'
|
|
1922
|
+
} | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
|
|
1923
|
+
mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
|
|
1924
|
+
} |`,
|
|
1925
|
+
)
|
|
1926
|
+
}
|
|
1927
|
+
if ((state.roster ?? []).length > 0) {
|
|
1928
|
+
lines.push('')
|
|
1929
|
+
lines.push('## Roster')
|
|
1930
|
+
lines.push('')
|
|
1931
|
+
lines.push('| Name | Role | Mission | State | Agent |')
|
|
1932
|
+
lines.push('|---|---|---|---|---|')
|
|
1933
|
+
for (const entry of state.roster) {
|
|
1934
|
+
lines.push(
|
|
1935
|
+
`| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
|
|
1936
|
+
)
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
lines.push('')
|
|
1940
|
+
return lines.join('\n')
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/** Write the board when docs.board is enabled; failures never break a mutation. */
|
|
1944
|
+
async writeBoard() {
|
|
1945
|
+
if (this.config.docs?.board === false) return null
|
|
1946
|
+
try {
|
|
1947
|
+
const state = await this.load()
|
|
1948
|
+
const file = this.config.docs?.boardLocation === 'repo'
|
|
1949
|
+
? path.join(this.root, 'MISSIONS.md')
|
|
1950
|
+
: path.join(this.paths.dir, 'MISSIONS.md')
|
|
1951
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
1952
|
+
await fsp.writeFile(tmp, this.boardMarkdown(state), 'utf8')
|
|
1953
|
+
await renameWithRetry(tmp, file)
|
|
1954
|
+
return file
|
|
1955
|
+
} catch {
|
|
1956
|
+
return null
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
boardFile() {
|
|
1961
|
+
return this.config.docs?.boardLocation === 'repo'
|
|
1962
|
+
? path.join(this.root, 'MISSIONS.md')
|
|
1963
|
+
: path.join(this.paths.dir, 'MISSIONS.md')
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
async enrichedMissions(state, onlyIds) {
|
|
1967
|
+
const wanted = onlyIds ? new Set(onlyIds) : null
|
|
1215
1968
|
const out = []
|
|
1216
1969
|
for (const mission of state.missions) {
|
|
1970
|
+
if (wanted && !wanted.has(mission.id)) continue
|
|
1217
1971
|
const summary = missionSummary(mission)
|
|
1218
1972
|
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1219
1973
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
@@ -1257,7 +2011,15 @@ export class Tower {
|
|
|
1257
2011
|
const state = await this.load()
|
|
1258
2012
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1259
2013
|
if (!mission) {
|
|
1260
|
-
return {
|
|
2014
|
+
return {
|
|
2015
|
+
ok: false,
|
|
2016
|
+
blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }],
|
|
2017
|
+
missionId: input.missionId,
|
|
2018
|
+
branchTip: null,
|
|
2019
|
+
baseTip: null,
|
|
2020
|
+
changedFiles: [],
|
|
2021
|
+
review: null,
|
|
2022
|
+
}
|
|
1261
2023
|
}
|
|
1262
2024
|
const activity = input.activity ?? {}
|
|
1263
2025
|
const blockers = []
|
|
@@ -1275,6 +2037,18 @@ export class Tower {
|
|
|
1275
2037
|
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
1276
2038
|
if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
1277
2039
|
if ((mission.blockers ?? []).length > 0) push('blockers-present', mission.blockers.join('; '))
|
|
2040
|
+
if (this.config.gates.requireChecks === true) {
|
|
2041
|
+
const checks = mission.completionReport?.checks ?? []
|
|
2042
|
+
if (checks.length === 0) {
|
|
2043
|
+
push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
2047
|
+
push(
|
|
2048
|
+
'merge-intent-pending',
|
|
2049
|
+
`a merge intent for ${state.mergeIntent.missionId} was recorded at ${new Date(Number(state.mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
|
|
2050
|
+
)
|
|
2051
|
+
}
|
|
1278
2052
|
|
|
1279
2053
|
const worktree = mission.worktree ?? null
|
|
1280
2054
|
let branchTip = null
|
|
@@ -1329,9 +2103,16 @@ export class Tower {
|
|
|
1329
2103
|
const branch = worktree ? worktree.branch : null
|
|
1330
2104
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1331
2105
|
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1332
|
-
|
|
2106
|
+
const exempt =
|
|
2107
|
+
mission.reviewPolicy === 'none' &&
|
|
2108
|
+
this.config.gates.allowReviewExemption === true &&
|
|
2109
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0
|
|
2110
|
+
if (mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
|
|
2111
|
+
push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
|
|
2112
|
+
}
|
|
2113
|
+
if (!exempt && !latest) {
|
|
1333
2114
|
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
|
|
1334
|
-
} else {
|
|
2115
|
+
} else if (!exempt) {
|
|
1335
2116
|
const waived = this.rebaseWaived(mission, latest)
|
|
1336
2117
|
if (latest.missionId !== mission.id) {
|
|
1337
2118
|
push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
|
|
@@ -1347,8 +2128,26 @@ export class Tower {
|
|
|
1347
2128
|
}
|
|
1348
2129
|
}
|
|
1349
2130
|
|
|
1350
|
-
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state
|
|
2131
|
+
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
|
|
1351
2132
|
if (activity[entry.agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
|
|
2133
|
+
else if (input.parentStatus && entry.parentSessionId && input.parentStatus[entry.parentSessionId] !== 'ok') {
|
|
2134
|
+
push(
|
|
2135
|
+
'worker-status-unknown',
|
|
2136
|
+
`cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`,
|
|
2137
|
+
)
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
const staleWorkers = (state.roster ?? []).filter(
|
|
2141
|
+
(row) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
|
|
2142
|
+
)
|
|
2143
|
+
if (staleWorkers.length > 0) {
|
|
2144
|
+
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
2145
|
+
}
|
|
2146
|
+
if (this.config.gates.requireTests === true) {
|
|
2147
|
+
const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
|
|
2148
|
+
if (runs.length === 0) {
|
|
2149
|
+
push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
|
|
2150
|
+
}
|
|
1352
2151
|
}
|
|
1353
2152
|
|
|
1354
2153
|
const checkoutBranch = await currentBranch(this.root)
|
|
@@ -1406,14 +2205,60 @@ export class Tower {
|
|
|
1406
2205
|
* exists and either the branch tip or the reviewed commit is already an
|
|
1407
2206
|
* ancestor of the base.
|
|
1408
2207
|
*/
|
|
2208
|
+
/** Clear a stale merge intent only when the crash left no merge in progress
|
|
2209
|
+
* or no merge commit. Returns true when the intent was consumed. */
|
|
2210
|
+
async reconcileMergeIntent(state, mission) {
|
|
2211
|
+
const intent = state.mergeIntent
|
|
2212
|
+
if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
|
|
2213
|
+
let head = null
|
|
2214
|
+
try {
|
|
2215
|
+
head = await revParse(this.root, 'HEAD')
|
|
2216
|
+
} catch {
|
|
2217
|
+
return false
|
|
2218
|
+
}
|
|
2219
|
+
const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
|
|
2220
|
+
const inProgress = await exists(mergeHeadPath)
|
|
2221
|
+
if (inProgress) return false
|
|
2222
|
+
let landed = null
|
|
2223
|
+
if (head === intent.baseTip) {
|
|
2224
|
+
landed = null // no merge commit was created; the intent is safe to drop
|
|
2225
|
+
} else {
|
|
2226
|
+
landed = await findLandedMerge(
|
|
2227
|
+
this.root,
|
|
2228
|
+
head,
|
|
2229
|
+
new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
|
|
2230
|
+
).catch(() => null)
|
|
2231
|
+
if (!landed) return false
|
|
2232
|
+
}
|
|
2233
|
+
const now = Date.now()
|
|
2234
|
+
delete state.mergeIntent
|
|
2235
|
+
state.revision += 1
|
|
2236
|
+
state.updatedAt = now
|
|
2237
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
2238
|
+
await appendJsonl(this.paths.activity, {
|
|
2239
|
+
at: now,
|
|
2240
|
+
type: 'merge',
|
|
2241
|
+
missionId: mission.id,
|
|
2242
|
+
text: landed
|
|
2243
|
+
? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
|
|
2244
|
+
: 'cleared a merge intent that created no commit',
|
|
2245
|
+
})
|
|
2246
|
+
return true
|
|
2247
|
+
}
|
|
2248
|
+
|
|
1409
2249
|
async reconcileLandedMerge(state, mission) {
|
|
1410
2250
|
if (!['completed', 'blocked'].includes(mission.status)) return null
|
|
1411
2251
|
if (!mission.worktree) return null
|
|
1412
2252
|
const branch = mission.worktree.branch
|
|
1413
2253
|
const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
2254
|
+
const exempt =
|
|
2255
|
+
mission.reviewPolicy === 'none' &&
|
|
2256
|
+
this.config.gates.allowReviewExemption === true &&
|
|
2257
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0
|
|
2258
|
+
if (reviews.length === 0 && !exempt) return null
|
|
2259
|
+
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
2260
|
+
if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
|
|
2261
|
+
if (!latest && !exempt) return null
|
|
1417
2262
|
let baseTip
|
|
1418
2263
|
let branchTip
|
|
1419
2264
|
try {
|
|
@@ -1422,7 +2267,7 @@ export class Tower {
|
|
|
1422
2267
|
} catch {
|
|
1423
2268
|
return null
|
|
1424
2269
|
}
|
|
1425
|
-
const reviewedTip = latest
|
|
2270
|
+
const reviewedTip = latest?.reviewedCommit ?? null
|
|
1426
2271
|
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
1427
2272
|
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
1428
2273
|
if (!branchLanded && !reviewedLanded) return null
|
|
@@ -1430,6 +2275,7 @@ export class Tower {
|
|
|
1430
2275
|
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
1431
2276
|
const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
|
|
1432
2277
|
const now = Date.now()
|
|
2278
|
+
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
1433
2279
|
mission.status = 'merged'
|
|
1434
2280
|
mission.mergedAt = now
|
|
1435
2281
|
mission.mergedCommit = mergedCommit
|
|
@@ -1458,13 +2304,15 @@ export class Tower {
|
|
|
1458
2304
|
baseTip,
|
|
1459
2305
|
branchTip,
|
|
1460
2306
|
parents: found ? found.parents : null,
|
|
1461
|
-
review:
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2307
|
+
review: latest
|
|
2308
|
+
? {
|
|
2309
|
+
round: latest.round,
|
|
2310
|
+
status: latest.status,
|
|
2311
|
+
mergeVerdict: latest.mergeVerdict,
|
|
2312
|
+
reviewer: latest.reviewer,
|
|
2313
|
+
reviewedCommit: reviewedTip,
|
|
2314
|
+
}
|
|
2315
|
+
: null,
|
|
1468
2316
|
}
|
|
1469
2317
|
}
|
|
1470
2318
|
|
|
@@ -1475,6 +2323,7 @@ export class Tower {
|
|
|
1475
2323
|
if (!mission) {
|
|
1476
2324
|
return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
|
|
1477
2325
|
}
|
|
2326
|
+
await this.reconcileMergeIntent(state, mission)
|
|
1478
2327
|
const baseTip = await revParse(this.root, state.base)
|
|
1479
2328
|
if (mission.status === 'merged') {
|
|
1480
2329
|
return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
|
|
@@ -1487,13 +2336,25 @@ export class Tower {
|
|
|
1487
2336
|
const worktree = mission.worktree
|
|
1488
2337
|
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
1489
2338
|
const branchTip = await revParse(worktree.path, 'HEAD')
|
|
1490
|
-
|
|
2339
|
+
// Record the intent before touching Git; a crash between merge and state
|
|
2340
|
+
// write can be reconciled by the next tower_merge/preflight instead of
|
|
2341
|
+
// leaving an unexplained base tip.
|
|
2342
|
+
state.mergeIntent = {
|
|
2343
|
+
missionId: mission.id,
|
|
2344
|
+
baseTip: baseTipBefore,
|
|
2345
|
+
branchTip,
|
|
2346
|
+
startedAt: Date.now(),
|
|
2347
|
+
}
|
|
2348
|
+
state.revision += 1
|
|
2349
|
+
state.updatedAt = state.mergeIntent.startedAt
|
|
2350
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
2351
|
+
const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
|
|
1491
2352
|
let failure = null
|
|
1492
2353
|
try {
|
|
1493
2354
|
await git(
|
|
1494
2355
|
this.root,
|
|
1495
2356
|
['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
1496
|
-
{ timeoutMs, maxBuffer:
|
|
2357
|
+
{ timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
1497
2358
|
)
|
|
1498
2359
|
} catch (error) {
|
|
1499
2360
|
failure = error
|
|
@@ -1563,6 +2424,7 @@ export class Tower {
|
|
|
1563
2424
|
}
|
|
1564
2425
|
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
1565
2426
|
const now = Date.now()
|
|
2427
|
+
delete state.mergeIntent
|
|
1566
2428
|
if (conflict) {
|
|
1567
2429
|
mission.status = 'blocked'
|
|
1568
2430
|
mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
|
|
@@ -1590,6 +2452,10 @@ export class Tower {
|
|
|
1590
2452
|
}
|
|
1591
2453
|
|
|
1592
2454
|
if (!expectedShape) {
|
|
2455
|
+
delete state.mergeIntent
|
|
2456
|
+
state.revision += 1
|
|
2457
|
+
state.updatedAt = Date.now()
|
|
2458
|
+
await writeJsonAtomic(this.paths.state, state).catch(() => {})
|
|
1593
2459
|
return {
|
|
1594
2460
|
ok: false,
|
|
1595
2461
|
blockers: [
|
|
@@ -1602,6 +2468,7 @@ export class Tower {
|
|
|
1602
2468
|
}
|
|
1603
2469
|
|
|
1604
2470
|
const now = Date.now()
|
|
2471
|
+
delete state.mergeIntent
|
|
1605
2472
|
mission.status = 'merged'
|
|
1606
2473
|
mission.mergedAt = now
|
|
1607
2474
|
mission.mergedCommit = newTip
|
|
@@ -1635,7 +2502,7 @@ export class Tower {
|
|
|
1635
2502
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1636
2503
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
1637
2504
|
const activity = input.activity ?? {}
|
|
1638
|
-
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state
|
|
2505
|
+
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
|
|
1639
2506
|
if (activity[entry.agentId] === 'running') {
|
|
1640
2507
|
return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
|
|
1641
2508
|
}
|
|
@@ -1714,7 +2581,7 @@ export class Tower {
|
|
|
1714
2581
|
continue
|
|
1715
2582
|
}
|
|
1716
2583
|
const live = (state.roster ?? []).some(
|
|
1717
|
-
(entry) => entry.missionId === mission.id && entry.state
|
|
2584
|
+
(entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
|
|
1718
2585
|
)
|
|
1719
2586
|
if (live) {
|
|
1720
2587
|
decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
|
|
@@ -1767,13 +2634,19 @@ export class Tower {
|
|
|
1767
2634
|
})
|
|
1768
2635
|
}
|
|
1769
2636
|
|
|
1770
|
-
async status() {
|
|
2637
|
+
async status(input = {}) {
|
|
1771
2638
|
const state = await this.load()
|
|
1772
2639
|
const activity = await readActivityTail(this.paths.activity, 12)
|
|
1773
2640
|
const counts = {}
|
|
1774
2641
|
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
1775
2642
|
for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
|
|
1776
2643
|
const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks).open, 0)
|
|
2644
|
+
const findingsByStatus = {}
|
|
2645
|
+
for (const status of FINDING_STATUSES) findingsByStatus[status] = 0
|
|
2646
|
+
for (const finding of state.findings ?? []) {
|
|
2647
|
+
const status = finding.status ?? 'open'
|
|
2648
|
+
findingsByStatus[status] = (findingsByStatus[status] ?? 0) + 1
|
|
2649
|
+
}
|
|
1777
2650
|
return {
|
|
1778
2651
|
repoRoot: state.repoRoot,
|
|
1779
2652
|
commonDir: state.commonDir,
|
|
@@ -1786,17 +2659,27 @@ export class Tower {
|
|
|
1786
2659
|
messages: (state.messages ?? []).length,
|
|
1787
2660
|
unreadTower: this.unreadFor(state, 'tower').length,
|
|
1788
2661
|
findings: (state.findings ?? []).length,
|
|
2662
|
+
findingsByStatus,
|
|
1789
2663
|
reviews: (state.reviews ?? []).length,
|
|
2664
|
+
config: {
|
|
2665
|
+
path: this.config.configPath ?? null,
|
|
2666
|
+
repoConfigPresent: this.config.repoConfigPresent === true,
|
|
2667
|
+
spawn: {
|
|
2668
|
+
workerPreset: this.config.spawn?.workerPreset ?? null,
|
|
2669
|
+
reviewerPreset: this.config.spawn?.reviewerPreset ?? null,
|
|
2670
|
+
},
|
|
2671
|
+
},
|
|
1790
2672
|
recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
|
|
1791
2673
|
id: finding.id,
|
|
1792
2674
|
type: finding.type,
|
|
1793
2675
|
severity: finding.severity,
|
|
2676
|
+
status: finding.status ?? 'open',
|
|
1794
2677
|
title: finding.title,
|
|
1795
2678
|
missionId: finding.missionId,
|
|
1796
2679
|
reportedBy: finding.reportedBy,
|
|
1797
2680
|
at: finding.at,
|
|
1798
2681
|
})),
|
|
1799
|
-
missions: await this.enrichedMissions(state),
|
|
2682
|
+
missions: await this.enrichedMissions(state, input.missionIds),
|
|
1800
2683
|
activity,
|
|
1801
2684
|
}
|
|
1802
2685
|
}
|