jackherd 1.24.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 +9 -0
- package/INSTALL.md +1 -1
- package/README.md +22 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -1
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +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/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/hooks/hooks.json +8 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +7 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +9 -4
- package/src/config/schema.ts +3 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/closable-records.ts +11 -10
- package/src/plans/close-reason.ts +19 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +3 -2
- 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/plans/settings.ts +3 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/waiting/command.ts +82 -26
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +100 -6
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/lane-report.ts +4 -0
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/obsolete-proposals.ts +175 -0
- package/src/watch/obsolete-records.ts +77 -0
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +2 -0
- package/src/watch/watch-events.ts +20 -0
|
@@ -224,5 +224,5 @@ export const boardCheckoutRows = (
|
|
|
224
224
|
checkoutText: string,
|
|
225
225
|
): number => {
|
|
226
226
|
const atBase = textAtBase(baseReaderOf(root, baseRefOf(settings)), boardRepoPathOf(root, settings, boardFile))
|
|
227
|
-
return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.
|
|
227
|
+
return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.boardLanguage).rowsFromCheckout
|
|
228
228
|
}
|
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)}`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { STACKS } from '../config/detect'
|
|
2
2
|
import { isObjectId } from '../lane/close-survey'
|
|
3
3
|
import { parseSprintLanes } from '../plan-format'
|
|
4
|
-
import type
|
|
4
|
+
import { type PlansSettings } from '../plans/settings'
|
|
5
5
|
import { lanePlanPath, watchedLanePlanText } from './lane-progress'
|
|
6
6
|
import type { OverlapOf } from './lane-report'
|
|
7
7
|
import { type LaneScope, type MergeOverlap, overlapOf } from './merge-duties'
|
|
@@ -39,7 +39,7 @@ const scopeOf = (
|
|
|
39
39
|
boardText: string,
|
|
40
40
|
name: string,
|
|
41
41
|
): LaneScope | null => {
|
|
42
|
-
const lane = parseSprintLanes(boardText, settings.
|
|
42
|
+
const lane = parseSprintLanes(boardText, settings.boardLanguage).find((candidate) => candidate.name === name)
|
|
43
43
|
if (lane === undefined) return null
|
|
44
44
|
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
45
45
|
if (text === null) return null
|
|
@@ -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('; ')}`
|
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
const worktreePlanOf = (root: string, settings: PlansSettings, boardFile: string, laneName: string): string | null => {
|
|
20
20
|
const file = join(worktreeOf(root, settings, laneName), boardRepoPathOf(root, settings, boardFile))
|
|
21
21
|
if (!existsSync(file)) return null
|
|
22
|
-
const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.
|
|
22
|
+
const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.boardLanguage).find(
|
|
23
|
+
({ name }) => name === laneName,
|
|
24
|
+
)
|
|
23
25
|
const path = lane === undefined ? null : lanePlanPath(root, settings, boardFile, lane)
|
|
24
26
|
return path === null || isOutsideRepository(path) ? null : path
|
|
25
27
|
}
|
|
@@ -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,
|
|
@@ -23,6 +23,13 @@ import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels }
|
|
|
23
23
|
import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
|
|
24
24
|
import { isStallState, LANE_STALLED_EVENT, type LaneStalledEvent, type StallState } from './lane-stalled'
|
|
25
25
|
import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
|
|
26
|
+
import {
|
|
27
|
+
isProposalState,
|
|
28
|
+
type ProposalState,
|
|
29
|
+
WAITING_OBSOLETE_EVENT,
|
|
30
|
+
type WaitingObsoleteEvent,
|
|
31
|
+
} from './obsolete-proposals'
|
|
32
|
+
import { WAITING_CLOSED_EVENT, type WaitingClosedEvent } from './obsolete-records'
|
|
26
33
|
import { isPlanMissingState, PLAN_MISSING_EVENT, type PlanMissingEvent, type PlanMissingState } from './plan-missing'
|
|
27
34
|
import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
|
|
28
35
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
@@ -71,6 +78,8 @@ export const WATCH_EVENT = {
|
|
|
71
78
|
laneStalled: LANE_STALLED_EVENT,
|
|
72
79
|
unlinkedArtifact: UNLINKED_ARTIFACT_EVENT,
|
|
73
80
|
planMissing: PLAN_MISSING_EVENT,
|
|
81
|
+
waitingClosed: WAITING_CLOSED_EVENT,
|
|
82
|
+
waitingObsolete: WAITING_OBSOLETE_EVENT,
|
|
74
83
|
approval: APPROVAL_EVENT,
|
|
75
84
|
source: SOURCE_EVENT,
|
|
76
85
|
due: APPOINTMENT_EVENT.due,
|
|
@@ -137,6 +146,10 @@ export type WatchState = {
|
|
|
137
146
|
unlinkedArtifacts: UnlinkedState
|
|
138
147
|
/** Per working lane whose board row links a plan the base ref lacks: the plan last reported as `plan missing on base`. */
|
|
139
148
|
planMissing: PlanMissingState
|
|
149
|
+
/** The lanes of open waiting points whose directory was missing at the last heartbeat; missing twice in a row, a lane is gone. */
|
|
150
|
+
waitingMissingLanes: string[]
|
|
151
|
+
/** Per open waiting point (record id or board row key): the reasons already proposed as `waiting obsolete?`. */
|
|
152
|
+
waitingProposals: ProposalState
|
|
140
153
|
/** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
|
|
141
154
|
approvals: string[]
|
|
142
155
|
/** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
|
|
@@ -207,6 +220,8 @@ export type WatchEvent =
|
|
|
207
220
|
| LaneStalledEvent
|
|
208
221
|
| UnlinkedArtifactEvent
|
|
209
222
|
| PlanMissingEvent
|
|
223
|
+
| WaitingClosedEvent
|
|
224
|
+
| WaitingObsoleteEvent
|
|
210
225
|
| ApprovalEvent
|
|
211
226
|
| SourceEvent
|
|
212
227
|
| AppointmentEvent
|
|
@@ -248,6 +263,8 @@ export const emptyWatchState = (): WatchState => ({
|
|
|
248
263
|
laneStalls: {},
|
|
249
264
|
unlinkedArtifacts: {},
|
|
250
265
|
planMissing: {},
|
|
266
|
+
waitingMissingLanes: [],
|
|
267
|
+
waitingProposals: {},
|
|
251
268
|
approvals: [],
|
|
252
269
|
sources: emptySourceState(),
|
|
253
270
|
watchedSources: null,
|
|
@@ -304,6 +321,9 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
|
304
321
|
isStallState(value.laneStalls) &&
|
|
305
322
|
isUnlinkedState(value.unlinkedArtifacts) &&
|
|
306
323
|
isPlanMissingState(value.planMissing) &&
|
|
324
|
+
Array.isArray(value.waitingMissingLanes) &&
|
|
325
|
+
value.waitingMissingLanes.every((lane) => typeof lane === 'string') &&
|
|
326
|
+
isProposalState(value.waitingProposals) &&
|
|
307
327
|
Array.isArray(value.approvals) &&
|
|
308
328
|
value.approvals.every((id) => typeof id === 'string') &&
|
|
309
329
|
isSourceState(value.sources) &&
|