jackherd 1.23.2 → 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 +8 -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/hooks/hooks/merge-guard/decision.ts +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 +5 -1
- 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/check/skill-budget-defaults.ts +2 -1
- 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
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.24.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -821,7 +821,7 @@ const paneInputSentence = (commands: string[]): string[] =>
|
|
|
821
821
|
commands.length === 0
|
|
822
822
|
? []
|
|
823
823
|
: [
|
|
824
|
-
`${commands.map((command) => `\`${command}\``).join(' and ')} writes input into a pane; the herd takes clicks from it, so the user confirms it. Only \`${LIFTABLE_PANE_INPUT}\` passes, once per call, into a plain pane id (\`${LIFTABLE_PANE_INPUT} w1:p2 …\` at the start of its command, no wrapper or assignment before it, every word fixed) whose foreground herdr names and which is not the herd, with a command that names no guarded command, no input into a pane and no herd. To end a lane's session, do not type into its pane: send \`herdr agent prompt <lane> /exit\` as a call of its own.`,
|
|
824
|
+
`${commands.map((command) => `\`${command}\``).join(' and ')} writes input into a pane; the herd takes clicks from it, so the user confirms it. Only \`${LIFTABLE_PANE_INPUT}\` passes, once per call, into a plain pane id (\`${LIFTABLE_PANE_INPUT} w1:p2 …\` at the start of its command, no wrapper or assignment before it, every word fixed) whose foreground herdr names and which is not the herd, with a command that names no guarded command, no input into a pane and no herd. To end a lane's session, do not type into its pane: send \`herdr agent prompt <lane> /exit\` as a call of its own. To restart a dev server, stop it by its PID (the port names it, \`/proc/<pid>/cwd\` shows the lane's worktree), then \`herdr pane run\` the same command in its pane.`,
|
|
825
825
|
]
|
|
826
826
|
|
|
827
827
|
/** Why an input into an agent asks: a key there can answer the dialog the agent has open, as if the user clicked. */
|
|
@@ -86,7 +86,8 @@ through steps 0 to 8. One line to the user afterwards.
|
|
|
86
86
|
## 3. Dev server
|
|
87
87
|
|
|
88
88
|
A lane whose phase changes something a user sees gets its dev server **when the lane starts**:
|
|
89
|
-
[`references/start-lane.md`](references/start-lane.md).
|
|
89
|
+
[`references/start-lane.md`](references/start-lane.md); with `lanes.backend`, the services of the shared backend:
|
|
90
|
+
[`references/backend.md`](references/backend.md).
|
|
90
91
|
|
|
91
92
|
## 4. Watch
|
|
92
93
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# § 3: the shared backend of `lanes.backend`
|
|
2
|
+
|
|
3
|
+
Read on a `backend:` watcher line, or at a lane's start (`start-lane.md` § 3) when `lanes.backend`
|
|
4
|
+
is set. The services every lane's app talks to run in one pane of a tab in the main checkout; the watcher starts and
|
|
5
|
+
keeps it. Without `lanes.backend` nothing here applies.
|
|
6
|
+
|
|
7
|
+
## At a lane's start
|
|
8
|
+
|
|
9
|
+
When the phase's Writes column names a path of one of those services, hand that service to the lane first, from the
|
|
10
|
+
main checkout: `jh backend release <service> --lane <lane>` restarts the backend pane without it and prints the freed
|
|
11
|
+
port; then `jh lane dev <lane> --filter <service> --port <port>`. Only you call `jh backend release`, from the main
|
|
12
|
+
checkout; a lane that finds out later asks in its report. Before `jh lane close`, stop the lane's dev server, then
|
|
13
|
+
`jh backend return <service>`; done when it prints that the service returned. Skipped, the watcher returns it once the
|
|
14
|
+
worktree is gone.
|
|
15
|
+
|
|
16
|
+
## Watcher lines
|
|
17
|
+
|
|
18
|
+
Each line is said once while it stands, without the `backend: ` prefix. A `kit updated` leaves the pane running.
|
|
19
|
+
|
|
20
|
+
| Line | What to do |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `started in <pane>` | Nothing. |
|
|
23
|
+
| `port <n> in use, not started` | Find the holder as in `start-lane.md` § 3; a lane's server for a handed-over service (`jh backend` lists them) is the lane's, any other goes to the user. The pane starts once the port is free. |
|
|
24
|
+
| `running command differs from lanes.backend` | The config changed after the start. Tell the user once; `jh backend restart` after they agree, since it stops the services. |
|
|
25
|
+
| `gave up after 3 starts in 10 minutes` | The command ends right after it starts. Read the pane, tell the user the cause; `jh backend restart` once fixed. |
|
|
26
|
+
| `<service> returned from <lane> (worktree gone)` | Nothing. |
|
|
27
|
+
| `backend check failed, still watching: <reason>` | Fix the cause (herdr answering, one workspace of the main checkout, `lanes.backend` readable); done when `jh backend` shows the pane. |
|
|
28
|
+
| `… left alone` (several panes with the label, an agent in the pane, a pane outside the main checkout) | Tell the user; the pane is theirs. |
|
|
29
|
+
|
|
30
|
+
`jh backend` only ever stops the process in the backend pane; a pane it cannot read goes to the user.
|
|
@@ -9,6 +9,7 @@ or when the user names a lane to close. Run both commands from the main checkout
|
|
|
9
9
|
nothing: resolve each reason it prints (a named file goes to the user first, a working agent is waited for, a lock
|
|
10
10
|
is lifted once its session ended, an unmerged branch is not closed) and repeat until it exits 0.
|
|
11
11
|
A worktree on no branch closes when its head is on the base branch and only staged plan changes the base holds are left.
|
|
12
|
+
Handed-over service: [`backend.md`](backend.md).
|
|
12
13
|
2. `jh lane close <lane>` Run it without asking the user; its checks are the safeguard. It repeats the step 1 checks (resolve a refusal as in step 1) and runs the listed steps, one line each. After a failure,
|
|
13
14
|
run it again: with the worktree gone and the branch left, `jh lane close <lane> --branch <branch>` finishes the
|
|
14
15
|
branch and the board; a lane closed in full says so and changes nothing.
|
|
@@ -62,5 +62,9 @@ herdr pane run <new-pane> "PORT=<port> {{kit.command.dev}}"
|
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
The new pane starts in the lane's worktree; check with `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) that the server runs
|
|
65
|
-
there,
|
|
65
|
+
there, also before stopping one by PID. Stop only a server you started; another's port
|
|
66
66
|
goes to the user.
|
|
67
|
+
|
|
68
|
+
To restart it after a rebase, stop it by PID (found through the port) once `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) shows
|
|
69
|
+
the lane's worktree, then `herdr pane run <pane> "<the same command>"` in the same pane. Each step is its own call and
|
|
70
|
+
passes the pane guard without a question; keys typed into the pane ask the user.
|
package/package.json
CHANGED
|
@@ -156,6 +156,48 @@
|
|
|
156
156
|
"devCommand": {
|
|
157
157
|
"type": "string",
|
|
158
158
|
"minLength": 1
|
|
159
|
+
},
|
|
160
|
+
"backend": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"command": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"minLength": 1
|
|
166
|
+
},
|
|
167
|
+
"services": {
|
|
168
|
+
"minItems": 1,
|
|
169
|
+
"type": "array",
|
|
170
|
+
"items": {
|
|
171
|
+
"type": "object",
|
|
172
|
+
"properties": {
|
|
173
|
+
"name": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"pattern": "^(?!-)[\\w@./:-]+$"
|
|
176
|
+
},
|
|
177
|
+
"port": {
|
|
178
|
+
"type": "integer",
|
|
179
|
+
"minimum": 1,
|
|
180
|
+
"maximum": 65535
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
"required": [
|
|
184
|
+
"name",
|
|
185
|
+
"port"
|
|
186
|
+
],
|
|
187
|
+
"additionalProperties": false
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"label": {
|
|
191
|
+
"default": "backend",
|
|
192
|
+
"type": "string",
|
|
193
|
+
"pattern": "^(?!-)[\\w.-]+$"
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
"required": [
|
|
197
|
+
"command",
|
|
198
|
+
"services"
|
|
199
|
+
],
|
|
200
|
+
"additionalProperties": false
|
|
159
201
|
}
|
|
160
202
|
},
|
|
161
203
|
"additionalProperties": false
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readConfig } from '../config/read-config'
|
|
2
|
+
import { TERMINAL_ADAPTER } from '../config/schema'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { mainCheckout } from '../plans/git'
|
|
5
|
+
import { asksForHelp } from '../preview/options'
|
|
6
|
+
import { CLI_NAME } from '../version/release'
|
|
7
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
8
|
+
import {
|
|
9
|
+
type BackendOutcome,
|
|
10
|
+
type BackendSystem,
|
|
11
|
+
releaseService,
|
|
12
|
+
restartBackend,
|
|
13
|
+
returnService,
|
|
14
|
+
statusLines,
|
|
15
|
+
} from './control'
|
|
16
|
+
import { withBackendLock } from './lock'
|
|
17
|
+
import type { BackendConfig } from './schema'
|
|
18
|
+
import { backendFilesOf, backendSystemOf } from './system'
|
|
19
|
+
|
|
20
|
+
/** The exit codes of `jh backend`: done, refused, and a call it does not understand. */
|
|
21
|
+
export const BACKEND_EXIT = { done: 0, refused: 1, usage: 2 } as const
|
|
22
|
+
|
|
23
|
+
export const BACKEND_REQUEST = { status: 'status', restart: 'restart', release: 'release', return: 'return' } as const
|
|
24
|
+
|
|
25
|
+
/** What `jh backend` was asked for. */
|
|
26
|
+
export type BackendRequest =
|
|
27
|
+
| { kind: typeof BACKEND_REQUEST.status }
|
|
28
|
+
| { kind: typeof BACKEND_REQUEST.restart }
|
|
29
|
+
| { kind: typeof BACKEND_REQUEST.release; service: string; lane: string }
|
|
30
|
+
| { kind: typeof BACKEND_REQUEST.return; service: string }
|
|
31
|
+
|
|
32
|
+
const LANE = '--lane'
|
|
33
|
+
|
|
34
|
+
/** How long a call waits for the watcher's check, which may itself wait for a stop. */
|
|
35
|
+
const LOCK_WAIT_MS = 90_000
|
|
36
|
+
|
|
37
|
+
export const BACKEND_USAGE = `usage: ${CLI_NAME} backend
|
|
38
|
+
${CLI_NAME} backend restart
|
|
39
|
+
${CLI_NAME} backend release <service> ${LANE} <lane>
|
|
40
|
+
${CLI_NAME} backend return <service>
|
|
41
|
+
|
|
42
|
+
The shared backend of the lanes (lanes.backend): one pane in a tab of the main checkout that the watcher starts and
|
|
43
|
+
keeps. Without arguments it shows the pane, the command last started, the services handed to lanes and the recent
|
|
44
|
+
starts. restart stops the pane, forgets "gave up" and starts it again. release takes a service out of the backend,
|
|
45
|
+
so a lane runs it itself (${CLI_NAME} lane dev <lane> --filter <service> --port <port>); return puts it back. A busy
|
|
46
|
+
service port prevents a start; nothing frees a port.`
|
|
47
|
+
|
|
48
|
+
const usage = (): never => {
|
|
49
|
+
throw new Error(BACKEND_USAGE)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reads the arguments of `jh backend`; throws with the usage on anything else. */
|
|
53
|
+
export const parseBackendRequest = (args: readonly string[]): BackendRequest => {
|
|
54
|
+
const [kind, service, option, lane, ...rest] = args
|
|
55
|
+
if (kind === undefined) return { kind: BACKEND_REQUEST.status }
|
|
56
|
+
if (kind === BACKEND_REQUEST.restart && args.length === 1) return { kind }
|
|
57
|
+
if (kind === BACKEND_REQUEST.return && service !== undefined && args.length === 2) return { kind, service }
|
|
58
|
+
const isRelease = kind === BACKEND_REQUEST.release && service !== undefined && option === LANE
|
|
59
|
+
if (isRelease && lane !== undefined && rest.length === 0 && SAFE_LANE_NAME.test(lane)) return { kind, service, lane }
|
|
60
|
+
return usage()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Carries out `request` against the backend; the caller holds the lock. */
|
|
64
|
+
export const carryOutRequest = async (
|
|
65
|
+
request: BackendRequest,
|
|
66
|
+
backend: BackendConfig,
|
|
67
|
+
system: BackendSystem,
|
|
68
|
+
): Promise<BackendOutcome> => {
|
|
69
|
+
if (request.kind === BACKEND_REQUEST.restart) return restartBackend(backend, system)
|
|
70
|
+
if (request.kind === BACKEND_REQUEST.release) return releaseService(backend, system, request.service, request.lane)
|
|
71
|
+
if (request.kind === BACKEND_REQUEST.return) return returnService(backend, system, request.service)
|
|
72
|
+
return { ok: true, lines: statusLines(await system.look(), system.readState(), system.now()) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The backend of the main checkout's own config: a lane's worktree must not change the command the main checkout runs. */
|
|
76
|
+
const backendOfMainCheckout = (root: string) => {
|
|
77
|
+
const config = readConfig([root])
|
|
78
|
+
if (config.terminal.adapter !== TERMINAL_ADAPTER.herdr) throw new Error('backend: needs terminal.adapter herdr')
|
|
79
|
+
if (!config.lanes.backend) throw new Error('backend: no lanes.backend in the main checkout .jackherd/config.json')
|
|
80
|
+
return { backend: config.lanes.backend, worktreeDirectory: config.worktreeDirectory }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const print = (lines: readonly string[]) => {
|
|
84
|
+
for (const line of lines) console.log(terminalSafe(line))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `jh backend [restart | release <service> --lane <lane> | return <service>]`: works on the main checkout from any
|
|
89
|
+
* worktree. Exit 0 done, 1 refused, 2 usage.
|
|
90
|
+
*/
|
|
91
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
92
|
+
if (asksForHelp(args)) {
|
|
93
|
+
console.log(BACKEND_USAGE)
|
|
94
|
+
return BACKEND_EXIT.done
|
|
95
|
+
}
|
|
96
|
+
let request: BackendRequest
|
|
97
|
+
try {
|
|
98
|
+
request = parseBackendRequest(args)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
101
|
+
return BACKEND_EXIT.usage
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const root = mainCheckout(cwd)
|
|
105
|
+
const { backend, worktreeDirectory } = backendOfMainCheckout(root)
|
|
106
|
+
const files = backendFilesOf(root)
|
|
107
|
+
const system = backendSystemOf({ root, worktreeDirectory, backend, files })
|
|
108
|
+
const outcome = await withBackendLock(files.lock, LOCK_WAIT_MS, () => carryOutRequest(request, backend, system))
|
|
109
|
+
if (outcome === null) throw new Error('backend: the watcher holds the backend lock; try again in a minute')
|
|
110
|
+
print(outcome.lines)
|
|
111
|
+
return outcome.ok ? BACKEND_EXIT.done : BACKEND_EXIT.refused
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
114
|
+
return BACKEND_EXIT.refused
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type BackendConfig, SERVICES_PLACEHOLDER } from './schema'
|
|
2
|
+
|
|
3
|
+
/** Frees the lanes' `PORT` for the services: a service reading `PORT ?? <NAME>_PORT` must not bind a lane's port. */
|
|
4
|
+
export const BACKEND_ENVIRONMENT_PREFIX = 'env -u PORT '
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shell line of the backend pane: the configured command without `PORT`, with `{services}` filled by the services
|
|
8
|
+
* not handed to a lane, in configuration order. Null when none is left to run.
|
|
9
|
+
*/
|
|
10
|
+
export const backendCommandOf = (backend: BackendConfig, released: readonly string[]): string | null => {
|
|
11
|
+
const names = backend.services.map(({ name }) => name).filter((name) => !released.includes(name))
|
|
12
|
+
if (names.length === 0) return null
|
|
13
|
+
return `${BACKEND_ENVIRONMENT_PREFIX}${backend.command.replaceAll(SERVICES_PLACEHOLDER, names.join(' '))}`
|
|
14
|
+
}
|
|
@@ -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
|
+
}
|