jackherd 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/INSTALL.md +2 -1
- package/README.md +22 -21
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +3 -2
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +77 -26
- package/src/check/skill-budgets.ts +58 -10
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { type SendPorts, sendLanePrompt } from '../lane/send'
|
|
3
|
+
import { errorText } from '../plans/error-text'
|
|
4
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { type ResumeDecision, resumeDecision } from './decision'
|
|
6
|
+
import { type ResumeRecord, resumedRecord } from './record'
|
|
7
|
+
import { type AgentPane, type Streak, steadyTarget, TARGET_MISS, type TargetMissed, targetFor } from './targets'
|
|
8
|
+
import { resumeTexts } from './texts'
|
|
9
|
+
|
|
10
|
+
type Limits = { attempts: number; intervalMs: number }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* After a crash the coordinator's session was back after ~8 s and the lanes' after ~3 s (`RESTORE_LIMITS`), but a slow
|
|
14
|
+
* machine resuming many sessions at once takes longer, and a message lost here is a lost restart: three minutes each
|
|
15
|
+
* for the coordinator and for the lanes, polled every two seconds.
|
|
16
|
+
*/
|
|
17
|
+
export const RESUME_LIMITS = {
|
|
18
|
+
coordinator: { attempts: 90, intervalMs: 2_000 },
|
|
19
|
+
lanes: { attempts: 90, intervalMs: 2_000 },
|
|
20
|
+
} as const satisfies Record<string, Limits>
|
|
21
|
+
|
|
22
|
+
/** A record file and what it holds; null for a damaged one. */
|
|
23
|
+
export type RecordEntry = { path: string; record: ResumeRecord | null }
|
|
24
|
+
|
|
25
|
+
/** What `jh resume` reads and does; `run` wires them to the files, the watcher lock and herdr. */
|
|
26
|
+
export type ResumePorts = {
|
|
27
|
+
now: () => number
|
|
28
|
+
records: () => RecordEntry[]
|
|
29
|
+
/** The record at `path` once more, right before it is marked. */
|
|
30
|
+
reread: (path: string) => ResumeRecord | null
|
|
31
|
+
writeRecord: (path: string, record: ResumeRecord) => void
|
|
32
|
+
watcherRunning: (root: string) => boolean
|
|
33
|
+
boardExists: (board: string) => boolean
|
|
34
|
+
settingsOf: (root: string) => { language: PlanLanguage; worktreeDirectory: string }
|
|
35
|
+
/** herdr's agent panes; throws with herdr's error when it does not answer. */
|
|
36
|
+
agentPanes: () => AgentPane[]
|
|
37
|
+
send: SendPorts
|
|
38
|
+
sleep: (ms: number) => Promise<void>
|
|
39
|
+
log: (root: string, message: string) => void
|
|
40
|
+
notify: (title: string, body: string) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Resume = Extract<ResumeDecision, { kind: 'resume' }>
|
|
44
|
+
|
|
45
|
+
type Recipient = { name: string; directory: string }
|
|
46
|
+
|
|
47
|
+
type Search = { found: Map<Recipient, string>; missed: Map<Recipient, TargetMissed> }
|
|
48
|
+
|
|
49
|
+
const NOTE_TITLE = 'jackherd resume'
|
|
50
|
+
|
|
51
|
+
const missLine = (who: string, miss: TargetMissed): string => {
|
|
52
|
+
if (miss.reason === TARGET_MISS.ambiguous)
|
|
53
|
+
return `${who}: no target, ${miss.panes.length} unnamed agent panes (${miss.panes.join(', ')}); nothing sent`
|
|
54
|
+
if (miss.reason === TARGET_MISS.elsewhere)
|
|
55
|
+
return `${who}: no target, the agent of that name runs in another directory (${miss.panes.join(', ')}); nothing sent`
|
|
56
|
+
return `${who}: no target, the agent did not come back in time; nothing sent`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const planLine = ({ coordinator, interrupted, blocked }: Resume): string =>
|
|
60
|
+
`resume: coordinator ${coordinator}, interrupted ${interrupted.join(', ') || '-'}, blocked ${blocked.join(', ') || '-'}`
|
|
61
|
+
|
|
62
|
+
/** herdr's panes of one poll; none, with a log line, when herdr does not answer. */
|
|
63
|
+
const pollPanes = (ports: ResumePorts, root: string): AgentPane[] => {
|
|
64
|
+
try {
|
|
65
|
+
return ports.agentPanes()
|
|
66
|
+
} catch (error) {
|
|
67
|
+
ports.log(root, `herdr did not answer: ${errorText(error)}`)
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Polls herdr until every recipient has a steady target (`steadyTarget`) or the window is used up; `onFound` runs for
|
|
74
|
+
* each as soon as it has one. Returns the targets found and, for the others, the last miss.
|
|
75
|
+
*/
|
|
76
|
+
const awaitTargets = async (
|
|
77
|
+
ports: ResumePorts,
|
|
78
|
+
root: string,
|
|
79
|
+
recipients: readonly Recipient[],
|
|
80
|
+
limits: Limits,
|
|
81
|
+
onFound: (target: string) => Promise<void>,
|
|
82
|
+
): Promise<Search> => {
|
|
83
|
+
const search: Search = { found: new Map(), missed: new Map() }
|
|
84
|
+
const streaks = new Map<Recipient, Streak>()
|
|
85
|
+
for (let attempt = 1; search.found.size < recipients.length; attempt++) {
|
|
86
|
+
const panes = pollPanes(ports, root)
|
|
87
|
+
for (const recipient of recipients.filter((each) => !search.found.has(each))) {
|
|
88
|
+
const steady = steadyTarget(targetFor(panes, recipient.name, recipient.directory), streaks.get(recipient) ?? null)
|
|
89
|
+
streaks.set(recipient, steady.streak)
|
|
90
|
+
if (steady.target.kind === 'none') search.missed.set(recipient, steady.target)
|
|
91
|
+
else {
|
|
92
|
+
search.found.set(recipient, steady.target.target)
|
|
93
|
+
search.missed.delete(recipient)
|
|
94
|
+
await onFound(steady.target.target)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (search.found.size === recipients.length || attempt >= limits.attempts) break
|
|
98
|
+
await ports.sleep(limits.intervalMs)
|
|
99
|
+
}
|
|
100
|
+
return search
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const reportMisses = (ports: ResumePorts, root: string, missed: Search['missed'], who: (name: string) => string) => {
|
|
104
|
+
for (const [recipient, miss] of missed) {
|
|
105
|
+
const line = missLine(who(recipient.name), miss)
|
|
106
|
+
ports.log(root, line)
|
|
107
|
+
if (miss.reason !== TARGET_MISS.missing) ports.notify(NOTE_TITLE, `${root} ${line}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Marks the record as resumed, after reading it once more: a record another start marked or a new watcher replaced
|
|
113
|
+
* in between is left alone. False when it was left alone or could not be written, so nothing is sent.
|
|
114
|
+
*/
|
|
115
|
+
const marked = (ports: ResumePorts, path: string, record: ResumeRecord): boolean => {
|
|
116
|
+
const current = ports.reread(path)
|
|
117
|
+
if (current === null || current.resumedAt !== null || current.startedAt !== record.startedAt) {
|
|
118
|
+
ports.log(record.root, 'record changed since it was read; nothing sent')
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
ports.writeRecord(path, resumedRecord(current, ports.now()))
|
|
123
|
+
return true
|
|
124
|
+
} catch (error) {
|
|
125
|
+
ports.log(record.root, `cannot mark the record (${errorText(error)}); nothing sent`)
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sendTo = async (ports: ResumePorts, root: string, target: string, text: string) => {
|
|
131
|
+
ports.log(root, (await sendLanePrompt(ports.send, target, text)).line)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The coordinator's target once it is back in the main checkout; null, logged, when it is not within the window. */
|
|
135
|
+
const awaitCoordinator = async (ports: ResumePorts, decision: Resume): Promise<string | null> => {
|
|
136
|
+
const recipient = { name: decision.coordinator, directory: decision.root }
|
|
137
|
+
const search = await awaitTargets(ports, decision.root, [recipient], RESUME_LIMITS.coordinator, async () => {})
|
|
138
|
+
reportMisses(ports, decision.root, search.missed, (name) => `no coordinator ${name}`)
|
|
139
|
+
return search.found.get(recipient) ?? null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resumeLanes = async (ports: ResumePorts, decision: Resume, worktreeDirectory: string, text: string) => {
|
|
143
|
+
const recipients = decision.interrupted.map((name) => ({
|
|
144
|
+
name,
|
|
145
|
+
directory: join(decision.root, worktreeDirectory, name),
|
|
146
|
+
}))
|
|
147
|
+
const search = await awaitTargets(ports, decision.root, recipients, RESUME_LIMITS.lanes, (target) =>
|
|
148
|
+
sendTo(ports, decision.root, target, text),
|
|
149
|
+
)
|
|
150
|
+
reportMisses(ports, decision.root, search.missed, (name) => name)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wakes one repository: waits for its coordinator first, since a coordinator that never comes back here runs in
|
|
155
|
+
* another herdr session and its record stays for that one; marks the record; then sends the coordinator its line and
|
|
156
|
+
* each interrupted lane its text.
|
|
157
|
+
*/
|
|
158
|
+
const resumeRepository = async (ports: ResumePorts, path: string, record: ResumeRecord, decision: Resume) => {
|
|
159
|
+
const { language, worktreeDirectory } = ports.settingsOf(decision.root)
|
|
160
|
+
const texts = resumeTexts(language)
|
|
161
|
+
ports.log(decision.root, planLine(decision))
|
|
162
|
+
if (decision.unsafe.length > 0) ports.log(decision.root, `left out, no lane names: ${decision.unsafe.join(', ')}`)
|
|
163
|
+
const coordinator = await awaitCoordinator(ports, decision)
|
|
164
|
+
if (coordinator === null || !marked(ports, path, record)) return
|
|
165
|
+
const line = texts.coordinatorLine(decision.board, decision.interrupted, decision.blocked)
|
|
166
|
+
await sendTo(ports, decision.root, coordinator, line)
|
|
167
|
+
await resumeLanes(ports, decision, worktreeDirectory, texts.laneText)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const resumeEntry = async (ports: ResumePorts, path: string, record: ResumeRecord) => {
|
|
171
|
+
const decision = resumeDecision({
|
|
172
|
+
record,
|
|
173
|
+
now: ports.now(),
|
|
174
|
+
watcherRunning: ports.watcherRunning(record.root),
|
|
175
|
+
boardExists: ports.boardExists(record.board),
|
|
176
|
+
})
|
|
177
|
+
if (decision.kind === 'none') ports.log(record.root, decision.reason)
|
|
178
|
+
else await resumeRepository(ports, path, record, decision)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every repository with a record, one after the other; a failure in one is logged and the next goes on. Always 0. */
|
|
182
|
+
export const resumeAll = async (ports: ResumePorts): Promise<number> => {
|
|
183
|
+
try {
|
|
184
|
+
const entries = ports.records()
|
|
185
|
+
if (entries.length === 0) ports.log('-', 'no resume record')
|
|
186
|
+
for (const { path, record } of entries) {
|
|
187
|
+
try {
|
|
188
|
+
if (record === null) ports.log(path, 'damaged record, read as none')
|
|
189
|
+
else await resumeEntry(ports, path, record)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
ports.log('-', `cannot read the records: ${errorText(error)}`)
|
|
196
|
+
}
|
|
197
|
+
return 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `jh resume`: herdr's second `[[startup]]` hook. After a crash of the watcher (its record still marked running, no
|
|
202
|
+
* watcher holding the lock now) sends the coordinator one fixed `/orchestrate … ` line and each lane that worked one
|
|
203
|
+
* fixed text, at most once per record. Every step goes to `~/.jackherd/logs/resume.log`; the exit code is always 0,
|
|
204
|
+
* a hook leaves nothing behind but its log.
|
|
205
|
+
*/
|
|
206
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
207
|
+
if (args.length > 0) {
|
|
208
|
+
console.log('Usage: jh resume (run by the herdr plugin when herdr starts; takes no arguments)')
|
|
209
|
+
return args.includes('--help') || args.includes('-h') ? 0 : 1
|
|
210
|
+
}
|
|
211
|
+
const { systemResumePorts } = await import('./ports')
|
|
212
|
+
return resumeAll(systemResumePorts())
|
|
213
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
3
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
4
|
+
import type { ResumeRecord } from './record'
|
|
5
|
+
|
|
6
|
+
/** Why `jh resume` leaves a repository alone. */
|
|
7
|
+
export const RESUME_SKIP = {
|
|
8
|
+
noRecord: 'no-record',
|
|
9
|
+
stopped: 'stopped',
|
|
10
|
+
resumed: 'resumed',
|
|
11
|
+
stale: 'stale',
|
|
12
|
+
watcherRunning: 'watcher-running',
|
|
13
|
+
boardMissing: 'board-missing',
|
|
14
|
+
unsafeBoard: 'unsafe-board',
|
|
15
|
+
unsafeCoordinator: 'unsafe-coordinator',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export type ResumeSkip = (typeof RESUME_SKIP)[keyof typeof RESUME_SKIP]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long after the watcher last saved its record a herdr start still resumes: a crash in the evening
|
|
22
|
+
* and a herdr start the next morning resume, a start days later does not, since the interrupted commands then meet
|
|
23
|
+
* code that has moved on. Measured from `savedAt`.
|
|
24
|
+
*/
|
|
25
|
+
export const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
export type ResumeInput = {
|
|
28
|
+
record: ResumeRecord | null
|
|
29
|
+
/** The time of this herdr start, against which the record's age is measured. */
|
|
30
|
+
now: number
|
|
31
|
+
/** Whether a watcher holds the repository's lock now: then herdr was handed over live, nothing crashed. */
|
|
32
|
+
watcherRunning: boolean
|
|
33
|
+
boardExists: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ResumeDecision =
|
|
37
|
+
| { kind: 'none'; reason: ResumeSkip }
|
|
38
|
+
| {
|
|
39
|
+
kind: 'resume'
|
|
40
|
+
root: string
|
|
41
|
+
/** The board relative to the main checkout, one path without blanks, as the coordinator line carries it. */
|
|
42
|
+
board: string
|
|
43
|
+
coordinator: string
|
|
44
|
+
/** The lanes that worked when the watcher went down; each gets the resume text. */
|
|
45
|
+
interrupted: string[]
|
|
46
|
+
/** The lanes that waited for an answer; only named to the coordinator. */
|
|
47
|
+
blocked: string[]
|
|
48
|
+
/** Lane names that are no single file name: never sent to, only logged. */
|
|
49
|
+
unsafe: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Blanks, control and format characters (bidi marks, zero-width ones): a board path with them is not one plain word. */
|
|
53
|
+
const NOT_ONE_WORD = /[\s\p{Cc}\p{Cf}]/u
|
|
54
|
+
|
|
55
|
+
/** The board relative to `root`, null when it is not a file inside it or would not stay one word on the line. */
|
|
56
|
+
const boardInside = (root: string, board: string): string | null => {
|
|
57
|
+
const path = relative(root, board)
|
|
58
|
+
const outside = path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
|
|
59
|
+
return outside || NOT_ONE_WORD.test(path) ? null : path
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A single file name that herdr cannot read as an option: `herdr agent prompt <target> …` takes it as an argument. */
|
|
63
|
+
const isSafeName = (name: string): boolean => SAFE_LANE_NAME.test(name) && !name.startsWith('-')
|
|
64
|
+
|
|
65
|
+
const lanesWith = (lanes: Record<string, string>, status: AgentStatus): string[] =>
|
|
66
|
+
Object.entries(lanes).flatMap(([name, laneStatus]) => (laneStatus === status && isSafeName(name) ? [name] : []))
|
|
67
|
+
|
|
68
|
+
const skip = (reason: ResumeSkip): ResumeDecision => ({ kind: 'none', reason })
|
|
69
|
+
|
|
70
|
+
/** The reason the record itself gives against a restart, checked before what the system says now. */
|
|
71
|
+
const recordSkip = (record: ResumeRecord, now: number): ResumeSkip | null => {
|
|
72
|
+
if (record.stoppedAt !== null) return RESUME_SKIP.stopped
|
|
73
|
+
if (record.resumedAt !== null) return RESUME_SKIP.resumed
|
|
74
|
+
if (now - record.savedAt > RESUME_MAX_AGE_MS) return RESUME_SKIP.stale
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a herdr start follows a crash of the watcher and whom to wake: a record still marked running, never resumed,
|
|
80
|
+
* saved within `RESUME_MAX_AGE_MS`, with no watcher running now and its board in place. Only lanes that worked get a
|
|
81
|
+
* message; names that are no single file name, a coordinator name of that kind and a board outside the main checkout
|
|
82
|
+
* never reach a send.
|
|
83
|
+
*/
|
|
84
|
+
export const resumeDecision = ({ record, now, watcherRunning, boardExists }: ResumeInput): ResumeDecision => {
|
|
85
|
+
if (record === null) return skip(RESUME_SKIP.noRecord)
|
|
86
|
+
const reason = recordSkip(record, now)
|
|
87
|
+
if (reason !== null) return skip(reason)
|
|
88
|
+
if (watcherRunning) return skip(RESUME_SKIP.watcherRunning)
|
|
89
|
+
if (!isSafeName(record.coordinator)) return skip(RESUME_SKIP.unsafeCoordinator)
|
|
90
|
+
const board = boardInside(record.root, record.board)
|
|
91
|
+
if (board === null) return skip(RESUME_SKIP.unsafeBoard)
|
|
92
|
+
if (!boardExists) return skip(RESUME_SKIP.boardMissing)
|
|
93
|
+
return {
|
|
94
|
+
kind: 'resume',
|
|
95
|
+
root: record.root,
|
|
96
|
+
board,
|
|
97
|
+
coordinator: record.coordinator,
|
|
98
|
+
interrupted: lanesWith(record.lanes, AGENT_STATUS.working),
|
|
99
|
+
blocked: lanesWith(record.lanes, AGENT_STATUS.blocked),
|
|
100
|
+
unsafe: Object.keys(record.lanes).filter((name) => !isSafeName(name)),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { errorText } from '../plans/error-text'
|
|
2
|
+
import type { LaneSnapshot } from '../watch/lane-watch'
|
|
3
|
+
import { followedRecord, type RecordStart, type ResumeRecord, runningRecord, stoppedRecord } from './record'
|
|
4
|
+
|
|
5
|
+
/** The one record file of a repository; `read` gives null for a missing or damaged one. */
|
|
6
|
+
export type RecordStore = {
|
|
7
|
+
read: () => ResumeRecord | null
|
|
8
|
+
write: (record: ResumeRecord) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type KeeperPorts = {
|
|
12
|
+
store: RecordStore
|
|
13
|
+
log: (line: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What the watcher calls: `follow` at every herdr poll, `stop` once on a clean end. */
|
|
17
|
+
export type RecordKeeper = {
|
|
18
|
+
follow: (lanes: LaneSnapshot, now: number) => void
|
|
19
|
+
stop: (now: number) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const laneStatusesOf = (snapshot: LaneSnapshot): Record<string, string> =>
|
|
23
|
+
Object.fromEntries(Object.entries(snapshot).map(([name, lane]) => [name, lane.status]))
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keeps the resume record of the watcher in `start.pid` beside its loop: running at the start, saved when a lane status
|
|
27
|
+
* changes or `RESUME_REFRESH_MS` is up, stamped as stopped at a clean end unless a successor holds the file by then (a
|
|
28
|
+
* missing or damaged file is left alone). A write that fails is retried at the next poll and logged once per series; it
|
|
29
|
+
* never ends the watch, since without the record a crash only goes unresumed.
|
|
30
|
+
*
|
|
31
|
+
* Accepted: `follow` writes without reading the file first, so a predecessor that outlived `takeOverLock`'s wait can
|
|
32
|
+
* overwrite its successor's record until the successor's next write.
|
|
33
|
+
*/
|
|
34
|
+
export const keepRecord = (start: RecordStart, now: number, ports: KeeperPorts): RecordKeeper => {
|
|
35
|
+
let failing = false
|
|
36
|
+
const attempt = (action: () => void): boolean => {
|
|
37
|
+
try {
|
|
38
|
+
action()
|
|
39
|
+
if (failing) ports.log('resume record written again')
|
|
40
|
+
failing = false
|
|
41
|
+
return true
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!failing) ports.log(`resume record failed: ${errorText(error)}`)
|
|
44
|
+
failing = true
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let current = runningRecord(start, now)
|
|
49
|
+
let unsaved = !attempt(() => ports.store.write(current))
|
|
50
|
+
return {
|
|
51
|
+
follow: (lanes, at) => {
|
|
52
|
+
const next = followedRecord(current, laneStatusesOf(lanes), at)
|
|
53
|
+
if (next === current && !unsaved) return
|
|
54
|
+
current = next
|
|
55
|
+
unsaved = !attempt(() => ports.store.write(next))
|
|
56
|
+
},
|
|
57
|
+
stop: (at) => {
|
|
58
|
+
attempt(() => {
|
|
59
|
+
const stored = ports.store.read()
|
|
60
|
+
const stopped = stored === null ? null : stoppedRecord(stored, start.pid, at)
|
|
61
|
+
if (stopped !== null) ports.store.write(stopped)
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { isOwnedBy } from '../../kit/hooks/hooks/shared/private-directory'
|
|
3
|
+
import { isPrivateLevels, type LocationSystem } from '../shared/private-location'
|
|
4
|
+
|
|
5
|
+
const OTHERS_WRITE = 0o002
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether `jh resume` may trust the records in `directory`: the directory itself is the user's, no link, closed to
|
|
9
|
+
* group and others (`isPrivateLevels`), and the level above (`~/.jackherd`) is the user's, no link and closed to others'
|
|
10
|
+
* writes, so nobody else can swap the directory. That level is checked less strictly: it holds the user's own config,
|
|
11
|
+
* and a umask of `002` leaves it open to the user's own group.
|
|
12
|
+
*/
|
|
13
|
+
export const isTrustedRecordDirectory = (directory: string, system: LocationSystem): boolean => {
|
|
14
|
+
const parent = system.entryAt(dirname(directory))
|
|
15
|
+
const parentHolds =
|
|
16
|
+
parent !== null &&
|
|
17
|
+
!parent.isSymbolicLink() &&
|
|
18
|
+
parent.isDirectory() &&
|
|
19
|
+
isOwnedBy(parent, system.uid) &&
|
|
20
|
+
(parent.mode & OTHERS_WRITE) === 0
|
|
21
|
+
return parentHolds && isPrivateLevels(directory, directory, system)
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
|
|
5
|
+
/** How many lines the resume log keeps: a few restarts' worth, so one can be checked afterwards. */
|
|
6
|
+
export const RESUME_LOG_LINES = 200
|
|
7
|
+
|
|
8
|
+
const LOG_DIRECTORY = 'logs'
|
|
9
|
+
const LOG_FILE = 'resume.log'
|
|
10
|
+
|
|
11
|
+
export const resumeLogDirectory = (home: string): string => join(home, TOOL_DIRECTORY, LOG_DIRECTORY)
|
|
12
|
+
|
|
13
|
+
export const resumeLogPath = (home: string): string => join(resumeLogDirectory(home), LOG_FILE)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One log line: the time, the repository and the step. A line break of herdr's answers becomes a blank, and every
|
|
17
|
+
* other control character is dropped (`terminalSafe`): the line is also printed, and a name read from a record must
|
|
18
|
+
* not act on the terminal.
|
|
19
|
+
*/
|
|
20
|
+
export const logLine = (now: number, root: string, message: string): string =>
|
|
21
|
+
terminalSafe(`${new Date(now).toISOString()} ${root} ${message}`.replace(/\s*\n\s*/g, ' '))
|
|
22
|
+
|
|
23
|
+
/** The log with `lines` appended, cut to its last `max` lines. */
|
|
24
|
+
export const trimmedLog = (existing: string | null, lines: readonly string[], max: number): string => {
|
|
25
|
+
const kept = [...(existing ?? '').split('\n').filter((line) => line !== ''), ...lines]
|
|
26
|
+
return `${kept.slice(-max).join('\n')}\n`
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
|
+
import { herdrSendPorts } from '../lane/send-herdr'
|
|
6
|
+
import { errorText } from '../plans/error-text'
|
|
7
|
+
import { readSettings } from '../plans/settings'
|
|
8
|
+
import { readPrivateText, systemLocation } from '../shared/private-location'
|
|
9
|
+
import { herdrRunner } from '../terminal/herdr'
|
|
10
|
+
import { isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from '../watch/watcher-lock'
|
|
11
|
+
import type { RecordEntry, ResumePorts } from './command'
|
|
12
|
+
import type { RecordStore } from './keeper'
|
|
13
|
+
import { isTrustedRecordDirectory } from './location'
|
|
14
|
+
import { logLine, RESUME_LOG_LINES, resumeLogDirectory, resumeLogPath, trimmedLog } from './log'
|
|
15
|
+
import { isRecordFileOf, parseResumeRecord, type ResumeRecord, resumeRecordDirectory, resumeRecordPath } from './record'
|
|
16
|
+
import { resumablePanesOf } from './targets'
|
|
17
|
+
|
|
18
|
+
const RECORD_FILE = /^[A-Za-z0-9._-]+\.json$/
|
|
19
|
+
|
|
20
|
+
const readRecord = (path: string): ResumeRecord | null => {
|
|
21
|
+
const text = readPrivateText(path)
|
|
22
|
+
return text === null ? null : parseResumeRecord(text)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A record counts only under the name its own `root` gives; a file under another name reads as damaged. */
|
|
26
|
+
const entryOf = (directory: string, name: string): RecordEntry => {
|
|
27
|
+
const path = join(directory, name)
|
|
28
|
+
const record = readRecord(path)
|
|
29
|
+
return { path, record: record !== null && isRecordFileOf(name, record) ? record : null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The records of `~/.jackherd/resume/`, read only from a directory nobody but the user can change
|
|
34
|
+
* (`isTrustedRecordDirectory`): board and lane names in there reach other sessions. Throws when the directory cannot be
|
|
35
|
+
* listed; `resumeAll` logs it.
|
|
36
|
+
*/
|
|
37
|
+
const readRecords = (home: string): RecordEntry[] => {
|
|
38
|
+
const directory = resumeRecordDirectory(home)
|
|
39
|
+
if (!isTrustedRecordDirectory(directory, systemLocation())) return []
|
|
40
|
+
return readdirSync(directory)
|
|
41
|
+
.filter((name) => RECORD_FILE.test(name))
|
|
42
|
+
.map((name) => entryOf(directory, name))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const writeRecord = (home: string, path: string, record: ResumeRecord) => {
|
|
46
|
+
ensurePrivateDirectory(resumeRecordDirectory(home))
|
|
47
|
+
writePrivateFile(path, `${JSON.stringify(record)}\n`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The watcher's record of `root` below `home`, the file `jh resume` reads after a crash. */
|
|
51
|
+
export const systemRecordStore = (root: string, home: string = homedir()): RecordStore => {
|
|
52
|
+
const path = resumeRecordPath(home, root)
|
|
53
|
+
return { read: () => readRecord(path), write: (record) => writeRecord(home, path, record) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isFile = (path: string): boolean => {
|
|
57
|
+
try {
|
|
58
|
+
return lstatSync(path).isFile()
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const appendLog = (home: string, line: string) => {
|
|
65
|
+
try {
|
|
66
|
+
ensurePrivateDirectory(resumeLogDirectory(home))
|
|
67
|
+
const path = resumeLogPath(home)
|
|
68
|
+
writePrivateFile(path, trimmedLog(readPrivateText(path), [line], RESUME_LOG_LINES))
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`jh resume: cannot write the log: ${errorText(error)}`)
|
|
71
|
+
}
|
|
72
|
+
console.log(line)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The ports of `jh resume` on this machine: the user's records and log below `home`, the watcher lock, herdr. */
|
|
76
|
+
export const systemResumePorts = (home: string = homedir()): ResumePorts => {
|
|
77
|
+
const herdr = herdrRunner(process.env)
|
|
78
|
+
const log = (root: string, message: string) => appendLog(home, logLine(Date.now(), root, message))
|
|
79
|
+
return {
|
|
80
|
+
now: Date.now,
|
|
81
|
+
records: () => readRecords(home),
|
|
82
|
+
reread: readRecord,
|
|
83
|
+
writeRecord: (path, record) => writeRecord(home, path, record),
|
|
84
|
+
watcherRunning: (root) => isWatcherRunning(join(watcherDirectoryOf(root), WATCHER_FILES.lock)),
|
|
85
|
+
boardExists: isFile,
|
|
86
|
+
settingsOf: (root) => {
|
|
87
|
+
const { responseLanguage, worktreeDirectory } = readSettings([root], home)
|
|
88
|
+
return { language: responseLanguage, worktreeDirectory }
|
|
89
|
+
},
|
|
90
|
+
agentPanes: () => resumablePanesOf(herdr(['agent', 'list']), herdr(['pane', 'list'])),
|
|
91
|
+
send: herdrSendPorts(),
|
|
92
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
93
|
+
log,
|
|
94
|
+
notify: (title, body) => {
|
|
95
|
+
try {
|
|
96
|
+
herdr(['notification', 'show', title, '--body', body])
|
|
97
|
+
} catch (error) {
|
|
98
|
+
log('-', `herdr note failed: ${errorText(error)}`)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isAbsolute, join } from 'node:path'
|
|
2
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
4
|
+
import { parseJsonOrNull } from '../shared/parse-json'
|
|
5
|
+
import { repositoryKey } from '../watch/watcher-lock'
|
|
6
|
+
|
|
7
|
+
export const RESUME_RECORD_VERSION = 1
|
|
8
|
+
|
|
9
|
+
/** Below `~/.jackherd/`: the user's own, outside every repository an agent writes to. */
|
|
10
|
+
const RESUME_DIRECTORY = 'resume'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the watcher keeps about one repository so `jh resume` can tell a crash from a stop: a record without `stoppedAt`
|
|
14
|
+
* was left by a watcher that never ended cleanly. `lanes` holds each lane's last `agent_status`, `resumedAt` the one
|
|
15
|
+
* restart this record may cause.
|
|
16
|
+
*/
|
|
17
|
+
export type ResumeRecord = {
|
|
18
|
+
version: typeof RESUME_RECORD_VERSION
|
|
19
|
+
root: string
|
|
20
|
+
/** The board the watcher followed, absolute. */
|
|
21
|
+
board: string
|
|
22
|
+
/** The coordinator's herdr name. */
|
|
23
|
+
coordinator: string
|
|
24
|
+
pid: number
|
|
25
|
+
startedAt: number
|
|
26
|
+
savedAt: number
|
|
27
|
+
lanes: Record<string, string>
|
|
28
|
+
stoppedAt: number | null
|
|
29
|
+
resumedAt: number | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the watcher knows when it takes the lock. */
|
|
33
|
+
export type RecordStart = Pick<ResumeRecord, 'root' | 'board' | 'coordinator' | 'pid'>
|
|
34
|
+
|
|
35
|
+
const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
|
36
|
+
|
|
37
|
+
const isTimeOrNull = (value: unknown): boolean => value === null || isTime(value)
|
|
38
|
+
|
|
39
|
+
const isAbsolutePath = (value: unknown): boolean => typeof value === 'string' && isAbsolute(value)
|
|
40
|
+
|
|
41
|
+
const isLaneStatuses = (value: unknown): boolean =>
|
|
42
|
+
isRecord(value) && Object.values(value).every((status) => typeof status === 'string')
|
|
43
|
+
|
|
44
|
+
export const isResumeRecord = (value: unknown): value is ResumeRecord =>
|
|
45
|
+
isRecord(value) &&
|
|
46
|
+
value.version === RESUME_RECORD_VERSION &&
|
|
47
|
+
isAbsolutePath(value.root) &&
|
|
48
|
+
isAbsolutePath(value.board) &&
|
|
49
|
+
typeof value.coordinator === 'string' &&
|
|
50
|
+
Number.isInteger(value.pid) &&
|
|
51
|
+
(value.pid as number) > 0 &&
|
|
52
|
+
isTime(value.startedAt) &&
|
|
53
|
+
isTime(value.savedAt) &&
|
|
54
|
+
isLaneStatuses(value.lanes) &&
|
|
55
|
+
'stoppedAt' in value &&
|
|
56
|
+
isTimeOrNull(value.stoppedAt) &&
|
|
57
|
+
'resumedAt' in value &&
|
|
58
|
+
isTimeOrNull(value.resumedAt)
|
|
59
|
+
|
|
60
|
+
/** The record in `text`; null for a damaged or foreign one, which the caller logs. */
|
|
61
|
+
export const parseResumeRecord = (text: string): ResumeRecord | null => {
|
|
62
|
+
const value = parseJsonOrNull(text)
|
|
63
|
+
return isResumeRecord(value) ? value : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `~/.jackherd/resume/<repositoryKey>.json`, the key the watcher directory of the same repository carries. */
|
|
67
|
+
export const resumeRecordDirectory = (home: string): string => join(home, TOOL_DIRECTORY, RESUME_DIRECTORY)
|
|
68
|
+
|
|
69
|
+
const recordFileName = (root: string): string => `${repositoryKey(root)}.json`
|
|
70
|
+
|
|
71
|
+
export const resumeRecordPath = (home: string, root: string): string =>
|
|
72
|
+
join(resumeRecordDirectory(home), recordFileName(root))
|
|
73
|
+
|
|
74
|
+
/** Whether the record lies under the name its `root` gives: a file under another name was not written by the watcher. */
|
|
75
|
+
export const isRecordFileOf = (fileName: string, record: ResumeRecord): boolean =>
|
|
76
|
+
fileName === recordFileName(record.root)
|
|
77
|
+
|
|
78
|
+
export const runningRecord = (start: RecordStart, now: number): ResumeRecord => ({
|
|
79
|
+
version: RESUME_RECORD_VERSION,
|
|
80
|
+
...start,
|
|
81
|
+
startedAt: now,
|
|
82
|
+
savedAt: now,
|
|
83
|
+
lanes: {},
|
|
84
|
+
stoppedAt: null,
|
|
85
|
+
resumedAt: null,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const sameStatuses = (left: Record<string, string>, right: Record<string, string>): boolean => {
|
|
89
|
+
const names = Object.keys(left)
|
|
90
|
+
return names.length === Object.keys(right).length && names.every((name) => left[name] === right[name])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The record with the lanes' statuses; the same object when none changed, so the watcher writes only on a change. */
|
|
94
|
+
export const withLanes = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
|
|
95
|
+
sameStatuses(record.lanes, lanes) ? record : { ...record, lanes: { ...lanes }, savedAt: now }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How often the watcher saves its record although no lane changed: once an hour, so a watcher that rests longer than
|
|
99
|
+
* `RESUME_MAX_AGE_MS` of `decision.ts` without a change still leaves a record young enough to resume after a crash.
|
|
100
|
+
*/
|
|
101
|
+
export const RESUME_REFRESH_MS = 60 * 60 * 1000
|
|
102
|
+
|
|
103
|
+
/** The record with a new save time once `RESUME_REFRESH_MS` is up; the same object before, so nothing is written. */
|
|
104
|
+
export const refreshedRecord = (record: ResumeRecord, now: number): ResumeRecord =>
|
|
105
|
+
now - record.savedAt < RESUME_REFRESH_MS ? record : { ...record, savedAt: now }
|
|
106
|
+
|
|
107
|
+
/** The record after one herdr poll: new statuses, else a new save time once due; the same object when neither. */
|
|
108
|
+
export const followedRecord = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
|
|
109
|
+
refreshedRecord(withLanes(record, lanes, now), now)
|
|
110
|
+
|
|
111
|
+
/** The record stamped as stopped cleanly; null when it names another process, whose record a successor keeps. */
|
|
112
|
+
export const stoppedRecord = (record: ResumeRecord, ownPid: number, now: number): ResumeRecord | null =>
|
|
113
|
+
record.pid === ownPid ? { ...record, stoppedAt: now, savedAt: now } : null
|
|
114
|
+
|
|
115
|
+
/** The record stamped as resumed, written before the first message goes out: one restart per record. */
|
|
116
|
+
export const resumedRecord = (record: ResumeRecord, now: number): ResumeRecord => ({ ...record, resumedAt: now })
|