jackherd 1.25.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/INSTALL.md +1 -1
- package/README.md +19 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/record.ts +81 -1
- package/kit/hooks/hooks/open-question/run.ts +17 -3
- package/kit/hooks/hooks/question-session-end/index.ts +18 -0
- package/kit/hooks/hooks/question-session-end/run.ts +37 -0
- package/kit/hooks/hooks/sent-file/record.ts +4 -1
- package/kit/hooks/hooks.json +8 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
- package/package.json +1 -1
- package/src/check/skill-budget-defaults.ts +3 -1
- package/src/plans/closable-records.ts +11 -10
- package/src/plans/close-reason.ts +19 -0
- package/src/plans/live.ts +2 -1
- package/src/plans/question-records.ts +23 -2
- package/src/plans/questions-read.ts +2 -2
- package/src/plans/sent-files-read.ts +2 -2
- package/src/plans/sent-files.ts +12 -4
- package/src/waiting/command.ts +82 -26
- package/src/watch/command.ts +95 -1
- package/src/watch/lane-report.ts +4 -0
- package/src/watch/obsolete-proposals.ts +175 -0
- package/src/watch/obsolete-records.ts +77 -0
- package/src/watch/reference-paths.ts +2 -0
- package/src/watch/watch-events.ts +20 -0
package/src/plans/sent-files.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type SentFileRecord = {
|
|
|
16
16
|
session: string
|
|
17
17
|
agentId: string | null
|
|
18
18
|
closedAt: number | null
|
|
19
|
+
/** Who closed the record and why (`closeReasonOf`); absent or null on a record that predates the field or is open. */
|
|
20
|
+
closedReason?: string | null
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** A badge for a sent file: a `file:` address and the file's name. */
|
|
@@ -55,7 +57,8 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
|
|
|
55
57
|
typeof record.cwd === 'string' &&
|
|
56
58
|
typeof record.session === 'string' &&
|
|
57
59
|
(record.agentId === null || typeof record.agentId === 'string') &&
|
|
58
|
-
(record.closedAt === null || typeof record.closedAt === 'number')
|
|
60
|
+
(record.closedAt === null || typeof record.closedAt === 'number') &&
|
|
61
|
+
(record.closedReason == null || typeof record.closedReason === 'string')
|
|
59
62
|
)
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -69,7 +72,7 @@ export const sentLaneOf = ({ files, cwd }: SentFileRecord, worktreeDirectory: st
|
|
|
69
72
|
.find((lane) => lane !== null) ?? laneDirectoryOf(cwd, worktreeDirectory)
|
|
70
73
|
|
|
71
74
|
/** The records without an earlier send of the same first file: an artifact updated each round is one send, the newest. */
|
|
72
|
-
const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
|
|
75
|
+
export const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
|
|
73
76
|
records.filter((record, index) => !records.slice(index + 1).some((later) => later.files[0] === record.files[0]))
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -94,12 +97,17 @@ export const sentChipsByLane = (
|
|
|
94
97
|
return lanes
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/** What a row of "Your turn" shows of a send: the first file's label and how many more follow. */
|
|
101
|
+
export const sentTextOf = (record: SentFileRecord): string => {
|
|
102
|
+
const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
|
|
103
|
+
return `${labelOf(record.files[0], record.caption)}${more}`
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
const itemOf = (record: SentFileRecord): WaitingItem => {
|
|
98
107
|
const [first] = record.files
|
|
99
|
-
const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
|
|
100
108
|
return {
|
|
101
109
|
since: clockText(record.sentAt),
|
|
102
|
-
item:
|
|
110
|
+
item: sentTextOf(record),
|
|
103
111
|
kind: WAITING_KIND.file,
|
|
104
112
|
id: record.id,
|
|
105
113
|
open: true,
|
package/src/waiting/command.ts
CHANGED
|
@@ -1,14 +1,64 @@
|
|
|
1
1
|
import { installedReferenceHint } from '../lane/skill-hint'
|
|
2
|
+
import { clockText, DAY_MS } from '../plan-format/clock'
|
|
2
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
4
|
import type { WaitingItem } from '../plan-format/waiting'
|
|
5
|
+
import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
|
|
4
6
|
import { mainCheckout } from '../plans/git'
|
|
7
|
+
import { type QuestionRecord, questionTextOf } from '../plans/question-records'
|
|
5
8
|
import { closeAllQuestionRecords, closeQuestionRecords } from '../plans/questions-read'
|
|
9
|
+
import { type SentFileRecord, sentTextOf } from '../plans/sent-files'
|
|
6
10
|
import { closeAllSentRecords, closeSentRecords } from '../plans/sent-files-read'
|
|
7
|
-
import { recordWaitingItems } from '../plans/waiting-records'
|
|
11
|
+
import { readWaitingRecords, recordWaitingItems } from '../plans/waiting-records'
|
|
8
12
|
import { CLI_NAME } from '../version/release'
|
|
9
13
|
import { REFERENCE, referenceLine } from '../watch/reference-paths'
|
|
10
14
|
|
|
11
|
-
const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
|
|
15
|
+
const USAGE = `usage: ${CLI_NAME} waiting [--closed | close <id> ... [--reason "<text>"] | close --all [--reason "<text>"]]`
|
|
16
|
+
|
|
17
|
+
const CLOSE_ALL = '--all'
|
|
18
|
+
const CLOSED = '--closed'
|
|
19
|
+
const REASON = '--reason'
|
|
20
|
+
|
|
21
|
+
/** A closed record as `jh waiting --closed` lists it. */
|
|
22
|
+
type ClosedRecord = { id: string; closedAt: number; closedReason?: string | null; text: string }
|
|
23
|
+
|
|
24
|
+
const closedRecordsOf = (sent: readonly SentFileRecord[], questions: readonly QuestionRecord[]): ClosedRecord[] =>
|
|
25
|
+
[
|
|
26
|
+
...sent.map((record) => ({ ...record, text: sentTextOf(record) })),
|
|
27
|
+
...questions.map((record) => ({ ...record, text: questionTextOf(record) })),
|
|
28
|
+
].flatMap(({ id, closedAt, closedReason, text }) => (closedAt === null ? [] : [{ id, closedAt, closedReason, text }]))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The lines `jh waiting --closed` prints: the records closed within the last 24 hours, newest first, each as
|
|
32
|
+
* `id · time of closing · reason · text` (a dash for a record closed without a reason: one that predates the field, or one the hook closed itself); a note when none.
|
|
33
|
+
*/
|
|
34
|
+
export const closedListing = (
|
|
35
|
+
sent: readonly SentFileRecord[],
|
|
36
|
+
questions: readonly QuestionRecord[],
|
|
37
|
+
now: number,
|
|
38
|
+
): string[] => {
|
|
39
|
+
const recent = closedRecordsOf(sent, questions)
|
|
40
|
+
.filter(({ closedAt }) => now - closedAt <= DAY_MS)
|
|
41
|
+
.sort((left, right) => right.closedAt - left.closedAt)
|
|
42
|
+
return recent.length === 0
|
|
43
|
+
? ['nothing was closed in the last 24 hours']
|
|
44
|
+
: recent.map(({ id, closedAt, closedReason, text }) =>
|
|
45
|
+
terminalSafe(`${id} · ${clockText(closedAt)} · ${closedReason ?? '-'} · ${text}`),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `jh waiting close` was asked to close and with which reason. */
|
|
50
|
+
export type CloseArguments = { all: true; reason: string } | { all: false; ids: string[]; reason: string }
|
|
51
|
+
|
|
52
|
+
/** The arguments after `close`: the ids or `--all`, then optionally `--reason "<text>"`; the reason is `coordinator` plus the text. Null when they do not fit. */
|
|
53
|
+
export const closeArgumentsOf = (args: readonly string[]): CloseArguments | null => {
|
|
54
|
+
const at = args.indexOf(REASON)
|
|
55
|
+
if (at !== -1 && at !== args.length - 2) return null
|
|
56
|
+
const ids = at === -1 ? [...args] : args.slice(0, at)
|
|
57
|
+
const all = ids.includes(CLOSE_ALL)
|
|
58
|
+
if (ids.length === 0 || (all && ids.length > 1)) return null
|
|
59
|
+
const reason = closeReasonOf(CLOSE_REASON.coordinator, at === -1 ? '' : args[at + 1])
|
|
60
|
+
return all ? { all, reason } : { all, ids, reason }
|
|
61
|
+
}
|
|
12
62
|
|
|
13
63
|
/**
|
|
14
64
|
* The lines `jh waiting` prints: id, time and text of each item, made safe for the terminal, then the reference that
|
|
@@ -19,41 +69,47 @@ export const waitingListing = (items: readonly WaitingItem[], hint = ''): string
|
|
|
19
69
|
? ['nothing waits on you']
|
|
20
70
|
: [...items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`)), ...referenceLine(hint)]
|
|
21
71
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export const closeWaiting = (root: string, ids: readonly string[], now: number): number =>
|
|
26
|
-
closeSentRecords(root, ids, now) + closeQuestionRecords(root, ids, now)
|
|
72
|
+
/** Closes the records the ids name with `reason`, files and questions alike; how many it closed. */
|
|
73
|
+
export const closeWaiting = (root: string, ids: readonly string[], now: number, reason: string): number =>
|
|
74
|
+
closeSentRecords(root, ids, now, reason) + closeQuestionRecords(root, ids, now, reason)
|
|
27
75
|
|
|
28
|
-
/** Closes every open record
|
|
29
|
-
export const closeAllWaiting = (root: string, now: number): number =>
|
|
30
|
-
closeAllSentRecords(root, now) + closeAllQuestionRecords(root, now)
|
|
76
|
+
/** Closes every open record with `reason`, files and questions alike; how many it closed. */
|
|
77
|
+
export const closeAllWaiting = (root: string, now: number, reason: string): number =>
|
|
78
|
+
closeAllSentRecords(root, now, reason) + closeAllQuestionRecords(root, now, reason)
|
|
31
79
|
|
|
32
80
|
/**
|
|
33
81
|
* `jh waiting` lists the files sent to the user and the questions left unanswered that no one closed (their id, time
|
|
34
|
-
* and text); `
|
|
35
|
-
* command line.
|
|
82
|
+
* and text); `--closed` lists what was closed in the last 24 hours with its reason; `jh waiting close <id> ...` or
|
|
83
|
+
* `close --all` closes them once the user has answered. Exit 0, 1 on a wrong command line.
|
|
36
84
|
*/
|
|
37
85
|
export const run = async (args: string[]): Promise<number> => {
|
|
38
86
|
const root = mainCheckout(process.cwd())
|
|
39
|
-
if (args.length === 0)
|
|
40
|
-
|
|
41
|
-
recordWaitingItems(root, Date.now()),
|
|
42
|
-
installedReferenceHint(root, REFERENCE.waiting),
|
|
43
|
-
))
|
|
44
|
-
console.log(line)
|
|
45
|
-
return 0
|
|
46
|
-
}
|
|
87
|
+
if (args.length === 0) return printWaiting(root)
|
|
88
|
+
if (args.length === 1 && args[0] === CLOSED) return printClosed(root)
|
|
47
89
|
const [command, ...rest] = args
|
|
48
|
-
|
|
90
|
+
const close = command === 'close' ? closeArgumentsOf(rest) : null
|
|
91
|
+
if (close === null) {
|
|
49
92
|
console.error(USAGE)
|
|
50
93
|
return 1
|
|
51
94
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
const closed = rest[0] === CLOSE_ALL ? closeAllWaiting(root, Date.now()) : closeWaiting(root, rest, Date.now())
|
|
95
|
+
const closed = close.all
|
|
96
|
+
? closeAllWaiting(root, Date.now(), close.reason)
|
|
97
|
+
: closeWaiting(root, close.ids, Date.now(), close.reason)
|
|
57
98
|
console.log(`closed ${closed}`)
|
|
58
99
|
return 0
|
|
59
100
|
}
|
|
101
|
+
|
|
102
|
+
const printWaiting = (root: string): number => {
|
|
103
|
+
for (const line of waitingListing(
|
|
104
|
+
recordWaitingItems(root, Date.now()),
|
|
105
|
+
installedReferenceHint(root, REFERENCE.waiting),
|
|
106
|
+
))
|
|
107
|
+
console.log(line)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const printClosed = (root: string): number => {
|
|
112
|
+
const { sent, questions } = readWaitingRecords(root)
|
|
113
|
+
for (const line of closedListing(sent, questions, Date.now())) console.log(line)
|
|
114
|
+
return 0
|
|
115
|
+
}
|
package/src/watch/command.ts
CHANGED
|
@@ -50,12 +50,15 @@ import { contextReport } from '../context/report'
|
|
|
50
50
|
import { agentLog } from '../context/session-log'
|
|
51
51
|
import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
|
|
52
52
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
53
|
+
import { openWaitingItems } from '../plan-format/waiting'
|
|
53
54
|
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
54
55
|
import { ensurePlanPanes } from '../plans/ensure-panes'
|
|
55
56
|
import { errorText } from '../plans/error-text'
|
|
56
57
|
import { baseReaderOf, mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
|
|
57
58
|
import { discoverLanes, isUnchanged, laneSetChange } from '../plans/lane-discovery'
|
|
58
59
|
import { readerOncePerRef } from '../plans/plan-heads'
|
|
60
|
+
import { readQuestionRecords } from '../plans/questions-read'
|
|
61
|
+
import { closeSentRecords, readSentRecords } from '../plans/sent-files-read'
|
|
59
62
|
import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
|
|
60
63
|
import { readRunningSubagents, subagentSourceOf } from '../plans/subagents-read'
|
|
61
64
|
import { boardTexts } from '../plans/texts'
|
|
@@ -182,6 +185,21 @@ import {
|
|
|
182
185
|
import { isBehindOnBase, mainCheckoutDutyLine } from './main-checkout'
|
|
183
186
|
import { mergeOverlapReader } from './merge-overlap-read'
|
|
184
187
|
import { monitorToolAvailable } from './monitor-tool'
|
|
188
|
+
import {
|
|
189
|
+
obsoleteProposalEvents,
|
|
190
|
+
type ProposalInput,
|
|
191
|
+
type ProposalState,
|
|
192
|
+
type WaitingObsoleteEvent,
|
|
193
|
+
waitingLanesOf,
|
|
194
|
+
} from './obsolete-proposals'
|
|
195
|
+
import {
|
|
196
|
+
type Closing,
|
|
197
|
+
closingsByReason,
|
|
198
|
+
laneAbsenceOf,
|
|
199
|
+
obsoleteSendsOf,
|
|
200
|
+
WAITING_CLOSED_EVENT,
|
|
201
|
+
type WaitingClosedEvent,
|
|
202
|
+
} from './obsolete-records'
|
|
185
203
|
import { parseWatchOptions, type WatchOptions } from './options'
|
|
186
204
|
import type { PlanMissingEvent, PlanMissingState } from './plan-missing'
|
|
187
205
|
import { readPlanMissingEvents } from './plan-missing-read'
|
|
@@ -306,6 +324,8 @@ type Watch = {
|
|
|
306
324
|
herdr: boolean
|
|
307
325
|
/** `lanes.backend` and the worktree directory of the main checkout's config; undefined without one, then the backend is never touched. */
|
|
308
326
|
backend: MainCheckoutBackend | undefined
|
|
327
|
+
/** Runs in the main checkout, not in a lane's worktree: only then does it close records of "Your turn" or propose to. */
|
|
328
|
+
inMainCheckout: boolean
|
|
309
329
|
}
|
|
310
330
|
|
|
311
331
|
/** The coordinator's agent name (null when none is found), its last read (null before the first one) and the tokens of that read (null without a value). */
|
|
@@ -376,6 +396,10 @@ type Watching = {
|
|
|
376
396
|
unlinkedArtifacts: UnlinkedState
|
|
377
397
|
/** Per working lane, the plan last reported as `plan missing on base`; persisted like the fingerprints. */
|
|
378
398
|
planMissing: PlanMissingState
|
|
399
|
+
/** The lanes of open waiting points missing at the last heartbeat; persisted like the fingerprints. */
|
|
400
|
+
waitingMissingLanes: string[]
|
|
401
|
+
/** The reasons proposed per open waiting point as `waiting obsolete?`; persisted like the fingerprints. */
|
|
402
|
+
waitingProposals: ProposalState
|
|
379
403
|
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
380
404
|
approvalRequests: ApprovalRequestFile[] | null
|
|
381
405
|
/** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
|
|
@@ -443,6 +467,8 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
443
467
|
laneStalls: watching.laneStalls,
|
|
444
468
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
445
469
|
planMissing: watching.planMissing,
|
|
470
|
+
waitingMissingLanes: watching.waitingMissingLanes,
|
|
471
|
+
waitingProposals: watching.waitingProposals,
|
|
446
472
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
447
473
|
...recordedSourceState(withReads.sources, watching.sourceRuns, watching.sources.sources, watching.githubReadAt),
|
|
448
474
|
appointments: watching.appointments
|
|
@@ -1322,6 +1348,69 @@ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArt
|
|
|
1322
1348
|
}
|
|
1323
1349
|
}
|
|
1324
1350
|
|
|
1351
|
+
/** Closes the sends through the log, one call per reason; returns the closings that were written, so a failed write is neither reported nor counted. */
|
|
1352
|
+
const closeObsoleteSends = (root: string, closings: readonly Closing[], now: number): Closing[] =>
|
|
1353
|
+
[...closingsByReason(closings)].flatMap(([reason, ids]) => {
|
|
1354
|
+
try {
|
|
1355
|
+
closeSentRecords(root, ids, now, reason)
|
|
1356
|
+
return ids.map((id) => ({ id, reason }))
|
|
1357
|
+
} catch {
|
|
1358
|
+
return []
|
|
1359
|
+
}
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
/** What the proposals read besides the records: the pull requests of the last GitHub read and the lanes' branches. */
|
|
1363
|
+
const proposalContextOf = (watching: Watching): Pick<ProposalInput, 'pullRequests' | 'laneBranches' | 'knownLanes'> => {
|
|
1364
|
+
const laneBranches = { ...watching.state.laneBranches, ...watching.laneBranches }
|
|
1365
|
+
return {
|
|
1366
|
+
pullRequests: answered(watching.github)?.pullRequests ?? null,
|
|
1367
|
+
laneBranches,
|
|
1368
|
+
knownLanes: new Set([...Object.keys(laneBranches), ...watching.lanes]),
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* This heartbeat's run over "Your turn", only from the main checkout: closes the sends a safe signal makes obsolete
|
|
1374
|
+
* (`waiting closed`, one event per run that closes something), then proposes what only looks obsolete (`waiting
|
|
1375
|
+
* obsolete?`, each point and reason once); records the missing lanes and the proposals in `watching`. Never writes
|
|
1376
|
+
* into the board. A read error closes nothing and reports nothing: the next heartbeat reads again.
|
|
1377
|
+
*/
|
|
1378
|
+
const waitingObsoleteEventsOf = (
|
|
1379
|
+
watch: Watch,
|
|
1380
|
+
watching: Watching,
|
|
1381
|
+
now: number,
|
|
1382
|
+
): (WaitingClosedEvent | WaitingObsoleteEvent)[] => {
|
|
1383
|
+
if (!watch.inMainCheckout) return []
|
|
1384
|
+
try {
|
|
1385
|
+
const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
|
|
1386
|
+
if (!existsSync(worktreeDirectory)) return []
|
|
1387
|
+
const sends = readSentRecords(watch.root)
|
|
1388
|
+
const questions = readQuestionRecords(watch.root)
|
|
1389
|
+
const boardText = readBoardText(watch)
|
|
1390
|
+
const board = boardText === null ? null : openWaitingItems(boardText, watch.settings.boardLanguage)
|
|
1391
|
+
const { missing, gone } = laneAbsenceOf(
|
|
1392
|
+
waitingLanesOf({ sends, questions, board, worktreeDirectory }),
|
|
1393
|
+
new Set(worktreeNames(worktreeDirectory)),
|
|
1394
|
+
watching.waitingMissingLanes,
|
|
1395
|
+
)
|
|
1396
|
+
const closings = closeObsoleteSends(watch.root, obsoleteSendsOf(sends, worktreeDirectory, gone), now)
|
|
1397
|
+
const closed = new Set(closings.map(({ id }) => id))
|
|
1398
|
+
const { events, state } = obsoleteProposalEvents(watching.waitingProposals, {
|
|
1399
|
+
sends: sends.filter(({ id }) => !closed.has(id)),
|
|
1400
|
+
questions,
|
|
1401
|
+
board,
|
|
1402
|
+
worktreeDirectory,
|
|
1403
|
+
gone,
|
|
1404
|
+
...proposalContextOf(watching),
|
|
1405
|
+
})
|
|
1406
|
+
watching.waitingMissingLanes = missing
|
|
1407
|
+
watching.waitingProposals = state
|
|
1408
|
+
return closings.length === 0 ? events : [{ kind: WAITING_CLOSED_EVENT, closings }, ...events]
|
|
1409
|
+
} catch {
|
|
1410
|
+
return []
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1325
1414
|
/**
|
|
1326
1415
|
* This heartbeat's `plan missing on base` events: the working lanes whose board row, as their worktree has it, links a
|
|
1327
1416
|
* plan the base ref lacks, each once; records the state in `watching`. Nothing when the boards or git cannot be read:
|
|
@@ -1351,13 +1440,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
|
|
|
1351
1440
|
return progress
|
|
1352
1441
|
}
|
|
1353
1442
|
|
|
1354
|
-
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the plans missing on the base. */
|
|
1443
|
+
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the obsolete waiting points, the plans missing on the base. */
|
|
1355
1444
|
const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
1356
1445
|
const progress = await workingProgressOf(watch, watching)
|
|
1357
1446
|
return [
|
|
1358
1447
|
...taskParallelEventsOf(watch, watching, now),
|
|
1359
1448
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1360
1449
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1450
|
+
...waitingObsoleteEventsOf(watch, watching, now),
|
|
1361
1451
|
...planMissingEventsOf(watch, watching),
|
|
1362
1452
|
]
|
|
1363
1453
|
}
|
|
@@ -1421,6 +1511,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
1421
1511
|
...taskParallelEventsOf(watch, watching, now),
|
|
1422
1512
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1423
1513
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1514
|
+
...waitingObsoleteEventsOf(watch, watching, now),
|
|
1424
1515
|
...planMissingEventsOf(watch, watching),
|
|
1425
1516
|
]
|
|
1426
1517
|
for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
|
|
@@ -1589,6 +1680,8 @@ const initialWatching = (
|
|
|
1589
1680
|
laneStalls: start.state.laneStalls,
|
|
1590
1681
|
unlinkedArtifacts: start.state.unlinkedArtifacts,
|
|
1591
1682
|
planMissing: start.state.planMissing,
|
|
1683
|
+
waitingMissingLanes: start.state.waitingMissingLanes,
|
|
1684
|
+
waitingProposals: start.state.waitingProposals,
|
|
1592
1685
|
approvalRequests: null,
|
|
1593
1686
|
appointments: null,
|
|
1594
1687
|
reportQuestions: null,
|
|
@@ -1737,6 +1830,7 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1737
1830
|
selfMergeMinutes: config.guard.selfMerge.approvalMinutes,
|
|
1738
1831
|
herdr: insideHerdr(settings.adapter, process.env),
|
|
1739
1832
|
backend: mainCheckoutBackendOf(root, checkoutRoot, config),
|
|
1833
|
+
inMainCheckout: checkoutRoot === root,
|
|
1740
1834
|
}
|
|
1741
1835
|
}
|
|
1742
1836
|
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -27,6 +27,8 @@ import type { LaneParallelEvent } from './lane-parallel'
|
|
|
27
27
|
import { laneStalledLine } from './lane-stalled'
|
|
28
28
|
import { type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
29
29
|
import { MERGE_OVERLAP, type MergeOverlap } from './merge-duties'
|
|
30
|
+
import { waitingObsoleteLine } from './obsolete-proposals'
|
|
31
|
+
import { waitingClosedLine } from './obsolete-records'
|
|
30
32
|
import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
|
|
31
33
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
32
34
|
import { SOURCE_FIELD } from './source-fields'
|
|
@@ -204,6 +206,8 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
204
206
|
if (laneLine !== null) return laneLine
|
|
205
207
|
if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
|
|
206
208
|
if (event.kind === WATCH_EVENT.unlinkedArtifact) return `=== ${unlinkedArtifactLine(event)}`
|
|
209
|
+
if (event.kind === WATCH_EVENT.waitingClosed) return `=== ${waitingClosedLine(event)}`
|
|
210
|
+
if (event.kind === WATCH_EVENT.waitingObsolete) return `=== ${waitingObsoleteLine(event)}`
|
|
207
211
|
if (event.kind === WATCH_EVENT.approval)
|
|
208
212
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
|
|
209
213
|
if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import type { WaitingItem } from '../plan-format/waiting'
|
|
4
|
+
import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
|
|
5
|
+
import { type QuestionRecord, questionTextOf } from '../plans/question-records'
|
|
6
|
+
import { type SentFileRecord, sentLaneOf, sentTextOf } from '../plans/sent-files'
|
|
7
|
+
import { laneDirectoryOf } from '../plans/unlisted-lanes'
|
|
8
|
+
import { isRecord } from '../shared/is-record'
|
|
9
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
10
|
+
import { latestPullRequestOf, PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
11
|
+
|
|
12
|
+
export const WAITING_OBSOLETE_EVENT = 'waiting obsolete?'
|
|
13
|
+
|
|
14
|
+
/** A point of "Your turn" that looks obsolete: a record's id or a board row's key, why, and what it shows. */
|
|
15
|
+
export type Proposal = { key: string; reason: string; text: string }
|
|
16
|
+
|
|
17
|
+
export type WaitingObsoleteEvent = { kind: typeof WAITING_OBSOLETE_EVENT } & Proposal
|
|
18
|
+
|
|
19
|
+
/** Per open point, the reasons already proposed for it; a point that closed or left the board has no entry. */
|
|
20
|
+
export type ProposalState = Record<string, string[]>
|
|
21
|
+
|
|
22
|
+
/** The pull request numbers and lane names a free text names. */
|
|
23
|
+
export type TextReferences = { pullRequests: number[]; lanes: string[] }
|
|
24
|
+
|
|
25
|
+
/** What a heartbeat read for its proposals; null where the source did not answer, so nothing is said about it. */
|
|
26
|
+
export type ProposalInput = {
|
|
27
|
+
sends: readonly SentFileRecord[]
|
|
28
|
+
questions: readonly QuestionRecord[]
|
|
29
|
+
/** The open rows of the board's waiting section; null when the board cannot be read. */
|
|
30
|
+
board: readonly WaitingItem[] | null
|
|
31
|
+
/** The pull requests of the watcher's last GitHub read; null before one answered. */
|
|
32
|
+
pullRequests: readonly WatchedPullRequest[] | null
|
|
33
|
+
/** Lane → branch, as the watcher keeps it after a lane was closed. */
|
|
34
|
+
laneBranches: Readonly<Record<string, string>>
|
|
35
|
+
worktreeDirectory: string
|
|
36
|
+
/** The lanes whose directory was missing in two heartbeats in a row (`laneAbsenceOf`). */
|
|
37
|
+
gone: ReadonlySet<string>
|
|
38
|
+
/** The lanes known to have existed; a board row naming a lane that never had a worktree proposes nothing. */
|
|
39
|
+
knownLanes: ReadonlySet<string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The word that names a lane before its name in backticks, per board language (`Spur \`name\``); every one is read. */
|
|
43
|
+
const LANE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'Spur', [PLAN_LANGUAGE.en]: 'lane' }
|
|
44
|
+
|
|
45
|
+
/** `#<digits>` standing alone: not `#AB12CD`, not `#12a`, not a part of a word or an address (`a#1`, `/#1`). */
|
|
46
|
+
const PULL_REQUEST_REFERENCE = /(?<![\w#&/])#(\d+)(?!\w)/g
|
|
47
|
+
|
|
48
|
+
const LANE_REFERENCE = new RegExp(`\\b(?:${Object.values(LANE_WORD).join('|')})\\s+\`([^\`\\s]+)\``, 'gi')
|
|
49
|
+
|
|
50
|
+
const ROW_KEY_PREFIX = 'row-'
|
|
51
|
+
const ROW_HASH_LENGTH = 8
|
|
52
|
+
const SUPERSEDED = 'superseded'
|
|
53
|
+
/** The reason of a question whose own session has ended (`SessionEnd`, also after `/clear`) with the question still open. */
|
|
54
|
+
const SESSION_ENDED = 'session ended'
|
|
55
|
+
|
|
56
|
+
/** What of a board row names its pull request or lane: the item and the origin. */
|
|
57
|
+
const rowText = ({ item, origin }: WaitingItem): string => `${item} ${origin ?? ''}`
|
|
58
|
+
|
|
59
|
+
/** The pull request numbers (`#<n>`) and lane names (`Spur \`name\``, `lane \`name\``) a free text names, each once. */
|
|
60
|
+
export const textReferencesOf = (text: string): TextReferences => ({
|
|
61
|
+
pullRequests: [...new Set([...text.matchAll(PULL_REQUEST_REFERENCE)].map((match) => Number(match[1])))],
|
|
62
|
+
lanes: [...new Set([...text.matchAll(LANE_REFERENCE)].map((match) => match[1]))],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** The lanes the open points name: a send's lane, a question's lane by its directory, a board row's lanes in its text. */
|
|
66
|
+
export const waitingLanesOf = (
|
|
67
|
+
input: Pick<ProposalInput, 'sends' | 'questions' | 'board' | 'worktreeDirectory'>,
|
|
68
|
+
): string[] => [
|
|
69
|
+
...input.sends.flatMap((record) =>
|
|
70
|
+
record.closedAt === null ? (sentLaneOf(record, input.worktreeDirectory) ?? []) : [],
|
|
71
|
+
),
|
|
72
|
+
...input.questions.flatMap((record) =>
|
|
73
|
+
record.closedAt === null ? (laneDirectoryOf(record.cwd, input.worktreeDirectory) ?? []) : [],
|
|
74
|
+
),
|
|
75
|
+
...(input.board ?? []).flatMap((row) => textReferencesOf(rowText(row)).lanes),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
/** A board row's key: `row-` and a short hash of its item, stable while the row's text stays. */
|
|
79
|
+
export const rowKeyOf = (item: string): string =>
|
|
80
|
+
`${ROW_KEY_PREFIX}${createHash('sha256').update(item).digest('hex').slice(0, ROW_HASH_LENGTH)}`
|
|
81
|
+
|
|
82
|
+
const hasEnded = ({ state }: WatchedPullRequest): boolean => state !== PULL_REQUEST_STATE.open
|
|
83
|
+
|
|
84
|
+
const pullRequestReason = ({ number, state }: WatchedPullRequest): string => `pr #${number} ${state.toLowerCase()}`
|
|
85
|
+
|
|
86
|
+
const laneRemovedReason = (lane: string): string => closeReasonOf(CLOSE_REASON.laneRemoved, lane)
|
|
87
|
+
|
|
88
|
+
const sendProposals = (input: ProposalInput): Proposal[] =>
|
|
89
|
+
input.sends.flatMap((record) => {
|
|
90
|
+
const lane = sentLaneOf(record, input.worktreeDirectory)
|
|
91
|
+
const branch = lane === null ? undefined : input.laneBranches[lane]
|
|
92
|
+
const pullRequest = branch === undefined ? null : latestPullRequestOf(input.pullRequests ?? [], branch)
|
|
93
|
+
return record.closedAt === null && pullRequest !== null && hasEnded(pullRequest)
|
|
94
|
+
? [{ key: record.id, reason: pullRequestReason(pullRequest), text: sentTextOf(record) }]
|
|
95
|
+
: []
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const questionReasons = (record: QuestionRecord, input: ProposalInput): string[] => {
|
|
99
|
+
const lane = laneDirectoryOf(record.cwd, input.worktreeDirectory)
|
|
100
|
+
const superseded = record.supersededBy ? [`${SUPERSEDED}: ${record.supersededBy.question}`] : []
|
|
101
|
+
const ended = typeof record.sessionEndedAt === 'number' ? [SESSION_ENDED] : []
|
|
102
|
+
return [...superseded, ...ended, ...(lane !== null && input.gone.has(lane) ? [laneRemovedReason(lane)] : [])]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const questionProposals = (input: ProposalInput): Proposal[] =>
|
|
106
|
+
input.questions
|
|
107
|
+
.filter(({ closedAt }) => closedAt === null)
|
|
108
|
+
.flatMap((record) =>
|
|
109
|
+
questionReasons(record, input).map((reason) => ({ key: record.id, reason, text: questionTextOf(record) })),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const rowReasons = (row: WaitingItem, input: ProposalInput): string[] => {
|
|
113
|
+
const { pullRequests, lanes } = textReferencesOf(rowText(row))
|
|
114
|
+
const ended = (input.pullRequests ?? []).filter((pullRequest) => pullRequests.includes(pullRequest.number))
|
|
115
|
+
const removed = lanes.filter((lane) => input.gone.has(lane) && input.knownLanes.has(lane))
|
|
116
|
+
return [...ended.filter(hasEnded).map(pullRequestReason), ...removed.map(laneRemovedReason)]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const boardProposals = (input: ProposalInput): Proposal[] =>
|
|
120
|
+
(input.board ?? []).flatMap((row) =>
|
|
121
|
+
rowReasons(row, input).map((reason) => ({ key: rowKeyOf(row.item), reason, text: row.item })),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What looks obsolete but is not safe to close: an open send whose lane's pull request was merged or closed, an open
|
|
126
|
+
* board row naming such a pull request (`#<n>`) or a lane whose directory is gone, an open question a later dialog
|
|
127
|
+
* probably answered (`supersededBy`), whose session has ended (`session ended`) or whose lane is gone. The coordinator decides; nothing here closes anything.
|
|
128
|
+
*/
|
|
129
|
+
export const obsoleteProposalsOf = (input: ProposalInput): Proposal[] => [
|
|
130
|
+
...sendProposals(input),
|
|
131
|
+
...questionProposals(input),
|
|
132
|
+
...boardProposals(input),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
/** The keys still open: the open records and board rows; while the board cannot be read, the rows proposed before. */
|
|
136
|
+
const openKeysOf = (previous: ProposalState, input: ProposalInput): Set<string> => {
|
|
137
|
+
const records = [...input.sends, ...input.questions].filter(({ closedAt }) => closedAt === null).map(({ id }) => id)
|
|
138
|
+
const rows =
|
|
139
|
+
input.board === null
|
|
140
|
+
? Object.keys(previous).filter((key) => key.startsWith(ROW_KEY_PREFIX))
|
|
141
|
+
: input.board.map(({ item }) => rowKeyOf(item))
|
|
142
|
+
return new Set([...records, ...rows])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One event per point and reason, once while the point is open: a reason proposed before is not said again, and a
|
|
147
|
+
* point that closed leaves the state. A source that did not answer keeps what was proposed from it.
|
|
148
|
+
*/
|
|
149
|
+
export const obsoleteProposalEvents = (
|
|
150
|
+
previous: ProposalState,
|
|
151
|
+
input: ProposalInput,
|
|
152
|
+
): { events: WaitingObsoleteEvent[]; state: ProposalState } => {
|
|
153
|
+
const open = openKeysOf(previous, input)
|
|
154
|
+
const state: ProposalState = Object.fromEntries(Object.entries(previous).filter(([key]) => open.has(key)))
|
|
155
|
+
const events: WaitingObsoleteEvent[] = []
|
|
156
|
+
for (const proposal of obsoleteProposalsOf(input)) {
|
|
157
|
+
const said = state[proposal.key] ?? []
|
|
158
|
+
if (said.includes(proposal.reason)) continue
|
|
159
|
+
events.push({ kind: WAITING_OBSOLETE_EVENT, ...proposal })
|
|
160
|
+
state[proposal.key] = [...said, proposal.reason]
|
|
161
|
+
}
|
|
162
|
+
return { events, state }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const quoted = (text: string): string => terminalSafe(text).replaceAll('"', "'")
|
|
166
|
+
|
|
167
|
+
/** `waiting obsolete? <key> <reason> "<text>"`; double quotes inside become single ones, so the quotes stay the text's bounds. */
|
|
168
|
+
export const waitingObsoleteLine = ({ key, reason, text }: WaitingObsoleteEvent): string =>
|
|
169
|
+
`${WAITING_OBSOLETE_EVENT} ${terminalSafe(key)} ${quoted(reason)} "${quoted(text)}"`
|
|
170
|
+
|
|
171
|
+
export const isProposalState = (value: unknown): value is ProposalState =>
|
|
172
|
+
isRecord(value) &&
|
|
173
|
+
Object.values(value).every(
|
|
174
|
+
(reasons) => Array.isArray(reasons) && reasons.every((reason) => typeof reason === 'string'),
|
|
175
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
|
|
3
|
+
import { latestPerFile, type SentFileRecord, sentLaneOf } from '../plans/sent-files'
|
|
4
|
+
|
|
5
|
+
export const WAITING_CLOSED_EVENT = 'waiting closed'
|
|
6
|
+
|
|
7
|
+
/** A record the watcher closes on a safe signal, and the reason it writes to it (`closeReasonOf`). */
|
|
8
|
+
export type Closing = { id: string; reason: string }
|
|
9
|
+
|
|
10
|
+
/** What a heartbeat knows of the lanes' directories: the ones missing now, and those missing in two heartbeats in a row. */
|
|
11
|
+
export type LaneAbsence = { missing: string[]; gone: Set<string> }
|
|
12
|
+
|
|
13
|
+
export type WaitingClosedEvent = { kind: typeof WAITING_CLOSED_EVENT; closings: Closing[] }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which of the `lanes` have no directory among `existing`, and which of those were missing at the last heartbeat
|
|
17
|
+
* too (`previousMissing`): only these count as gone, so a worktree renamed or slow to list closes nothing.
|
|
18
|
+
*/
|
|
19
|
+
export const laneAbsenceOf = (
|
|
20
|
+
lanes: Iterable<string>,
|
|
21
|
+
existing: ReadonlySet<string>,
|
|
22
|
+
previousMissing: readonly string[],
|
|
23
|
+
): LaneAbsence => {
|
|
24
|
+
const missing = [...new Set(lanes)].filter((lane) => !existing.has(lane)).sort()
|
|
25
|
+
return { missing, gone: new Set(missing.filter((lane) => previousMissing.includes(lane))) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Each older send of a first entry that a newer send has, closed with the newer send's id (`latestPerFile`). */
|
|
29
|
+
const olderVersionsOf = (records: readonly SentFileRecord[]): Closing[] => {
|
|
30
|
+
const ordered = [...records].sort((left, right) => left.sentAt - right.sentAt)
|
|
31
|
+
const latest = latestPerFile(ordered)
|
|
32
|
+
const newestOf = new Map(latest.map((record) => [record.files[0], record.id]))
|
|
33
|
+
const kept = new Set(latest.map(({ id }) => id))
|
|
34
|
+
return ordered.flatMap((record) => {
|
|
35
|
+
const newer = newestOf.get(record.files[0])
|
|
36
|
+
return kept.has(record.id) || newer === undefined
|
|
37
|
+
? []
|
|
38
|
+
: [{ id: record.id, reason: closeReasonOf(CLOSE_REASON.newerVersion, newer) }]
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The open sends a safe signal makes obsolete: an older send of a first entry that was sent again (`newer version:
|
|
44
|
+
* <id>`, whoever sent it), and a send whose lane is gone (`lane removed: <name>`); the newer version wins when both hold.
|
|
45
|
+
* A send without a lane (lane null) is never closed as removed, and a question record is never closed here: the
|
|
46
|
+
* coordinator decides on those.
|
|
47
|
+
*/
|
|
48
|
+
export const obsoleteSendsOf = (
|
|
49
|
+
records: readonly SentFileRecord[],
|
|
50
|
+
worktreeDirectory: string,
|
|
51
|
+
gone: ReadonlySet<string>,
|
|
52
|
+
): Closing[] => {
|
|
53
|
+
const open = records.flatMap((record) => {
|
|
54
|
+
const lane = sentLaneOf(record, worktreeDirectory)
|
|
55
|
+
return record.closedAt === null ? [{ id: record.id, lane }] : []
|
|
56
|
+
})
|
|
57
|
+
const openIds = new Set(open.map(({ id }) => id))
|
|
58
|
+
const newer = olderVersionsOf(records).filter(({ id }) => openIds.has(id))
|
|
59
|
+
const closedByNewer = new Set(newer.map(({ id }) => id))
|
|
60
|
+
const removed = open.flatMap(({ id, lane }) =>
|
|
61
|
+
lane !== null && gone.has(lane) && !closedByNewer.has(id)
|
|
62
|
+
? [{ id, reason: closeReasonOf(CLOSE_REASON.laneRemoved, lane) }]
|
|
63
|
+
: [],
|
|
64
|
+
)
|
|
65
|
+
return [...newer, ...removed]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The closings grouped by reason, each group one call of the log's `close`. */
|
|
69
|
+
export const closingsByReason = (closings: readonly Closing[]): Map<string, string[]> => {
|
|
70
|
+
const groups = new Map<string, string[]>()
|
|
71
|
+
for (const { id, reason } of closings) groups.set(reason, [...(groups.get(reason) ?? []), id])
|
|
72
|
+
return groups
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `waiting closed <n>: <id> <reason>; …`, the reasons without control characters. */
|
|
76
|
+
export const waitingClosedLine = ({ closings }: WaitingClosedEvent): string =>
|
|
77
|
+
`${WAITING_CLOSED_EVENT} ${closings.length}: ${closings.map(({ id, reason }) => `${terminalSafe(id)} ${terminalSafe(reason)}`).join('; ')}`
|
|
@@ -53,6 +53,8 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
|
53
53
|
[WATCH_EVENT.noTaskSignal]: REFERENCE.events,
|
|
54
54
|
[WATCH_EVENT.unlinkedArtifact]: REFERENCE.events,
|
|
55
55
|
[WATCH_EVENT.planMissing]: REFERENCE.events,
|
|
56
|
+
[WATCH_EVENT.waitingClosed]: REFERENCE.waiting,
|
|
57
|
+
[WATCH_EVENT.waitingObsolete]: REFERENCE.waiting,
|
|
56
58
|
[WATCH_EVENT.deploy]: REFERENCE.events,
|
|
57
59
|
[WATCH_EVENT.laneStalled]: REFERENCE.events,
|
|
58
60
|
[WATCH_EVENT.due]: REFERENCE.appointments,
|