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.
- package/CHANGELOG.md +9 -0
- package/INSTALL.md +16 -0
- package/README.md +23 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +13 -1
- package/dist/config.js +178 -132
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -1
- package/kit/workflow/skills/orchestrate/references/backend.md +30 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/start-lane.md +2 -2
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +49 -0
- package/src/backend/command-line.ts +116 -0
- package/src/backend/command.ts +14 -0
- package/src/backend/control.ts +194 -0
- package/src/backend/handover.ts +28 -0
- package/src/backend/herdr.ts +147 -0
- package/src/backend/lock.ts +84 -0
- package/src/backend/process-cwd.ts +32 -0
- package/src/backend/schema.ts +47 -0
- package/src/backend/state.ts +57 -0
- package/src/backend/step.ts +93 -0
- package/src/backend/system.ts +150 -0
- package/src/backend/watch.ts +75 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +6 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/dev.ts +2 -2
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +1 -1
- package/src/plans/plan-panes.ts +1 -1
- package/src/plans/settings.ts +6 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/shared/shell-word.ts +2 -0
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +32 -5
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/plan-missing-read.ts +3 -1
- 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
|
+
})
|