@joshuafolkken/kit 1.1383.0 → 1.1387.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.
Files changed (52) hide show
  1. package/dist/hooks/batch-guard.js +6 -6
  2. package/dist/hooks/{chunk-4MGHBTOM.js → chunk-2YSW3CXH.js} +8 -2
  3. package/dist/hooks/{chunk-KCF2YE7O.js → chunk-DCY2GWHC.js} +5 -5
  4. package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-EAQEGME7.js} +1 -1
  5. package/dist/hooks/{chunk-LC56R53A.js → chunk-EBSHN25W.js} +3 -3
  6. package/dist/hooks/{chunk-6MAW7BCM.js → chunk-GJ7JPIHN.js} +14 -14
  7. package/dist/hooks/{chunk-6AC4C2IO.js → chunk-GNDJ7NJ7.js} +59 -462
  8. package/dist/hooks/{chunk-LOJTBU5V.js → chunk-IYVOLN6A.js} +3 -3
  9. package/dist/hooks/{chunk-Y5R47QHU.js → chunk-J3KSS7II.js} +1 -1
  10. package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-LMWBRJIP.js} +3 -3
  11. package/dist/hooks/{chunk-TT742SIO.js → chunk-SL4GQ2XP.js} +2 -2
  12. package/dist/hooks/{chunk-YKUITFT7.js → chunk-STDSEPWU.js} +2 -2
  13. package/dist/hooks/{chunk-2X2ART5G.js → chunk-U32VSRNG.js} +420 -4
  14. package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-WNFJLBXI.js} +3 -3
  15. package/dist/hooks/codex-hook-adapter.js +13 -13
  16. package/dist/hooks/duplicate-read-guard.js +7 -7
  17. package/dist/hooks/format-edited.js +3 -3
  18. package/dist/hooks/inputs.json +208 -206
  19. package/dist/hooks/investigation-guard.js +7 -7
  20. package/dist/hooks/pretool-guard.js +12 -12
  21. package/dist/hooks/session-lang.js +65 -9
  22. package/dist/hooks/stop-guard.js +91 -23
  23. package/dist/josh.js +1 -1
  24. package/package.json +1 -1
  25. package/scripts/backlog/backlog-drive-cli.ts +2 -4
  26. package/scripts/backlog/backlog-drive-launch.ts +2 -0
  27. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  28. package/scripts/backlog/backlogrun-launch.ts +56 -5
  29. package/scripts/document/document-byte-budget.ts +1 -1
  30. package/scripts/init/start-plan.ts +15 -4
  31. package/scripts/init/start-steps.ts +31 -3
  32. package/scripts/init/start.ts +20 -13
  33. package/scripts/josh/josh-commands-project.ts +1 -1
  34. package/scripts/run/board/run-board-header.ts +39 -13
  35. package/scripts/run/board/run-board-read.ts +9 -2
  36. package/scripts/run/board/run-board-setup.ts +73 -0
  37. package/scripts/run/board/run-board-span.ts +49 -7
  38. package/scripts/run/board/run-board-status.ts +10 -6
  39. package/scripts/run/board/run-board-tick.ts +10 -2
  40. package/scripts/run/carry/run-carry-added.ts +1 -1
  41. package/scripts/run/carry/run-carry-change.ts +1 -1
  42. package/scripts/run/carry/run-carry-cli.ts +37 -7
  43. package/scripts/run/carry/run-carry-ended.ts +9 -1
  44. package/scripts/run/carry/run-carry.ts +16 -0
  45. package/scripts/run/event/run-event-render.ts +1 -0
  46. package/scripts/run/event/run-event-setup.ts +47 -0
  47. package/scripts/run/event/run-event-stream.ts +26 -5
  48. package/scripts/run/run-stranded-detect.ts +58 -12
  49. package/scripts/run/wake/run-wake-cli.ts +51 -6
  50. package/scripts/run/wake/run-wake-describe.ts +2 -0
  51. package/scripts/run/wake/run-wake-driver.ts +10 -1
  52. package/scripts/run/wake/run-wake-handoff.ts +1 -1
@@ -18,6 +18,7 @@ interface StepContext {
18
18
  profile: ProjectProfile
19
19
  visibility: Visibility
20
20
  init_command: string | undefined
21
+ push_preflight_command: string | undefined
21
22
  }
