jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RunningSubagent } from '../plans/subagents'
|
|
2
|
+
import { laneDirectoryOf } from '../plans/unlisted-lanes'
|
|
3
|
+
import { type LaneNotes, type PhaseCount, progressNotes } from './heartbeat-progress'
|
|
4
|
+
|
|
5
|
+
/** The running subagents of each worktree below `worktreeDirectory`, by lane name; one that works elsewhere is left out. */
|
|
6
|
+
export const runningSubagentCountsOf = (
|
|
7
|
+
running: readonly Pick<RunningSubagent, 'cwd'>[],
|
|
8
|
+
worktreeDirectory: string,
|
|
9
|
+
): Record<string, number> => {
|
|
10
|
+
const counts: Record<string, number> = {}
|
|
11
|
+
for (const { cwd } of running) {
|
|
12
|
+
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
13
|
+
if (lane !== null) counts[lane] = (counts[lane] ?? 0) + 1
|
|
14
|
+
}
|
|
15
|
+
return counts
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What a heartbeat says about running subagents: `3 subagents`; null for none. */
|
|
19
|
+
export const subagentsNote = (count: number): string | null =>
|
|
20
|
+
count <= 0 ? null : `${count} ${count === 1 ? 'subagent' : 'subagents'}`
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The heartbeat notes with the running subagents of each working lane appended (`P12 7/12, 3 subagents`); a lane that
|
|
24
|
+
* has no count note yet gets the subagents alone. Resting lanes and lanes without running subagents keep their note.
|
|
25
|
+
*/
|
|
26
|
+
export const withSubagentNotes = (
|
|
27
|
+
notes: LaneNotes,
|
|
28
|
+
working: readonly string[],
|
|
29
|
+
counts: Readonly<Record<string, number>>,
|
|
30
|
+
): LaneNotes => {
|
|
31
|
+
const extra = working.flatMap((lane) => {
|
|
32
|
+
const note = subagentsNote(counts[lane] ?? 0)
|
|
33
|
+
return note === null ? [] : [[lane, [notes[lane], note].filter(Boolean).join(', ')]]
|
|
34
|
+
})
|
|
35
|
+
return { ...notes, ...Object.fromEntries(extra) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The notes of a heartbeat: each working lane's phase count and its running subagents; resting lanes have neither. */
|
|
39
|
+
export const heartbeatNotesOf = (
|
|
40
|
+
working: readonly string[],
|
|
41
|
+
phaseCounts: Readonly<Record<string, PhaseCount>>,
|
|
42
|
+
subagentCounts: Readonly<Record<string, number>>,
|
|
43
|
+
): LaneNotes => withSubagentNotes(progressNotes(working, phaseCounts), working, subagentCounts)
|
|
@@ -1,13 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
3
4
|
import { errorText } from '../plans/error-text'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
changedSections,
|
|
7
|
+
orchestrateChangeLines,
|
|
8
|
+
REFERENCE_CHANGE,
|
|
9
|
+
type ReferenceChange,
|
|
10
|
+
type ReferenceDelta,
|
|
11
|
+
referenceChanges,
|
|
12
|
+
SKILL_CHANGE,
|
|
13
|
+
type SkillChange,
|
|
14
|
+
} from '../version/skill-diff'
|
|
5
15
|
import type { StartedKit } from '../version/started-kit'
|
|
6
|
-
import {
|
|
16
|
+
import { ORCHESTRATE_SKILL_NAME } from './reference-paths'
|
|
17
|
+
|
|
18
|
+
/** The orchestrate skill as the kit ships it: its text and its reference files by name. */
|
|
19
|
+
export type OrchestrateSnapshot = { skill: string; references: Record<string, string> }
|
|
20
|
+
|
|
21
|
+
/** The change since a snapshot: the skill itself, and each reference that changed, is new or is gone. */
|
|
22
|
+
export type OrchestrateChange = { skill: SkillChange; references: ReferenceChange[] }
|
|
23
|
+
|
|
24
|
+
type WrittenDiff = { text: string; path: string }
|
|
25
|
+
|
|
26
|
+
/** A reference name that stays a plain Markdown file name, since it goes into the name of a diff file. */
|
|
27
|
+
const REFERENCE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/
|
|
28
|
+
|
|
29
|
+
const skillDirectory = (packageRoot: string): string =>
|
|
30
|
+
join(packageRoot, 'kit', 'workflow', 'skills', ORCHESTRATE_SKILL_NAME)
|
|
7
31
|
|
|
8
32
|
/** The kit's own text of the orchestrate skill, with its placeholders, inside the package. */
|
|
9
|
-
export const orchestrateSkillPath = (packageRoot: string): string =>
|
|
10
|
-
|
|
33
|
+
export const orchestrateSkillPath = (packageRoot: string): string => join(skillDirectory(packageRoot), 'SKILL.md')
|
|
34
|
+
|
|
35
|
+
/** The directory of the skill's reference files in the kit at `packageRoot`. */
|
|
36
|
+
export const orchestrateReferencesPath = (packageRoot: string): string =>
|
|
37
|
+
join(skillDirectory(packageRoot), 'references')
|
|
11
38
|
|
|
12
39
|
/** The orchestrate skill's text in the kit at `packageRoot`; null when it cannot be read. */
|
|
13
40
|
export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
@@ -18,10 +45,45 @@ export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
|
18
45
|
}
|
|
19
46
|
}
|
|
20
47
|
|
|
48
|
+
/** The skill's reference files by name (every `*.md` file below `references/`); none when the directory is missing. */
|
|
49
|
+
export const readOrchestrateReferences = (packageRoot: string): Record<string, string> => {
|
|
50
|
+
const directory = orchestrateReferencesPath(packageRoot)
|
|
51
|
+
try {
|
|
52
|
+
const names = readdirSync(directory, { withFileTypes: true })
|
|
53
|
+
.filter((entry) => entry.isFile() && REFERENCE_FILE.test(entry.name))
|
|
54
|
+
.map((entry) => entry.name)
|
|
55
|
+
return Object.fromEntries(names.map((name) => [name, readFileSync(join(directory, name), 'utf8')]))
|
|
56
|
+
} catch {
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The skill and its references in the kit at `packageRoot`; null when the skill cannot be read. */
|
|
62
|
+
export const readOrchestrateSnapshot = (packageRoot: string): OrchestrateSnapshot | null => {
|
|
63
|
+
const skill = readOrchestrateSkill(packageRoot)
|
|
64
|
+
return skill === null ? null : { skill, references: readOrchestrateReferences(packageRoot) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The unified diff (`git diff --no-index`) between `before` and the file at `currentPath`, written to `directory` as
|
|
69
|
+
* `<name>.diff`; null when git has none to give.
|
|
70
|
+
*/
|
|
71
|
+
const writeDiff = (before: string, currentPath: string, directory: string, name: string): WrittenDiff | null => {
|
|
72
|
+
ensurePrivateDirectory(directory)
|
|
73
|
+
const beforePath = join(directory, `${name}.before.md`)
|
|
74
|
+
writePrivateFile(beforePath, before)
|
|
75
|
+
const diff = Bun.spawnSync(['git', 'diff', '--no-index', '--unified=3', '--', beforePath, currentPath])
|
|
76
|
+
const text = diff.stdout.toString()
|
|
77
|
+
if (diff.exitCode !== 1 || text === '') return null
|
|
78
|
+
const path = join(directory, `${name}.diff`)
|
|
79
|
+
writePrivateFile(path, text)
|
|
80
|
+
return { text, path }
|
|
81
|
+
}
|
|
82
|
+
|
|
21
83
|
/**
|
|
22
84
|
* The change of the orchestrate skill between `snapshot` and the kit at `packageRoot` now: the unified diff
|
|
23
|
-
*
|
|
24
|
-
*
|
|
85
|
+
* written to `directory` and the sections it touches, so the coordinator reads the change instead of the whole
|
|
86
|
+
* skill. Unavailable without a snapshot, a readable skill or a diff.
|
|
25
87
|
*/
|
|
26
88
|
export const skillChangeSince = (snapshot: string | null, packageRoot: string, directory: string): SkillChange => {
|
|
27
89
|
if (snapshot === null) return { kind: SKILL_CHANGE.unavailable, reason: 'no snapshot at start' }
|
|
@@ -29,33 +91,63 @@ export const skillChangeSince = (snapshot: string | null, packageRoot: string, d
|
|
|
29
91
|
if (current === null) return { kind: SKILL_CHANGE.unavailable, reason: 'skill not readable' }
|
|
30
92
|
if (current === snapshot) return { kind: SKILL_CHANGE.unchanged }
|
|
31
93
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const diff = Bun.spawnSync([
|
|
36
|
-
'git',
|
|
37
|
-
'diff',
|
|
38
|
-
'--no-index',
|
|
39
|
-
'--unified=3',
|
|
40
|
-
'--',
|
|
41
|
-
before,
|
|
42
|
-
orchestrateSkillPath(packageRoot),
|
|
43
|
-
])
|
|
44
|
-
const text = diff.stdout.toString()
|
|
45
|
-
if (diff.exitCode !== 1 || text === '')
|
|
46
|
-
return { kind: SKILL_CHANGE.unavailable, reason: `git diff exit ${diff.exitCode}` }
|
|
47
|
-
const diffPath = join(directory, 'orchestrate-skill.diff')
|
|
48
|
-
writePrivate(diffPath, text)
|
|
49
|
-
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, text), diffPath }
|
|
94
|
+
const diff = writeDiff(snapshot, orchestrateSkillPath(packageRoot), directory, `${ORCHESTRATE_SKILL_NAME}-skill`)
|
|
95
|
+
if (diff === null) return { kind: SKILL_CHANGE.unavailable, reason: 'git diff gave none' }
|
|
96
|
+
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, diff.text), diffPath: diff.path }
|
|
50
97
|
} catch (error) {
|
|
51
98
|
return { kind: SKILL_CHANGE.unavailable, reason: errorText(error) }
|
|
52
99
|
}
|
|
53
100
|
}
|
|
54
101
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
102
|
+
const referenceChangeOf = (
|
|
103
|
+
delta: ReferenceDelta,
|
|
104
|
+
snapshot: OrchestrateSnapshot,
|
|
105
|
+
packageRoot: string,
|
|
106
|
+
directory: string,
|
|
107
|
+
): ReferenceChange => {
|
|
108
|
+
const path = join(orchestrateReferencesPath(packageRoot), delta.name)
|
|
109
|
+
if (delta.kind === REFERENCE_CHANGE.deleted) return { name: delta.name, kind: REFERENCE_CHANGE.deleted }
|
|
110
|
+
if (delta.kind === REFERENCE_CHANGE.added) return { name: delta.name, kind: REFERENCE_CHANGE.added, path }
|
|
111
|
+
try {
|
|
112
|
+
const diff = writeDiff(
|
|
113
|
+
snapshot.references[delta.name] ?? '',
|
|
114
|
+
path,
|
|
115
|
+
directory,
|
|
116
|
+
`${ORCHESTRATE_SKILL_NAME}-reference-${delta.name}`,
|
|
117
|
+
)
|
|
118
|
+
return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: diff?.path ?? null }
|
|
119
|
+
} catch {
|
|
120
|
+
return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: null }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The change of the skill and of its references since `snapshot`. The references are judged only while the skill
|
|
126
|
+
* can be read, so a package being replaced does not make every reference look deleted.
|
|
127
|
+
*/
|
|
128
|
+
export const orchestrateChangeSince = (
|
|
129
|
+
snapshot: OrchestrateSnapshot | null,
|
|
130
|
+
packageRoot: string,
|
|
131
|
+
directory: string,
|
|
132
|
+
): OrchestrateChange => {
|
|
133
|
+
const skill = skillChangeSince(snapshot?.skill ?? null, packageRoot, directory)
|
|
134
|
+
if (snapshot === null || skill.kind === SKILL_CHANGE.unavailable) return { skill, references: [] }
|
|
135
|
+
const deltas = referenceChanges(snapshot.references, readOrchestrateReferences(packageRoot))
|
|
136
|
+
return { skill, references: deltas.map((delta) => referenceChangeOf(delta, snapshot, packageRoot, directory)) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The lines about the orchestrate skill after a kit update, for the kit the watcher started with: the skill's, then one
|
|
141
|
+
* per changed, new or deleted reference, joined by line breaks.
|
|
142
|
+
*/
|
|
143
|
+
export const skillLineFor = (
|
|
144
|
+
kit: StartedKit | null,
|
|
145
|
+
snapshot: OrchestrateSnapshot | null,
|
|
146
|
+
directory: string,
|
|
147
|
+
): string => {
|
|
148
|
+
if (kit === null) {
|
|
149
|
+
return orchestrateChangeLines({ kind: SKILL_CHANGE.unavailable, reason: 'kit not located' }, []).join('\n')
|
|
150
|
+
}
|
|
151
|
+
const { skill, references } = orchestrateChangeSince(snapshot, kit.packageRoot, directory)
|
|
152
|
+
return orchestrateChangeLines(skill, references).join('\n')
|
|
153
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isRestartTrigger, RESTART_DEBOUNCE_MS } from '../plans/restart-decision'
|
|
4
|
+
|
|
5
|
+
/** The changes to a package's `src/` seen so far: when the last one came (null before any), and the watch to close. */
|
|
6
|
+
export type SourceChanges = { lastChangeAt: () => number | null; close: () => void }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the package at `packageRoot` is a checkout's own source rather than an installed copy, which lies under a
|
|
10
|
+
* `node_modules` directory. Only the source changes under a running process (a pull or a fast-forward of the
|
|
11
|
+
* checkout); an installed copy changes by a reinstall, which its version shows.
|
|
12
|
+
*/
|
|
13
|
+
export const runsFromSource = (packageRoot: string): boolean => !packageRoot.split(/[\\/]/).includes('node_modules')
|
|
14
|
+
|
|
15
|
+
/** Whether a change seen at `lastChangeAt` is over: no further change for the window in which the herd collapses a burst into one restart. */
|
|
16
|
+
export const sourceChangeSettled = (lastChangeAt: number | null, now: number): boolean =>
|
|
17
|
+
lastChangeAt !== null && now - lastChangeAt >= RESTART_DEBOUNCE_MS
|
|
18
|
+
|
|
19
|
+
const NO_CHANGES: SourceChanges = { lastChangeAt: () => null, close: () => {} }
|
|
20
|
+
|
|
21
|
+
/** The watch of `directory`, closed on a later error (`EMFILE` while dev servers run), which would else end the process. */
|
|
22
|
+
const watcherOf = (directory: string, onChange: () => void): FSWatcher | null => {
|
|
23
|
+
try {
|
|
24
|
+
const watcher = watch(directory, { recursive: true }, (_event, name) => {
|
|
25
|
+
if (name && isRestartTrigger(name.toString())) onChange()
|
|
26
|
+
})
|
|
27
|
+
watcher.on('error', () => watcher.close())
|
|
28
|
+
return watcher
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Follows the source files below `<packageRoot>/src` with the trigger the herd restarts on (`isRestartTrigger`: a
|
|
36
|
+
* `.ts` file, tests and declarations left out), stamping each change with `clock`. A package without `src/`, or one
|
|
37
|
+
* the platform cannot watch, is followed for nothing.
|
|
38
|
+
*/
|
|
39
|
+
export const watchSourceChanges = (packageRoot: string, clock: () => number = Date.now): SourceChanges => {
|
|
40
|
+
const directory = join(packageRoot, 'src')
|
|
41
|
+
if (!existsSync(directory)) return NO_CHANGES
|
|
42
|
+
let lastChangeAt: number | null = null
|
|
43
|
+
const watcher = watcherOf(directory, () => {
|
|
44
|
+
lastChangeAt = clock()
|
|
45
|
+
})
|
|
46
|
+
if (!watcher) return NO_CHANGES
|
|
47
|
+
return { lastChangeAt: () => lastChangeAt, close: () => watcher.close() }
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lineText,
|
|
3
|
+
reportsItem,
|
|
4
|
+
type SeenEntry,
|
|
5
|
+
type SourceEntry,
|
|
6
|
+
type SourceItem,
|
|
7
|
+
type SourceRecord,
|
|
8
|
+
type SourceRun,
|
|
9
|
+
type SourceSettings,
|
|
10
|
+
seenMapOf,
|
|
11
|
+
storedValue,
|
|
12
|
+
} from './sources'
|
|
13
|
+
|
|
14
|
+
/** The most entries a source keeps for the herd. */
|
|
15
|
+
export const ENTRY_LIMIT = 20
|
|
16
|
+
|
|
17
|
+
const shownAt = ({ changedAt, seenAt }: SourceEntry): number => changedAt ?? seenAt
|
|
18
|
+
|
|
19
|
+
const entryOf = (
|
|
20
|
+
item: SourceItem,
|
|
21
|
+
known: SourceEntry | undefined,
|
|
22
|
+
seen: ReadonlyMap<string, SeenEntry> | null,
|
|
23
|
+
settings: SourceSettings,
|
|
24
|
+
now: number,
|
|
25
|
+
): SourceEntry => ({
|
|
26
|
+
id: item.id,
|
|
27
|
+
title: storedValue(lineText(item.title)),
|
|
28
|
+
url: item.url,
|
|
29
|
+
count: item.count,
|
|
30
|
+
seenAt: known?.seenAt ?? now,
|
|
31
|
+
changedAt: seen && reportsItem(seen, item, settings) ? now : (known?.changedAt ?? null),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The entries a source that is no ticket keeps after `run`: those of a successful run, at most `ENTRY_LIMIT`, newest
|
|
36
|
+
* change first, each with the time the watcher first saw it and the time it last reported a change of it (none for the
|
|
37
|
+
* baseline run). A failed run keeps what the source had; a ticket source keeps none (its tickets are the list).
|
|
38
|
+
*/
|
|
39
|
+
export const entriesAfter = (
|
|
40
|
+
previous: SourceRecord | undefined,
|
|
41
|
+
run: SourceRun | undefined,
|
|
42
|
+
settings: SourceSettings | undefined,
|
|
43
|
+
now: number,
|
|
44
|
+
): SourceEntry[] | undefined => {
|
|
45
|
+
if (!settings || settings.ticket) return undefined
|
|
46
|
+
if (!run?.result.ok) return previous?.entries
|
|
47
|
+
const seen = seenMapOf(previous)
|
|
48
|
+
const known = new Map((previous?.entries ?? []).map((entry) => [entry.id, entry]))
|
|
49
|
+
return run.result.items
|
|
50
|
+
.map((item) => entryOf(item, known.get(item.id), seen, settings, now))
|
|
51
|
+
.map((entry, index) => ({ entry, index }))
|
|
52
|
+
.sort((a, b) => shownAt(b.entry) - shownAt(a.entry) || a.index - b.index)
|
|
53
|
+
.map(({ entry }) => entry)
|
|
54
|
+
.slice(0, ENTRY_LIMIT)
|
|
55
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isFresh, NEW_FOR_MS } from './plan-badges'
|
|
2
|
+
import { previewUrlOf } from './previews'
|
|
3
|
+
import { lineText, type SourceEntry, type SourceRecord } from './sources'
|
|
4
|
+
import type { WatchState } from './watch-events'
|
|
5
|
+
|
|
6
|
+
/** The newest entry a source reported, as the herd draws it; `isNew` while it is younger than `NEW_FOR_MS`. */
|
|
7
|
+
export type LatestStatus = { title: string; url: string | null; isNew: boolean }
|
|
8
|
+
|
|
9
|
+
/** An entry as the herd lists it under its source: `at` is when it last changed, else when the watcher first saw it; `isNew` only for a reported change younger than `NEW_FOR_MS`. */
|
|
10
|
+
export type EntryStatus = {
|
|
11
|
+
id: string
|
|
12
|
+
title: string
|
|
13
|
+
url: string | null
|
|
14
|
+
count: string | null
|
|
15
|
+
at: number
|
|
16
|
+
isNew: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the herd shows of a source: when it ran last, since when it fails, its newest report and its entries, newest change first. */
|
|
20
|
+
export type SourceStatus = {
|
|
21
|
+
name: string
|
|
22
|
+
ranAt: number | null
|
|
23
|
+
failingSince: number | null
|
|
24
|
+
latest: LatestStatus | null
|
|
25
|
+
entries: EntryStatus[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const entryStatusOf = (entry: SourceEntry, now: number): EntryStatus => ({
|
|
29
|
+
id: entry.id,
|
|
30
|
+
title: lineText(entry.title),
|
|
31
|
+
url: entry.url === null ? null : previewUrlOf(entry.url, undefined),
|
|
32
|
+
count: entry.count,
|
|
33
|
+
at: entry.changedAt ?? entry.seenAt,
|
|
34
|
+
isNew: entry.changedAt !== null && now - entry.changedAt < NEW_FOR_MS,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const latestOf = (latest: SourceRecord['latest'], now: number): LatestStatus | null =>
|
|
38
|
+
latest
|
|
39
|
+
? {
|
|
40
|
+
title: lineText(latest.title),
|
|
41
|
+
url: latest.url === null ? null : previewUrlOf(latest.url, undefined),
|
|
42
|
+
isNew: now - latest.at < NEW_FOR_MS,
|
|
43
|
+
}
|
|
44
|
+
: null
|
|
45
|
+
|
|
46
|
+
const statusOf = (name: string, record: SourceRecord | undefined, now: number): SourceStatus => ({
|
|
47
|
+
name,
|
|
48
|
+
ranAt: record?.ranAt ?? null,
|
|
49
|
+
failingSince: record?.failingSince ?? null,
|
|
50
|
+
latest: latestOf(record?.latest, now),
|
|
51
|
+
entries: (record?.entries ?? []).map((entry) => entryStatusOf(entry, now)),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The status of each source in `names`, in their order, from the watcher's saved state alone. A name the watcher has
|
|
56
|
+
* not run yet shows without a run; a title is drawn as one line and the URL is checked again, as for the tickets, and a
|
|
57
|
+
* state older than `STALE_AFTER_MS` shows none.
|
|
58
|
+
*/
|
|
59
|
+
export const sourceStatusesOf = (state: WatchState, names: readonly string[], now: number): SourceStatus[] =>
|
|
60
|
+
isFresh(state.savedAt, now) ? names.map((name) => statusOf(name, state.sources[name], now)) : []
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { entriesAfter } from './source-entries'
|
|
1
2
|
import {
|
|
3
|
+
type LatestReport,
|
|
4
|
+
lineText,
|
|
2
5
|
recordSources,
|
|
3
6
|
reportsItem,
|
|
4
7
|
SEEN_LIMIT,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
type SourceSettings,
|
|
10
13
|
type SourceState,
|
|
11
14
|
seenMapOf,
|
|
15
|
+
storedValue,
|
|
12
16
|
type TicketRecord,
|
|
13
17
|
} from './sources'
|
|
14
18
|
|
|
@@ -39,6 +43,19 @@ const ticketsAfter = (
|
|
|
39
43
|
)
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/** The newest entry this run reported (the last of them), else what was recorded; failures and baselines record nothing. */
|
|
47
|
+
const latestAfter = (
|
|
48
|
+
previous: SourceRecord | undefined,
|
|
49
|
+
run: SourceRun | undefined,
|
|
50
|
+
settings: SourceSettings | undefined,
|
|
51
|
+
now: number,
|
|
52
|
+
): LatestReport | undefined => {
|
|
53
|
+
const seen = seenMapOf(previous)
|
|
54
|
+
if (!seen || !settings || !run?.result.ok) return previous?.latest
|
|
55
|
+
const reported = run.result.items.filter((item) => reportsItem(seen, item, settings)).at(-1)
|
|
56
|
+
return reported ? { title: storedValue(lineText(reported.title)), url: reported.url, at: now } : previous?.latest
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
const withoutTickets = ({ tickets: _tickets, ...record }: SourceRecord): SourceRecord => record
|
|
43
60
|
|
|
44
61
|
/** The tickets a source keeps: those of a successful run, else the ones it had; none for a source not marked. */
|
|
@@ -71,7 +88,10 @@ const recordTickets = (
|
|
|
71
88
|
const source = settings.find((candidate) => candidate.name === name)
|
|
72
89
|
const run = runs.find((candidate) => candidate.name === name)
|
|
73
90
|
const tickets = ticketsOf(previous[name], run, source, now)
|
|
74
|
-
|
|
91
|
+
const latest = latestAfter(previous[name], run, source, now)
|
|
92
|
+
const entries = entriesAfter(previous[name], run, source, now)
|
|
93
|
+
const base = { ...withoutTickets(record), ...(latest ? { latest } : {}), ...(entries ? { entries } : {}) }
|
|
94
|
+
return [name, tickets ? { ...base, tickets } : base]
|
|
75
95
|
}),
|
|
76
96
|
)
|
|
77
97
|
|
package/src/watch/sources.ts
CHANGED
|
@@ -67,6 +67,8 @@ export type SourceItem = {
|
|
|
67
67
|
author: string | null
|
|
68
68
|
/** `map.status` as the state keeps it (`storedValue`), null when not mapped or empty. */
|
|
69
69
|
status: string | null
|
|
70
|
+
/** `map.count` as one line (how often an error happened); null when not mapped or empty. */
|
|
71
|
+
count: string | null
|
|
70
72
|
fingerprint: string
|
|
71
73
|
/** The `reportOn` fields as the state keeps them (`storedValue`); empty without `reportOn`. */
|
|
72
74
|
values: Record<string, string>
|
|
@@ -86,6 +88,22 @@ export type SeenEntry = [string, string] | [string, string, Record<string, strin
|
|
|
86
88
|
*/
|
|
87
89
|
export type TicketRecord = { url: string | null; status: string | null; changedAt: number | null }
|
|
88
90
|
|
|
91
|
+
/** The newest entry a source reported: its title as one line, its checked URL or null, and the epoch milliseconds of the run that reported it. */
|
|
92
|
+
export type LatestReport = { title: string; url: string | null; at: number }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An entry of a source as the herd lists it: its title as one line, its checked URL, its count, when the watcher first
|
|
96
|
+
* saw it (`seenAt`) and when it last reported a change of it (`changedAt`, null while only the baseline knows it).
|
|
97
|
+
*/
|
|
98
|
+
export type SourceEntry = {
|
|
99
|
+
id: string
|
|
100
|
+
title: string
|
|
101
|
+
url: string | null
|
|
102
|
+
count: string | null
|
|
103
|
+
seenAt: number
|
|
104
|
+
changedAt: number | null
|
|
105
|
+
}
|
|
106
|
+
|
|
89
107
|
/**
|
|
90
108
|
* What the watcher keeps per source: `seen` holds a `SeenEntry` per id, the entries seen most recently last, or null
|
|
91
109
|
* before the first successful run (which is a baseline); `failingSince` is the epoch milliseconds of the first failed
|
|
@@ -94,8 +112,14 @@ export type TicketRecord = { url: string | null; status: string | null; changedA
|
|
|
94
112
|
export type SourceRecord = {
|
|
95
113
|
seen: SeenEntry[] | null
|
|
96
114
|
failingSince: number | null
|
|
115
|
+
/** When its last run started, failed or not (`isSourceDue`); absent in a state written before it was kept: never. */
|
|
116
|
+
ranAt?: number
|
|
97
117
|
/** Of a ticket source only: the entries of its last successful run by id. */
|
|
98
118
|
tickets?: Record<string, TicketRecord>
|
|
119
|
+
/** The newest entry the source reported, kept for the herd's row of the source; absent until one was reported. */
|
|
120
|
+
latest?: LatestReport
|
|
121
|
+
/** Of a source that is no ticket: the entries of its last successful run for the herd's expanded row, newest change first. */
|
|
122
|
+
entries?: SourceEntry[]
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
export type SourceState = Record<string, SourceRecord>
|
|
@@ -143,7 +167,7 @@ const scalarText = (value: unknown): string | null => {
|
|
|
143
167
|
export const textAt = (entry: unknown, path: SourcePath | undefined): string | null =>
|
|
144
168
|
path === undefined ? null : scalarText(selectPath(entry, path))
|
|
145
169
|
|
|
146
|
-
type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status'>
|
|
170
|
+
type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status' | 'count'>
|
|
147
171
|
|
|
148
172
|
const hashOf = (text: string, length: number): string =>
|
|
149
173
|
createHash('sha256').update(text).digest('hex').slice(0, length)
|
|
@@ -189,6 +213,9 @@ const valuesOf = (
|
|
|
189
213
|
const statusOf = (entry: unknown, map: SourceMap): string | null =>
|
|
190
214
|
storedValue(lineText(textAt(entry, map.status) ?? '')) || null
|
|
191
215
|
|
|
216
|
+
const countOf = (entry: unknown, map: SourceMap): string | null =>
|
|
217
|
+
storedValue(lineText(textAt(entry, map.count) ?? '')) || null
|
|
218
|
+
|
|
192
219
|
/** One entry after `map`, or null when its id, or a mapped updatedAt, is missing or no text; unmapped it is `''`. */
|
|
193
220
|
const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 'reportOn'>): SourceItem | null => {
|
|
194
221
|
const { map } = settings
|
|
@@ -207,6 +234,7 @@ const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 're
|
|
|
207
234
|
return {
|
|
208
235
|
...fields,
|
|
209
236
|
status: statusOf(entry, map),
|
|
237
|
+
count: countOf(entry, map),
|
|
210
238
|
fingerprint: contentFingerprint(fields, map),
|
|
211
239
|
values: valuesOf(entry, fields, settings),
|
|
212
240
|
}
|
|
@@ -248,7 +276,7 @@ export const parseSourceOutput = (
|
|
|
248
276
|
return { ok: true, items: uniqueById(items as SourceItem[]) }
|
|
249
277
|
}
|
|
250
278
|
|
|
251
|
-
/** Whether a source last started at `lastRunAt` (undefined: never
|
|
279
|
+
/** Whether a source last started at `lastRunAt` (`SourceRecord.ranAt`; undefined: never) is due again at `now`. */
|
|
252
280
|
export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
|
|
253
281
|
lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
|
|
254
282
|
|
|
@@ -342,8 +370,8 @@ const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): Se
|
|
|
342
370
|
|
|
343
371
|
const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
|
|
344
372
|
result.ok
|
|
345
|
-
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
|
|
346
|
-
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
|
|
373
|
+
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null, ranAt: now }
|
|
374
|
+
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now, ranAt: now }
|
|
347
375
|
|
|
348
376
|
/**
|
|
349
377
|
* The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
|
|
@@ -381,10 +409,25 @@ const isTicketRecord = (value: unknown): boolean =>
|
|
|
381
409
|
isTextOrNull(value.status) &&
|
|
382
410
|
(value.changedAt === null || typeof value.changedAt === 'number')
|
|
383
411
|
|
|
412
|
+
const isLatestReport = (value: unknown): boolean =>
|
|
413
|
+
isRecord(value) && typeof value.title === 'string' && isTextOrNull(value.url) && typeof value.at === 'number'
|
|
414
|
+
|
|
415
|
+
const isSourceEntry = (value: unknown): boolean =>
|
|
416
|
+
isRecord(value) &&
|
|
417
|
+
typeof value.id === 'string' &&
|
|
418
|
+
typeof value.title === 'string' &&
|
|
419
|
+
isTextOrNull(value.url) &&
|
|
420
|
+
isTextOrNull(value.count) &&
|
|
421
|
+
typeof value.seenAt === 'number' &&
|
|
422
|
+
(value.changedAt === null || typeof value.changedAt === 'number')
|
|
423
|
+
|
|
384
424
|
const isSourceRecord = (value: unknown): boolean =>
|
|
385
425
|
isRecord(value) &&
|
|
426
|
+
(value.entries === undefined || (Array.isArray(value.entries) && value.entries.every(isSourceEntry))) &&
|
|
427
|
+
(value.latest === undefined || isLatestReport(value.latest)) &&
|
|
386
428
|
(value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
|
|
387
429
|
(value.failingSince === null || typeof value.failingSince === 'number') &&
|
|
430
|
+
(value.ranAt === undefined || typeof value.ranAt === 'number') &&
|
|
388
431
|
(value.tickets === undefined || (isRecord(value.tickets) && Object.values(value.tickets).every(isTicketRecord)))
|
|
389
432
|
|
|
390
433
|
export const isSourceState = (value: unknown): value is SourceState =>
|
|
@@ -17,7 +17,8 @@ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests:
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
|
|
20
|
-
* message of its own: `=== heartbeat <time> · <lane> <status> · …`.
|
|
20
|
+
* message of its own: `=== heartbeat <time> · <lane> <status> · …`. `standing` are the lines that stay until
|
|
21
|
+
* their cause is gone (what waits on the user, the main checkout behind its base), each one entry at the end.
|
|
21
22
|
*/
|
|
22
23
|
export const streamHeartbeatLine = (
|
|
23
24
|
time: string,
|
|
@@ -25,12 +26,12 @@ export const streamHeartbeatLine = (
|
|
|
25
26
|
current: LaneSnapshot,
|
|
26
27
|
notes: LaneNotes,
|
|
27
28
|
rests: LaneNotes = {},
|
|
28
|
-
|
|
29
|
+
standing: readonly string[] = [],
|
|
29
30
|
): string => {
|
|
30
31
|
const states = lanes.map((lane) => laneState(lane, current, notes, rests))
|
|
31
32
|
return [
|
|
32
33
|
`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
|
|
33
34
|
...(states.length > 0 ? states : ['no lanes']),
|
|
34
|
-
...(
|
|
35
|
+
...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
|
|
35
36
|
].join(SEPARATOR)
|
|
36
37
|
}
|