jackherd 1.23.3 → 1.25.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/INSTALL.md +16 -0
  3. package/README.md +23 -22
  4. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  5. package/dist/config.d.ts +13 -1
  6. package/dist/config.js +178 -132
  7. package/dist/plan-format.d.ts +121 -3
  8. package/dist/plan-format.js +176 -35
  9. package/docs/plan-contract.md +52 -5
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/approval-gate/index.ts +2 -2
  13. package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
  14. package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
  15. package/kit/hooks/hooks/shared/boards.ts +22 -6
  16. package/kit/workflow/skills/orchestrate/SKILL.md +2 -1
  17. package/kit/workflow/skills/orchestrate/references/backend.md +30 -0
  18. package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -0
  19. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  20. package/kit/workflow/skills/orchestrate/references/start-lane.md +2 -2
  21. package/kit/workflow/skills/plan/SKILL.md +3 -1
  22. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  23. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  24. package/package.json +1 -1
  25. package/schemas/jackherd.schema.json +49 -0
  26. package/src/backend/command-line.ts +116 -0
  27. package/src/backend/command.ts +14 -0
  28. package/src/backend/control.ts +194 -0
  29. package/src/backend/handover.ts +28 -0
  30. package/src/backend/herdr.ts +147 -0
  31. package/src/backend/lock.ts +84 -0
  32. package/src/backend/process-cwd.ts +32 -0
  33. package/src/backend/schema.ts +47 -0
  34. package/src/backend/state.ts +57 -0
  35. package/src/backend/step.ts +93 -0
  36. package/src/backend/system.ts +150 -0
  37. package/src/backend/watch.ts +75 -0
  38. package/src/brief/command.ts +1 -1
  39. package/src/brief/lane-facts.ts +2 -2
  40. package/src/check/command.ts +2 -1
  41. package/src/check/plan-structure-check.ts +68 -14
  42. package/src/check/settings.ts +6 -1
  43. package/src/check/skill-budget-defaults.ts +6 -3
  44. package/src/cli.ts +1 -0
  45. package/src/config/schema.ts +6 -0
  46. package/src/lane/close-board.ts +4 -4
  47. package/src/lane/close-command.ts +2 -2
  48. package/src/lane/dev-command.ts +1 -1
  49. package/src/lane/dev.ts +2 -2
  50. package/src/lane/port-command.ts +1 -1
  51. package/src/plan-contract/command.ts +2 -1
  52. package/src/plan-contract/contract.ts +30 -9
  53. package/src/plan-format/bare-codes.ts +4 -4
  54. package/src/plan-format/dependencies.ts +5 -5
  55. package/src/plan-format/lanes.ts +3 -4
  56. package/src/plan-format/markdown.ts +125 -10
  57. package/src/plan-format/open-status.ts +15 -0
  58. package/src/plan-format/phases.ts +45 -4
  59. package/src/plan-format/plan-head.ts +31 -9
  60. package/src/plan-format/plan-structure.ts +43 -7
  61. package/src/plan-format/progress.ts +3 -0
  62. package/src/plan-format/sprint-document.ts +4 -4
  63. package/src/plan-format/unrecognized-sections.ts +21 -0
  64. package/src/plan-format/waiting.ts +13 -13
  65. package/src/plans/board-close.ts +25 -16
  66. package/src/plans/board-pick.ts +1 -1
  67. package/src/plans/ensure-panes.ts +2 -2
  68. package/src/plans/layout-summary.ts +8 -1
  69. package/src/plans/layout.ts +14 -5
  70. package/src/plans/live.ts +1 -1
  71. package/src/plans/plan-panes.ts +1 -1
  72. package/src/plans/settings.ts +6 -0
  73. package/src/plans/snapshot.ts +9 -5
  74. package/src/plans/sprint-export.ts +1 -1
  75. package/src/plans/texts.ts +3 -0
  76. package/src/plans/view.ts +1 -0
  77. package/src/resume/coordinator-read.ts +2 -2
  78. package/src/settled/command.ts +1 -1
  79. package/src/settled/settle.ts +5 -5
  80. package/src/shared/shell-word.ts +2 -0
  81. package/src/sync/render.ts +20 -5
  82. package/src/sync/sync-plan.ts +6 -2
  83. package/src/sync/sync.ts +3 -2
  84. package/src/vocabulary/index.ts +192 -4
  85. package/src/watch/appointments.ts +13 -20
  86. package/src/watch/command.ts +32 -5
  87. package/src/watch/lane-progress.ts +1 -1
  88. package/src/watch/merge-overlap-read.ts +2 -2
  89. package/src/watch/plan-missing-read.ts +3 -1
  90. package/src/watch/reference-paths.ts +4 -0
