jackherd 1.15.1 → 1.16.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 +22 -0
- package/README.md +22 -19
- package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
- package/dist/config.d.ts +47 -2
- package/dist/config.js +65 -4
- package/dist/plan-format.js +3 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +49 -4
- package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
- package/kit/workflow/skills/follow/SKILL.md +18 -10
- package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
- package/package.json +2 -1
- package/recipes/README.md +3 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/sources/google-calendar-today.ts +32 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources.md +15 -3
- package/schemas/jackherd.schema.json +12 -0
- package/schemas/sources.schema.json +141 -0
- package/src/check/skill-budget-defaults.ts +11 -4
- package/src/cli.ts +1 -0
- package/src/config/json-schema.ts +7 -3
- package/src/config/location.ts +7 -0
- package/src/config/personal-sources.ts +83 -0
- package/src/config/schema.ts +29 -0
- package/src/plans/herd-claim.ts +9 -31
- package/src/plans/layout.ts +135 -18
- package/src/plans/live.ts +54 -7
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/settings.ts +20 -7
- package/src/plans/snapshot.ts +34 -22
- package/src/plans/source-rows.ts +35 -2
- package/src/secrets/command.ts +163 -0
- package/src/secrets/environment.ts +45 -0
- package/src/secrets/hidden-input.ts +51 -0
- package/src/secrets/listing.ts +63 -0
- package/src/secrets/options.ts +45 -0
- package/src/secrets/store.ts +74 -0
- package/src/shared/process-info.ts +89 -0
- package/src/sync/command-context.ts +26 -4
- package/src/sync/render.ts +9 -4
- package/src/watch/command.ts +44 -12
- package/src/watch/lane-report.ts +2 -1
- package/src/watch/plan-badges-read.ts +28 -10
- package/src/watch/source-dry-run-command.ts +23 -12
- package/src/watch/source-dry-run.ts +20 -3
- package/src/watch/source-reload.ts +100 -0
- package/src/watch/source-run.ts +63 -7
- package/src/watch/source-tickets.ts +15 -0
- package/src/watch/sources.ts +21 -0
- package/src/watch/texts.ts +1 -1
- package/src/watch/watch-events.ts +9 -0
- package/src/watch/watched-source.ts +39 -0
- package/src/watch/watcher-lock.ts +8 -1
package/src/watch/command.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
32
32
|
import { homedir } from 'node:os'
|
|
33
33
|
import { basename, dirname, join, relative } from 'node:path'
|
|
34
34
|
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
35
|
+
import { sourcesWithPersonal } from '../config/personal-sources'
|
|
35
36
|
import { readConfig, readResolvedConfig } from '../config/read-config'
|
|
36
37
|
import type { JackherdConfig } from '../config/schema'
|
|
37
38
|
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
@@ -182,9 +183,16 @@ import { withSingleReportNotice } from './single-report'
|
|
|
182
183
|
import { readOrchestrateSnapshot, skillLineFor } from './skill-change'
|
|
183
184
|
import { runsFromSource, sourceChangeSettled, watchSourceChanges } from './source-change'
|
|
184
185
|
import { sourceDryRun } from './source-dry-run-command'
|
|
186
|
+
import {
|
|
187
|
+
type LoadedSources,
|
|
188
|
+
nextLoadedSources,
|
|
189
|
+
readFileStamps,
|
|
190
|
+
readWatchedSources,
|
|
191
|
+
sourceFilePaths,
|
|
192
|
+
} from './source-reload'
|
|
185
193
|
import { runSource, SOURCE_LIMITS } from './source-run'
|
|
186
|
-
import {
|
|
187
|
-
import { isSourceDue, type SourceRun, type
|
|
194
|
+
import { recordedSourceState } from './source-tickets'
|
|
195
|
+
import { isSourceDue, type SourceRun, type SourceState, sourceEvents } from './sources'
|
|
188
196
|
import { heartbeatStateOf, isHeartbeatNews, streamHeartbeatLine } from './stream-output'
|
|
189
197
|
import {
|
|
190
198
|
formatTaskParallelLine,
|
|
@@ -257,7 +265,10 @@ type Watch = {
|
|
|
257
265
|
/** The switch `watch.parallelTasks.auto` and the paths that always ask, both from the main checkout's config. */
|
|
258
266
|
parallelAuto: AutoSettings
|
|
259
267
|
previews: PreviewsConfig
|
|
260
|
-
sources
|
|
268
|
+
/** The sources as read at the start; the loop takes up later changes of their files (`Watching.sources`). */
|
|
269
|
+
sources: LoadedSources
|
|
270
|
+
/** The checkouts whose config the sources are read from, as `readResolvedConfig` takes them. */
|
|
271
|
+
configRoots: string[]
|
|
261
272
|
texts: WatchTexts
|
|
262
273
|
/** Where the orchestrate skill is installed, the project's copy first; the events name a reference inside the one found. */
|
|
263
274
|
skillCopies: SkillCopy[]
|
|
@@ -305,6 +316,8 @@ type Watching = {
|
|
|
305
316
|
failureReadings: FailureReading[]
|
|
306
317
|
/** The last preview read that answered; recorded with every state like the feedback read. */
|
|
307
318
|
previewRead: PreviewRead | null
|
|
319
|
+
/** The sources run now, read again whenever one of their files changes (`nextLoadedSources`). */
|
|
320
|
+
sources: LoadedSources
|
|
308
321
|
/** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
|
|
309
322
|
sourceRuns: SourceRun[]
|
|
310
323
|
/** The lanes' fills of the last read; a lane without an entry or with null has no value. */
|
|
@@ -381,7 +394,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
381
394
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
382
395
|
planMissing: watching.planMissing,
|
|
383
396
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
384
|
-
|
|
397
|
+
...recordedSourceState(withReads.sources, watching.sourceRuns, watching.sources.sources, watching.githubReadAt),
|
|
385
398
|
appointments: watching.appointments
|
|
386
399
|
? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
|
|
387
400
|
: withReads.appointments,
|
|
@@ -887,15 +900,26 @@ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchE
|
|
|
887
900
|
* source stops the watcher. The run's start is recorded with the GitHub read's time, which is `now`.
|
|
888
901
|
*/
|
|
889
902
|
const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
|
|
890
|
-
const due =
|
|
903
|
+
const due = watching.sources.sources.filter((source) =>
|
|
891
904
|
isSourceDue(watching.state.sources[source.name]?.ranAt, source.every, now),
|
|
892
905
|
)
|
|
893
906
|
return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
|
|
894
907
|
}
|
|
895
908
|
|
|
896
|
-
|
|
909
|
+
/**
|
|
910
|
+
* Takes up a change of the files the sources come from before the due ones run (`nextLoadedSources`): a source read
|
|
911
|
+
* anew has no `ranAt` and runs at once, its first run a baseline; an invalid file keeps the list and is said once.
|
|
912
|
+
*/
|
|
913
|
+
const reloadSources = (watch: Watch, watching: Watching) => {
|
|
914
|
+
const stamps = readFileStamps(Object.keys(watching.sources.stamps))
|
|
915
|
+
const next = nextLoadedSources(watching.sources, stamps, () => readWatchedSources(watch.configRoots, homedir()))
|
|
916
|
+
watching.sources = next.loaded
|
|
917
|
+
for (const line of next.lines) console.log(line)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const recordSourceRuns = (watching: Watching, runs: SourceRun[]): WatchEvent[] => {
|
|
897
921
|
watching.sourceRuns = runs
|
|
898
|
-
return sourceEvents(watching.state.sources, runs,
|
|
922
|
+
return sourceEvents(watching.state.sources, runs, watching.sources.sources)
|
|
899
923
|
}
|
|
900
924
|
|
|
901
925
|
/** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
|
|
@@ -917,7 +941,8 @@ const syncPlanHeads = (watch: Watch, watching: Watching, pullRequests: readonly
|
|
|
917
941
|
|
|
918
942
|
/**
|
|
919
943
|
* 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
|
|
920
|
-
* while a read is pending saves the state as it was, so no event is taken as reported.
|
|
944
|
+
* while a read is pending saves the state as it was, so no event is taken as reported. Before the sources run, a
|
|
945
|
+
* change of the files they come from is taken up (`reloadSources`), which prints its notes.
|
|
921
946
|
*/
|
|
922
947
|
const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
923
948
|
syncBase(watch, watching)
|
|
@@ -925,6 +950,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
|
|
|
925
950
|
const fills = fillReadFor(watch, name, watching.agents, watching.lanes)
|
|
926
951
|
const laneBranches = laneBranchesOf(watch, watching.lanes)
|
|
927
952
|
const searched = Object.values({ ...watching.state.laneBranches, ...laneBranches })
|
|
953
|
+
reloadSources(watch, watching)
|
|
928
954
|
const [github, sourceRuns] = await Promise.all([
|
|
929
955
|
readGithub(watch.root, watch.deploy, watch.settings.baseBranch, searched),
|
|
930
956
|
runDueSources(watch, watching, now),
|
|
@@ -940,7 +966,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
|
|
|
940
966
|
...recordGithub(watching, github, laneBranches, now, failureReadings),
|
|
941
967
|
...recordFeedbackRead(watching, feedback?.read ?? null),
|
|
942
968
|
...recordPreviewRead(watching, previews),
|
|
943
|
-
...recordSourceRuns(
|
|
969
|
+
...recordSourceRuns(watching, sourceRuns),
|
|
944
970
|
...recordCoordinator(watching, { name, read: fills.coordinator }),
|
|
945
971
|
...recordLaneFills(watching, fills.lanes),
|
|
946
972
|
...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models)),
|
|
@@ -1355,6 +1381,7 @@ const initialWatching = (
|
|
|
1355
1381
|
feedbackRead: null,
|
|
1356
1382
|
failureReadings: [],
|
|
1357
1383
|
previewRead: null,
|
|
1384
|
+
sources: watch.sources,
|
|
1358
1385
|
sourceRuns: [],
|
|
1359
1386
|
laneReadings: {},
|
|
1360
1387
|
laneModelReadings: {},
|
|
@@ -1458,8 +1485,12 @@ const autoSettingsOf = (root: string, checkoutRoot: string, config: JackherdConf
|
|
|
1458
1485
|
const watchOf = (options: WatchOptions): Watch => {
|
|
1459
1486
|
const root = mainCheckout(process.cwd())
|
|
1460
1487
|
const checkoutRoot = worktreeRoot(process.cwd())
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1488
|
+
const configRoots = [checkoutRoot, root]
|
|
1489
|
+
// Stamped before the read: a change in between is read again at the first poll, never missed.
|
|
1490
|
+
const stamps = readFileStamps(sourceFilePaths(configRoots, homedir()))
|
|
1491
|
+
const { config, notes } = readResolvedConfig(configRoots)
|
|
1492
|
+
const merged = sourcesWithPersonal(config.watch.sources)
|
|
1493
|
+
for (const note of [...notes, ...merged.notes]) console.log(note)
|
|
1463
1494
|
const settings = plansSettingsOf(config)
|
|
1464
1495
|
const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
|
|
1465
1496
|
foundLanesOf(root, settings, () => agentNamesOf(listAgents())),
|
|
@@ -1478,7 +1509,8 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1478
1509
|
taskSignalMinutes: config.watch.taskSignalMinutes,
|
|
1479
1510
|
parallelAuto: autoSettingsOf(root, checkoutRoot, config),
|
|
1480
1511
|
previews: config.watch.previews,
|
|
1481
|
-
sources:
|
|
1512
|
+
sources: { sources: merged.sources, stamps },
|
|
1513
|
+
configRoots,
|
|
1482
1514
|
texts: watchTexts(settings.responseLanguage),
|
|
1483
1515
|
skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
|
|
1484
1516
|
herdr: insideHerdr(settings.adapter, process.env),
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -409,7 +409,8 @@ const readyPhaseText = ({ id, after }: ReadyPhase): string =>
|
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
411
|
* What a resting lane (`done` or `idle`) with ready phases asks of the coordinator (`orchestrate` § 2): one line per such
|
|
412
|
-
* lane, naming every ready phase, to start it now; a pull request waiting for approval
|
|
412
|
+
* lane, naming every ready phase, to start it now; a pull request of the lane waiting for approval or merge
|
|
413
|
+
* holds only the commit.
|
|
413
414
|
*/
|
|
414
415
|
export const readyPhaseDutyLines = (
|
|
415
416
|
stopped: readonly string[],
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { dirname, join } from 'node:path'
|
|
2
|
+
import { type HerdSourceSettings, herdSourceSettingsOf } from '../plans/settings'
|
|
2
3
|
import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
|
|
3
4
|
import {
|
|
4
5
|
sourceTicketsOf,
|
|
5
|
-
type TicketSource,
|
|
6
6
|
WATCH_HEALTH,
|
|
7
|
+
type WatchHealth,
|
|
7
8
|
type WatchReading,
|
|
8
9
|
watchBadgesOf,
|
|
9
10
|
watchHealthOf,
|
|
@@ -22,21 +23,35 @@ const readState = (file: string): WatchState | null => {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** A reading with the sources it was drawn for: those the watcher wrote into its state, else the herd's own. */
|
|
27
|
+
export type SourcedWatchReading = WatchReading & { sourceSettings: HerdSourceSettings }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The sources a live watcher keeps in its state (`watchedSources`); `fallback`, the herd's config, for a watcher that
|
|
31
|
+
* stopped (its state may predate a config change) or a state written before it kept them.
|
|
32
|
+
*/
|
|
33
|
+
export const sourceSettingsOf = (
|
|
34
|
+
state: WatchState | null,
|
|
35
|
+
health: WatchHealth,
|
|
36
|
+
fallback: HerdSourceSettings,
|
|
37
|
+
): HerdSourceSettings =>
|
|
38
|
+
health.kind === WATCH_HEALTH.live && state?.watchedSources ? herdSourceSettingsOf(state.watchedSources) : fallback
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
|
|
27
|
-
* its badges, the tickets of
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
42
|
+
* its badges, the tickets of the ticket sources, the status of the other sources, and its health, so the view can say
|
|
43
|
+
* why there are none. The sources are those the state names, so one the watcher took up since the herd started shows
|
|
44
|
+
* too; `fallback` stands in for a state without them. A missing or damaged file shows no badges; the view goes on
|
|
45
|
+
* without them. Nothing is read unless both levels of the watcher directory are private (`isPrivateLevels`), and
|
|
46
|
+
* only regular files, so a directory, a file or a FIFO someone else planted neither feeds the view nor blocks it.
|
|
31
47
|
*/
|
|
32
48
|
export const readWatchReading = (
|
|
33
49
|
root: string,
|
|
34
50
|
now: number,
|
|
35
|
-
|
|
51
|
+
fallback: HerdSourceSettings,
|
|
36
52
|
lookUp?: CommandLineOf,
|
|
37
53
|
system: LocationSystem = systemLocation(),
|
|
38
|
-
|
|
39
|
-
): WatchReading => {
|
|
54
|
+
): SourcedWatchReading => {
|
|
40
55
|
const directory = watcherDirectoryOf(root, system)
|
|
41
56
|
const isPrivate = isPrivateLevels(dirname(directory), directory, system)
|
|
42
57
|
const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
|
|
@@ -45,11 +60,14 @@ export const readWatchReading = (
|
|
|
45
60
|
isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
46
61
|
now,
|
|
47
62
|
)
|
|
48
|
-
|
|
63
|
+
const sourceSettings = sourceSettingsOf(state, health, fallback)
|
|
64
|
+
if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health, sourceSettings }
|
|
65
|
+
const statusNames = sourceSettings.herdSources.map(({ name }) => name)
|
|
49
66
|
return {
|
|
50
67
|
badges: watchBadgesOf(state, now),
|
|
51
|
-
tickets: sourceTicketsOf(state, ticketSources, now),
|
|
68
|
+
tickets: sourceTicketsOf(state, sourceSettings.ticketSources, now),
|
|
52
69
|
health,
|
|
53
70
|
...(statusNames.length > 0 ? { sources: sourceStatusesOf(state, statusNames, now) } : {}),
|
|
71
|
+
sourceSettings,
|
|
54
72
|
}
|
|
55
73
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { personalSourcesPath } from '../config/location'
|
|
3
|
+
import { configuredSources, isNoteAbout } from '../config/personal-sources'
|
|
4
|
+
import { parseSourceDraft } from '../config/schema'
|
|
3
5
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
4
6
|
import type { SourceDryRun } from './options'
|
|
5
7
|
import { type DryRunTarget, dryRunExitCode, dryRunLines, dryRunTarget, emptyMapFields } from './source-dry-run'
|
|
6
|
-
import {
|
|
8
|
+
import { SOURCE_LIMITS, sourceAnswer, sourceRunOf } from './source-run'
|
|
7
9
|
import type { SourceSettings } from './sources'
|
|
8
10
|
|
|
9
|
-
const settingsOf = async (target: DryRunTarget,
|
|
11
|
+
const settingsOf = async (target: DryRunTarget, sources: readonly SourceSettings[]): Promise<SourceSettings> => {
|
|
10
12
|
if (target.kind === 'name') {
|
|
11
|
-
const found =
|
|
12
|
-
if (!found) throw new Error(`no source ${target.name} in watch.sources`)
|
|
13
|
+
const found = sources.find(({ name }) => name === target.name)
|
|
14
|
+
if (!found) throw new Error(`no source ${target.name} in watch.sources or the personal sources file`)
|
|
13
15
|
return found
|
|
14
16
|
}
|
|
15
17
|
const text = target.kind === 'stdin' ? await Bun.stdin.text() : await Bun.file(target.path).text()
|
|
@@ -17,19 +19,28 @@ const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
|
-
* `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout, as the
|
|
21
|
-
* watcher would, and prints what it found and would report; `--raw` adds
|
|
22
|
+
* `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout (a personal one in `~/.jackherd`), as the
|
|
23
|
+
* watcher would, its `secrets` handed to the command alike, and prints what it found and would report; `--raw` adds
|
|
24
|
+
* the command's stdout, which by the recipe rules carries no secret value. Takes no lock and
|
|
22
25
|
* neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
|
|
23
26
|
* `map` field is empty in every entry.
|
|
24
27
|
*/
|
|
25
28
|
export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
|
|
26
29
|
const root = mainCheckout(process.cwd())
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
+
const home = homedir()
|
|
31
|
+
const merged = configuredSources([worktreeRoot(process.cwd()), root], home)
|
|
32
|
+
const settings = await settingsOf(dryRunTarget(target), merged.sources)
|
|
33
|
+
const answer = await sourceAnswer(settings, root, SOURCE_LIMITS, home)
|
|
30
34
|
const run = sourceRunOf(settings, answer)
|
|
31
35
|
const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
|
|
32
36
|
const stdout = raw && answer.ok ? answer.stdout : undefined
|
|
33
|
-
console.log(
|
|
37
|
+
console.log(
|
|
38
|
+
dryRunLines(settings, run, {
|
|
39
|
+
mapCheck,
|
|
40
|
+
stdout,
|
|
41
|
+
personalPath: personalSourcesPath(home),
|
|
42
|
+
notes: merged.notes.filter((note) => isNoteAbout(note, settings.name)),
|
|
43
|
+
}).join('\n'),
|
|
44
|
+
)
|
|
34
45
|
return dryRunExitCode(run, mapCheck)
|
|
35
46
|
}
|
|
@@ -3,6 +3,7 @@ import { sourceEventLines } from './lane-report'
|
|
|
3
3
|
import {
|
|
4
4
|
isIgnoredAuthor,
|
|
5
5
|
listAt,
|
|
6
|
+
SOURCE_ORIGIN,
|
|
6
7
|
type SourceItem,
|
|
7
8
|
type SourceRun,
|
|
8
9
|
type SourceSettings,
|
|
@@ -17,7 +18,14 @@ export type DryRunTarget = { kind: 'name'; name: string } | { kind: 'file'; path
|
|
|
17
18
|
export type MapCheck = { total: number; empty: { field: string; path: string }[] }
|
|
18
19
|
|
|
19
20
|
/** What one dry run shows besides the run: the map check (null when the output has no list) and the raw output. */
|
|
20
|
-
export type DryRunExtras = {
|
|
21
|
+
export type DryRunExtras = {
|
|
22
|
+
mapCheck: MapCheck | null
|
|
23
|
+
stdout?: string
|
|
24
|
+
/** The personal sources file, named for a personal source. */
|
|
25
|
+
personalPath?: string
|
|
26
|
+
/** Notes of the merge that concern this source, such as the repository source that ran in place of a personal one. */
|
|
27
|
+
notes?: readonly string[]
|
|
28
|
+
}
|
|
21
29
|
|
|
22
30
|
const STDIN = '-'
|
|
23
31
|
const LISTED_ENTRIES = 10
|
|
@@ -27,7 +35,7 @@ const RAW_LINE_LENGTH = 200
|
|
|
27
35
|
const INDENT = ' '
|
|
28
36
|
const BASELINE = 'first run of this source: baseline, nothing reported'
|
|
29
37
|
const NOT_CHECKED = '0 entries: map not checked'
|
|
30
|
-
const FOOTER = 'watcher state not read or changed;
|
|
38
|
+
const FOOTER = 'watcher state not read or changed; a running watcher takes a new source over at its next cycle'
|
|
31
39
|
|
|
32
40
|
export const dryRunTarget = (value: string): DryRunTarget => {
|
|
33
41
|
if (value === STDIN) return { kind: 'stdin' }
|
|
@@ -118,7 +126,16 @@ const exampleLines = (settings: SourceSettings, run: SourceRun): string[] => {
|
|
|
118
126
|
*/
|
|
119
127
|
export const dryRunLines = (settings: SourceSettings, run: SourceRun, extras: DryRunExtras): string[] => {
|
|
120
128
|
const head = `dry run source ${terminalSafe(settings.name)}: ${terminalSafe(JSON.stringify(settings.command))}`
|
|
121
|
-
const
|
|
129
|
+
const origin =
|
|
130
|
+
settings.origin === SOURCE_ORIGIN.personal ? [`origin: personal, ${terminalSafe(extras.personalPath ?? '')}`] : []
|
|
131
|
+
const notes = (extras.notes ?? []).map((note) => `note: ${terminalSafe(note)}`)
|
|
132
|
+
const top = [
|
|
133
|
+
head,
|
|
134
|
+
...origin,
|
|
135
|
+
...notes,
|
|
136
|
+
...(extras.stdout === undefined ? [] : rawLines(extras.stdout)),
|
|
137
|
+
...mapCheckLines(extras.mapCheck),
|
|
138
|
+
]
|
|
122
139
|
if (!run.result.ok) return [...top, ...eventsOf(settings, run).flatMap(sourceEventLines), FOOTER]
|
|
123
140
|
return [...top, ...entryLines(run.result.items, settings), ...exampleLines(settings, run), FOOTER]
|
|
124
141
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { locateConfigFile, PROJECT_CONFIG_PATH, personalSourcesPath } from '../config/location'
|
|
4
|
+
import { mergedSources, readPersonalSources } from '../config/personal-sources'
|
|
5
|
+
import { readResolvedConfig } from '../config/read-config'
|
|
6
|
+
import { errorText } from '../plans/error-text'
|
|
7
|
+
import type { SourceSettings } from './sources'
|
|
8
|
+
|
|
9
|
+
/** A file's change time in epoch milliseconds, null while it is missing; by path. */
|
|
10
|
+
export type FileStamps = Record<string, number | null>
|
|
11
|
+
|
|
12
|
+
/** The sources the watcher runs and the change times of the files they were read from. */
|
|
13
|
+
export type LoadedSources = { sources: SourceSettings[]; stamps: FileStamps }
|
|
14
|
+
|
|
15
|
+
/** A file the sources come from and how to read them; `read` throws for an invalid file. */
|
|
16
|
+
export type SourceFileReader = { path: string; read: () => readonly SourceSettings[] }
|
|
17
|
+
|
|
18
|
+
/** A file that could not be read, as the line the watcher prints. */
|
|
19
|
+
type ReadFailure = { ok: false; line: string }
|
|
20
|
+
|
|
21
|
+
/** A reading of both files: the merged sources with the notes of the skipped ones, or the line naming the file that failed. */
|
|
22
|
+
export type SourceRead = { ok: true; sources: SourceSettings[]; notes: string[] } | ReadFailure
|
|
23
|
+
|
|
24
|
+
/** What a line starts with when a changed file left the sources as they were. */
|
|
25
|
+
export const NOT_RELOADED = 'sources not reloaded:'
|
|
26
|
+
|
|
27
|
+
const sameStamps = (previous: FileStamps, current: FileStamps): boolean => {
|
|
28
|
+
const paths = new Set([...Object.keys(previous), ...Object.keys(current)])
|
|
29
|
+
return [...paths].every((path) => previous[path] === current[path])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The error of `path` on one line, without the `<path> is invalid:` the config readers put before the issues. */
|
|
33
|
+
const reasonOf = (path: string, error: unknown): string =>
|
|
34
|
+
errorText(error).replace(`${path} is invalid:`, '').replace(/\s+/g, ' ').trim()
|
|
35
|
+
|
|
36
|
+
const readOne = (reader: SourceFileReader): { ok: true; sources: readonly SourceSettings[] } | ReadFailure => {
|
|
37
|
+
try {
|
|
38
|
+
return { ok: true, sources: reader.read() }
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return { ok: false, line: `${NOT_RELOADED} ${reader.path}: ${reasonOf(reader.path, error)}` }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reads the repository's sources, then the personal ones, and merges them (`mergedSources`); the first file that fails is named. */
|
|
45
|
+
export const readSourceFiles = (repo: SourceFileReader, personal: SourceFileReader): SourceRead => {
|
|
46
|
+
const repoRead = readOne(repo)
|
|
47
|
+
if (!repoRead.ok) return repoRead
|
|
48
|
+
const personalRead = readOne(personal)
|
|
49
|
+
if (!personalRead.ok) return personalRead
|
|
50
|
+
return { ok: true, ...mergedSources(repoRead.sources, personalRead.sources, personal.path) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The sources after one cycle: with the stamps as they were, the same list without a read; after a change, the list
|
|
55
|
+
* `read` gives and its notes, or, when a changed file is invalid, the list as it was and the line naming the file.
|
|
56
|
+
* The new stamps are taken either way, so one change reports once and a source leaves the list only after a valid read.
|
|
57
|
+
*/
|
|
58
|
+
export const nextLoadedSources = (
|
|
59
|
+
previous: LoadedSources,
|
|
60
|
+
stamps: FileStamps,
|
|
61
|
+
read: () => SourceRead,
|
|
62
|
+
): { loaded: LoadedSources; lines: string[] } => {
|
|
63
|
+
if (sameStamps(previous.stamps, stamps)) return { loaded: previous, lines: [] }
|
|
64
|
+
const result = read()
|
|
65
|
+
return result.ok
|
|
66
|
+
? { loaded: { sources: result.sources, stamps }, lines: result.notes }
|
|
67
|
+
: { loaded: { sources: previous.sources, stamps }, lines: [result.line] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The files the watcher's sources come from: the project config of each checkout in `roots`, once, then the personal file. */
|
|
71
|
+
export const sourceFilePaths = (roots: readonly string[], home: string): string[] => [
|
|
72
|
+
...new Set(roots.map((root) => join(root, PROJECT_CONFIG_PATH))),
|
|
73
|
+
personalSourcesPath(home),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const changeTimeOf = (path: string): number | null => {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(path).mtimeMs
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const readFileStamps = (paths: readonly string[]): FileStamps =>
|
|
85
|
+
Object.fromEntries(paths.map((path) => [path, changeTimeOf(path)]))
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reads the sources again: `watch.sources` of the config `readResolvedConfig` finds in `roots` (the rest of the config
|
|
89
|
+
* is not taken over), and those of the personal file of `home`. Unlike the start (`sourcesWithPersonal`), where an
|
|
90
|
+
* invalid personal file leaves the repository's sources alone, here it keeps the list the watcher runs: both keep
|
|
91
|
+
* what ran before, which at the start is no personal source.
|
|
92
|
+
*/
|
|
93
|
+
export const readWatchedSources = (roots: string[], home: string): SourceRead =>
|
|
94
|
+
readSourceFiles(
|
|
95
|
+
{
|
|
96
|
+
path: locateConfigFile(roots, home, existsSync) ?? join(roots[0] ?? home, PROJECT_CONFIG_PATH),
|
|
97
|
+
read: () => readResolvedConfig(roots, home).config.watch.sources,
|
|
98
|
+
},
|
|
99
|
+
{ path: personalSourcesPath(home), read: (): SourceSettings[] => readPersonalSources(home) },
|
|
100
|
+
)
|
package/src/watch/source-run.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { personalSourcesDirectory } from '../config/location'
|
|
3
|
+
import { type Environment, sourceEnvironment } from '../secrets/environment'
|
|
4
|
+
import { SECRET_LOOKUP, SECRET_TIMEOUT_MS, type SecretLookupFn, secretStore } from '../secrets/store'
|
|
1
5
|
import { resolveSourceCommand } from './source-command'
|
|
2
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
exitFailure,
|
|
8
|
+
parseSourceOutput,
|
|
9
|
+
SOURCE_FAILURE,
|
|
10
|
+
SOURCE_ORIGIN,
|
|
11
|
+
type SourceRun,
|
|
12
|
+
type SourceSettings,
|
|
13
|
+
} from './sources'
|
|
3
14
|
|
|
4
15
|
/** How long a source's command may run and how much it may print before it is stopped. */
|
|
5
16
|
export type SourceLimits = { timeoutMs: number; maxBytes: number }
|
|
@@ -38,10 +49,11 @@ const readCapped = async (child: Child, maxBytes: number): Promise<string | null
|
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/** Started in a process group of its own, so a timeout reaches what the command started too (`curl` behind `sh`). */
|
|
41
|
-
const spawned = (command: readonly string[], cwd: string): Child | null => {
|
|
52
|
+
const spawned = (command: readonly string[], cwd: string, env: Environment | undefined): Child | null => {
|
|
42
53
|
try {
|
|
43
54
|
return Bun.spawn(resolveSourceCommand(command, process.execPath), {
|
|
44
55
|
cwd,
|
|
56
|
+
env,
|
|
45
57
|
stdin: 'ignore',
|
|
46
58
|
stdout: 'pipe',
|
|
47
59
|
stderr: 'ignore',
|
|
@@ -62,7 +74,8 @@ const killGroup = (child: Child) => {
|
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
/**
|
|
65
|
-
* Runs `command` as given, without a shell, in `cwd` with the user's environment; never rejects.
|
|
77
|
+
* Runs `command` as given, without a shell, in `cwd` with `env` (the user's environment without it); never rejects.
|
|
78
|
+
* `env` reaches the command only: it is neither an argument nor printed. stderr is not read,
|
|
66
79
|
* so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. Past `timeoutMs` or
|
|
67
80
|
* `maxBytes` the command's process group is killed, and the answer never waits past `timeoutMs` for a pipe a leftover
|
|
68
81
|
* process keeps open; the group is also killed after a normal end, so a source leaves no process behind that stays in its group.
|
|
@@ -71,8 +84,9 @@ export const runSourceCommand = async (
|
|
|
71
84
|
command: readonly string[],
|
|
72
85
|
cwd: string,
|
|
73
86
|
limits: SourceLimits,
|
|
87
|
+
env?: Environment,
|
|
74
88
|
): Promise<SourceAnswer> => {
|
|
75
|
-
const child = spawned(command, cwd)
|
|
89
|
+
const child = spawned(command, cwd, env)
|
|
76
90
|
if (!child) return { ok: false, reason: SOURCE_FAILURE.notStarted }
|
|
77
91
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
78
92
|
const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
@@ -92,6 +106,48 @@ export const runSourceCommand = async (
|
|
|
92
106
|
}
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
/**
|
|
96
|
-
export const
|
|
97
|
-
|
|
109
|
+
/** Where a source runs: a personal one in the personal directory under `home`, a repository one in `root`, the main checkout. */
|
|
110
|
+
export const sourceDirectory = (settings: Pick<SourceSettings, 'origin'>, root: string, home: string): string =>
|
|
111
|
+
settings.origin === SOURCE_ORIGIN.personal ? personalSourcesDirectory(home) : root
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The keychain lookup a run uses unless it is handed another: bounded by the store's own limit and never past the
|
|
115
|
+
* run's, so a keychain that hangs costs each run at most that long.
|
|
116
|
+
*/
|
|
117
|
+
const keychainLookup = (limits: SourceLimits): SecretLookupFn =>
|
|
118
|
+
secretStore(Bun.secrets, Math.min(SECRET_TIMEOUT_MS, limits.timeoutMs)).get
|
|
119
|
+
|
|
120
|
+
const noKeychain: SecretLookupFn = async () => ({ kind: SECRET_LOOKUP.absent })
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The lookup a source's secrets go through: the keychain for a personal source only. A repository source, which
|
|
124
|
+
* anyone with a commit can write, gets the watcher's environment and never a keychain value.
|
|
125
|
+
*/
|
|
126
|
+
export const secretLookupFor = (settings: Pick<SourceSettings, 'origin'>, keychain: SecretLookupFn): SecretLookupFn =>
|
|
127
|
+
settings.origin === SOURCE_ORIGIN.personal ? keychain : noKeychain
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The answer of a source's command in `sourceDirectory`, its `secrets` resolved first (`sourceEnvironment` over
|
|
131
|
+
* `secretLookupFor`, looked up afresh on each run and dropped after it). A secret that cannot be resolved fails the run
|
|
132
|
+
* before the command starts.
|
|
133
|
+
*/
|
|
134
|
+
export const sourceAnswer = async (
|
|
135
|
+
settings: SourceSettings,
|
|
136
|
+
root: string,
|
|
137
|
+
limits: SourceLimits,
|
|
138
|
+
home: string = homedir(),
|
|
139
|
+
lookup: SecretLookupFn = keychainLookup(limits),
|
|
140
|
+
): Promise<SourceAnswer> => {
|
|
141
|
+
const environment = await sourceEnvironment(settings.secrets ?? [], secretLookupFor(settings, lookup), process.env)
|
|
142
|
+
if (!environment.ok) return environment
|
|
143
|
+
return runSourceCommand(settings.command, sourceDirectory(settings, root, home), limits, environment.environment)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One run of a source in `sourceDirectory`: its secrets and command (`sourceAnswer`), then its output over `items` and `map`. */
|
|
147
|
+
export const runSource = async (
|
|
148
|
+
settings: SourceSettings,
|
|
149
|
+
root: string,
|
|
150
|
+
limits: SourceLimits,
|
|
151
|
+
home: string = homedir(),
|
|
152
|
+
lookup: SecretLookupFn = keychainLookup(limits),
|
|
153
|
+
): Promise<SourceRun> => sourceRunOf(settings, await sourceAnswer(settings, root, limits, home, lookup))
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
storedValue,
|
|
16
16
|
type TicketRecord,
|
|
17
17
|
} from './sources'
|
|
18
|
+
import { type WatchedSource, watchedSourceOf } from './watched-source'
|
|
18
19
|
|
|
19
20
|
const ticketOf = (
|
|
20
21
|
item: SourceItem,
|
|
@@ -117,3 +118,17 @@ export const recordWatchedSources = (
|
|
|
117
118
|
settings,
|
|
118
119
|
now,
|
|
119
120
|
)
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The two parts of the watcher's state the sources write after `runs`: their records (`recordWatchedSources`) and
|
|
124
|
+
* what the herd draws them with (`watchedSources`), both from the sources the watcher runs now.
|
|
125
|
+
*/
|
|
126
|
+
export const recordedSourceState = (
|
|
127
|
+
previous: SourceState,
|
|
128
|
+
runs: readonly SourceRun[],
|
|
129
|
+
settings: readonly SourceSettings[],
|
|
130
|
+
now: number,
|
|
131
|
+
): { sources: SourceState; watchedSources: WatchedSource[] } => ({
|
|
132
|
+
sources: recordWatchedSources(previous, runs, settings, now),
|
|
133
|
+
watchedSources: settings.map(watchedSourceOf),
|
|
134
|
+
})
|
package/src/watch/sources.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ToneName } from '../config/tones'
|
|
2
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
4
|
import { isRecord } from '../shared/is-record'
|
|
4
5
|
import { previewUrlOf } from './previews'
|
|
@@ -35,6 +36,16 @@ export type SourceMap = {
|
|
|
35
36
|
status?: SourcePath
|
|
36
37
|
} & Record<string, SourcePath | undefined>
|
|
37
38
|
|
|
39
|
+
/** Where a source is defined: the repository's config or the user's `~/.jackherd/sources.json`. */
|
|
40
|
+
export const SOURCE_ORIGIN = { repo: 'repo', personal: 'personal' } as const
|
|
41
|
+
|
|
42
|
+
export type SourceOrigin = (typeof SOURCE_ORIGIN)[keyof typeof SOURCE_ORIGIN]
|
|
43
|
+
|
|
44
|
+
/** How the herd shows a source's entries: `entries` shows them open, in a block of their own. */
|
|
45
|
+
export const SOURCE_SHOW = { entries: 'entries' } as const
|
|
46
|
+
|
|
47
|
+
export type SourceShow = (typeof SOURCE_SHOW)[keyof typeof SOURCE_SHOW]
|
|
48
|
+
|
|
38
49
|
/**
|
|
39
50
|
* One `watch.sources` entry as the watcher uses it; `every` is in minutes, `items` the path of the list (the root
|
|
40
51
|
* without it), `reportLimit` the most entries one report shows of it.
|
|
@@ -52,6 +63,16 @@ export type SourceSettings = {
|
|
|
52
63
|
reportOn?: readonly string[]
|
|
53
64
|
/** Its entries are tickets, which the state keeps for the herd's badges (`TicketRecord`). */
|
|
54
65
|
ticket?: boolean
|
|
66
|
+
/** Names of the secrets the command gets as environment variables. */
|
|
67
|
+
secrets?: readonly string[]
|
|
68
|
+
/** `entries` shows its entries open, in a herd block of their own. */
|
|
69
|
+
show?: SourceShow
|
|
70
|
+
/** Where the source is defined; without it the repository's config. */
|
|
71
|
+
origin?: SourceOrigin
|
|
72
|
+
/** Shown before the source's rows in the herd; without it its name. */
|
|
73
|
+
label?: string
|
|
74
|
+
/** The tone of its mark in the herd while its newest report is new. */
|
|
75
|
+
tone?: ToneName
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
/**
|
package/src/watch/texts.ts
CHANGED
|
@@ -79,7 +79,7 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
79
79
|
laneModelDuty:
|
|
80
80
|
'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
|
|
81
81
|
readyPhaseDuty: (count) =>
|
|
82
|
-
`start ${count === 1 ? 'it' : 'them'} now (§ 2)
|
|
82
|
+
`start ${count === 1 ? 'it' : 'them'} now (§ 2), on a branch over the open PR of the lane if it waits for approval or merge: that holds only the commit`,
|
|
83
83
|
mainCheckoutBehind: (remoteBase, count) =>
|
|
84
84
|
`main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
|
|
85
85
|
mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type UnlinkedArtifactEvent,
|
|
39
39
|
type UnlinkedState,
|
|
40
40
|
} from './unlinked-artifacts'
|
|
41
|
+
import { isWatchedSource, type WatchedSource } from './watched-source'
|
|
41
42
|
import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
|
|
42
43
|
|
|
43
44
|
/** The event `updateAvailableLine` prints; it is no `WatchEvent`, the announcer logs it on its own. */
|
|
@@ -130,6 +131,11 @@ export type WatchState = {
|
|
|
130
131
|
approvals: string[]
|
|
131
132
|
/** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
|
|
132
133
|
sources: SourceState
|
|
134
|
+
/**
|
|
135
|
+
* The sources the watcher runs, of the repository and the personal file, in its order and as the herd draws them;
|
|
136
|
+
* null in a state written before the watcher kept them, so the herd reads its own config.
|
|
137
|
+
*/
|
|
138
|
+
watchedSources: WatchedSource[] | null
|
|
133
139
|
/** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
|
|
134
140
|
reportMtimes: Record<string, number | null>
|
|
135
141
|
/** Lane → the questions of its report last shown or seen (`questionsFingerprint`); a lane without an entry is unseen. */
|
|
@@ -232,6 +238,7 @@ export const emptyWatchState = (): WatchState => ({
|
|
|
232
238
|
planMissing: {},
|
|
233
239
|
approvals: [],
|
|
234
240
|
sources: emptySourceState(),
|
|
241
|
+
watchedSources: null,
|
|
235
242
|
reportMtimes: {},
|
|
236
243
|
reportQuestions: {},
|
|
237
244
|
statusLines: null,
|
|
@@ -287,6 +294,8 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
|
287
294
|
Array.isArray(value.approvals) &&
|
|
288
295
|
value.approvals.every((id) => typeof id === 'string') &&
|
|
289
296
|
isSourceState(value.sources) &&
|
|
297
|
+
(value.watchedSources === null ||
|
|
298
|
+
(Array.isArray(value.watchedSources) && value.watchedSources.every(isWatchedSource))) &&
|
|
290
299
|
everyValue(value.reportMtimes, isSeq) &&
|
|
291
300
|
everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
|
|
292
301
|
(value.statusLines === null ||
|