jackherd 1.23.3 → 1.24.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 +4 -0
- package/INSTALL.md +16 -0
- package/README.md +20 -19
- package/dist/config.d.ts +9 -1
- package/dist/config.js +174 -131
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- 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/start-lane.md +2 -2
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +42 -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/cli.ts +1 -0
- package/src/config/schema.ts +3 -0
- package/src/lane/dev.ts +2 -2
- package/src/plans/plan-panes.ts +1 -1
- package/src/plans/settings.ts +3 -0
- package/src/shared/shell-word.ts +2 -0
- package/src/watch/command.ts +27 -0
- package/src/watch/reference-paths.ts +4 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
2
|
+
import { BACKEND_PANE, type BackendPane, type BackendState } from './state'
|
|
3
|
+
|
|
4
|
+
/** At most this many automatic starts within `BACKEND_START_WINDOW_MS`. */
|
|
5
|
+
export const BACKEND_START_LIMIT = 3
|
|
6
|
+
export const BACKEND_START_WINDOW_MS = 10 * MS_PER_MINUTE
|
|
7
|
+
|
|
8
|
+
export const BACKEND_STEP_ACTION = { none: 'none', start: 'start', split: 'split' } as const
|
|
9
|
+
|
|
10
|
+
export type BackendStepAction = (typeof BACKEND_STEP_ACTION)[keyof typeof BACKEND_STEP_ACTION]
|
|
11
|
+
|
|
12
|
+
export const BACKEND_GIVE_UP_LINE = `backend: gave up after ${BACKEND_START_LIMIT} starts in ${BACKEND_START_WINDOW_MS / MS_PER_MINUTE} minutes; jh backend restart`
|
|
13
|
+
export const BACKEND_DIFFERS_LINE = 'backend: running command differs from lanes.backend; jh backend restart'
|
|
14
|
+
|
|
15
|
+
/** What the watcher found: the pane, where it sits, the busy service ports of the current list, the wanted command. */
|
|
16
|
+
export type BackendObservation = {
|
|
17
|
+
pane: BackendPane
|
|
18
|
+
/** The pane's id, or the tab label when the pane is still to be created; named in the start line. */
|
|
19
|
+
place: string
|
|
20
|
+
busyPorts: readonly number[]
|
|
21
|
+
/** The command line `backendCommandOf` gives; null when every service is released. */
|
|
22
|
+
desired: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type BackendStep = { action: BackendStepAction; state: BackendState; line: string | null }
|
|
26
|
+
|
|
27
|
+
const stay = (state: BackendState): BackendStep => ({ action: BACKEND_STEP_ACTION.none, state, line: null })
|
|
28
|
+
|
|
29
|
+
/** Reports `line` only when it differs from the one already standing. */
|
|
30
|
+
export const noticed = (state: BackendState, line: string): BackendStep => ({
|
|
31
|
+
action: BACKEND_STEP_ACTION.none,
|
|
32
|
+
state: { ...state, notice: line },
|
|
33
|
+
line: state.notice === line ? null : line,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const settled = (state: BackendState): BackendState => (state.notice === null ? state : { ...state, notice: null })
|
|
37
|
+
|
|
38
|
+
const runningStep = (state: BackendState, desired: string | null): BackendStep =>
|
|
39
|
+
desired === null || state.command === desired ? stay(settled(state)) : noticed(state, BACKEND_DIFFERS_LINE)
|
|
40
|
+
|
|
41
|
+
const portLine = (port: number): string => `backend: port ${port} in use, not started`
|
|
42
|
+
|
|
43
|
+
const started = (
|
|
44
|
+
state: BackendState,
|
|
45
|
+
observation: BackendObservation,
|
|
46
|
+
desired: string,
|
|
47
|
+
starts: number[],
|
|
48
|
+
): BackendStep => ({
|
|
49
|
+
action: observation.pane === BACKEND_PANE.missing ? BACKEND_STEP_ACTION.split : BACKEND_STEP_ACTION.start,
|
|
50
|
+
state: { ...state, command: desired, starts, notice: null },
|
|
51
|
+
line: `backend: started in ${observation.place}`,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const startStep = (state: BackendState, observation: BackendObservation, desired: string, now: number): BackendStep => {
|
|
55
|
+
const recent = state.starts.filter((start) => now - start < BACKEND_START_WINDOW_MS)
|
|
56
|
+
if (recent.length >= BACKEND_START_LIMIT)
|
|
57
|
+
return noticed({ ...state, starts: recent, gaveUp: true }, BACKEND_GIVE_UP_LINE)
|
|
58
|
+
return started(state, observation, desired, [...recent, now])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const refusedByHand = (state: BackendState, line: string): BackendStep => ({
|
|
62
|
+
action: BACKEND_STEP_ACTION.none,
|
|
63
|
+
state,
|
|
64
|
+
line,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The start `jh backend` makes after it stopped the pane (`restart`, `release`, `return`): no start limit, no start
|
|
69
|
+
* counted, every refusal said each time, since a person asked for it. A busy port prevents it as at every check.
|
|
70
|
+
*/
|
|
71
|
+
export const startedByHand = (state: BackendState, observation: BackendObservation): BackendStep => {
|
|
72
|
+
const { pane, desired, busyPorts, place } = observation
|
|
73
|
+
if (pane === BACKEND_PANE.running) return refusedByHand(state, `backend: still running in ${place}, not started`)
|
|
74
|
+
if (desired === null)
|
|
75
|
+
return refusedByHand({ ...state, command: null }, 'backend: every service is handed to a lane, nothing started')
|
|
76
|
+
const [busy] = busyPorts
|
|
77
|
+
if (busy !== undefined) return refusedByHand(state, portLine(busy))
|
|
78
|
+
return started(state, observation, desired, state.starts)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The decision of one check: what to do with the pane, the state to keep and the line to report, if any. A standing
|
|
83
|
+
* condition (a busy port, a differing command, the given-up state) is reported once, not at every check. Nothing here
|
|
84
|
+
* stops a process or frees a port: a busy port only prevents the start.
|
|
85
|
+
*/
|
|
86
|
+
export const backendStep = (state: BackendState, observation: BackendObservation, now: number): BackendStep => {
|
|
87
|
+
const { pane, desired, busyPorts } = observation
|
|
88
|
+
if (pane === BACKEND_PANE.running) return runningStep(state, desired)
|
|
89
|
+
if (desired === null || state.gaveUp) return stay(state)
|
|
90
|
+
const [busy] = busyPorts
|
|
91
|
+
if (busy !== undefined) return noticed(state, portLine(busy))
|
|
92
|
+
return startStep(state, observation, desired, now)
|
|
93
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
4
|
+
import { worktreeNames } from '../plans/git'
|
|
5
|
+
import { paneListOf } from '../plans/plan-panes'
|
|
6
|
+
import { listeningPorts } from '../plans/port-listening'
|
|
7
|
+
import { readPrivateText } from '../shared/private-location'
|
|
8
|
+
import { type HerdrRunner, renameArguments, runHerdr } from '../terminal/herdr'
|
|
9
|
+
import { ensureWatcherDirectory } from '../watch/watcher-lock'
|
|
10
|
+
import type { BackendSystem } from './control'
|
|
11
|
+
import {
|
|
12
|
+
type BackendSight,
|
|
13
|
+
backendSightOf,
|
|
14
|
+
createdPaneOf,
|
|
15
|
+
mainWorkspaceOf,
|
|
16
|
+
type PaneProcesses,
|
|
17
|
+
paneProcessesOf,
|
|
18
|
+
stopTargetOf,
|
|
19
|
+
tabIdsOf,
|
|
20
|
+
} from './herdr'
|
|
21
|
+
import { processCwdOf } from './process-cwd'
|
|
22
|
+
import type { BackendConfig } from './schema'
|
|
23
|
+
import { BACKEND_PANE, parseBackendState } from './state'
|
|
24
|
+
|
|
25
|
+
/** The backend's files in the watcher's directory of the repository, beside `watch-state.json`. */
|
|
26
|
+
export const BACKEND_FILES = { state: 'backend.json', lock: 'backend.lock' } as const
|
|
27
|
+
|
|
28
|
+
export type BackendFiles = Record<keyof typeof BACKEND_FILES, string>
|
|
29
|
+
|
|
30
|
+
type Context = { root: string; label: string; run: HerdrRunner }
|
|
31
|
+
|
|
32
|
+
type StopResult = { ok: true } | { ok: false; line: string }
|
|
33
|
+
|
|
34
|
+
/** How long `jh backend` waits for a stopped backend to free its pane and ports. */
|
|
35
|
+
const STOP_WAIT_MS = 30_000
|
|
36
|
+
const STOP_POLL_MS = 500
|
|
37
|
+
|
|
38
|
+
/** The paths of the backend's state and lock for the main checkout `root`; creates the private directory. */
|
|
39
|
+
export const backendFilesOf = (root: string): BackendFiles => {
|
|
40
|
+
const directory = ensureWatcherDirectory(root)
|
|
41
|
+
return { state: join(directory, BACKEND_FILES.state), lock: join(directory, BACKEND_FILES.lock) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const workspaceOf = ({ root, run }: Context): string => mainWorkspaceOf(run(['workspace', 'list']), root)
|
|
45
|
+
|
|
46
|
+
const processesOf = (context: Context, pane: string): PaneProcesses =>
|
|
47
|
+
paneProcessesOf(context.run(['pane', 'process-info', '--pane', pane]))
|
|
48
|
+
|
|
49
|
+
const lookWith = (context: Context): BackendSight =>
|
|
50
|
+
backendSightOf({
|
|
51
|
+
panes: paneListOf(context.run(['pane', 'list'])),
|
|
52
|
+
tabs: tabIdsOf(context.run(['tab', 'list', '--workspace', workspaceOf(context)])),
|
|
53
|
+
label: context.label,
|
|
54
|
+
root: context.root,
|
|
55
|
+
foregroundOf: (pane) => processesOf(context, pane).processes.map(({ argv }) => argv),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const busyPortsOf = async (ports: readonly number[]): Promise<number[]> => {
|
|
59
|
+
const listening = await listeningPorts(ports)
|
|
60
|
+
return ports.filter((port) => listening.has(port))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A new tab in the main checkout's workspace, without focus, working in `root`; its pane labelled and running `command`. */
|
|
64
|
+
const createWith = (context: Context, command: string): string => {
|
|
65
|
+
const { root, label, run } = context
|
|
66
|
+
const created = run([
|
|
67
|
+
'tab',
|
|
68
|
+
'create',
|
|
69
|
+
'--workspace',
|
|
70
|
+
workspaceOf(context),
|
|
71
|
+
'--cwd',
|
|
72
|
+
root,
|
|
73
|
+
'--label',
|
|
74
|
+
label,
|
|
75
|
+
'--no-focus',
|
|
76
|
+
])
|
|
77
|
+
const pane = createdPaneOf(created)
|
|
78
|
+
run(renameArguments(pane, label))
|
|
79
|
+
run(['pane', 'run', pane, command])
|
|
80
|
+
return pane
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isStopped = async (context: Context, ports: readonly number[]): Promise<boolean> => {
|
|
84
|
+
const sight = lookWith(context)
|
|
85
|
+
return sight.kind === 'found' && sight.pane !== BACKEND_PANE.running && (await busyPortsOf(ports)).length === 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** SIGTERM to `pid`; a process that ended since it was read is what the stop wanted, any other failure propagates. */
|
|
89
|
+
const terminate = (pid: number) => {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 'SIGTERM')
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const waitUntilStopped = async (context: Context, pane: string, pid: number, ports: readonly number[]) => {
|
|
98
|
+
const deadline = Date.now() + STOP_WAIT_MS
|
|
99
|
+
while (Date.now() < deadline) {
|
|
100
|
+
if (await isStopped(context, ports)) return { ok: true } as const
|
|
101
|
+
await Bun.sleep(STOP_POLL_MS)
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
line: `backend: still running ${STOP_WAIT_MS / 1000} s after SIGTERM to ${pid}; stop it in pane ${pane}`,
|
|
106
|
+
} as const
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* SIGTERM to the leader of the pane's foreground group once `stopTargetOf` accepted it (never the shell, only a
|
|
111
|
+
* process in the main checkout), then a wait until the pane is a shell again and the ports are free. The pane is read
|
|
112
|
+
* a second time after the directory check, and the signal goes out only when the same group still leads it, so a
|
|
113
|
+
* process id reused in between is not hit. Never a port kill.
|
|
114
|
+
*/
|
|
115
|
+
const stopWith = async (context: Context, pane: string, ports: readonly number[]): Promise<StopResult> => {
|
|
116
|
+
const target = stopTargetOf(processesOf(context, pane), context.root, (pid) => processCwdOf(pid), pane)
|
|
117
|
+
if (!target.ok) return target
|
|
118
|
+
if (processesOf(context, pane).groupId !== target.pid)
|
|
119
|
+
return { ok: false, line: `backend: the process in pane ${pane} changed while it was checked; try again` }
|
|
120
|
+
terminate(target.pid)
|
|
121
|
+
return waitUntilStopped(context, pane, target.pid, ports)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The backend's calls for the main checkout `root`: herdr through `run`, the state in `files`, the ports probed on
|
|
126
|
+
* this machine, the lanes' worktrees under `worktreeDirectory`.
|
|
127
|
+
*/
|
|
128
|
+
export const backendSystemOf = (input: {
|
|
129
|
+
root: string
|
|
130
|
+
worktreeDirectory: string
|
|
131
|
+
backend: BackendConfig
|
|
132
|
+
files: BackendFiles
|
|
133
|
+
run?: HerdrRunner
|
|
134
|
+
}): BackendSystem => {
|
|
135
|
+
const root = realpathSync(input.root)
|
|
136
|
+
const context: Context = { root, label: input.backend.label, run: input.run ?? runHerdr }
|
|
137
|
+
return {
|
|
138
|
+
readState: () => parseBackendState(readPrivateText(input.files.state)),
|
|
139
|
+
writeState: (state) => writePrivateFile(input.files.state, `${JSON.stringify(state)}\n`),
|
|
140
|
+
look: () => lookWith(context),
|
|
141
|
+
busyPorts: busyPortsOf,
|
|
142
|
+
create: (command) => createWith(context, command),
|
|
143
|
+
run: (pane, command) => {
|
|
144
|
+
context.run(['pane', 'run', pane, command])
|
|
145
|
+
},
|
|
146
|
+
stop: (pane, ports) => stopWith(context, pane, ports),
|
|
147
|
+
worktrees: () => worktreeNames(join(root, input.worktreeDirectory)),
|
|
148
|
+
now: Date.now,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readConfig } from '../config/read-config'
|
|
2
|
+
import type { JackherdConfig } from '../config/schema'
|
|
3
|
+
import { errorText } from '../plans/error-text'
|
|
4
|
+
import { checkBackend } from './control'
|
|
5
|
+
import { withBackendLock } from './lock'
|
|
6
|
+
import type { BackendConfig } from './schema'
|
|
7
|
+
import { backendFilesOf, backendSystemOf } from './system'
|
|
8
|
+
|
|
9
|
+
/** The watcher's memory of the backend between polls: the last check and whether a failure was already said. */
|
|
10
|
+
export type BackendWatch = { checkedAt: number | null; failureReported: boolean }
|
|
11
|
+
|
|
12
|
+
export type BackendWatchEffects = {
|
|
13
|
+
/** One check; null when `jh backend` holds the lock, so the next check tries again. */
|
|
14
|
+
check: () => Promise<string[] | null>
|
|
15
|
+
log: (line: string) => void
|
|
16
|
+
/** Put behind every line: where the coordinator reads what to do. */
|
|
17
|
+
hint: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The backend and the lanes' worktree directory, both from the main checkout's config. */
|
|
21
|
+
export type MainCheckoutBackend = { backend: BackendConfig; worktreeDirectory: string }
|
|
22
|
+
|
|
23
|
+
/** The watcher checks the backend pane at its start and then once a minute, not at every poll. */
|
|
24
|
+
export const BACKEND_CHECK_MS = 60_000
|
|
25
|
+
|
|
26
|
+
export const INITIAL_BACKEND_WATCH: BackendWatch = { checkedAt: null, failureReported: false }
|
|
27
|
+
|
|
28
|
+
export const backendCheckDue = ({ checkedAt }: BackendWatch, now: number): boolean =>
|
|
29
|
+
checkedAt === null || now - checkedAt >= BACKEND_CHECK_MS
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks the backend when one is due and prints its lines. A failure never ends the watch; it is printed once until
|
|
33
|
+
* a check works again, as `keepPlanViews` does for the herd panes.
|
|
34
|
+
*/
|
|
35
|
+
export const keepBackend = async (
|
|
36
|
+
watch: BackendWatch,
|
|
37
|
+
now: number,
|
|
38
|
+
effects: BackendWatchEffects,
|
|
39
|
+
): Promise<BackendWatch> => {
|
|
40
|
+
if (!backendCheckDue(watch, now)) return watch
|
|
41
|
+
try {
|
|
42
|
+
for (const line of (await effects.check()) ?? []) effects.log(`${line}${effects.hint}`)
|
|
43
|
+
return { checkedAt: now, failureReported: false }
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!watch.failureReported) effects.log(`backend check failed, still watching: ${errorText(error)}${effects.hint}`)
|
|
46
|
+
return { checkedAt: now, failureReported: true }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `lanes.backend` and `worktreeDirectory` of the main checkout's config only: a lane's worktree must change neither
|
|
52
|
+
* what the watcher runs in the main checkout nor which lanes count as gone. `config` is reused when the watcher
|
|
53
|
+
* already read the main checkout; undefined without a backend or when that config cannot be read.
|
|
54
|
+
*/
|
|
55
|
+
export const mainCheckoutBackendOf = (
|
|
56
|
+
root: string,
|
|
57
|
+
checkoutRoot: string,
|
|
58
|
+
config: JackherdConfig,
|
|
59
|
+
): MainCheckoutBackend | undefined => {
|
|
60
|
+
try {
|
|
61
|
+
const main = checkoutRoot === root ? config : readConfig([root])
|
|
62
|
+
return main.lanes.backend ? { backend: main.lanes.backend, worktreeDirectory: main.worktreeDirectory } : undefined
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One check of the main checkout's backend under the lock, without waiting for it. */
|
|
69
|
+
export const backendCheckOf =
|
|
70
|
+
(root: string, { backend, worktreeDirectory }: MainCheckoutBackend) =>
|
|
71
|
+
(): Promise<string[] | null> => {
|
|
72
|
+
const files = backendFilesOf(root)
|
|
73
|
+
const system = backendSystemOf({ root, worktreeDirectory, backend, files })
|
|
74
|
+
return withBackendLock(files.lock, 0, () => checkBackend(backend, system))
|
|
75
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ const ALIASES = new Set<string>([HERD_COMMAND.alias])
|
|
|
19
19
|
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
20
20
|
export const COMMANDS: CommandTable = {
|
|
21
21
|
approval: () => import('./approval/command'),
|
|
22
|
+
backend: () => import('./backend/command-line'),
|
|
22
23
|
brief: () => import('./brief/command'),
|
|
23
24
|
check: () => import('./check/command'),
|
|
24
25
|
config: () => import('./config/command'),
|
package/src/config/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { backendSchema } from '../backend/schema'
|
|
2
3
|
import { checkConfigSchema } from '../check/schema'
|
|
3
4
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
5
|
import { SECRET_NAME } from '../secrets/options'
|
|
@@ -607,6 +608,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
607
608
|
basePort: z.number().int().min(1).max(65535).optional(),
|
|
608
609
|
/** The command `jh lane dev` runs as `PORT=<port> <command>` in a pane beside the lane's agent; `{port}` and `{filter}` (`--filter <package>`) are filled in. Without it `jh lane dev` refuses. */
|
|
609
610
|
devCommand: z.string().min(1).optional(),
|
|
611
|
+
/** The services every lane's app talks to, run in one pane of the main checkout that the watcher starts and keeps; without it `jh backend` has nothing to run. */
|
|
612
|
+
backend: backendSchema.optional(),
|
|
610
613
|
})
|
|
611
614
|
.prefault({}),
|
|
612
615
|
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
package/src/lane/dev.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '../shared/is-record'
|
|
2
|
+
import { SHELL_WORD } from '../shared/shell-word'
|
|
2
3
|
import { CLI_NAME } from '../version/release'
|
|
3
4
|
import { MAX_PORT } from './port'
|
|
4
5
|
|
|
@@ -36,7 +37,6 @@ board, the command is lanes.devCommand of the config ({port} and {filter} stand
|
|
|
36
37
|
PORT=<port> <command> in a pane labelled "<lane> dev :<port>". A port something listens on is refused; a pane with
|
|
37
38
|
that label in the lane's tab is reported and left alone.`
|
|
38
39
|
|
|
39
|
-
const FILTER_VALUE = /^[\w@./:-]+$/
|
|
40
40
|
const PORT_PLACEHOLDER = '{port}'
|
|
41
41
|
const FILTER_PLACEHOLDER = '{filter}'
|
|
42
42
|
|
|
@@ -83,7 +83,7 @@ export const devLabelOf = (lane: string, port: number): string => `${lane} dev :
|
|
|
83
83
|
*/
|
|
84
84
|
export const devCommandOf = (template: string | null | undefined, port: number, filter: string | null): string => {
|
|
85
85
|
if (!template) throw new Error('no dev command: set lanes.devCommand in .jackherd/config.json')
|
|
86
|
-
if (filter !== null && !
|
|
86
|
+
if (filter !== null && !SHELL_WORD.test(filter)) throw new Error(`${FILTER} ${filter} is no package name`)
|
|
87
87
|
if (filter === null && template.includes(FILTER_PLACEHOLDER))
|
|
88
88
|
throw new Error(`lanes.devCommand names {filter}: pass ${FILTER} <package>`)
|
|
89
89
|
const command = template.replaceAll(PORT_PLACEHOLDER, String(port)).replaceAll(FILTER_PLACEHOLDER, filter ?? '')
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -151,7 +151,7 @@ export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
|
|
|
151
151
|
* A restored pane whose shell is still starting: nothing in the foreground yet, or a shell first (its prompt may run
|
|
152
152
|
* `git` beside it). Another program there, an editor or a pager, would take the command as its input.
|
|
153
153
|
*/
|
|
154
|
-
const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
|
|
154
|
+
export const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
|
|
155
155
|
!agent && (foreground.length === 0 || SHELLS.has(programOf(foreground[0])))
|
|
156
156
|
|
|
157
157
|
const hasHerdBeside = (panes: readonly PaneState[], agent: PaneState): boolean =>
|
package/src/plans/settings.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type PlansSettings = Pick<
|
|
|
23
23
|
basePort: number
|
|
24
24
|
/** The command `jh lane dev` starts a lane's dev server with (`lanes.devCommand`); undefined when none is set. */
|
|
25
25
|
devCommand: string | undefined
|
|
26
|
+
/** The shared backend of the lanes (`lanes.backend`); undefined when none is set. */
|
|
27
|
+
backend: JackherdConfig['lanes']['backend']
|
|
26
28
|
fetchIntervalMinutes: number
|
|
27
29
|
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
28
30
|
shimmer: boolean
|
|
@@ -69,6 +71,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
69
71
|
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
70
72
|
basePort: config.lanes.basePort ?? DEFAULT_BASE_PORT,
|
|
71
73
|
devCommand: config.lanes.devCommand,
|
|
74
|
+
backend: config.lanes.backend,
|
|
72
75
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
73
76
|
shimmer: config.plans.shimmer,
|
|
74
77
|
linkKinds: config.plans.linkKinds,
|