@@ -0,0 +1,194 @@
1
+ import { MS_PER_MINUTE } from '../plan-format/clock'
2
+ import { backendCommandOf } from './command'
3
+ import { lanesGone, released, returned } from './handover'
4
+ import type { BackendSight } from './herdr'
5
+ import type { BackendConfig } from './schema'
6
+ import { BACKEND_PANE, type BackendState, restarted } from './state'
7
+ import {
8
+ BACKEND_START_WINDOW_MS,
9
+ BACKEND_STEP_ACTION,
10
+ type BackendObservation,
11
+ type BackendStep,
12
+ backendStep,
13
+ noticed,
14
+ startedByHand,
15
+ } from './step'
16
+
17
+ type Awaitable<T> = T | Promise<T>
18
+
19
+ /** The calls the backend makes; `backendSystemOf` wires them to herdr, the state file, the port probe and the clock. */
20
+ export type BackendSystem = {
21
+ readState: () => BackendState
22
+ writeState: (state: BackendState) => void
23
+ /** The backend pane as it is now, or why it is left alone. */
24
+ look: () => Awaitable<BackendSight>
25
+ busyPorts: (ports: readonly number[]) => Awaitable<number[]>
26
+ /** Opens the backend's tab in the main checkout, labels its pane and runs `command` there; answers the pane. */
27
+ create: (command: string) => Awaitable<string>
28
+ run: (pane: string, command: string) => Awaitable<void>
29
+ /** Stops the process in `pane` with SIGTERM and waits until the pane is a shell again and `ports` are free. */
30
+ stop: (pane: string, ports: readonly number[]) => Awaitable<{ ok: true } | { ok: false; line: string }>
31
+ /** The lanes' worktree names. */
32
+ worktrees: () => string[]
33
+ now: () => number
34
+ }
35
+
36
+ /** What a `jh backend` call did: its lines and whether it got done. */
37
+ export type BackendOutcome = { ok: boolean; lines: string[] }
38
+
39
+ type Looked = { kind: 'refused'; line: string } | { kind: 'seen'; observation: BackendObservation; pane: string | null }
40
+
41
+ const releasedNames = (state: BackendState): string[] => state.released.map(({ service }) => service)
42
+
43
+ /** The ports of the services the backend runs while `state` holds. */
44
+ export const runningPortsOf = (backend: BackendConfig, state: BackendState): number[] =>
45
+ backend.services.filter(({ name }) => !releasedNames(state).includes(name)).map(({ port }) => port)
46
+
47
+ const desiredOf = (backend: BackendConfig, state: BackendState): string | null =>
48
+ backendCommandOf(backend, releasedNames(state))
49
+
50
+ /** The pane and, unless it runs, which of the ports `state` asks for are busy. */
51
+ const lookAt = async (backend: BackendConfig, state: BackendState, system: BackendSystem): Promise<Looked> => {
52
+ const sight = await system.look()
53
+ if (sight.kind === 'refused') return sight
54
+ const busyPorts = sight.pane === BACKEND_PANE.running ? [] : await system.busyPorts(runningPortsOf(backend, state))
55
+ const observation = { pane: sight.pane, place: sight.place, busyPorts, desired: desiredOf(backend, state) }
56
+ return { kind: 'seen', observation, pane: sight.paneId }
57
+ }
58
+
59
+ /** Carries out a step's action: a new tab for `split`, a run in the pane for `start`. */
60
+ const carryOut = async (step: BackendStep, pane: string | null, system: BackendSystem): Promise<void> => {
61
+ const command = step.state.command
62
+ if (step.action === BACKEND_STEP_ACTION.none || command === null) return
63
+ if (step.action === BACKEND_STEP_ACTION.split) await system.create(command)
64
+ else if (pane !== null) await system.run(pane, command)
65
+ }
66
+
67
+ const lineList = (line: string | null): string[] => (line === null ? [] : [line])
68
+
69
+ /** A refusal said once while it stands, as the step does for its own standing conditions. */
70
+ const refusedOnce = (state: BackendState, line: string, system: BackendSystem): string[] => {
71
+ const step = noticed(state, line)
72
+ system.writeState(step.state)
73
+ return lineList(step.line)
74
+ }
75
+
76
+ /** A start by hand got done: the pane was started, or every service is handed to a lane and nothing is to run. */
77
+ const isHandStartDone = (step: BackendStep): boolean =>
78
+ step.action !== BACKEND_STEP_ACTION.none || step.state.command === null
79
+
80
+ /**
81
+ * Stops the pane that ran with `before` and starts it with `after`, as `jh backend` asks. A pane that does not run is
82
+ * only started; a stop that fails leaves the start to the next check once the pane is a shell again.
83
+ */
84
+ const restartWith = async (
85
+ backend: BackendConfig,
86
+ system: BackendSystem,
87
+ before: BackendState,
88
+ after: BackendState,
89
+ ): Promise<BackendOutcome> => {
90
+ system.writeState(after)
91
+ const first = await lookAt(backend, after, system)
92
+ if (first.kind === 'refused') return { ok: false, lines: [first.line] }
93
+ if (first.observation.pane === BACKEND_PANE.running && first.pane !== null) {
94
+ const stopped = await system.stop(first.pane, runningPortsOf(backend, before))
95
+ if (!stopped.ok) return { ok: false, lines: [stopped.line] }
96
+ }
97
+ const looked = await lookAt(backend, after, system)
98
+ if (looked.kind === 'refused') return { ok: false, lines: [looked.line] }
99
+ const step = startedByHand(after, looked.observation)
100
+ await carryOut(step, looked.pane, system)
101
+ system.writeState(step.state)
102
+ return { ok: isHandStartDone(step), lines: lineList(step.line) }
103
+ }
104
+
105
+ /** Gives back the services whose lane has no worktree any more and restarts the pane with them; null when none is gone. */
106
+ const returnGone = async (backend: BackendConfig, system: BackendSystem, state: BackendState) => {
107
+ const gone = lanesGone(state, system.worktrees())
108
+ if (gone.length === 0) return null
109
+ const after = { ...state, released: state.released.filter(({ service }) => !gone.includes(service)) }
110
+ const lines = state.released
111
+ .filter(({ service }) => gone.includes(service))
112
+ .map(({ service, lane }) => `backend: ${service} returned from ${lane} (worktree gone)`)
113
+ return [...lines, ...(await restartWith(backend, system, state, after)).lines]
114
+ }
115
+
116
+ /**
117
+ * The watcher's check: services of lanes that are gone come back first; otherwise `backendStep` decides on the pane
118
+ * as it is, and its action is carried out. Answers the lines to print; a standing condition is printed once.
119
+ */
120
+ export const checkBackend = async (backend: BackendConfig, system: BackendSystem): Promise<string[]> => {
121
+ const state = system.readState()
122
+ const back = await returnGone(backend, system, state)
123
+ if (back !== null) return back
124
+ const looked = await lookAt(backend, state, system)
125
+ if (looked.kind === 'refused') return refusedOnce(state, looked.line, system)
126
+ const step = backendStep(state, looked.observation, system.now())
127
+ await carryOut(step, looked.pane, system)
128
+ system.writeState(step.state)
129
+ return lineList(step.line)
130
+ }
131
+
132
+ /** `jh backend restart`: forgets the start history and "gave up", stops the pane and starts it again. */
133
+ export const restartBackend = (backend: BackendConfig, system: BackendSystem): Promise<BackendOutcome> => {
134
+ const state = system.readState()
135
+ return restartWith(backend, system, state, restarted(state))
136
+ }
137
+
138
+ const portOf = (backend: BackendConfig, service: string): number | null =>
139
+ backend.services.find(({ name }) => name === service)?.port ?? null
140
+
141
+ /**
142
+ * `jh backend release <service> --lane <lane>`: takes the service out of the backend's command, restarts the pane
143
+ * and names the call that starts the service in the lane.
144
+ */
145
+ export const releaseService = async (
146
+ backend: BackendConfig,
147
+ system: BackendSystem,
148
+ service: string,
149
+ lane: string,
150
+ ): Promise<BackendOutcome> => {
151
+ const before = system.readState()
152
+ const handed = released(
153
+ before,
154
+ backend.services.map(({ name }) => name),
155
+ service,
156
+ lane,
157
+ )
158
+ if (!handed.ok) return { ok: false, lines: [handed.line] }
159
+ const restart = await restartWith(backend, system, before, handed.state)
160
+ const port = portOf(backend, service)
161
+ const next = restart.ok
162
+ ? `backend: ${service} handed to ${lane}, port ${port} is free; jh lane dev ${lane} --filter ${service} --port ${port}`
163
+ : `backend: ${service} handed to ${lane}; port ${port} is free once the backend pane has stopped`
164
+ return { ok: restart.ok, lines: [...restart.lines, next] }
165
+ }
166
+
167
+ /** `jh backend return <service>`: puts the service back into the backend's command and restarts the pane. */
168
+ export const returnService = async (
169
+ backend: BackendConfig,
170
+ system: BackendSystem,
171
+ service: string,
172
+ ): Promise<BackendOutcome> => {
173
+ const before = system.readState()
174
+ const back = returned(before, service)
175
+ if (!back.ok) return { ok: false, lines: [back.line] }
176
+ const restart = await restartWith(backend, system, before, back.state)
177
+ return { ok: restart.ok, lines: [...restart.lines, `backend: ${service} returned`] }
178
+ }
179
+
180
+ const sightLine = (sight: BackendSight): string =>
181
+ sight.kind === 'refused' ? sight.line : `backend: pane ${sight.paneId ?? '(none)'}, ${sight.pane}`
182
+
183
+ /** `jh backend`: the pane, the command last started, the services handed to lanes, the recent starts, "gave up". */
184
+ export const statusLines = (sight: BackendSight, state: BackendState, now: number): string[] => {
185
+ const handed = state.released.map(({ service, lane }) => `${service} → ${lane}`).join(', ')
186
+ const recent = state.starts.filter((start) => now - start < BACKEND_START_WINDOW_MS).length
187
+ return [
188
+ sightLine(sight),
189
+ `backend: command ${state.command ?? '(none started)'}`,
190
+ `backend: handed to lanes ${handed === '' ? '(none)' : handed}`,
191
+ `backend: ${recent} automatic starts in the last ${BACKEND_START_WINDOW_MS / MS_PER_MINUTE} minutes`,
192
+ ...(state.gaveUp ? ['backend: gave up; jh backend restart'] : []),
193
+ ]
194
+ }
@@ -0,0 +1,28 @@
1
+ import type { BackendState } from './state'
2
+
3
+ /** The new state, or the line that says why the request is refused. */
4
+ export type HandoverResult = { ok: true; state: BackendState } | { ok: false; line: string }
5
+
6
+ /** Hands `service` to `lane`; refuses a service the backend does not run and one already handed over. */
7
+ export const released = (
8
+ state: BackendState,
9
+ known: readonly string[],
10
+ service: string,
11
+ lane: string,
12
+ ): HandoverResult => {
13
+ if (!known.includes(service)) return { ok: false, line: `backend: ${service} is no service of lanes.backend` }
14
+ const holder = state.released.find((entry) => entry.service === service)
15
+ if (holder) return { ok: false, line: `backend: ${service} is already handed to ${holder.lane}` }
16
+ return { ok: true, state: { ...state, released: [...state.released, { service, lane }] } }
17
+ }
18
+
19
+ /** Takes `service` back; refuses one that is not handed over. */
20
+ export const returned = (state: BackendState, service: string): HandoverResult => {
21
+ if (!state.released.some((entry) => entry.service === service))
22
+ return { ok: false, line: `backend: ${service} is not handed to a lane` }
23
+ return { ok: true, state: { ...state, released: state.released.filter((entry) => entry.service !== service) } }
24
+ }
25
+
26
+ /** The handed-over services whose lane has no worktree any more. */
27
+ export const lanesGone = (state: BackendState, worktrees: readonly string[]): string[] =>
28
+ state.released.filter(({ lane }) => !worktrees.includes(lane)).map(({ service }) => service)
@@ -0,0 +1,147 @@
1
+ import { isIdleShell, isStartingShell, type PaneState } from '../plans/plan-panes'
2
+ import { isRecord } from '../shared/is-record'
3
+ import { BACKEND_PANE, type BackendPane } from './state'
4
+
5
+ /** One process of a pane's foreground as `herdr pane process-info` names it. */
6
+ export type ForegroundProcess = { pid: number; argv: string[] }
7
+
8
+ /** The process facts of one pane: its shell, the foreground process group and the processes in it. */
9
+ export type PaneProcesses = { shellPid: number | null; groupId: number | null; processes: ForegroundProcess[] }
10
+
11
+ /** A pane as the backend check reads it from `herdr pane list`. */
12
+ export type PaneListing = Omit<PaneState, 'foreground'>
13
+
14
+ /** What the check found: the pane (or none yet) and where it sits, or why it leaves everything alone. */
15
+ export type BackendSight =
16
+ | { kind: 'found'; pane: BackendPane; paneId: string | null; place: string }
17
+ | { kind: 'refused'; line: string }
18
+
19
+ export type StopTarget = { ok: true; pid: number } | { ok: false; line: string }
20
+
21
+ const positiveInteger = (value: unknown): number | null =>
22
+ typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null
23
+
24
+ const isArgv = (value: unknown): value is string[] =>
25
+ Array.isArray(value) && value.every((arg) => typeof arg === 'string')
26
+
27
+ const foregroundProcessOf = (value: unknown): ForegroundProcess[] => {
28
+ if (!isRecord(value)) return []
29
+ const pid = positiveInteger(value.pid)
30
+ return pid !== null && isArgv(value.argv) ? [{ pid, argv: value.argv }] : []
31
+ }
32
+
33
+ const unexpected = (command: string): Error => new Error(`herdr ${command}: unexpected answer`)
34
+
35
+ /** The `result.<field>` of a herdr answer; throws when the answer has no `result`. */
36
+ const resultFieldOf = (json: string, field: string, command: string): unknown => {
37
+ const answer: unknown = JSON.parse(json)
38
+ const result = isRecord(answer) ? answer.result : undefined
39
+ if (!isRecord(result)) throw unexpected(command)
40
+ return result[field]
41
+ }
42
+
43
+ /** The shell, process group and foreground processes of a `herdr pane process-info` answer; throws on a changed answer. */
44
+ export const paneProcessesOf = (json: string): PaneProcesses => {
45
+ const info = resultFieldOf(json, 'process_info', 'pane process-info')
46
+ if (!isRecord(info)) throw unexpected('pane process-info')
47
+ const processes = Array.isArray(info.foreground_processes) ? info.foreground_processes : []
48
+ return {
49
+ shellPid: positiveInteger(info.shell_pid),
50
+ groupId: positiveInteger(info.foreground_process_group_id),
51
+ processes: processes.flatMap(foregroundProcessOf),
52
+ }
53
+ }
54
+
55
+ const workspaceEntries = (json: string): unknown[] => {
56
+ const workspaces = resultFieldOf(json, 'workspaces', 'workspace list')
57
+ if (!Array.isArray(workspaces)) throw unexpected('workspace list')
58
+ return workspaces
59
+ }
60
+
61
+ const isMainCheckoutOf =
62
+ (root: string) =>
63
+ (workspace: unknown): workspace is { workspace_id: string } => {
64
+ if (!isRecord(workspace) || typeof workspace.workspace_id !== 'string') return false
65
+ const { worktree } = workspace
66
+ return isRecord(worktree) && worktree.checkout_path === root && worktree.is_linked_worktree === false
67
+ }
68
+
69
+ /**
70
+ * The herdr workspace of the main checkout `root`; throws when there is none or several, since the backend's tab
71
+ * must sit in exactly one place.
72
+ */
73
+ export const mainWorkspaceOf = (json: string, root: string): string => {
74
+ const matches = workspaceEntries(json).filter(isMainCheckoutOf(root))
75
+ if (matches.length === 1 && matches[0]) return matches[0].workspace_id
76
+ throw new Error(`backend: ${matches.length} herdr workspaces of the main checkout, need exactly one`)
77
+ }
78
+
79
+ /** The tab ids of a `herdr tab list` answer; throws on a changed answer. */
80
+ export const tabIdsOf = (json: string): string[] => {
81
+ const tabs = resultFieldOf(json, 'tabs', 'tab list')
82
+ if (!Array.isArray(tabs)) throw unexpected('tab list')
83
+ return tabs.flatMap((tab) => (isRecord(tab) && typeof tab.tab_id === 'string' ? [tab.tab_id] : []))
84
+ }
85
+
86
+ /** The root pane of a `herdr tab create` answer; throws on a changed answer. */
87
+ export const createdPaneOf = (json: string): string => {
88
+ const pane = resultFieldOf(json, 'root_pane', 'tab create')
89
+ if (!isRecord(pane) || typeof pane.pane_id !== 'string') throw unexpected('tab create')
90
+ return pane.pane_id
91
+ }
92
+
93
+ /** A pane whose foreground is a shell, or nothing yet: the command it ran has ended. */
94
+ const isShellOnly = (pane: PaneState): boolean => isIdleShell(pane) || isStartingShell(pane)
95
+
96
+ /**
97
+ * The backend pane among `panes`: the one labelled `label` in a tab of the main checkout's workspace (`tabs`). It
98
+ * is touched only when it is the only such pane, works in `root` and holds no agent; otherwise the check refuses
99
+ * with a line and leaves every pane alone.
100
+ */
101
+ export const backendSightOf = (input: {
102
+ panes: readonly PaneListing[]
103
+ tabs: readonly string[]
104
+ label: string
105
+ root: string
106
+ foregroundOf: (pane: string) => string[][]
107
+ }): BackendSight => {
108
+ const { panes, tabs, label, root } = input
109
+ const labelled = panes.filter((pane) => pane.label === label && tabs.includes(pane.tab))
110
+ if (labelled.length === 0)
111
+ return { kind: 'found', pane: BACKEND_PANE.missing, paneId: null, place: `new tab ${label}` }
112
+ const [pane] = labelled
113
+ if (labelled.length > 1 || !pane)
114
+ return { kind: 'refused', line: `backend: ${labelled.length} panes labelled ${label}, left alone` }
115
+ if (pane.agent) return { kind: 'refused', line: `backend: an agent runs in pane ${pane.pane} (${label}), left alone` }
116
+ if (pane.cwd !== root)
117
+ return { kind: 'refused', line: `backend: pane ${pane.pane} (${label}) is not in the main checkout, left alone` }
118
+ const shellOnly = isShellOnly({ ...pane, foreground: input.foregroundOf(pane.pane) })
119
+ return {
120
+ kind: 'found',
121
+ pane: shellOnly ? BACKEND_PANE.exited : BACKEND_PANE.running,
122
+ paneId: pane.pane,
123
+ place: pane.pane,
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The process to send SIGTERM to: the leader of the pane's foreground process group, never the pane's shell, and only
129
+ * when `cwdOf` shows it works in `root`. Anything else refuses with a line that leaves the stop to the user.
130
+ */
131
+ export const stopTargetOf = (
132
+ processes: PaneProcesses,
133
+ root: string,
134
+ cwdOf: (pid: number) => string | null,
135
+ pane: string,
136
+ ): StopTarget => {
137
+ const { groupId, shellPid } = processes
138
+ const leader = processes.processes.find((each) => each.pid === groupId)
139
+ if (groupId === null || groupId === shellPid || !leader)
140
+ return { ok: false, line: `backend: no process to stop in pane ${pane}; stop it there by hand` }
141
+ if (cwdOf(leader.pid) !== root)
142
+ return {
143
+ ok: false,
144
+ line: `backend: process ${leader.pid} in pane ${pane} is not in the main checkout; stop it there by hand`,
145
+ }
146
+ return { ok: true, pid: leader.pid }
147
+ }
@@ -0,0 +1,84 @@
1
+ import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from 'node:fs'
2
+ import { lockedPid } from '../watch/watcher-lock'
3
+
4
+ const PRIVATE_FILE = 0o600
5
+ const RETRY_MS = 200
6
+ const UNFILLED_LOCK_MS = 10_000
7
+ /** Longer than any call holds the lock: a `jh backend` call waits at most 90 s for it and 30 s for a stop. */
8
+ const HELD_LOCK_MS = 5 * 60_000
9
+
10
+ /** Whether a process with this id exists; a process of another user still counts as alive. */
11
+ const isAlive = (pid: number): boolean => {
12
+ try {
13
+ process.kill(pid, 0)
14
+ return true
15
+ } catch (error) {
16
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
17
+ }
18
+ }
19
+
20
+ const readHolder = (path: string): number | null => {
21
+ try {
22
+ return lockedPid(readFileSync(path, 'utf8'))
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ const modifiedAt = (path: string): number | null => {
29
+ try {
30
+ return statSync(path).mtimeMs
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ /**
37
+ * A lock whose holder has ended, one older than `HELD_LOCK_MS` (no call holds it that long, so its process id was
38
+ * reused by another process), or one without a holder (a writer between creating and filling it) that is older than
39
+ * `UNFILLED_LOCK_MS`.
40
+ */
41
+ const isStale = (path: string): boolean => {
42
+ const modified = modifiedAt(path)
43
+ const age = modified === null ? 0 : Date.now() - modified
44
+ const holder = readHolder(path)
45
+ if (holder !== null) return !isAlive(holder) || age > HELD_LOCK_MS
46
+ return age > UNFILLED_LOCK_MS
47
+ }
48
+
49
+ /** Creates the lock file with this process's id; false when another holds it. A lock of a process that ended is taken over. */
50
+ const tryLock = (path: string): boolean => {
51
+ try {
52
+ const descriptor = openSync(path, 'wx', PRIVATE_FILE)
53
+ writeSync(descriptor, String(process.pid))
54
+ closeSync(descriptor)
55
+ return true
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
58
+ if (!isStale(path)) return false
59
+ rmSync(path, { force: true })
60
+ return tryLock(path)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Runs `work` while this process holds the lock at `path`, so the watcher and `jh backend` never write `backend.json`
66
+ * over each other. Waits up to `waitMs` for another holder; null when the lock stayed taken (the watcher passes 0 and
67
+ * tries again at its next check).
68
+ *
69
+ * Accepted risk: two callers that find the same stale lock at the same moment can both remove it, and the second
70
+ * removes the lock the first just created; both then run. It needs a holder that died and two callers within
71
+ * milliseconds, and both are the same user's watcher and `jh backend`.
72
+ */
73
+ export const withBackendLock = async <T>(path: string, waitMs: number, work: () => Promise<T>): Promise<T | null> => {
74
+ const deadline = Date.now() + waitMs
75
+ while (!tryLock(path)) {
76
+ if (Date.now() >= deadline) return null
77
+ await Bun.sleep(RETRY_MS)
78
+ }
79
+ try {
80
+ return await work()
81
+ } finally {
82
+ if (readHolder(path) === process.pid) rmSync(path, { force: true })
83
+ }
84
+ }
@@ -0,0 +1,32 @@
1
+ import { readlinkSync } from 'node:fs'
2
+
3
+ /** Longest an `lsof` call may take; a call past it counts as no answer. */
4
+ const LSOF_TIMEOUT_MS = 2000
5
+
6
+ /** The directory of an `lsof -Fn` answer for one process's `cwd`: the line that starts with `n`. */
7
+ export const cwdOfLsofOutput = (output: string): string | null => {
8
+ const line = output.split('\n').find((each) => each.startsWith('n'))
9
+ return line ? line.slice(1) : null
10
+ }
11
+
12
+ const linuxCwdOf = (pid: number): string | null => {
13
+ try {
14
+ return readlinkSync(`/proc/${pid}/cwd`)
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ const lsofCwdOf = (pid: number): string | null => {
21
+ const result = Bun.spawnSync(['lsof', '-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
22
+ stdin: 'ignore',
23
+ stdout: 'pipe',
24
+ stderr: 'ignore',
25
+ timeout: LSOF_TIMEOUT_MS,
26
+ })
27
+ return result.exitCode === 0 ? cwdOfLsofOutput(result.stdout.toString()) : null
28
+ }
29
+
30
+ /** The working directory of a process: `/proc/<pid>/cwd` on Linux, `lsof` elsewhere (macOS); null when unreadable. */
31
+ export const processCwdOf = (pid: number, platform: NodeJS.Platform = process.platform): string | null =>
32
+ platform === 'linux' ? linuxCwdOf(pid) : lsofCwdOf(pid)
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod'
2
+ import { MAX_PORT } from '../lane/port'
3
+ import { SHELL_WORD } from '../shared/shell-word'
4
+
5
+ /** Where the names of the services not handed to a lane go in `lanes.backend.command`. */
6
+ export const SERVICES_PLACEHOLDER = '{services}'
7
+
8
+ /** The pane label of the backend when the config names none. */
9
+ export const DEFAULT_BACKEND_LABEL = 'backend'
10
+
11
+ /** One herdr argument that cannot read as an option. */
12
+ const BACKEND_LABEL = /^(?!-)[\w.-]+$/
13
+
14
+ const duplicateIn = (values: readonly (string | number)[]): boolean => new Set(values).size !== values.length
15
+
16
+ const serviceSchema = z.strictObject({
17
+ /** The name the command's tool takes, e.g. a package name. */
18
+ name: z.string().regex(SHELL_WORD, 'must be one shell word of letters, digits and @ . / : _ -, not starting with -'),
19
+ /** The port the service listens on; checked before a start and named when the service is handed to a lane. */
20
+ port: z.number().int().min(1).max(MAX_PORT),
21
+ })
22
+
23
+ export const backendSchema = z
24
+ .strictObject({
25
+ /** The command that runs the services in the main checkout; `{services}` becomes the names of the services not handed to a lane. */
26
+ command: z
27
+ .string()
28
+ .min(1)
29
+ .refine((command) => command.includes(SERVICES_PLACEHOLDER), `must contain ${SERVICES_PLACEHOLDER}`),
30
+ services: z.array(serviceSchema).min(1),
31
+ /** The label of the backend pane; `backend` when left out. */
32
+ label: z
33
+ .string()
34
+ .regex(BACKEND_LABEL, 'letters, digits and . _ -, not starting with -')
35
+ .default(DEFAULT_BACKEND_LABEL),
36
+ })
37
+ .refine(({ services }) => !duplicateIn(services.map(({ name }) => name)), {
38
+ message: 'service names must be unique',
39
+ path: ['services'],
40
+ })
41
+ .refine(({ services }) => !duplicateIn(services.map(({ port }) => port)), {
42
+ message: 'service ports must be unique',
43
+ path: ['services'],
44
+ })
45
+
46
+ /** The shared backend of the lanes: its command, the services it runs and the pane label. */
47
+ export type BackendConfig = z.infer<typeof backendSchema>
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod'
2
+
3
+ /** What the watcher sees of the backend pane. */
4
+ export const BACKEND_PANE = { missing: 'missing', running: 'running', exited: 'exited' } as const
5
+
6
+ export type BackendPane = (typeof BACKEND_PANE)[keyof typeof BACKEND_PANE]
7
+
8
+ export const isBackendPane = (value: string): value is BackendPane =>
9
+ (Object.values(BACKEND_PANE) as string[]).includes(value)
10
+
11
+ /** The version `backend.json` is written with; a file of another version is read as empty. */
12
+ export const BACKEND_STATE_VERSION = 1
13
+
14
+ const backendStateSchema = z.strictObject({
15
+ version: z.literal(BACKEND_STATE_VERSION),
16
+ /** The command line last started, null before the first start. */
17
+ command: z.string().nullable(),
18
+ /** The services a lane works on, by service name and lane name. */
19
+ released: z.array(z.strictObject({ service: z.string(), lane: z.string() })),
20
+ /** When the automatic starts happened, in milliseconds since the epoch. */
21
+ starts: z.array(z.number()),
22
+ /** Whether the start limit was reached; only `jh backend restart` clears it. */
23
+ gaveUp: z.boolean(),
24
+ /** The last line the watcher reported for a standing condition, so the same line is not repeated; null when none stands. */
25
+ notice: z.string().nullable(),
26
+ })
27
+
28
+ /** The content of `backend.json` in the watcher's state directory. */
29
+ export type BackendState = z.infer<typeof backendStateSchema>
30
+
31
+ export const EMPTY_BACKEND_STATE: BackendState = {
32
+ version: BACKEND_STATE_VERSION,
33
+ command: null,
34
+ released: [],
35
+ starts: [],
36
+ gaveUp: false,
37
+ notice: null,
38
+ }
39
+
40
+ /** The state in the text of `backend.json`; a missing, old or damaged file reads as the empty state. */
41
+ export const parseBackendState = (text: string | null): BackendState => {
42
+ if (text === null) return EMPTY_BACKEND_STATE
43
+ try {
44
+ const parsed = backendStateSchema.safeParse(JSON.parse(text))
45
+ return parsed.success ? parsed.data : EMPTY_BACKEND_STATE
46
+ } catch {
47
+ return EMPTY_BACKEND_STATE
48
+ }
49
+ }
50
+
51
+ /** The state after `jh backend restart`: the start history and "gave up" are forgotten, the handovers stay. */
52
+ export const restarted = (state: BackendState): BackendState => ({
53
+ ...state,
54
+ starts: [],
55
+ gaveUp: false,
56
+ notice: null,
57
+ })