jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
2
|
+
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
+
import { APPOINTMENT_VOCABULARY, PLAN_LANGUAGE, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
4
|
+
|
|
5
|
+
type SectionColumns = {
|
|
6
|
+
heading: string
|
|
7
|
+
/** The header names of the two cells that identify a row, then the names that count as the status column. */
|
|
8
|
+
keys: readonly [string, string]
|
|
9
|
+
status: readonly string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DONE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'erledigt', [PLAN_LANGUAGE.en]: 'done' }
|
|
13
|
+
|
|
14
|
+
/** The status a closed row gets, `erledigt (User <date> <time>)`: the time comes from `now`, never from an estimate. */
|
|
15
|
+
export const doneStatus = (language: PlanLanguage, now: Date): string =>
|
|
16
|
+
`${DONE_WORD[language]} (User ${twoDigits(now.getDate())}.${twoDigits(now.getMonth() + 1)}. ${twoDigits(now.getHours())}:${twoDigits(now.getMinutes())})`
|
|
17
|
+
|
|
18
|
+
const squeezed = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
19
|
+
|
|
20
|
+
const waitingColumns = (language: PlanLanguage): SectionColumns => {
|
|
21
|
+
const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
|
|
22
|
+
return { heading, keys: [columns.since, columns.item], status: [columns.status, ...statusAlso] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const appointmentColumns = (language: PlanLanguage): SectionColumns => {
|
|
26
|
+
const { heading, columns, statusAlso } = APPOINTMENT_VOCABULARY[language]
|
|
27
|
+
return { heading, keys: [columns.when, columns.action], status: [columns.status, ...statusAlso] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The line with the status cell (the `index`th between the bars) replaced; the other cells keep their text as written. */
|
|
31
|
+
const withStatus = (line: string, index: number, status: string): string => {
|
|
32
|
+
const parts = line.split('|')
|
|
33
|
+
if (index + 1 >= parts.length) return line
|
|
34
|
+
parts[index + 1] = ` ${status} `
|
|
35
|
+
return parts.join('|')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The board with the status cell of the first row of the section's table whose two key cells equal `keys` replaced by
|
|
40
|
+
* `status`; null when the section, a column or the row is missing, so a row the board lost since the herd drew it
|
|
41
|
+
* writes nothing.
|
|
42
|
+
*/
|
|
43
|
+
const closeRow = (
|
|
44
|
+
board: string,
|
|
45
|
+
{ heading, keys: keyNames, status: statusNames }: SectionColumns,
|
|
46
|
+
keys: readonly [string, string],
|
|
47
|
+
status: string,
|
|
48
|
+
): string | null => {
|
|
49
|
+
const start = sectionStart(board, heading)
|
|
50
|
+
if (start === -1) return null
|
|
51
|
+
const lines = sectionLines(board, heading)
|
|
52
|
+
const header = lines.find((line) => line.startsWith('|'))
|
|
53
|
+
if (!header) return null
|
|
54
|
+
const names = header
|
|
55
|
+
.slice(1, header.endsWith('|') ? -1 : undefined)
|
|
56
|
+
.split('|')
|
|
57
|
+
.map((cell) => cell.trim())
|
|
58
|
+
const positions = keyNames.map((name) => names.indexOf(name))
|
|
59
|
+
const statusAt = names.findIndex((name) => statusNames.includes(name))
|
|
60
|
+
if (positions.includes(-1) || statusAt === -1) return null
|
|
61
|
+
const row = firstTableRows(lines).find(({ cells }) =>
|
|
62
|
+
positions.every((position, index) => squeezed(cells[position] ?? '') === squeezed(keys[index])),
|
|
63
|
+
)
|
|
64
|
+
if (!row) return null
|
|
65
|
+
const all = board.split('\n')
|
|
66
|
+
const at = start + row.index
|
|
67
|
+
all[at] = withStatus(all[at], statusAt, status)
|
|
68
|
+
return all.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The board with the waiting row `since`/`item` closed at `now`; null when the board has no such row. */
|
|
72
|
+
export const closeWaitingRow = (
|
|
73
|
+
board: string,
|
|
74
|
+
language: PlanLanguage,
|
|
75
|
+
{ since, item }: { since: string; item: string },
|
|
76
|
+
now: Date,
|
|
77
|
+
): string | null => closeRow(board, waitingColumns(language), [since, item], doneStatus(language, now))
|
|
78
|
+
|
|
79
|
+
/** The board with the appointment `when`/`action` closed at `now`; null when the board has no such row. */
|
|
80
|
+
export const closeAppointmentRow = (
|
|
81
|
+
board: string,
|
|
82
|
+
language: PlanLanguage,
|
|
83
|
+
{ when, action }: { when: string; action: string },
|
|
84
|
+
now: Date,
|
|
85
|
+
): string | null => closeRow(board, appointmentColumns(language), [when, action], doneStatus(language, now))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Milliseconds a child gets to end after a signal before it is killed. */
|
|
2
|
+
export const GRACE_MS = 2000
|
|
3
|
+
|
|
4
|
+
export type StopperDeps = {
|
|
5
|
+
kill: (signal: NodeJS.Signals) => void
|
|
6
|
+
setTimer: (run: () => void, ms: number) => unknown
|
|
7
|
+
clearTimer: (handle: unknown) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const STOP_REQUEST = { forwarded: 'forwarded', killed: 'killed' } as const
|
|
11
|
+
|
|
12
|
+
export type StopRequest = (typeof STOP_REQUEST)[keyof typeof STOP_REQUEST]
|
|
13
|
+
|
|
14
|
+
export type Stopper = {
|
|
15
|
+
/** The first call forwards `signal` and arms the kill; any further call kills at once. */
|
|
16
|
+
request: (signal: NodeJS.Signals) => StopRequest
|
|
17
|
+
/** The child has ended: nothing is left to kill. */
|
|
18
|
+
ended: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stops one child: the signal goes to it, and a child that ignores it is killed once `GRACE_MS` have passed, so a
|
|
23
|
+
* supervisor never waits on a view that does not end.
|
|
24
|
+
*/
|
|
25
|
+
export const stopperOf = ({ kill, setTimer, clearTimer }: StopperDeps): Stopper => {
|
|
26
|
+
let timer: unknown
|
|
27
|
+
let requested = false
|
|
28
|
+
|
|
29
|
+
const send = (signal: NodeJS.Signals): void => {
|
|
30
|
+
try {
|
|
31
|
+
kill(signal)
|
|
32
|
+
} catch {
|
|
33
|
+
// the child is already gone
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
request: (signal) => {
|
|
39
|
+
if (requested) {
|
|
40
|
+
clearTimer(timer)
|
|
41
|
+
send('SIGKILL')
|
|
42
|
+
return STOP_REQUEST.killed
|
|
43
|
+
}
|
|
44
|
+
requested = true
|
|
45
|
+
send(signal)
|
|
46
|
+
timer = setTimer(() => send('SIGKILL'), GRACE_MS)
|
|
47
|
+
return STOP_REQUEST.forwarded
|
|
48
|
+
},
|
|
49
|
+
ended: () => clearTimer(timer),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readRecords, writeRecordAtomically } from '../shared/private-records'
|
|
3
|
+
|
|
4
|
+
/** A record that waits on the user until something sets `closedAt`. */
|
|
5
|
+
export type ClosableRecord = { id: string; closedAt: number | null }
|
|
6
|
+
|
|
7
|
+
export type ClosableRecordLog<T extends ClosableRecord> = {
|
|
8
|
+
/** Every record of the log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
|
|
9
|
+
read: (root: string) => T[]
|
|
10
|
+
/** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
|
|
11
|
+
close: (root: string, ids: readonly string[], now: number) => number
|
|
12
|
+
/** Marks every open record `closedAt` and returns how many it closed. */
|
|
13
|
+
closeAll: (root: string, now: number) => number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The log of `.jackherd/<directoryName>/`: records the `guard` accepts, ordered by `timeOf`. */
|
|
17
|
+
export const closableRecordLog = <T extends ClosableRecord>(
|
|
18
|
+
directoryName: string,
|
|
19
|
+
guard: (value: unknown) => value is T,
|
|
20
|
+
timeOf: (record: T) => number,
|
|
21
|
+
): ClosableRecordLog<T> => {
|
|
22
|
+
const directoryOf = (root: string): string => join(root, '.jackherd', directoryName)
|
|
23
|
+
const read = (root: string): T[] => readRecords(directoryOf(root), guard, timeOf)
|
|
24
|
+
const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number): number => {
|
|
25
|
+
const open = read(root).filter((record) => record.closedAt === null && isChosen(record))
|
|
26
|
+
for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
|
|
27
|
+
return open.length
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
read,
|
|
31
|
+
close: (root, ids, now) => closeWhere(root, (record) => ids.includes(record.id), now),
|
|
32
|
+
closeAll: (root, now) => closeWhere(root, () => true, now),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/plans/command.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { boardFor, foundLanesOf } from './board-pick'
|
|
|
6
6
|
import { ensurePlanPanes } from './ensure-panes'
|
|
7
7
|
import { mainCheckout, worktreeRoot } from './git'
|
|
8
8
|
import { fetchPullRequests } from './github'
|
|
9
|
+
import { claimOwnTerminal, startExclusive } from './herd-claim'
|
|
9
10
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
10
11
|
import { runLive } from './live'
|
|
11
12
|
import { parseOptions } from './options'
|
|
@@ -59,8 +60,8 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
59
60
|
}
|
|
60
61
|
const source = { root, sprintFile: picked.file, options, settings, terminal }
|
|
61
62
|
if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
|
|
62
|
-
else if (isSupervisedChild(process.env)) runLive(source)
|
|
63
|
-
else
|
|
63
|
+
else if (isSupervisedChild(process.env)) await startExclusive(claimOwnTerminal, () => runLive(source))
|
|
64
|
+
else process.exit(await superviseView(args, await startedKit()))
|
|
64
65
|
return 0
|
|
65
66
|
} catch (error) {
|
|
66
67
|
console.error(error instanceof Error ? error.message : String(error))
|
package/src/plans/fetch.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { errorText } from './error-text'
|
|
2
2
|
import type { PullRequestState } from './github'
|
|
3
|
+
import { pullHeadRefspec } from './plan-heads'
|
|
4
|
+
import type { PullRequest } from './pull-requests'
|
|
3
5
|
|
|
4
6
|
/** The background fetch of the base branch: when it last ran, when it last worked and why it failed. */
|
|
5
7
|
export type FetchState = {
|
|
@@ -70,12 +72,13 @@ export const fetchNotice = (state: FetchState, baseRef: string, locale: string):
|
|
|
70
72
|
return `fetch failed (${state.failure}), reading ${baseRef} as it is: ${known}`
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
/** Fetches
|
|
74
|
-
export const
|
|
75
|
+
/** Fetches `refspecs` from `remote` in one call; returns the reason when it did not work, null when it did or there was nothing to fetch. Never rejects. */
|
|
76
|
+
export const fetchRefs = async (root: string, remote: string, refspecs: readonly string[]): Promise<string | null> => {
|
|
77
|
+
if (refspecs.length === 0) return null
|
|
75
78
|
let timedOut = false
|
|
76
79
|
let timer: Timer | undefined
|
|
77
80
|
try {
|
|
78
|
-
const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote,
|
|
81
|
+
const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote, ...refspecs], {
|
|
79
82
|
stdout: 'ignore',
|
|
80
83
|
stderr: 'pipe',
|
|
81
84
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
@@ -94,3 +97,23 @@ export const fetchBase = async (root: string, remote: string, branch: string): P
|
|
|
94
97
|
clearTimeout(timer)
|
|
95
98
|
}
|
|
96
99
|
}
|
|
100
|
+
|
|
101
|
+
/** Fetches the base branch; returns the reason when it did not work, null when it did. Never rejects. */
|
|
102
|
+
export const fetchBase = (root: string, remote: string, branch: string): Promise<string | null> =>
|
|
103
|
+
fetchRefs(root, remote, [branch])
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetches the heads of the given pull requests into the herd's own refs (`pullHeadRef`), in one call after the base.
|
|
107
|
+
* A failure is not reported: a head that was not fetched reads nothing, and the plan stays as the base has it.
|
|
108
|
+
*/
|
|
109
|
+
export const fetchPlanHeads = async (
|
|
110
|
+
root: string,
|
|
111
|
+
remote: string,
|
|
112
|
+
pullRequests: readonly Pick<PullRequest, 'number'>[],
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
await fetchRefs(
|
|
115
|
+
root,
|
|
116
|
+
remote,
|
|
117
|
+
pullRequests.map(({ number }) => pullHeadRefspec(number)),
|
|
118
|
+
)
|
|
119
|
+
}
|
package/src/plans/frame.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { paint, visibleLength } from '../terminal/text'
|
|
2
|
+
import { focusLine, isFocused } from './row-focus'
|
|
2
3
|
import type { ClickTarget, Selection } from './selection'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -73,17 +74,9 @@ const branches = (children: Frame[]): Frame[] => {
|
|
|
73
74
|
export const nest = (children: Frame[]): Frame => joinFrames(branches(children))
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* A row drawn
|
|
77
|
-
*
|
|
77
|
+
* A selectable row: drawn with the focus background and mark (`focusLine`) when selected or under the mouse, clickable
|
|
78
|
+
* across the pane on every line, `below` follows unselectable. Only the selected row holds the cursor.
|
|
78
79
|
*/
|
|
79
|
-
const highlight = (line: string, width: number): string => {
|
|
80
|
-
const bars = line.match(BAR_SEGMENT) ?? []
|
|
81
|
-
const texts = line.split(BAR_SEGMENT).map(stripAnsi)
|
|
82
|
-
const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
|
|
83
|
-
return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
|
|
87
80
|
export const rowFrame = (
|
|
88
81
|
key: string,
|
|
89
82
|
rows: string[],
|
|
@@ -92,7 +85,7 @@ export const rowFrame = (
|
|
|
92
85
|
): Frame => {
|
|
93
86
|
const selected = state.selected === key
|
|
94
87
|
return {
|
|
95
|
-
lines: [...(
|
|
88
|
+
lines: [...(isFocused(state, key) ? rows.map((row, index) => focusLine(row, width, index === 0)) : rows), ...below],
|
|
96
89
|
cursorLine: selected ? 0 : -1,
|
|
97
90
|
targets: rows.map((_, line) => ({ line, from: 0, to: width, key, wholeLine: true as const })),
|
|
98
91
|
}
|
package/src/plans/git.ts
CHANGED
|
@@ -32,21 +32,70 @@ export const showAtRef = (root: string, ref: string, path: string): string | nul
|
|
|
32
32
|
export const commitOf = (root: string, ref: string): string | null =>
|
|
33
33
|
git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
34
34
|
|
|
35
|
+
const lastChangeAt = (root: string, commit: string, path: string): number | null => {
|
|
36
|
+
const seconds = Number(git(root, ['log', '-1', '--format=%ct', commit, '--', path]))
|
|
37
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The last-change times read per `root\0commit`, by path, the commit asked last at the end; a change at a commit never moves. */
|
|
41
|
+
const cachedChanges = new Map<string, Map<string, number | null>>()
|
|
42
|
+
|
|
43
|
+
const changesOf = (root: string, commit: string): Map<string, number | null> => {
|
|
44
|
+
const key = `${root}\0${commit}`
|
|
45
|
+
const changes = cachedChanges.get(key) ?? new Map<string, number | null>()
|
|
46
|
+
cachedChanges.delete(key)
|
|
47
|
+
cachedChanges.set(key, changes)
|
|
48
|
+
const oldest = cachedChanges.keys().next().value
|
|
49
|
+
if (cachedChanges.size > CACHED_COMMITS && oldest !== undefined) cachedChanges.delete(oldest)
|
|
50
|
+
return changes
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A reader of the commit time, in epoch milliseconds, of the last commit at `ref` that changed a path; null when the
|
|
55
|
+
* ref or the file is unknown there. The commit is resolved once per reader and each answer is kept per commit, so a
|
|
56
|
+
* refresh while the ref stands costs one `git rev-parse`, and a moved ref reads afresh.
|
|
57
|
+
*/
|
|
58
|
+
export const lastChangeReaderOf = (root: string, ref: string): ((path: string) => number | null) => {
|
|
59
|
+
const commit = commitOf(root, ref)
|
|
60
|
+
if (commit === null) return () => null
|
|
61
|
+
const changes = changesOf(root, commit)
|
|
62
|
+
return (path) => {
|
|
63
|
+
if (changes.has(path)) return changes.get(path) ?? null
|
|
64
|
+
const at = lastChangeAt(root, commit, path)
|
|
65
|
+
changes.set(path, at)
|
|
66
|
+
return at
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
/** Reads files as they are at the base ref. */
|
|
36
71
|
export type BaseReader = (path: string) => string | null
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
/** How many commits keep their texts: the base ref and the heads of the open pull requests that change a plan. */
|
|
74
|
+
const CACHED_COMMITS = 16
|
|
75
|
+
|
|
76
|
+
/** The texts read per `root\0commit`, the one asked last at the end. */
|
|
77
|
+
const cachedTexts = new Map<string, Map<string, string>>()
|
|
78
|
+
|
|
79
|
+
const textsOf = (root: string, commit: string): Map<string, string> => {
|
|
80
|
+
const key = `${root}\0${commit}`
|
|
81
|
+
const texts = cachedTexts.get(key) ?? new Map<string, string>()
|
|
82
|
+
cachedTexts.delete(key)
|
|
83
|
+
cachedTexts.set(key, texts)
|
|
84
|
+
const oldest = cachedTexts.keys().next().value
|
|
85
|
+
if (cachedTexts.size > CACHED_COMMITS && oldest !== undefined) cachedTexts.delete(oldest)
|
|
86
|
+
return texts
|
|
87
|
+
}
|
|
39
88
|
|
|
40
89
|
/**
|
|
41
90
|
* A reader for the files at `ref`, with the commit resolved once. A file at a commit never changes, so what was
|
|
42
|
-
* read is kept for as long as the
|
|
43
|
-
* rev-parse` instead of one `git show` per plan
|
|
91
|
+
* read is kept for as long as the commit is among the last `CACHED_COMMITS` asked: a re-read of the sprint costs
|
|
92
|
+
* one `git rev-parse` per ref instead of one `git show` per plan, and the base and the pull request heads read in
|
|
93
|
+
* the same round do not evict each other.
|
|
44
94
|
*/
|
|
45
95
|
export const baseReaderOf = (root: string, ref: string): BaseReader => {
|
|
46
96
|
const commit = commitOf(root, ref)
|
|
47
97
|
if (commit === null) return () => null
|
|
48
|
-
|
|
49
|
-
const { texts } = cached
|
|
98
|
+
const texts = textsOf(root, commit)
|
|
50
99
|
return (path) => {
|
|
51
100
|
const kept = texts.get(path)
|
|
52
101
|
if (kept !== undefined) return kept
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DAY_MS } from '../plan-format/clock'
|
|
2
|
+
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { readAppointmentTable } from '../watch/appointments'
|
|
4
|
+
|
|
5
|
+
const GROOMING_INTERVAL_DAYS = 7
|
|
6
|
+
const STALE_FROM_DAYS = 8
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whole days since the board was last groomed, or null when the board has no open grooming appointment. The
|
|
10
|
+
* coordinator moves that appointment a week on after each grooming, so the last one was a week before it. A time
|
|
11
|
+
* before that (a board never groomed on the clock) counts as 0.
|
|
12
|
+
*/
|
|
13
|
+
export const groomedDaysAgo = (board: string, language: PlanLanguage, now: Date): number | null => {
|
|
14
|
+
const word = APPOINTMENT_VOCABULARY[language].grooming
|
|
15
|
+
const next = readAppointmentTable(board, language, now)
|
|
16
|
+
.appointments.filter(({ open, action }) => open && action.toLowerCase().startsWith(word))
|
|
17
|
+
.sort((left, right) => left.when.getTime() - right.when.getTime())[0]
|
|
18
|
+
if (!next) return null
|
|
19
|
+
const groomedAt = next.when.getTime() - GROOMING_INTERVAL_DAYS * DAY_MS
|
|
20
|
+
return Math.max(Math.floor((now.getTime() - groomedAt) / DAY_MS), 0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The header text; `stale` from eight days on, when the herd paints it amber. */
|
|
24
|
+
export const groomedText = (days: number): { text: string; stale: boolean } => ({
|
|
25
|
+
text: days === 0 ? 'groomed today' : `groomed ${days}d ago`,
|
|
26
|
+
stale: days >= STALE_FROM_DAYS,
|
|
27
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, readlinkSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
|
+
import {
|
|
6
|
+
type LocationSystem,
|
|
7
|
+
readPrivateText,
|
|
8
|
+
runtimeBaseOf,
|
|
9
|
+
systemLocation,
|
|
10
|
+
userDirectoryOf,
|
|
11
|
+
} from '../shared/private-location'
|
|
12
|
+
import { HERD_COMMAND } from './herd-command'
|
|
13
|
+
import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
14
|
+
|
|
15
|
+
const DIRECTORY_PREFIX = 'jackherd-herd'
|
|
16
|
+
const POLL_MS = 50
|
|
17
|
+
const POLLS_BEFORE_KILL = 20
|
|
18
|
+
|
|
19
|
+
export type ClaimDeps = {
|
|
20
|
+
selfPid: number
|
|
21
|
+
prepare: () => void
|
|
22
|
+
readRecord: () => string | null
|
|
23
|
+
writeRecord: (pid: number) => void
|
|
24
|
+
removeRecordIf: (pid: number) => void
|
|
25
|
+
isHerdView: (pid: number) => boolean
|
|
26
|
+
isGone: (pid: number) => boolean
|
|
27
|
+
signal: (pid: number, signal: NodeJS.Signals) => void
|
|
28
|
+
sleep: (ms: number) => Promise<void>
|
|
29
|
+
onExit: (run: () => void) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A process id as the record file holds it; nothing for anything but a positive integer. */
|
|
33
|
+
export const parsePid = (text: string | null): number | null => {
|
|
34
|
+
if (text === null || !/^\d+\s*$/.test(text)) return null
|
|
35
|
+
const pid = Number(text)
|
|
36
|
+
return pid > 0 ? pid : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The older herd view of this terminal that has to end before `selfPid` draws: the recorded pid when it is not
|
|
41
|
+
* this process and still is a herd view (a number the system has reused for something else is not).
|
|
42
|
+
*/
|
|
43
|
+
export const olderHerdToStop = (
|
|
44
|
+
recorded: number | null,
|
|
45
|
+
selfPid: number,
|
|
46
|
+
isHerdView: (pid: number) => boolean,
|
|
47
|
+
): number | null => (recorded === null || recorded === selfPid || !isHerdView(recorded) ? null : recorded)
|
|
48
|
+
|
|
49
|
+
const endOlderHerd = async (pid: number, deps: ClaimDeps): Promise<void> => {
|
|
50
|
+
deps.signal(pid, TAKEOVER_SIGNAL)
|
|
51
|
+
for (let poll = 0; poll < POLLS_BEFORE_KILL; poll++) {
|
|
52
|
+
if (deps.isGone(pid)) return
|
|
53
|
+
await deps.sleep(POLL_MS)
|
|
54
|
+
}
|
|
55
|
+
if (!deps.isGone(pid) && deps.isHerdView(pid)) deps.signal(pid, 'SIGKILL')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Makes this process the one herd view of its terminal: an older one is sent `TAKEOVER_SIGNAL` and, when it has not ended
|
|
60
|
+
* after about a second and its pid still is a herd view, SIGKILL; then the own pid is recorded and removed again when the process ends. The claim
|
|
61
|
+
* directory is verified first: a record in a directory of someone else is never read, and a directory that cannot be
|
|
62
|
+
* verified throws before anything is read, signalled or written.
|
|
63
|
+
*/
|
|
64
|
+
export const claimTerminal = async (deps: ClaimDeps): Promise<void> => {
|
|
65
|
+
deps.prepare()
|
|
66
|
+
const older = olderHerdToStop(parsePid(deps.readRecord()), deps.selfPid, deps.isHerdView)
|
|
67
|
+
if (older !== null) await endOlderHerd(older, deps)
|
|
68
|
+
deps.writeRecord(deps.selfPid)
|
|
69
|
+
deps.onExit(() => deps.removeRecordIf(deps.selfPid))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The directory holding the claim records: one private level `jackherd-herd-<uid>` below `runtimeBaseOf`. */
|
|
73
|
+
export const claimDirectoryOf = (system: LocationSystem): string =>
|
|
74
|
+
userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid)
|
|
75
|
+
|
|
76
|
+
const exitWhenTakenOver = (): void => {
|
|
77
|
+
process.on(TAKEOVER_SIGNAL, () => process.exit(TAKEN_OVER_CODE))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runs `start` once the takeover is done; a takeover that cannot be made (no terminal, no `/proc`) does not stop the
|
|
82
|
+
* view. `listen` runs first, so a newer herd's takeover signal that arrives while the claim is made ends this process
|
|
83
|
+
* as a wanted end instead of by the signal's default action; the view replaces that handler with its own.
|
|
84
|
+
*/
|
|
85
|
+
export const startExclusive = async (
|
|
86
|
+
claim: () => Promise<void>,
|
|
87
|
+
start: () => void,
|
|
88
|
+
listen: () => void = exitWhenTakenOver,
|
|
89
|
+
): Promise<void> => {
|
|
90
|
+
listen()
|
|
91
|
+
try {
|
|
92
|
+
await claim()
|
|
93
|
+
} catch {
|
|
94
|
+
// the view still starts: one stale herd is better than none
|
|
95
|
+
}
|
|
96
|
+
start()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const terminalOfStdin = (): string | null => {
|
|
100
|
+
try {
|
|
101
|
+
const target = readlinkSync('/proc/self/fd/0')
|
|
102
|
+
return target.startsWith('/dev/') ? target : null
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const readText = (path: string): string | null => {
|
|
109
|
+
try {
|
|
110
|
+
return readFileSync(path, 'utf8')
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isHerdViewProcess = (pid: number): boolean => {
|
|
117
|
+
const environment = readText(`/proc/${pid}/environ`)
|
|
118
|
+
const command = readText(`/proc/${pid}/cmdline`)
|
|
119
|
+
if (environment === null || command === null) return false
|
|
120
|
+
return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isGoneProcess = (pid: number): boolean => {
|
|
124
|
+
const stat = readText(`/proc/${pid}/stat`)
|
|
125
|
+
if (stat === null) return true
|
|
126
|
+
const stateIndex = stat.lastIndexOf(')') + 2
|
|
127
|
+
return stat.charAt(stateIndex) === 'Z'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, signal)
|
|
133
|
+
} catch {
|
|
134
|
+
// already gone
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const recordFileOf = (terminal: string): string =>
|
|
139
|
+
join(claimDirectoryOf(systemLocation()), `${createHash('sha256').update(terminal).digest('hex').slice(0, 16)}.pid`)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The claim operations for one record file; the claim directory is verified before the record is read or written, and
|
|
143
|
+
* a record that is a link or a FIFO is not read.
|
|
144
|
+
*/
|
|
145
|
+
export const herdClaimDeps = (file: string): ClaimDeps => ({
|
|
146
|
+
selfPid: process.pid,
|
|
147
|
+
prepare: () => ensurePrivateDirectory(join(file, '..')),
|
|
148
|
+
readRecord: () => readPrivateText(file),
|
|
149
|
+
writeRecord: (pid) => {
|
|
150
|
+
ensurePrivateDirectory(join(file, '..'))
|
|
151
|
+
writePrivateFile(file, `${pid}\n`)
|
|
152
|
+
},
|
|
153
|
+
removeRecordIf: (pid) => {
|
|
154
|
+
try {
|
|
155
|
+
if (parsePid(readPrivateText(file)) === pid) unlinkSync(file)
|
|
156
|
+
} catch {
|
|
157
|
+
// the next herd of this terminal overwrites it
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
isHerdView: isHerdViewProcess,
|
|
161
|
+
isGone: isGoneProcess,
|
|
162
|
+
signal: signalProcess,
|
|
163
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
164
|
+
onExit: (run) => {
|
|
165
|
+
process.on('exit', run)
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
|
|
170
|
+
export const claimOwnTerminal = async (): Promise<void> => {
|
|
171
|
+
const terminal = terminalOfStdin()
|
|
172
|
+
if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
|
|
173
|
+
}
|
package/src/plans/herd-memory.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
4
2
|
import { z } from 'zod'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
4
|
+
import {
|
|
5
|
+
ensurePrivateLevels,
|
|
6
|
+
type LocationSystem,
|
|
7
|
+
readPrivateText,
|
|
8
|
+
runtimeBaseOf,
|
|
9
|
+
systemLocation,
|
|
10
|
+
userDirectoryOf,
|
|
11
|
+
} from '../shared/private-location'
|
|
5
12
|
import { repositoryKey } from '../watch/watcher-lock'
|
|
6
13
|
import type { LaneMemory } from './current-phase'
|
|
7
14
|
|
|
8
15
|
const DIRECTORY_PREFIX = 'jackherd-herd'
|
|
9
16
|
const MEMORY_FILE = 'herd-memory.json'
|
|
10
|
-
const PRIVATE_DIRECTORY = 0o700
|
|
11
|
-
const PRIVATE_FILE = 0o600
|
|
12
17
|
|
|
13
18
|
const phaseRef = z.object({ planHref: z.string(), phaseId: z.string() })
|
|
14
19
|
|
|
@@ -54,41 +59,46 @@ export const mergedMemories = (
|
|
|
54
59
|
* Where the herds of the repository at `root` keep what they saw of each lane, one directory per user and
|
|
55
60
|
* repository like the watcher's, so a `--once` run and a restarted herd know when a phase last moved.
|
|
56
61
|
*/
|
|
57
|
-
const
|
|
58
|
-
join(
|
|
62
|
+
export const herdMemoryDirectoryOf = (root: string, system: LocationSystem): string =>
|
|
63
|
+
join(userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid), repositoryKey(root))
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
return readFileSync(file, 'utf8')
|
|
65
|
-
} catch {
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
65
|
+
/** The memory directory of `root` with both levels verified private (`ensurePrivateLevels`); throws when one is not. */
|
|
66
|
+
const ensureHerdMemoryDirectory = (root: string, system: LocationSystem): string => {
|
|
67
|
+
const directory = herdMemoryDirectoryOf(root, system)
|
|
68
|
+
return ensurePrivateLevels(dirname(directory), directory)
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export const memoryFileOf = (directory: string): string => join(directory, MEMORY_FILE)
|
|
72
|
+
|
|
70
73
|
export const readLaneMemories = (directory: string): Map<string, LaneMemory> =>
|
|
71
|
-
parseLaneMemories(
|
|
74
|
+
parseLaneMemories(readPrivateText(memoryFileOf(directory)))
|
|
72
75
|
|
|
73
76
|
/**
|
|
74
77
|
* Writes the memories of `lanes` into the file of `directory`, beside what other herds keep there; readable by the
|
|
75
|
-
* user only, and replaced in one step
|
|
78
|
+
* user only (the directory is verified with `ensurePrivateDirectory`), and replaced in one step through
|
|
79
|
+
* `writePrivateFile`, so a herd reading at the same time never sees half a file and a planted link is never followed.
|
|
76
80
|
*/
|
|
77
81
|
export const writeLaneMemories = (
|
|
78
82
|
directory: string,
|
|
79
83
|
memories: ReadonlyMap<string, LaneMemory>,
|
|
80
84
|
lanes: readonly string[],
|
|
81
85
|
): void => {
|
|
82
|
-
|
|
83
|
-
const file = memoryFileOf(directory)
|
|
86
|
+
ensurePrivateDirectory(directory)
|
|
84
87
|
const merged = mergedMemories(readLaneMemories(directory), memories, lanes)
|
|
85
|
-
|
|
86
|
-
writeFileSync(partial, JSON.stringify(merged), { mode: PRIVATE_FILE })
|
|
87
|
-
renameSync(partial, file)
|
|
88
|
+
writePrivateFile(memoryFileOf(directory), JSON.stringify(merged))
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The memories the herds of the repository at `root` kept; none when the directory cannot be verified private, so a
|
|
93
|
+
* file planted in a directory of someone else is never read.
|
|
94
|
+
*/
|
|
95
|
+
export const recallLanes = (root: string, system: LocationSystem = systemLocation()): Map<string, LaneMemory> => {
|
|
96
|
+
try {
|
|
97
|
+
return readLaneMemories(ensureHerdMemoryDirectory(root, system))
|
|
98
|
+
} catch {
|
|
99
|
+
return new Map()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
92
102
|
|
|
93
103
|
/**
|
|
94
104
|
* Keeps the memories of `lanes` for the next herd. A memory that cannot be written costs only the times of the next
|
|
@@ -98,9 +108,10 @@ export const rememberLanes = (
|
|
|
98
108
|
root: string,
|
|
99
109
|
memories: ReadonlyMap<string, LaneMemory>,
|
|
100
110
|
lanes: readonly string[],
|
|
111
|
+
system: LocationSystem = systemLocation(),
|
|
101
112
|
): void => {
|
|
102
113
|
try {
|
|
103
|
-
writeLaneMemories(
|
|
114
|
+
writeLaneMemories(ensureHerdMemoryDirectory(root, system), memories, lanes)
|
|
104
115
|
} catch {
|
|
105
116
|
return
|
|
106
117
|
}
|