jackherd 0.0.1 → 1.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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jh watch`: waits until the coordinator has something to do and prints it: a lane that stopped, a lane's
|
|
3
|
+
* pull request that was merged or whose checks settled, a deploy run of a lane's merge or a manual one (only with
|
|
4
|
+
* `watch.deploy` in the config), new feedback on a lane's open pull request (unless `watch.feedback` is false), a deploy preview for a
|
|
5
|
+
* lane's new head commit (only with `watch.previews`), a new or changed entry of a configured source or its failure (only
|
|
6
|
+
* with `watch.sources`), a gh outage, a lane's or the coordinator's own context fill rising to warn or alert, or the
|
|
7
|
+
* heartbeat. A blocked lane adds one duty line, a merge one per lane, a lane at alert one, and every report ends with one status line
|
|
8
|
+
* per lane and one for the coordinator, in the words of `responseLanguage`. When the installed kit changes version
|
|
9
|
+
* it prints `=== kit updated <old> → <new>` and ends. The coordinator is the agent in the
|
|
10
|
+
* watcher's own herdr pane unless `--coordinator` names it, else `orchestrator-<repository>`. A new start replaces a running watcher of the same
|
|
11
|
+
* repository; one in another repository keeps running.
|
|
12
|
+
*
|
|
13
|
+
* Without `--stream` it exits after one report, whose last line says so and points to the stream: that run is the
|
|
14
|
+
* fallback where no Monitor tool exists, a background task armed again after every wake-up.
|
|
15
|
+
* With `--stream` it prints each report in one write and keeps watching, the heartbeat as a single line: run it
|
|
16
|
+
* under Claude Code's Monitor tool, which turns every line into a message. Without lane names it watches every
|
|
17
|
+
* worktree whose name a herdr agent carries or the board's lanes table lists, and prints `lane added: <name>` /
|
|
18
|
+
* `lane removed: <name>` as that set changes. With every GitHub read it fetches `origin/<baseBranch>` once the
|
|
19
|
+
* remote moved past it, so the plans view, which watches `FETCH_HEAD`, shows a merged lane row at once. Inside herdr
|
|
20
|
+
* it brings back missing plans views of the board's lanes like `jh plans --ensure`: at its start, for every
|
|
21
|
+
* added lane and for every lane whose agent appeared.
|
|
22
|
+
*
|
|
23
|
+
* `watch source <name> --dry-run` runs one source once instead and prints what it would report (`source-dry-run-command.ts`).
|
|
24
|
+
*
|
|
25
|
+
* jh watch [--stream] [--heartbeat <minutes>] [--board <board.md>] [--coordinator <name>] [<lane> ...]
|
|
26
|
+
* jh watch source <name|draft.json|-> --dry-run
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync } from 'node:fs'
|
|
29
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
30
|
+
import { readResolvedConfig } from '../config/read-config'
|
|
31
|
+
import type { JackherdConfig } from '../config/schema'
|
|
32
|
+
import { defaultCoordinatorName } from '../context/coordinator-name'
|
|
33
|
+
import { coordinatorSettings } from '../context/options'
|
|
34
|
+
import { contextReport } from '../context/report'
|
|
35
|
+
import { agentLog } from '../context/session-log'
|
|
36
|
+
import { type LaneProgress, parseSprintLanes } from '../plan-format'
|
|
37
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
38
|
+
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
39
|
+
import { ensurePlanPanes } from '../plans/ensure-panes'
|
|
40
|
+
import { errorText } from '../plans/error-text'
|
|
41
|
+
import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
|
|
42
|
+
import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
|
|
43
|
+
import { clockLocaleOf, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
|
|
44
|
+
import { boardTexts } from '../plans/texts'
|
|
45
|
+
import { herdrLaneStatusSource } from '../terminal/herdr'
|
|
46
|
+
import { startedKit } from '../version/started-kit'
|
|
47
|
+
import {
|
|
48
|
+
type CoordinatorRead,
|
|
49
|
+
contextReadingOf,
|
|
50
|
+
coordinatorNameOf,
|
|
51
|
+
coordinatorReadOf,
|
|
52
|
+
coordinatorTargetOf,
|
|
53
|
+
explainedRead,
|
|
54
|
+
readingOf,
|
|
55
|
+
} from './coordinator-fill'
|
|
56
|
+
import {
|
|
57
|
+
type FeedbackItem,
|
|
58
|
+
type FeedbackRead,
|
|
59
|
+
type FeedbackTarget,
|
|
60
|
+
feedbackEvents,
|
|
61
|
+
feedbackReadOf,
|
|
62
|
+
feedbackSince,
|
|
63
|
+
feedbackTargets,
|
|
64
|
+
recordFeedback,
|
|
65
|
+
} from './feedback'
|
|
66
|
+
import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
|
|
67
|
+
import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
|
|
68
|
+
import { agentScreen, listAgents } from './herdr'
|
|
69
|
+
import { endedByKitUpdate } from './kit-update'
|
|
70
|
+
import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
|
|
71
|
+
import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
|
|
72
|
+
import { boardCheckoutRows, watchedLaneProgress } from './lane-progress'
|
|
73
|
+
import {
|
|
74
|
+
armLine,
|
|
75
|
+
blockedDutyLines,
|
|
76
|
+
coordinatorStatusLine,
|
|
77
|
+
feedbackEventLines,
|
|
78
|
+
ghUnavailableStatusLine,
|
|
79
|
+
type LaneReport,
|
|
80
|
+
laneFillDutyLines,
|
|
81
|
+
laneReportLine,
|
|
82
|
+
mergeDutyLines,
|
|
83
|
+
otherPullRequestsLine,
|
|
84
|
+
sourceEventLines,
|
|
85
|
+
sourceFailingStatusLine,
|
|
86
|
+
watchEventLine,
|
|
87
|
+
} from './lane-report'
|
|
88
|
+
import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
|
|
89
|
+
import { branchOf, fetchBaseWhenMoved } from './lane-sources'
|
|
90
|
+
import {
|
|
91
|
+
advanceBaseline,
|
|
92
|
+
armBaseline,
|
|
93
|
+
baselineWithNewAgents,
|
|
94
|
+
departedLanes,
|
|
95
|
+
firedLanes,
|
|
96
|
+
type HerdrAgent,
|
|
97
|
+
LANE_GONE,
|
|
98
|
+
type LaneSnapshot,
|
|
99
|
+
NO_AGENT,
|
|
100
|
+
toSnapshot,
|
|
101
|
+
} from './lane-watch'
|
|
102
|
+
import { monitorToolAvailable } from './monitor-tool'
|
|
103
|
+
import { parseWatchOptions, type WatchOptions } from './options'
|
|
104
|
+
import {
|
|
105
|
+
ENSURE_TRIGGER,
|
|
106
|
+
type EnsureTrigger,
|
|
107
|
+
insideHerdr,
|
|
108
|
+
keepPlanViews,
|
|
109
|
+
lanesWithNewAgent,
|
|
110
|
+
type PlanViews,
|
|
111
|
+
} from './plan-views'
|
|
112
|
+
import { readDeploymentCandidates } from './preview-read'
|
|
113
|
+
import {
|
|
114
|
+
commentCandidates,
|
|
115
|
+
feedbackFilterOf,
|
|
116
|
+
feedbackReadCovers,
|
|
117
|
+
type PreviewRead,
|
|
118
|
+
previewEvents,
|
|
119
|
+
previewReadOf,
|
|
120
|
+
recordPreviews,
|
|
121
|
+
unreportedTargets,
|
|
122
|
+
} from './previews'
|
|
123
|
+
import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
|
|
124
|
+
import { withSingleReportNotice } from './single-report'
|
|
125
|
+
import { readOrchestrateSkill, skillLineFor } from './skill-change'
|
|
126
|
+
import { sourceDryRun } from './source-dry-run-command'
|
|
127
|
+
import { runSource, SOURCE_LIMITS } from './source-run'
|
|
128
|
+
import {
|
|
129
|
+
isSourceDue,
|
|
130
|
+
recordSources,
|
|
131
|
+
type SourceRun,
|
|
132
|
+
type SourceSettings,
|
|
133
|
+
type SourceState,
|
|
134
|
+
sourceEvents,
|
|
135
|
+
} from './sources'
|
|
136
|
+
import { streamHeartbeatLine } from './stream-output'
|
|
137
|
+
import { WATCH_TEXTS, type WatchTexts } from './texts'
|
|
138
|
+
import {
|
|
139
|
+
coordinatorWatchEvent,
|
|
140
|
+
deployEvents,
|
|
141
|
+
emptyWatchState,
|
|
142
|
+
ghOutageEvent,
|
|
143
|
+
isHeartbeatDue,
|
|
144
|
+
mergeCommitsOf,
|
|
145
|
+
type Observation,
|
|
146
|
+
ownMergeCommitsOf,
|
|
147
|
+
parseWatchState,
|
|
148
|
+
pullRequestEvents,
|
|
149
|
+
recordCoordinatorLevel,
|
|
150
|
+
recordWatchState,
|
|
151
|
+
WATCH_EVENT,
|
|
152
|
+
type WatchEvent,
|
|
153
|
+
type WatchState,
|
|
154
|
+
withReportBaselines,
|
|
155
|
+
} from './watch-events'
|
|
156
|
+
import {
|
|
157
|
+
ensureWatcherDirectory,
|
|
158
|
+
releaseLock,
|
|
159
|
+
stopNoticeOf,
|
|
160
|
+
takeOverLock,
|
|
161
|
+
WATCHER_FILES,
|
|
162
|
+
writePrivate,
|
|
163
|
+
} from './watcher-lock'
|
|
164
|
+
import type { DeployConfig } from './workflow-runs'
|
|
165
|
+
|
|
166
|
+
type Files = { state: string; lock: string }
|
|
167
|
+
|
|
168
|
+
/** The logins whose feedback does not count, or false when the project turned feedback off. */
|
|
169
|
+
type FeedbackConfig = JackherdConfig['watch']['feedback']
|
|
170
|
+
|
|
171
|
+
/** Where deploy previews are read from; undefined when the project reads none. */
|
|
172
|
+
type PreviewsConfig = JackherdConfig['watch']['previews']
|
|
173
|
+
|
|
174
|
+
/** One feedback read: what counts as feedback, and every item it read, which the previews take their comments from. */
|
|
175
|
+
type FeedbackPoll = { read: FeedbackRead; items: FeedbackItem[] }
|
|
176
|
+
|
|
177
|
+
/** What one run of the watcher is set up with: the main checkout, the sprint, the command line and the config. */
|
|
178
|
+
type Watch = {
|
|
179
|
+
root: string
|
|
180
|
+
sprintFile: string
|
|
181
|
+
/** Why this board was chosen over others; null when there was no choice or `--board` named it. */
|
|
182
|
+
boardNotice: string | null
|
|
183
|
+
options: WatchOptions
|
|
184
|
+
files: Files
|
|
185
|
+
settings: PlansSettings
|
|
186
|
+
deploy: DeployConfig | undefined
|
|
187
|
+
feedback: FeedbackConfig
|
|
188
|
+
previews: PreviewsConfig
|
|
189
|
+
sources: SourceSettings[]
|
|
190
|
+
texts: WatchTexts
|
|
191
|
+
/** Runs inside herdr with the herdr adapter, so missing plans views can be brought back. */
|
|
192
|
+
herdr: boolean
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The coordinator's agent name (null when none is found) and its last read (null before the first one). */
|
|
196
|
+
type Coordinator = { name: string | null; read: CoordinatorRead | null }
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What the loop knows between polls. `state` is the record as of the last GitHub read that did not
|
|
200
|
+
* return; while a report is being written, a stop saves that and not the newer read, so the events
|
|
201
|
+
* of an interrupted report are reported again instead of being taken as done.
|
|
202
|
+
*/
|
|
203
|
+
type Watching = {
|
|
204
|
+
/** The lanes watched now: the named ones, or those found at the last herdr poll. */
|
|
205
|
+
lanes: string[]
|
|
206
|
+
/** The lane names of the board's lanes table as last read; a failed read keeps them. */
|
|
207
|
+
sprintLanes: string[]
|
|
208
|
+
/** Lanes that left the set with their agent since the last poll; they fire as gone once. */
|
|
209
|
+
departed: string[]
|
|
210
|
+
/** When the last report or heartbeat was printed, the start before the first. */
|
|
211
|
+
quietSince: number
|
|
212
|
+
state: WatchState
|
|
213
|
+
baseline: LaneSnapshot
|
|
214
|
+
current: LaneSnapshot
|
|
215
|
+
/** The last `herdr agent list` answer, kept for finding the coordinator by its pane. */
|
|
216
|
+
agents: HerdrAgent[]
|
|
217
|
+
github: GithubRead | null
|
|
218
|
+
laneBranches: Record<string, string>
|
|
219
|
+
githubReadAt: number
|
|
220
|
+
coordinator: Coordinator
|
|
221
|
+
herdrDown: boolean
|
|
222
|
+
reporting: boolean
|
|
223
|
+
/** A fetch of the base branch is under way; the next one waits for it. */
|
|
224
|
+
fetchingBase: boolean
|
|
225
|
+
/** The account `gh` runs as, read once it answers; feedback is read only once it is known. */
|
|
226
|
+
ownLogin: string | null
|
|
227
|
+
/** The last feedback read that answered; its targets and items are recorded with every state. */
|
|
228
|
+
feedbackRead: FeedbackRead | null
|
|
229
|
+
/** The last preview read that answered; recorded with every state like the feedback read. */
|
|
230
|
+
previewRead: PreviewRead | null
|
|
231
|
+
/** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
|
|
232
|
+
sourceRuns: SourceRun[]
|
|
233
|
+
/** Source name → when it was last started in this process; a source without an entry runs at the next GitHub poll. */
|
|
234
|
+
sourceRanAt: Record<string, number>
|
|
235
|
+
/** The lanes' fills of the last read; a lane without an entry or with null has no value. */
|
|
236
|
+
laneReadings: LaneReadings
|
|
237
|
+
planViews: PlanViews
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const POLL_MS = 5_000
|
|
241
|
+
const GITHUB_POLL_MS = 60_000
|
|
242
|
+
const MINUTE_MS = 60_000
|
|
243
|
+
const USAGE_EXIT = 2
|
|
244
|
+
|
|
245
|
+
const clock = (watch: Watch, epochMs: number): string =>
|
|
246
|
+
new Date(epochMs).toLocaleTimeString(clockLocaleOf(watch.settings))
|
|
247
|
+
|
|
248
|
+
const agentNamesOf = (agents: readonly HerdrAgent[]): string[] => agents.flatMap((agent) => agent.name ?? [])
|
|
249
|
+
|
|
250
|
+
const observationOf = (watching: Watching): Observation => ({
|
|
251
|
+
lanes: watching.current,
|
|
252
|
+
laneBranches: watching.laneBranches,
|
|
253
|
+
github: answered(watching.github),
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
/** Stamped with the time of the last GitHub read, not of the poll: a run created after it is still unseen. */
|
|
257
|
+
const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
258
|
+
const recorded = recordCoordinatorLevel(
|
|
259
|
+
recordWatchState(watching.state, observationOf(watching), watching.githubReadAt),
|
|
260
|
+
readingOf(watching.coordinator.read),
|
|
261
|
+
)
|
|
262
|
+
const read = watching.feedbackRead
|
|
263
|
+
const withFeedback = read ? { ...recorded, feedback: recordFeedback(recorded.feedback, read) } : recorded
|
|
264
|
+
const previews = watching.previewRead
|
|
265
|
+
const withPreviews = previews
|
|
266
|
+
? { ...withFeedback, previews: recordPreviews(withFeedback.previews, previews) }
|
|
267
|
+
: withFeedback
|
|
268
|
+
const withLevels = {
|
|
269
|
+
...withPreviews,
|
|
270
|
+
laneLevels: recordLaneLevels(withPreviews.laneLevels, watching.laneReadings, watching.lanes),
|
|
271
|
+
sources: recordSources(withPreviews.sources, watching.sourceRuns, sourceNamesOf(watch), watching.githubReadAt),
|
|
272
|
+
}
|
|
273
|
+
return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const sourceNamesOf = (watch: Watch): string[] => watch.sources.map(({ name }) => name)
|
|
277
|
+
|
|
278
|
+
/** The events of one GitHub read against the state it is compared with. */
|
|
279
|
+
const githubEventsOf = (state: WatchState, github: GithubRead, laneBranches: Record<string, string>): WatchEvent[] => {
|
|
280
|
+
if (!github.ok) {
|
|
281
|
+
const outage = ghOutageEvent(state, github.error)
|
|
282
|
+
return outage ? [outage] : []
|
|
283
|
+
}
|
|
284
|
+
const branches = { ...state.laneBranches, ...laneBranches }
|
|
285
|
+
const merges = mergeCommitsOf(github.pullRequests, branches)
|
|
286
|
+
const ownMerges = ownMergeCommitsOf(state, github.pullRequests, branches)
|
|
287
|
+
return [
|
|
288
|
+
...pullRequestEvents(state, github.pullRequests, branches),
|
|
289
|
+
...deployEvents(state, github.runs, merges, ownMerges),
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const screenOf = (lane: string): string => {
|
|
294
|
+
try {
|
|
295
|
+
return agentScreen(lane)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return `screen not readable: ${errorText(error)}`
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const reportPathOf = (watch: Watch, lane: string): string | null =>
|
|
302
|
+
laneReportPath(worktreeOf(watch.root, watch.settings, lane), lane)
|
|
303
|
+
|
|
304
|
+
/** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
|
|
305
|
+
const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
|
|
306
|
+
laneMessage({
|
|
307
|
+
status,
|
|
308
|
+
screen: screenOf(lane),
|
|
309
|
+
report: readLaneReport(reportPathOf(watch, lane)),
|
|
310
|
+
reportedMtime: state.reportMtimes[lane] ?? null,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
/** `--coordinator` if given, else the agent in the watcher's own pane, else `orchestrator-<repository>`. */
|
|
314
|
+
const coordinatorNameFor = (watch: Watch, agents: readonly HerdrAgent[]): string =>
|
|
315
|
+
watch.options.coordinator ??
|
|
316
|
+
coordinatorNameOf(agents, process.env.HERDR_PANE_ID) ??
|
|
317
|
+
defaultCoordinatorName(basename(watch.root))
|
|
318
|
+
|
|
319
|
+
/** One read of the fills: the coordinator's, and each watched lane's (null without a value). */
|
|
320
|
+
type FillRead = { coordinator: CoordinatorRead; lanes: LaneReadings }
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The coordinator's fill with its own thresholds, as `jh context <name> --coordinator` computes it, or why there
|
|
324
|
+
* is none, and each lane's fill with the lanes' thresholds, as the plans view shows it: from one herdr listing. When
|
|
325
|
+
* herdr cannot be asked, the lanes have no reading, so their stored levels stay.
|
|
326
|
+
*/
|
|
327
|
+
const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[], lanes: readonly string[]): FillRead => {
|
|
328
|
+
const target = coordinatorTargetOf(agents, name, process.env.HERDR_PANE_ID)
|
|
329
|
+
try {
|
|
330
|
+
const listed = herdrLaneStatusSource.listAgents(
|
|
331
|
+
target.kind === 'unnamedPane' ? { pane: target.pane, name } : undefined,
|
|
332
|
+
)
|
|
333
|
+
const own = contextReport(listed, name, agentLog, coordinatorSettings(watch.settings.context))
|
|
334
|
+
const laneReading = (lane: string) =>
|
|
335
|
+
contextReadingOf(contextReport(listed, lane, agentLog, watch.settings.context))
|
|
336
|
+
return {
|
|
337
|
+
coordinator: explainedRead(coordinatorReadOf(own), target, name, watch.texts),
|
|
338
|
+
lanes: Object.fromEntries(lanes.map((lane) => [lane, laneReading(lane)])),
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return { coordinator: { reason: errorText(error) }, lanes: {} }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The saved state, or an empty one; a damaged file throws. */
|
|
346
|
+
const readState = async (files: Files): Promise<WatchState> => {
|
|
347
|
+
const file = Bun.file(files.state)
|
|
348
|
+
return (await file.exists()) ? parseWatchState(await file.text()) : emptyWatchState()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const laneBranchesOf = (watch: Watch, lanes: readonly string[]): Record<string, string> =>
|
|
352
|
+
Object.fromEntries(
|
|
353
|
+
lanes.flatMap((lane) => {
|
|
354
|
+
const branch = branchOf(worktreeOf(watch.root, watch.settings, lane))
|
|
355
|
+
return branch ? [[lane, branch]] : []
|
|
356
|
+
}),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const eventLinesOf = async (
|
|
360
|
+
watch: Watch,
|
|
361
|
+
events: readonly WatchEvent[],
|
|
362
|
+
github: GithubRead | null,
|
|
363
|
+
): Promise<string[]> => {
|
|
364
|
+
const runs = answered(github)?.runs ?? []
|
|
365
|
+
const lines: string[] = []
|
|
366
|
+
for (const event of events) {
|
|
367
|
+
if (event.kind === WATCH_EVENT.feedback) {
|
|
368
|
+
lines.push(...feedbackEventLines(event))
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
if (event.kind === WATCH_EVENT.source) {
|
|
372
|
+
lines.push(...sourceEventLines(event))
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
const headSha = event.kind === WATCH_EVENT.deploy ? runs.find((run) => run.id === event.runId)?.headSha : undefined
|
|
376
|
+
const target =
|
|
377
|
+
event.kind === WATCH_EVENT.deploy && headSha && watch.deploy
|
|
378
|
+
? await deployTargetOf(watch.root, watch.deploy, event.runId, headSha)
|
|
379
|
+
: null
|
|
380
|
+
lines.push(watchEventLine(event, target ? targetText(target) : null))
|
|
381
|
+
}
|
|
382
|
+
return lines
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The progress of each watched lane, and the rows only the checkout's board file has, for the coordinator line. */
|
|
386
|
+
const boardReadOf = async (
|
|
387
|
+
watch: Watch,
|
|
388
|
+
lanes: readonly string[],
|
|
389
|
+
): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number }> => {
|
|
390
|
+
const text = await Bun.file(watch.sprintFile).text()
|
|
391
|
+
const sprintLanes = parseSprintLanes(text, watch.settings.planLanguage)
|
|
392
|
+
const watched = sprintLanes.filter((lane) => lanes.includes(lane.name))
|
|
393
|
+
const entries = await Promise.all(
|
|
394
|
+
watched.map(
|
|
395
|
+
async (lane) =>
|
|
396
|
+
[lane.name, await watchedLaneProgress(watch.root, watch.settings, watch.sprintFile, lane)] as const,
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
const checkoutRows = boardCheckoutRows(watch.root, watch.settings, watch.sprintFile, text)
|
|
400
|
+
return { progress: Object.fromEntries(entries), checkoutRows }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** What the status line of one lane is made of, from the loop's state. */
|
|
404
|
+
const laneReportOf = (
|
|
405
|
+
lane: string,
|
|
406
|
+
watching: Watching,
|
|
407
|
+
state: WatchState,
|
|
408
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
409
|
+
progress: LaneProgress | null,
|
|
410
|
+
): LaneReport => {
|
|
411
|
+
const branch = watching.laneBranches[lane] ?? null
|
|
412
|
+
const pullRequest = branch ? latestPullRequestOf(pullRequests, branch) : null
|
|
413
|
+
const feedback = pullRequest ? (state.feedback.pullRequests[String(pullRequest.number)]?.count ?? 0) : 0
|
|
414
|
+
return {
|
|
415
|
+
lane,
|
|
416
|
+
status: watching.current[lane]?.status ?? NO_AGENT,
|
|
417
|
+
branch,
|
|
418
|
+
pullRequest: pullRequest ? { number: pullRequest.number, checks: pullRequest.checks, feedback } : null,
|
|
419
|
+
progress,
|
|
420
|
+
context: watching.laneReadings[lane]?.level ?? null,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** `boardReadOf`, or when the board cannot be read, nothing of it and a note that says why. */
|
|
425
|
+
const boardReadOrNote = async (
|
|
426
|
+
watch: Watch,
|
|
427
|
+
lanes: readonly string[],
|
|
428
|
+
): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }> => {
|
|
429
|
+
try {
|
|
430
|
+
return { ...(await boardReadOf(watch, lanes)), notes: [] }
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return {
|
|
433
|
+
progress: {},
|
|
434
|
+
checkoutRows: 0,
|
|
435
|
+
notes: [boardTexts(watch.settings.board.term).notReadable(errorText(error))],
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The lane lines of the status block, the coordinator's line, the line of the own pull requests without a lane that
|
|
442
|
+
* follows it (`others`), and the lines after the block (notes, a gh outage).
|
|
443
|
+
*/
|
|
444
|
+
const statusLinesOf = async (
|
|
445
|
+
watch: Watch,
|
|
446
|
+
watching: Watching,
|
|
447
|
+
state: WatchState,
|
|
448
|
+
): Promise<{ laneLines: string[]; coordinator: string; others: string[]; rest: string[] }> => {
|
|
449
|
+
const github = answered(watching.github)
|
|
450
|
+
const pullRequests = github?.pullRequests ?? []
|
|
451
|
+
const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
|
|
452
|
+
const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes)
|
|
453
|
+
const laneLines = watching.lanes.map((lane) =>
|
|
454
|
+
laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
|
|
455
|
+
)
|
|
456
|
+
const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(watch, state.ghDownSince))]
|
|
457
|
+
const board = { term: watch.settings.board.term, checkoutRows }
|
|
458
|
+
const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
|
|
459
|
+
return {
|
|
460
|
+
laneLines,
|
|
461
|
+
coordinator,
|
|
462
|
+
others: others === null ? [] : [others],
|
|
463
|
+
rest: [...notes, ...outage, ...failingSourceLines(watch, state.sources)],
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const failingSourceLines = (watch: Watch, sources: SourceState): string[] =>
|
|
468
|
+
Object.entries(sources).flatMap(([name, { failingSince }]) =>
|
|
469
|
+
failingSince === null ? [] : [sourceFailingStatusLine(name, clock(watch, failingSince))],
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
/** The section of each stopped lane (its header and message) and the report times it leaves behind. */
|
|
473
|
+
const stoppedLaneSections = (
|
|
474
|
+
watch: Watch,
|
|
475
|
+
watching: Watching,
|
|
476
|
+
stopped: readonly string[],
|
|
477
|
+
next: WatchState,
|
|
478
|
+
): { lines: string[]; reportMtimes: Record<string, number | null> } => {
|
|
479
|
+
const sections = stopped.map((lane) => {
|
|
480
|
+
const status = watching.current[lane]?.status
|
|
481
|
+
if (!status) return { lane, lines: [`=== ${lane}: ${terminalSafe(LANE_GONE)}`], mtime: undefined }
|
|
482
|
+
const message = stopMessageOf(watch, lane, status, next)
|
|
483
|
+
return { lane, lines: [`=== ${lane}: ${terminalSafe(status)}`, ...message.lines], mtime: message.reportedMtime }
|
|
484
|
+
})
|
|
485
|
+
const shown = sections.flatMap(({ lane, mtime }) => (mtime === undefined ? [] : [[lane, mtime] as const]))
|
|
486
|
+
return {
|
|
487
|
+
lines: sections.flatMap(({ lines }) => lines),
|
|
488
|
+
reportMtimes: { ...next.reportMtimes, ...Object.fromEntries(shown) },
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
|
|
494
|
+
* the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
|
|
495
|
+
* lines prints the whole status block.
|
|
496
|
+
*/
|
|
497
|
+
const reportOf = async (
|
|
498
|
+
watch: Watch,
|
|
499
|
+
watching: Watching,
|
|
500
|
+
stopped: string[],
|
|
501
|
+
events: WatchEvent[],
|
|
502
|
+
next: WatchState,
|
|
503
|
+
): Promise<{ lines: string[]; next: WatchState }> => {
|
|
504
|
+
const stops = stoppedLaneSections(watch, watching, stopped, next)
|
|
505
|
+
const eventLines = await eventLinesOf(watch, events, watching.github)
|
|
506
|
+
const dutyLines = [
|
|
507
|
+
...blockedDutyLines(stopped, watching.current, watch.texts),
|
|
508
|
+
...mergeDutyLines(events, watching.lanes, watch.texts),
|
|
509
|
+
...laneFillDutyLines(events, watch.texts),
|
|
510
|
+
]
|
|
511
|
+
const status = await statusLinesOf(watch, watching, next)
|
|
512
|
+
const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
|
|
513
|
+
return {
|
|
514
|
+
lines: [...stops.lines, ...eventLines, ...dutyLines, ...block, ...status.rest],
|
|
515
|
+
next: {
|
|
516
|
+
...next,
|
|
517
|
+
reportMtimes: stops.reportMtimes,
|
|
518
|
+
statusLines: rememberedStatusLines(status.laneLines, status.others),
|
|
519
|
+
},
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const recordGithub = (
|
|
524
|
+
watching: Watching,
|
|
525
|
+
github: GithubRead,
|
|
526
|
+
laneBranches: Record<string, string>,
|
|
527
|
+
now: number,
|
|
528
|
+
): WatchEvent[] => {
|
|
529
|
+
watching.github = github
|
|
530
|
+
watching.laneBranches = laneBranches
|
|
531
|
+
watching.githubReadAt = now
|
|
532
|
+
return githubEventsOf(watching.state, watching.github, watching.laneBranches)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const recordCoordinator = (watching: Watching, coordinator: Coordinator): WatchEvent[] => {
|
|
536
|
+
watching.coordinator = coordinator
|
|
537
|
+
const event = coordinatorWatchEvent(watching.state, readingOf(coordinator.read))
|
|
538
|
+
return event ? [event] : []
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const recordLaneFills = (watching: Watching, readings: LaneReadings): WatchEvent[] => {
|
|
542
|
+
watching.laneReadings = readings
|
|
543
|
+
return laneFillEvents(watching.state.laneLevels, readings)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The account `gh` runs as, read once and kept; null while `gh` cannot tell. */
|
|
547
|
+
const ensureOwnLogin = async (watch: Watch, watching: Watching): Promise<string | null> => {
|
|
548
|
+
watching.ownLogin ??= await readOwnLogin(watch.root)
|
|
549
|
+
return watching.ownLogin
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The feedback on the lanes' open pull requests that counts under the project's config, read over REST. Null when it
|
|
554
|
+
* is off, there is no target, the login is unknown or the read failed: the last read then stays, so the next one
|
|
555
|
+
* reaches back over the gap (`feedbackSince`) and no pull request loses its record.
|
|
556
|
+
*/
|
|
557
|
+
const readFeedback = async (
|
|
558
|
+
watch: Watch,
|
|
559
|
+
watching: Watching,
|
|
560
|
+
targets: FeedbackTarget[],
|
|
561
|
+
now: number,
|
|
562
|
+
): Promise<FeedbackPoll | null> => {
|
|
563
|
+
if (watch.feedback === false || targets.length === 0) return null
|
|
564
|
+
const own = await ensureOwnLogin(watch, watching)
|
|
565
|
+
if (own === null) return null
|
|
566
|
+
const items = await readFeedbackItems(watch.root, targets, feedbackSince(watching.state.feedback, now))
|
|
567
|
+
if (!items.ok) return null
|
|
568
|
+
const filter = feedbackFilterOf(own, watch.feedback.ignore, watch.previews)
|
|
569
|
+
return { read: feedbackReadOf(now, targets, items.items, filter), items: items.items }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const recordFeedbackRead = (watching: Watching, read: FeedbackRead | null): WatchEvent[] => {
|
|
573
|
+
if (!read) return []
|
|
574
|
+
watching.feedbackRead = read
|
|
575
|
+
return feedbackEvents(watching.state.feedback, read)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The comments a preview author may have written: none without authors, else the feedback read's when it reached back
|
|
580
|
+
* as far as the last preview read (`feedbackReadCovers`), else a read of their own since then.
|
|
581
|
+
*/
|
|
582
|
+
const previewComments = async (
|
|
583
|
+
watch: Watch,
|
|
584
|
+
state: WatchState,
|
|
585
|
+
feedbackItems: FeedbackItem[] | null,
|
|
586
|
+
now: number,
|
|
587
|
+
): Promise<FeedbackItemsRead> => {
|
|
588
|
+
if (!watch.previews || watch.previews.authors.length === 0) return { ok: true, items: [] }
|
|
589
|
+
if (feedbackItems && feedbackReadCovers(state.feedback.since, state.previews.since))
|
|
590
|
+
return { ok: true, items: feedbackItems }
|
|
591
|
+
return readCommentItems(watch.root, feedbackSince(state.previews, now))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The deploy previews of `targets`, the lanes' open pull requests as GitHub's list just named them, under the project's
|
|
596
|
+
* config: the configured authors' comments and, with `deployments`, the deployments of each head that has no preview
|
|
597
|
+
* reported yet. Null when previews are off, there is no target or a read failed: the state then stays as it was.
|
|
598
|
+
*/
|
|
599
|
+
const readPreviews = async (
|
|
600
|
+
watch: Watch,
|
|
601
|
+
watching: Watching,
|
|
602
|
+
targets: FeedbackTarget[],
|
|
603
|
+
feedbackItems: FeedbackItem[] | null,
|
|
604
|
+
now: number,
|
|
605
|
+
): Promise<PreviewRead | null> => {
|
|
606
|
+
const previews = watch.previews
|
|
607
|
+
if (!previews || targets.length === 0) return null
|
|
608
|
+
const unreported = previews.deployments ? unreportedTargets(watching.state.previews, targets) : []
|
|
609
|
+
const [comments, deployments] = await Promise.all([
|
|
610
|
+
previewComments(watch, watching.state, feedbackItems, now),
|
|
611
|
+
readDeploymentCandidates(watch.root, unreported, watch.deploy?.environments ?? []),
|
|
612
|
+
])
|
|
613
|
+
if (!comments.ok || !deployments.ok) return null
|
|
614
|
+
const candidates = [...commentCandidates(comments.items, previews), ...deployments.candidates]
|
|
615
|
+
return previewReadOf(now, targets, candidates)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchEvent[] => {
|
|
619
|
+
if (!read) return []
|
|
620
|
+
watching.previewRead = read
|
|
621
|
+
return previewEvents(watching.state.previews, read)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Runs the sources whose interval has passed, all at once, each without a shell and within `SOURCE_LIMITS`; a failed
|
|
626
|
+
* run is a result, never a throw, so no source stops the watcher.
|
|
627
|
+
*/
|
|
628
|
+
const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
|
|
629
|
+
const due = watch.sources.filter((source) => isSourceDue(watching.sourceRanAt[source.name], source.every, now))
|
|
630
|
+
for (const source of due) watching.sourceRanAt[source.name] = now
|
|
631
|
+
return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const recordSourceRuns = (watch: Watch, watching: Watching, runs: SourceRun[]): WatchEvent[] => {
|
|
635
|
+
watching.sourceRuns = runs
|
|
636
|
+
return sourceEvents(watching.state.sources, runs, watch.sources)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
|
|
640
|
+
const syncBase = (watch: Watch, watching: Watching) => {
|
|
641
|
+
if (watching.fetchingBase) return
|
|
642
|
+
watching.fetchingBase = true
|
|
643
|
+
void fetchBaseWhenMoved(watch.root, watch.settings.baseBranch).finally(() => {
|
|
644
|
+
watching.fetchingBase = false
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Reads the coordinator's fill, GitHub, the feedback, the previews and the due sources, and stores them only once every read is complete: a SIGTERM
|
|
650
|
+
* while a read is pending saves the state as it was, so no event is taken as reported.
|
|
651
|
+
*/
|
|
652
|
+
const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
653
|
+
syncBase(watch, watching)
|
|
654
|
+
const name = coordinatorNameFor(watch, watching.agents)
|
|
655
|
+
const fills = fillReadFor(watch, name, watching.agents, watching.lanes)
|
|
656
|
+
const laneBranches = laneBranchesOf(watch, watching.lanes)
|
|
657
|
+
const searched = Object.values({ ...watching.state.laneBranches, ...laneBranches })
|
|
658
|
+
const [github, sourceRuns] = await Promise.all([
|
|
659
|
+
readGithub(watch.root, watch.deploy, watch.settings.baseBranch, searched),
|
|
660
|
+
runDueSources(watch, watching, now),
|
|
661
|
+
])
|
|
662
|
+
const pullRequests = answered(github)?.pullRequests ?? null
|
|
663
|
+
const branches = { ...watching.state.laneBranches, ...laneBranches }
|
|
664
|
+
const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
|
|
665
|
+
const feedback = await readFeedback(watch, watching, targets, now)
|
|
666
|
+
const previews = pullRequests ? await readPreviews(watch, watching, targets, feedback?.items ?? null, now) : null
|
|
667
|
+
return [
|
|
668
|
+
...recordGithub(watching, github, laneBranches, now),
|
|
669
|
+
...recordFeedbackRead(watching, feedback?.read ?? null),
|
|
670
|
+
...recordPreviewRead(watching, previews),
|
|
671
|
+
...recordSourceRuns(watch, watching, sourceRuns),
|
|
672
|
+
...recordCoordinator(watching, { name, read: fills.coordinator }),
|
|
673
|
+
...recordLaneFills(watching, fills.lanes),
|
|
674
|
+
]
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
|
|
678
|
+
const sprintLaneNames = (watch: Watch, previous: string[]): string[] => {
|
|
679
|
+
try {
|
|
680
|
+
return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage).map(
|
|
681
|
+
(lane) => lane.name,
|
|
682
|
+
)
|
|
683
|
+
} catch {
|
|
684
|
+
return previous
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: readonly string[]): string[] =>
|
|
689
|
+
watch.options.lanes ??
|
|
690
|
+
discoverLanes({
|
|
691
|
+
worktrees: worktreeNames(join(watch.root, watch.settings.worktreeDirectory)),
|
|
692
|
+
agentNames: agentNamesOf(agents),
|
|
693
|
+
sprintLanes,
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
/** Brings back missing plans views beside the coordinator and the board's lanes when `trigger` calls for it; only inside herdr. */
|
|
697
|
+
const keepViews = (watch: Watch, watching: Watching, trigger: EnsureTrigger) => {
|
|
698
|
+
if (!watch.herdr) return
|
|
699
|
+
watching.planViews = keepPlanViews(trigger, watching.planViews, {
|
|
700
|
+
ensure: () => ensurePlanPanes(watch.root, watch.settings, watch.sprintFile),
|
|
701
|
+
log: console.log,
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Takes up lanes that were found or dropped since the last poll and returns the lanes it added; named lanes never change. */
|
|
706
|
+
const followLaneSet = (watch: Watch, watching: Watching): string[] => {
|
|
707
|
+
if (watch.options.lanes !== null) return []
|
|
708
|
+
watching.sprintLanes = sprintLaneNames(watch, watching.sprintLanes)
|
|
709
|
+
const lanes = lanesFor(watch, watching.agents, watching.sprintLanes)
|
|
710
|
+
const change = laneSetChange(watching.lanes, lanes)
|
|
711
|
+
if (isUnchanged(change)) return []
|
|
712
|
+
watching.departed = [...watching.departed, ...departedLanes(change.removed, watching.baseline, watching.current)]
|
|
713
|
+
watching.lanes = lanes
|
|
714
|
+
watching.current = toSnapshot(watching.agents, lanes)
|
|
715
|
+
console.log(laneSetLines(change).join('\n'))
|
|
716
|
+
return change.added
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Reads herdr's agents into the loop; false when herdr did not answer, said once per outage. */
|
|
720
|
+
const readAgents = (watching: Watching): boolean => {
|
|
721
|
+
try {
|
|
722
|
+
watching.agents = listAgents()
|
|
723
|
+
watching.current = toSnapshot(watching.agents, watching.lanes)
|
|
724
|
+
watching.herdrDown = false
|
|
725
|
+
return true
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (!watching.herdrDown) console.log(`herdr poll failed, still watching: ${errorText(error)}`)
|
|
728
|
+
watching.herdrDown = true
|
|
729
|
+
return false
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** One herdr poll: the agents, the lane set, and once per poll the plans views of added lanes and of new agents. */
|
|
734
|
+
const pollLanes = (watch: Watch, watching: Watching) => {
|
|
735
|
+
const previous = watching.current
|
|
736
|
+
if (!readAgents(watching)) return
|
|
737
|
+
const added = followLaneSet(watch, watching)
|
|
738
|
+
const withNewAgent = lanesWithNewAgent(previous, watching.current, watching.lanes)
|
|
739
|
+
keepViews(watch, watching, { kind: ENSURE_TRIGGER.poll, added, withNewAgent })
|
|
740
|
+
watching.baseline = baselineWithNewAgents(watching.baseline, watching.current)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Prints one report in a single write, so a stream delivers it as one message, then records its state; a single report ends with the notice that the call is over. */
|
|
744
|
+
const report = async (
|
|
745
|
+
watch: Watch,
|
|
746
|
+
watching: Watching,
|
|
747
|
+
stopped: string[],
|
|
748
|
+
events: WatchEvent[],
|
|
749
|
+
next: WatchState,
|
|
750
|
+
): Promise<WatchState> => {
|
|
751
|
+
watching.reporting = true
|
|
752
|
+
const { lines, next: shown } = await reportOf(watch, watching, stopped, events, next)
|
|
753
|
+
console.log(
|
|
754
|
+
(watch.options.stream ? lines : withSingleReportNotice(lines, monitorToolAvailable(process.env))).join('\n'),
|
|
755
|
+
)
|
|
756
|
+
writePrivate(watch.files.state, JSON.stringify(shown))
|
|
757
|
+
watching.reporting = false
|
|
758
|
+
return shown
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* One poll: reports what is due and tells whether to keep watching. Without `--stream` the first
|
|
763
|
+
* report ends the watch; in a stream every reported lane counts as seen and the heartbeat is one line.
|
|
764
|
+
* The state is also saved after every GitHub read, so a watcher killed without SIGTERM (a monitor
|
|
765
|
+
* that expires) loses no first sighting of a pull request.
|
|
766
|
+
*/
|
|
767
|
+
const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
|
|
768
|
+
const { stream, heartbeatMinutes } = watch.options
|
|
769
|
+
const now = Date.now()
|
|
770
|
+
const due = now - watching.githubReadAt >= GITHUB_POLL_MS
|
|
771
|
+
const polledEvents = due ? await pollGithubAndCoordinator(watch, watching, now) : []
|
|
772
|
+
const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
|
|
773
|
+
watching.departed = []
|
|
774
|
+
const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
|
|
775
|
+
let next = recordedOf(watch, watching)
|
|
776
|
+
const reported = stopped.length > 0 || polledEvents.length > 0
|
|
777
|
+
if (reported) {
|
|
778
|
+
next = await report(watch, watching, stopped, polledEvents, next)
|
|
779
|
+
watching.baseline = { ...watching.current }
|
|
780
|
+
} else if (heartbeat && !stream) {
|
|
781
|
+
next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
|
|
782
|
+
} else {
|
|
783
|
+
if (heartbeat) console.log(streamHeartbeatLine(clock(watch, now), watching.lanes, watching.current))
|
|
784
|
+
if (due) writePrivate(watch.files.state, JSON.stringify(next))
|
|
785
|
+
watching.baseline = advanceBaseline(watching.baseline, watching.current)
|
|
786
|
+
}
|
|
787
|
+
watching.state = next
|
|
788
|
+
const spoke = reported || heartbeat
|
|
789
|
+
if (spoke) watching.quietSince = Date.now()
|
|
790
|
+
return stream || !spoke
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** The loop's state at the start: the found lanes armed from what they show now and from the saved state. */
|
|
794
|
+
const initialWatching = (
|
|
795
|
+
watch: Watch,
|
|
796
|
+
start: { lanes: string[]; sprintLanes: string[]; state: WatchState; armed: LaneSnapshot; agents: HerdrAgent[] },
|
|
797
|
+
): Watching => ({
|
|
798
|
+
lanes: start.lanes,
|
|
799
|
+
sprintLanes: start.sprintLanes,
|
|
800
|
+
departed: [],
|
|
801
|
+
quietSince: Date.now(),
|
|
802
|
+
state: start.state,
|
|
803
|
+
baseline: armBaseline(start.armed, start.state.seenSeqs),
|
|
804
|
+
current: start.armed,
|
|
805
|
+
agents: start.agents,
|
|
806
|
+
github: null,
|
|
807
|
+
laneBranches: {},
|
|
808
|
+
githubReadAt: 0,
|
|
809
|
+
coordinator: { name: watch.options.coordinator, read: null },
|
|
810
|
+
herdrDown: false,
|
|
811
|
+
reporting: false,
|
|
812
|
+
fetchingBase: false,
|
|
813
|
+
ownLogin: null,
|
|
814
|
+
feedbackRead: null,
|
|
815
|
+
previewRead: null,
|
|
816
|
+
sourceRuns: [],
|
|
817
|
+
sourceRanAt: {},
|
|
818
|
+
laneReadings: {},
|
|
819
|
+
planViews: { failureReported: false },
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
/** Saves the state a stop leaves behind: the one from before an unfinished report or an unread GitHub answer, else the latest. */
|
|
823
|
+
const saveState = (watch: Watch, watching: Watching) => {
|
|
824
|
+
const unread = watching.reporting || watching.github === null
|
|
825
|
+
writePrivate(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const watchLanes = async (watch: Watch) => {
|
|
829
|
+
const takeOver = await takeOverLock(watch.files.lock)
|
|
830
|
+
const state = await readState(watch.files)
|
|
831
|
+
const agents = listAgents()
|
|
832
|
+
const sprintLanes = watch.options.lanes ? [] : sprintLaneNames(watch, [])
|
|
833
|
+
const lanes = lanesFor(watch, agents, sprintLanes)
|
|
834
|
+
const armed = toSnapshot(agents, lanes)
|
|
835
|
+
console.log(armLine(lanes, armed, takeOver, relative(watch.root, watch.sprintFile)))
|
|
836
|
+
if (watch.boardNotice) console.log(watch.boardNotice)
|
|
837
|
+
|
|
838
|
+
const watching = initialWatching(watch, { lanes, sprintLanes, state, armed, agents })
|
|
839
|
+
keepViews(watch, watching, { kind: ENSURE_TRIGGER.start })
|
|
840
|
+
process.on('SIGTERM', () => {
|
|
841
|
+
saveState(watch, watching)
|
|
842
|
+
console.log(stopNoticeOf(watch.files.lock))
|
|
843
|
+
process.exit(0)
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
const kit = await startedKit()
|
|
847
|
+
const skillSnapshot = kit ? readOrchestrateSkill(kit.packageRoot) : null
|
|
848
|
+
const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
|
|
849
|
+
while (await pollOnce(watch, watching)) {
|
|
850
|
+
await Bun.sleep(POLL_MS)
|
|
851
|
+
pollLanes(watch, watching)
|
|
852
|
+
if (endedByKitUpdate(kit, { saveState: () => saveState(watch, watching), log: console.log, skillLine })) return
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const filesOf = (root: string): Files => {
|
|
857
|
+
const directory = ensureWatcherDirectory(root)
|
|
858
|
+
return { state: join(directory, WATCHER_FILES.state), lock: join(directory, WATCHER_FILES.lock) }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** The watch as the command line and the config set it up; throws when the config or the sprint is missing. */
|
|
862
|
+
const watchOf = (options: WatchOptions): Watch => {
|
|
863
|
+
const root = mainCheckout(process.cwd())
|
|
864
|
+
const { config, notes } = readResolvedConfig([worktreeRoot(process.cwd()), root])
|
|
865
|
+
for (const note of notes) console.log(note)
|
|
866
|
+
const settings = plansSettingsOf(config)
|
|
867
|
+
const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
|
|
868
|
+
foundLanesOf(root, settings, () => agentNamesOf(listAgents())),
|
|
869
|
+
)
|
|
870
|
+
return {
|
|
871
|
+
root,
|
|
872
|
+
sprintFile: picked.file,
|
|
873
|
+
boardNotice: picked.notice,
|
|
874
|
+
options,
|
|
875
|
+
files: filesOf(root),
|
|
876
|
+
settings,
|
|
877
|
+
deploy: config.watch.deploy,
|
|
878
|
+
feedback: config.watch.feedback,
|
|
879
|
+
previews: config.watch.previews,
|
|
880
|
+
sources: config.watch.sources,
|
|
881
|
+
texts: WATCH_TEXTS[settings.responseLanguage],
|
|
882
|
+
herdr: insideHerdr(settings.adapter, process.env),
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
887
|
+
let options: WatchOptions
|
|
888
|
+
try {
|
|
889
|
+
options = parseWatchOptions(args)
|
|
890
|
+
} catch (error) {
|
|
891
|
+
// The usage text is the program's own and spans lines; errorText would strip its line breaks.
|
|
892
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
893
|
+
return USAGE_EXIT
|
|
894
|
+
}
|
|
895
|
+
if (options.sourceDryRun !== null) {
|
|
896
|
+
try {
|
|
897
|
+
return await sourceDryRun(options.sourceDryRun)
|
|
898
|
+
} catch (error) {
|
|
899
|
+
console.error(errorText(error))
|
|
900
|
+
return 1
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
let watch: Watch
|
|
904
|
+
try {
|
|
905
|
+
watch = watchOf(options)
|
|
906
|
+
} catch (error) {
|
|
907
|
+
console.error(errorText(error))
|
|
908
|
+
return 1
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
await watchLanes(watch)
|
|
912
|
+
return 0
|
|
913
|
+
} catch (error) {
|
|
914
|
+
console.log(`watcher stopped: ${errorText(error)}`)
|
|
915
|
+
return 1
|
|
916
|
+
} finally {
|
|
917
|
+
releaseLock(watch.files.lock)
|
|
918
|
+
}
|
|
919
|
+
}
|