22
23
 
23
24
  interface RunContext extends StepContext {
@@ -69,6 +70,13 @@ function self_run_refusal(root: string): string | undefined {
69
70
  return self_sync_guard.self_sync_refusal(PACKAGE_DIR, root)
70
71
  }
71
72
 
73
+ // A caller's command line runs without a shell, split the way execa splits one.
74
+ function run_command_line(command_line: string, extra: ReadonlyArray<string>, root: string): void {
75
+ const [command = '', ...args] = parseCommandString(command_line)
76
+
77
+ run_local(command, [...args, ...extra], root)
78
+ }
79
+
72
80
  function create_git_repository(context: StepContext): void {
73
81
  initial_commit.create_repository(context.root)
74
82
  }
@@ -84,9 +92,7 @@ function initialize(context: StepContext): void {
84
92
  return
85
93
  }
86
94
 
87
- const [command = '', ...args] = parseCommandString(context.init_command)
88
-
89
- run_local(command, [...args, '--profile', context.profile], context.root)
95
+ run_command_line(context.init_command, ['--profile', context.profile], context.root)
90
96
  }
91
97
 
92
98
  function commit_all(context: StepContext): void {
@@ -122,6 +128,26 @@ function baseline_of(
122
128
  return start_setup_pr.changed_paths(context.root)
123
129
  }
124
130
 
131
+ // A toolkit's own pre-push hook can need something kit knows nothing about (app-kit's needs a Docker
132
+ // daemon), and a push it refuses lands after `gh repo create` has made the repository. So the caller
133
+ // names a check that runs only when the plan pushes, before the first step: a run that does not push
134
+ // is never stopped by it, and one it stops has written nothing (#3702).
135
+ function run_push_preflight(steps: ReadonlyArray<StartStep>, context: StepContext): void {
136
+ const command_line = context.push_preflight_command
137
+
138
+ if (command_line === undefined || !start_plan.has_push(steps)) return
139
+
140
+ console.info(`\nPush preflight: ${command_line}`)
141
+
142
+ try {
143
+ run_command_line(command_line, [], context.root)
144
+ } catch (error) {
145
+ const cause = error_text.message_of(error)
146
+
147
+ throw new Error(start_plan.preflight_report(command_line, cause), { cause: error })
148
+ }
149
+ }
150
+
125
151
  type StepAction = (context: RunContext) => void | Promise<void>
126
152
 
127
153
  const STEP_ACTIONS: Readonly<Record<StartStep, StepAction>> = {
@@ -150,6 +176,8 @@ async function run_step(
150
176
 
151
177
  async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): Promise<void> {
152
178
  const labels = start_plan.step_labels(context.init_command)
179
+
180
+ run_push_preflight(steps, context)
153
181
  const run_context: RunContext = { ...context, baseline: baseline_of(steps, context) }
154
182
 
155
183
  for (const [index, step] of steps.entries()) {
@@ -15,8 +15,9 @@ import { start_steps } from './start-steps'
15
15
  const ARGUMENT_START_INDEX = 2
16
16
  const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
17
17
  const START_USAGE =
18
- 'josh start [--profile basic|full] [--yes] [--github] [--public] [--init-command <command>]'
18
+ 'josh start [--profile basic|full] [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]'
19
19
  const INIT_COMMAND_FLAG = '--init-command'
20
+ const PUSH_PREFLIGHT_COMMAND_FLAG = '--push-preflight-command'
20
21
  const YES_FLAG = '--yes'
21
22
  const GITHUB_FLAG = '--github'
22
23
  const PUBLIC_FLAG = '--public'
@@ -28,34 +29,38 @@ interface Prepared {
28
29
  choices: Choices
29
30
  }
30
31
 
31
- interface InitCommandSplit {
32
- init_command: string | undefined
32
+ interface CommandSplit {
33
+ command: string | undefined
33
34
  rest: ReadonlyArray<string>
34
35
  }
35
36
 
36
37
  // A value that is blank or another flag means the command itself was left out, which would otherwise
37
- // swallow the next switch or hand the initialize step nothing to run.
38
+ // swallow the next switch or hand its step nothing to run.
38
39
  function is_command_value(value: string | undefined): value is string {
39
40
  return value !== undefined && value.trim() !== '' && !value.startsWith('-')
40
41
  }
41
42
 
42
43
  // The command arrives as the one argument after its flag and is removed together with it.
43
- function split_init_command(args: ReadonlyArray<string>): InitCommandSplit {
44
- const index = args.indexOf(INIT_COMMAND_FLAG)
44
+ function split_command(args: ReadonlyArray<string>, flag: string): CommandSplit {
45
+ const index = args.indexOf(flag)
45
46
 
46
- if (index === -1) return { init_command: undefined, rest: args }
47
+ if (index === -1) return { command: undefined, rest: args }
47
48
 
48
49
  const value = args[index + 1]
49
50
 
50
51
  if (!is_command_value(value)) throw new Error(`Usage: ${START_USAGE}`)
51
52
 
52
- return { init_command: value, rest: args.filter((_, at) => at !== index && at !== index + 1) }
53
+ return { command: value, rest: args.filter((_, at) => at !== index && at !== index + 1) }
53
54
  }
54
55
 
55
- // The switches and the initialize command are peeled off first, so what remains is exactly the
56
+ // The switches and the caller's commands are peeled off first, so what remains is exactly the
56
57
  // `--profile` pair `josh init` accepts — one parser for the profile in both commands.
57
58
  function parse_start_options(args: ReadonlyArray<string>): StartOptions {
58
- const { init_command, rest } = split_init_command(args)
59
+ const init = split_command(args, INIT_COMMAND_FLAG)
60
+ const { command: push_preflight_command, rest } = split_command(
61
+ init.rest,
62
+ PUSH_PREFLIGHT_COMMAND_FLAG,
63
+ )
59
64
  const profile_args = rest.filter((argument) => !SWITCHES.has(argument))
60
65
 
61
66
  return {
@@ -63,7 +68,8 @@ function parse_start_options(args: ReadonlyArray<string>): StartOptions {
63
68
  is_yes: rest.includes(YES_FLAG),
64
69
  is_github: rest.includes(GITHUB_FLAG),
65
70
  visibility: rest.includes(PUBLIC_FLAG) ? 'public' : 'private',
66
- init_command,
71
+ init_command: init.command,
72
+ push_preflight_command,
67
73
  }
68
74
  }
69
75
 
@@ -112,12 +118,13 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
112
118
  const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
113
119
 
114
120
  const { root, options } = choices
115
- const { visibility, init_command } = options
121
+ const { visibility, init_command, push_preflight_command } = options
116
122
 
117
123
  console.info(start_plan.plan_summary(steps, start_plan.step_labels(init_command)))
118
124
  const profile = await resolve_profile(choices, interaction)
125
+ const context = { root, profile, visibility, init_command, push_preflight_command }
119
126
 
120
- await start_steps.run_steps(steps, { root, profile, visibility, init_command })
127
+ await start_steps.run_steps(steps, context)
121
128
  console.info(`\n${READY_MESSAGE}`)
122
129
  }
123
130
 
@@ -17,7 +17,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
17
17
  description: 'Set a project up for the GitHub Issue workflow, from git init to the setup PR',
18
18
  category: 'Project',
19
19
  reference: [
20
- `${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>]`,
20
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]`,
21
21
  'developer',
22
22
  ['files', 'git', 'network', 'processes'],
23
23
  ],
@@ -74,13 +74,15 @@ interface BoardCounts {
74
74
  remaining: number
75
75
  }
76
76
 
77
- type RunMark = 'running' | 'idle' | 'human' | 'stopped'
77
+ // `setup` is a run with no child running that is getting one ready (`run-board-setup.ts`).
78
+ type RunMark = 'running' | 'setup' | 'idle' | 'human' | 'stopped'
78
79
 
79
80
  type TextColor = Parameters<typeof styleText>[0]
80
81
 
81
82
  // The run's state as a colored glyph before its name.
82
83
  const RUN_MARKS: Readonly<Record<RunMark, { glyph: string; color: TextColor }>> = {
83
84
  running: { glyph: '▶', color: 'green' },
85
+ setup: { glyph: '▶', color: 'green' },
84
86
  idle: { glyph: '⏸', color: 'yellow' },
85
87
  human: { glyph: '✋', color: 'magenta' },
86
88
  stopped: { glyph: '■', color: 'gray' },
@@ -99,23 +101,33 @@ function counts_of(layout: BoardLayout): BoardCounts {
99
101
  return { total, settled, parked, running, remaining: total - settled - running }
100
102
  }
101
103
 
104
+ // The marks of a run that is working: they turn the spinner, and only they can go stale.
105
+ const WORKING_MARKS: ReadonlySet<RunMark> = new Set<RunMark>(['running', 'setup'])
106
+ // What follows the stage once the plan is read: how many issues the run is setting up for.
107
+ const STAGE_SEPARATOR = ' · '
108
+ const PLANNED = 'planned'
109
+
102
110
  // Nothing running: the run is waiting on a person when all the plan has left is what
103
- // `needs-decision` holds, and otherwise idle.
104
- function quiet_mark(layout: BoardLayout | undefined): RunMark {
105
- return layout?.waves.length === 0 && layout.people.length > 0 ? 'human' : 'idle'
111
+ // `needs-decision` holds; otherwise it is getting a child ready, or idle.
112
+ function quiet_mark(header: BoardHeader): RunMark {
113
+ const { layout } = header
114
+
115
+ if (layout?.waves.length === 0 && layout.people.length > 0) return 'human'
116
+
117
+ return header.activity.is_preparing === true ? 'setup' : 'idle'
106
118
  }
107
119
 
108
120
  // The run's state, each state overriding the ones below it.
109
121
  function mark_of(header: BoardHeader, running: number): RunMark {
110
122
  if (header.activity.is_stopped) return 'stopped'
111
123
 
112
- return running > 0 ? 'running' : quiet_mark(header.layout)
124
+ return running > 0 ? 'running' : quiet_mark(header)
113
125
  }
114
126
 
115
- // A running run turns the spinner in place of ▶, both one column wide; every other mark stands still.
127
+ // A working run turns the spinner in place of ▶, both one column wide; every other mark stands still.
116
128
  function run_part(mark: RunMark, spinner: string | undefined): string {
117
129
  const { glyph, color } = RUN_MARKS[mark]
118
- const shown = mark === 'running' ? (spinner ?? glyph) : glyph
130
+ const shown = WORKING_MARKS.has(mark) ? (spinner ?? glyph) : glyph
119
131
 
120
132
  return styleText(color, `${shown} ${RUN_NAME}`)
121
133
  }
@@ -153,7 +165,7 @@ function ended_part(header: BoardHeader): string {
153
165
  }
154
166
 
155
167
  // The `💓` under the run's age, in the `⏰`'s own format with its digits right-aligned to `width` —
156
- // only a running run can be stale, so only it is colored. Only an ended run draws anything else there:
168
+ // only a working run can be stale, so only it is colored. Only an ended run draws anything else there:
157
169
  // the minute it ended.
158
170
  function beat_part(header: BoardHeader, mark: RunMark, width: number): string {
159
171
  const age = age_of(header)
@@ -162,7 +174,7 @@ function beat_part(header: BoardHeader, mark: RunMark, width: number): string {
162
174
 
163
175
  const part = `${HEADER_ICONS.heartbeat} ${span_of(age).padStart(width)}`
164
176
 
165
- return mark === 'running' ? aged(part, age) : part
177
+ return WORKING_MARKS.has(mark) ? aged(part, age) : part
166
178
  }
167
179
 
168
180
  // A run's age over the `💓`, or over the minute an ended run ended: the shorter of the two lengths is
@@ -243,9 +255,23 @@ function progress_parts(counts: BoardCounts, header: BoardHeader): Array<string>
243
255
  ]
244
256
  }
245
257
 
246
- // The run's progress, then what its plan read is doing.
247
- function progress_part(counts: BoardCounts, header: BoardHeader): string {
248
- const parts = [...progress_parts(counts, header), loading_part(header), plan_warning(header)]
258
+ // The stage a run is at before its first child launches, standing where the progress will: a tally of
259
+ // nothing settled says less than what the run is doing. Only a run marked as setting up is at a stage:
260
+ // a stopped run and one waiting on a person keep their progress.
261
+ function stage_part(counts: BoardCounts, header: BoardHeader, mark: RunMark): string | undefined {
262
+ const { setup } = header.activity
263
+
264
+ if (setup === undefined || mark !== 'setup') return undefined
265
+ if (!is_plan_read(header)) return setup
266
+
267
+ return `${setup}${STAGE_SEPARATOR}${String(counts.total)} ${PLANNED}`
268
+ }
269
+
270
+ // The run's stage or its progress, then what its plan read is doing.
271
+ function progress_part(counts: BoardCounts, header: BoardHeader, mark: RunMark): string {
272
+ const stage = stage_part(counts, header, mark)
273
+ const progress = stage === undefined ? progress_parts(counts, header) : [stage]
274
+ const parts = [...progress, loading_part(header), plan_warning(header)]
249
275
 
250
276
  return parts.filter((part) => part !== undefined).join(GAP)
251
277
  }
@@ -286,7 +312,7 @@ function header_lines(header: BoardHeader): Array<string> {
286
312
  const columns = [
287
313
  column_of([run_part(mark, header.spinner), repo_part(header, mark)]),
288
314
  column_of(time_parts(header, mark)),
289
- [progress_part(counts, header), machine_part(counts, header)],
315
+ [progress_part(counts, header, mark), machine_part(counts, header)],
290
316
  ]
291
317
 
292
318
  return [0, 1].map((line) => columns.map((column) => column[line]).join(GAP))
@@ -1,8 +1,10 @@
1
1
  import type { NamedPlan } from '#scripts/backlog/backlog-plan'
2
+ import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
2
3
  import { lane_registry } from '#scripts/lane/lane-registry'
3
4
  import { run_carry } from '#scripts/run/carry/run-carry'
4
5
  import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
5
6
  import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
7
+ import { run_stranded_detect } from '#scripts/run/run-stranded-detect'
6
8
  import { run_board_scope } from './run-board-scope'
7
9
  import { run_board_span, type BoardSpan } from './run-board-span'
8
10
 
@@ -18,6 +20,9 @@ interface LocalRead {
18
20
  lanes: ReadonlyArray<string>
19
21
  // The session a stopped run is resumed from; `undefined` otherwise.
20
22
  resume: string | undefined
23
+ // The run is carried but nothing drives it — its owner and its supervisor are both gone
24
+ // (`run-stranded.ts`). Drawn as a stopped run, since no one is looking for its next issue.
25
+ is_stranded: boolean
21
26
  }
22
27
 
23
28
  interface SpanHere {
@@ -28,10 +33,11 @@ interface SpanHere {
28
33
  function span_here(repository: string): SpanHere {
29
34
  const read = run_carry.read_carry(run_carry.carry_path(repository))
30
35
  const ended = run_carry_ended.read_ended(run_carry_ended.ended_path(repository))
36
+ const launch = backlogrun_launch.pending_launch(repository)
31
37
 
32
38
  return {
33
- span: run_board_span.span_of(read, ended),
34
- resume: run_board_span.resume_of(read, ended),
39
+ span: run_board_span.span_of(read, ended, launch),
40
+ resume: run_board_span.resume_of(read, ended, launch),
35
41
  }
36
42
  }
37
43
 
@@ -53,6 +59,7 @@ async function read_local(): Promise<LocalRead | undefined> {
53
59
  events: run_board_span.events_of(events, span),
54
60
  lanes: lanes.map((lane) => lane.issue),
55
61
  resume,
62
+ is_stranded: run_stranded_detect.is_stranded_at(repository),
56
63
  }
57
64
  }
58
65
 
@@ -0,0 +1,73 @@
1
+ import { run_event_scope } from '#scripts/run/event/run-event-scope'
2
+ import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
3
+
4
+ // What the run's `setup` events (`run-event-setup.ts`) tell `run:board`: the stage a run is at before
5
+ // its first child launches, whether it is preparing a launch now, and which lanes are not children yet.
6
+ // The events carry it all — nothing here infers a stage from which records exist.
7
+
8
+ const KIND = run_event_stream.EVENT_KIND
9
+
10
+ interface SetupRead {
11
+ // The newest stage's own words while the run is starting; `undefined` from the first launch — or a
12
+ // drain before one — on, when the plan's counts say more than a stage does.
13
+ setup: string | undefined
14
+ // The run is getting a child ready: any stage while it is starting, a lane opening after that.
15
+ is_preparing: boolean
16
+ }
17
+
18
+ const NO_SETUP: SetupRead = { setup: undefined, is_preparing: false }
19
+
20
+ function is_setup(event: RunEvent): boolean {
21
+ return event.kind === KIND.SETUP
22
+ }
23
+
24
+ function has_launched(events: ReadonlyArray<RunEvent>): boolean {
25
+ return events.some((event) => event.kind === KIND.CHILD_LAUNCH)
26
+ }
27
+
28
+ // The start is over at the first launch, or at a drain that came before one: a run whose backlog
29
+ // emptied with nothing launched is watching, and the hand-off each wake of that watch writes is not a
30
+ // run getting ready.
31
+ function has_started(events: ReadonlyArray<RunEvent>): boolean {
32
+ return has_launched(events) || events.some((event) => event.kind === KIND.DRAIN)
33
+ }
34
+
35
+ // A lane is opening while the stage naming its issue is newer than the run's newest position: the
36
+ // launch it leads to, or whatever the run did instead, ends it. A stage that names no issue — a
37
+ // hand-off between two children — prepares nothing a person waits on.
38
+ function is_opening(events: ReadonlyArray<RunEvent>, stage: RunEvent): boolean {
39
+ if (run_event_scope.issue_named(stage) === undefined) return false
40
+
41
+ const position = events.findLast((event) => !run_event_stream.TRACE_KINDS.has(event.kind))
42
+
43
+ return position === undefined || position.pos < stage.pos
44
+ }
45
+
46
+ function read(events: ReadonlyArray<RunEvent>): SetupRead {
47
+ const stage = events.findLast((event) => is_setup(event))
48
+
49
+ if (stage === undefined) return NO_SETUP
50
+ if (!has_started(events)) return { setup: stage.text, is_preparing: true }
51
+
52
+ return { setup: undefined, is_preparing: is_opening(events, stage) }
53
+ }
54
+
55
+ function names(event: RunEvent, issue: number): boolean {
56
+ return Number(run_event_scope.issue_named(event)) === issue
57
+ }
58
+
59
+ // A lane the stream saw no launch for is not yet this run's child when the run says it is still
60
+ // setting up: it has launched nothing at all, so any lane here is an earlier run's, or it is opening
61
+ // this very lane. A stream with no stage on it says neither, and the lane is read as before.
62
+ function is_lane_pending(events: ReadonlyArray<RunEvent>, issue: number): boolean {
63
+ const stages = events.filter((event) => is_setup(event))
64
+
65
+ if (stages.length === 0) return false
66
+
67
+ return !has_launched(events) || stages.some((stage) => names(stage, issue))
68
+ }
69
+
70
+ const run_board_setup = { is_lane_pending, read }
71
+
72
+ export { run_board_setup }
73
+ export type { SetupRead }
@@ -1,3 +1,4 @@
1
+ import type { PendingLaunch } from '#scripts/backlog/backlogrun-launch'
1
2
  import type { CarryRead } from '#scripts/run/carry/run-carry'
2
3
  import type { EndedRun } from '#scripts/run/carry/run-carry-ended'
3
4
  import { run_event_scope } from '#scripts/run/event/run-event-scope'
@@ -27,9 +28,40 @@ function ended_span(ended: EndedRun | undefined): BoardSpan | undefined {
27
28
  return { invocation: ended.invocation, started_ms, ended_ms }
28
29
  }
29
30
 
31
+ // A run `josh backlogrun` launched and its parent has not claimed yet: it has no carry record, so the
32
+ // launch record is the only one that says a run is here and since when.
33
+ function launch_span(launch: PendingLaunch | undefined): BoardSpan | undefined {
34
+ const started_ms = launch === undefined ? undefined : moment_of(launch.started_at)
35
+
36
+ if (launch === undefined || started_ms === undefined) return undefined
37
+
38
+ return { invocation: launch.invocation, started_ms, ended_ms: undefined }
39
+ }
40
+
41
+ // With no carry record: the run on its way, else the one that last ended. **A launch older than that
42
+ // end is the ended run's own** — its parent outlives `run:carry --end` for a moment — so it never
43
+ // redraws a finished run as one starting.
44
+ function idle_span(
45
+ ended: EndedRun | undefined,
46
+ launch: PendingLaunch | undefined,
47
+ ): BoardSpan | undefined {
48
+ const last = ended_span(ended)
49
+ const coming = launch_span(launch)
50
+
51
+ if (coming === undefined) return last
52
+
53
+ return (last?.ended_ms ?? 0) >= coming.started_ms ? last : coming
54
+ }
55
+
30
56
  // The running run wins over the ended one, so a run that begins replaces the finished board at once.
31
- function span_of(read: CarryRead, ended: EndedRun | undefined): BoardSpan | undefined {
32
- if (read.kind !== 'carried') return read.kind === 'none' ? ended_span(ended) : undefined
57
+ // **The launch's start is the carry's**: `run:carry --begin` inherits it for the parent the launch
58
+ // started, so the clock does not restart when the record appears.
59
+ function span_of(
60
+ read: CarryRead,
61
+ ended: EndedRun | undefined,
62
+ launch?: PendingLaunch,
63
+ ): BoardSpan | undefined {
64
+ if (read.kind !== 'carried') return read.kind === 'none' ? idle_span(ended, launch) : undefined
33
65
 
34
66
  const started_ms = moment_of(read.carry.started_at)
35
67
 
@@ -38,12 +70,22 @@ function span_of(read: CarryRead, ended: EndedRun | undefined): BoardSpan | unde
38
70
  return { invocation: read.carry.invocation, started_ms, ended_ms: undefined }
39
71
  }
40
72
 
41
- // The session a person resumes a stopped run from: only while the ended run is
42
- // the one drawn, and only when it stopped rather than finished. A run that begins hides it at once.
43
- function resume_of(read: CarryRead, ended: EndedRun | undefined): string | undefined {
44
- if (read.kind !== 'none' || ended?.stopped === undefined) return undefined
73
+ // Whether the span drawn with no carry record is the ended run's, rather than a launch's.
74
+ function is_ended_drawn(ended: EndedRun | undefined, launch: PendingLaunch | undefined): boolean {
75
+ return idle_span(ended, launch)?.ended_ms !== undefined
76
+ }
45
77
 
46
- return ended.session
78
+ // The session a person resumes a stopped run from: only while the ended run is
79
+ // the one drawn, and only when it stopped rather than finished. A run that begins — or is on its way —
80
+ // hides it at once.
81
+ function resume_of(
82
+ read: CarryRead,
83
+ ended: EndedRun | undefined,
84
+ launch?: PendingLaunch,
85
+ ): string | undefined {
86
+ if (read.kind !== 'none' || !is_ended_drawn(ended, launch)) return undefined
87
+
88
+ return ended?.stopped === undefined ? undefined : ended.session
47
89
  }
48
90
 
49
91
  function is_within(event: RunEvent, span: BoardSpan): boolean {
@@ -7,6 +7,7 @@ import { run_event_scope } from '#scripts/run/event/run-event-scope'
7
7
  import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
8
8
  import type { ClosedIssue } from './run-board-closed'
9
9
  import { run_board_phase, type Phase } from './run-board-phase'
10
+ import { run_board_setup, type SetupRead } from './run-board-setup'
10
11
 
11
12
  // What each issue of a run is doing now, read from the run's own event stream and its open lanes.
12
13
  // Local reads only — no `gh` call — so `run:board` can redraw it every few seconds; the plan these
@@ -26,8 +27,9 @@ interface ItemStatus {
26
27
  track?: ReadonlyArray<Phase> | undefined
27
28
  }
28
29
 
29
- // The run as a whole, read off the same stream.
30
- interface RunActivity {
30
+ // The run as a whole, read off the same stream. The setup half is `run-board-setup.ts`'s, absent
31
+ // where a stream carries no stage.
32
+ interface RunActivity extends Partial<SetupRead> {
31
33
  last_event_ms: number | undefined
32
34
  // The run's last position is a `stop` that names no child — the run itself ended.
33
35
  is_stopped: boolean
@@ -125,16 +127,17 @@ function fold_events(events: ReadonlyArray<RunEvent>): Map<number, ItemStatus> {
125
127
  function is_running_lane(
126
128
  issue: number,
127
129
  status: ItemStatus | undefined,
130
+ events: ReadonlyArray<RunEvent>,
128
131
  in_run: ReadonlySet<number>,
129
132
  ): boolean {
130
- if (status === undefined) return in_run.has(issue)
133
+ if (status !== undefined) return status.state === 'running'
131
134
 
132
- return status.state === 'running'
135
+ return in_run.has(issue) && !run_board_setup.is_lane_pending(events, issue)
133
136
  }
134
137
 
135
138
  // Every child the run has touched, with the lane each running child holds. A lane the stream never saw
136
139
  // launched — a run restored after the stream rolled — still reads as running while its issue is in the
137
- // run's plan (`in_run`).
140
+ // run's plan (`in_run`); one the run is still setting up for stays waiting until its launch.
138
141
  function statuses_of(
139
142
  events: ReadonlyArray<RunEvent>,
140
143
  lanes: ReadonlyArray<string>,
@@ -146,7 +149,7 @@ function statuses_of(
146
149
  const issue = Number(lane)
147
150
  const status = statuses.get(issue)
148
151
 
149
- if (is_running_lane(issue, status, in_run)) {
152
+ if (is_running_lane(issue, status, events, in_run)) {
150
153
  statuses.set(issue, { state: 'running', ...status, lane: `lane ${lane}` })
151
154
  }
152
155
  }
@@ -251,6 +254,7 @@ function activity_of(events: ReadonlyArray<RunEvent>, is_ended = false): RunActi
251
254
  const position = events.findLast((event) => !run_event_stream.TRACE_KINDS.has(event.kind))
252
255
 
253
256
  return {
257
+ ...run_board_setup.read(events),
254
258
  last_event_ms: last === undefined ? undefined : moment(last),
255
259
  is_stopped: is_ended || is_run_stop(position),
256
260
  }
@@ -11,7 +11,7 @@ import type { LocalRead } from './run-board-read'
11
11
  import { run_board_render, type FrameBounds } from './run-board-render'
12
12
  import { run_board_spin, type Spun } from './run-board-spin'
13
13
  import { run_board_state, type BoardPorts, type BoardState, type Pace } from './run-board-state'
14
- import { run_board_status } from './run-board-status'
14
+ import { run_board_status, type RunActivity } from './run-board-status'
15
15
  import { run_board_usage } from './run-board-usage'
16
16
 
17
17
  // One `run:board` redraw. **Three speeds**: the frame is redrawn once a second, on the second, so
@@ -116,6 +116,14 @@ interface Redraw {
116
116
  now_ms: number
117
117
  }
118
118
 
119
+ // A run `run:carry --end` closed and one nobody drives any more are both drawn as stopped.
120
+ function activity_here(local: LocalRead): RunActivity {
121
+ return run_board_status.activity_of(
122
+ local.events,
123
+ local.ended_ms !== undefined || local.is_stranded,
124
+ )
125
+ }
126
+
119
127
  function header_of(
120
128
  state: BoardState,
121
129
  local: LocalRead,
@@ -126,7 +134,7 @@ function header_of(
126
134
  now_ms: redraw.now_ms,
127
135
  started_ms: local.started_ms,
128
136
  ended_ms: local.ended_ms,
129
- activity: run_board_status.activity_of(local.events, local.ended_ms !== undefined),
137
+ activity: activity_here(local),
130
138
  layout,
131
139
  baseline_total: state.baseline_total,
132
140
  plan_fetched_ms: state.fetched_ms,
@@ -51,7 +51,7 @@ function add_issues(
51
51
  ): RunCarry {
52
52
  const next: RunCarry = { ...carry, added: next_added(carry, additions) }
53
53
 
54
- stamp_file.write_stamp(target, next)
54
+ stamp_file.replace_stamp(target, next)
55
55
 
56
56
  return next
57
57
  }
@@ -83,7 +83,7 @@ function apply_change(
83
83
  retrospective: effective.retrospective === true || carry.retrospective,
84
84
  }
85
85
 
86
- stamp_file.write_stamp(target, next)
86
+ stamp_file.replace_stamp(target, next)
87
87
 
88
88
  return next
89
89
  }