@jals1212/ci-local-pi 0.1.0 → 0.2.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 CHANGED
@@ -6,6 +6,40 @@ add or change behaviour and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.2.0
10
+
11
+ ### Added
12
+
13
+ - Tool `ci_local_run`: starts a run of the repository's own ci-local
14
+ (`node_modules/.bin`, else `PATH`) detached from Pi, for this session.
15
+ (#42, #43)
16
+ - It returns once the run is queued or started, or with ci-local's output
17
+ when it ends first (`--list`, a usage or config error, `--no-wait` on a
18
+ busy machine).
19
+ - No background job, so no job notice: the footer and the end card report
20
+ the run.
21
+ - A launch that ends after the tool returned, without the session ever
22
+ seeing its run, gets an end card of its own with the end of its output.
23
+ - Launch outputs live under `$TMPDIR` and are removed after a week.
24
+ - Skill: starts runs with `ci_local_run` when present. (#42)
25
+
26
+ ### Changed
27
+
28
+ - Status line: the state's glyph sits by the state, after the name
29
+ (`🚦 ci-local ✓ passed · 4m12s`, was `🚦 ✓ ci-local passed`). (#40)
30
+ - Status line: a queued run says how many runs are ahead of it and which one
31
+ is running (`🚦 ci-local ◌ queued · 2 ahead (erp-ci running)`). It said
32
+ only `behind erp-ci`, the run at the head of the queue, so every waiting
33
+ session read the same. (#40)
34
+
35
+ ## 0.1.1
36
+
37
+ ### Changed
38
+
39
+ - The bridge role is gone from the extension: the CLI 0.8.0 no longer has a
40
+ bridge. The flag that keeps a CLI 0.7.x bridge quiet while this package is
41
+ loaded stays. (#35)
42
+
9
43
  ## 0.1.0
10
44
 
11
45
  ### Added
package/README.md CHANGED
@@ -15,9 +15,7 @@ bash tool).
15
15
 
16
16
  Until 0.7.x, the extension shipped inside `@jals1212/ci-local`. If you
17
17
  installed it from there, switch: `pi install npm:@jals1212/ci-local-pi`, then
18
- `pi remove npm:@jals1212/ci-local`. Until 0.8.0 the CLI keeps a bridge that
19
- says so once per session and steps aside when this package is loaded, so
20
- having both never shows a card twice.
18
+ `pi remove npm:@jals1212/ci-local`.
21
19
 
22
20
  ## What it follows
23
21
 
@@ -25,8 +23,8 @@ In a session opened inside a repository with `ci-local.config.sh` (with a
25
23
  literal `CI_PROJECT=`), it follows the events file of that project; it never
26
24
  runs ci-local or Docker itself:
27
25
 
28
- - **Status line**: `🚦 ◌ ci-local queued · behind erp-ci`, `🚦 ⧗ ci-local [3/8] lint · 2m05s`,
29
- then `🚦 ✓ ci-local passed · 4m12s` or `🚦 ✗ ci-local failed at lint · 2m10s` until
26
+ - **Status line**: `🚦 ci-local ◌ queued · 2 ahead (erp-ci running)`, `🚦 ci-local ⧗ [3/8] lint · 2m05s`,
27
+ then `🚦 ci-local ✓ passed · 4m12s` or `🚦 ci-local ✗ failed at lint · 2m10s` until
30
28
  the next run. A run already over when a session starts shows only while its
31
29
  result is undelivered, like an unread message: it ended with no session of
32
30
  the repository open. Once a session has shown it finished, a later session
@@ -75,9 +73,24 @@ runs ci-local or Docker itself:
75
73
  session starts is not announced.
76
74
  - **Tool `ci_local_status`**: the same three lines for the agent, without a
77
75
  shell.
78
- - **Skill `ci-local`**: how an agent runs and waits for ci-local (in the
79
- background (quiet without a terminal), never two runs, read only the failing
80
- step's log).
76
+ - **Tool `ci_local_run`**: starts the repository's own ci-local
77
+ (`node_modules/.bin/ci-local`, else the one on `PATH`) with optional
78
+ arguments (`args`), detached from Pi: its own process group, no terminal,
79
+ stdout and stderr in a private file it names (under `$TMPDIR`, removed by a
80
+ launch a week later). The run records the
81
+ session's `PI_SESSION_ID`, so it is the session's own. The tool returns
82
+ once the run is queued or started (up to 15 seconds: a run of the same
83
+ project that still holds the events file keeps a new one silent until its
84
+ turn), or with its output when it ends first (`--list`, `--print-plan`, a
85
+ usage or config error, a busy machine with `--no-wait`). There is no
86
+ background job, so no job notice: the footer and the end card report the
87
+ run, and the hidden wake reaches an idle agent. A launch that ends after
88
+ the tool returned without the session ever seeing its run (no event, or
89
+ one replaced before the session read it) gets a failed end card with the
90
+ end of its output.
91
+ - **Skill `ci-local`**: how an agent runs and waits for ci-local (with
92
+ `ci_local_run`, else in the background, quiet without a terminal; never two
93
+ runs; read only the failing step's log).
81
94
  Branch and merge policy stay the project's.
82
95
 
83
96
  The extension follows the file ci-local would write: `CI_LOCAL_EVENTS` from
@@ -1,9 +1,9 @@
1
1
  // Pi extension for @jals1212/ci-local: the state of this repository's
2
2
  // ci-local run in Pi's own UI, so neither the agent nor you write it by hand.
3
3
  //
4
- // - Status line: `🚦 ⧗ ci-local [3/8] lint · 2m05s`, `🚦 ◌ ci-local queued ·
5
- // behind erp-ci`, then `🚦 ✓ ci-local passed · 4m12s` / `🚦 ✗ ci-local failed
6
- // at lint` until the next run (🚦 is ci-local's own emoji, see BRAND).
4
+ // - Status line: `🚦 ci-local ⧗ [3/8] lint · 2m05s`, `🚦 ci-local ◌ queued ·
5
+ // 2 ahead (erp-ci running)`, then `🚦 ci-local ✓ passed · 4m12s` / `🚦 ci-local
6
+ // ✗ failed at lint` until the next run (🚦 is ci-local's own emoji, see BRAND).
7
7
  // A run already over when a session starts is shown only if no session
8
8
  // showed its result yet, like a message not yet delivered (createStatusLine).
9
9
  // - Cards and the agent: only for runs this session started, as Gentle AI's job
@@ -24,20 +24,22 @@
24
24
  // in a run gets it steered in; busy without a run (compacting), it waits. A
25
25
  // failed delivery is tried again, never dropped.
26
26
  // - Tool `ci_local_status`: the same three lines, for the agent, without bash.
27
+ // - Tool `ci_local_run`: starts the repository's own ci-local detached, named
28
+ // for this session, and returns once it is queued or started (or with its
29
+ // output when it ends first). No background job, so no job notice: the
30
+ // footer and the end card report the run. One that ends later before any
31
+ // event gets an end card of its own.
27
32
  //
28
33
  // It reads the events file each run writes (see README, "Following a run");
29
34
  // it never runs ci-local or Docker. The logic lives in ./core.js.
30
35
  //
31
- // Two packages load this file: @jals1212/ci-local-pi (role 'package') and, for
32
- // one more minor, the CLI's own `pi` entry (pi/bridge.ts, role 'bridge'). The
33
- // bridge says once per session that the extension moved, and steps aside when
34
- // the package is loaded too, so a user with both sees every card once.
35
36
 
36
37
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
37
38
  import { Box, Text } from '@earendil-works/pi-tui'
38
39
  import { Type } from 'typebox'
39
40
 
40
41
  import {
42
+ LAUNCH_WAIT_MS,
41
43
  WAKE_TEXT,
42
44
  cardContent,
43
45
  createDelivery,
@@ -45,9 +47,13 @@ import {
45
47
  createTracker,
46
48
  deliveryOptions,
47
49
  eventsFileFor,
50
+ exitCard,
48
51
  findProject,
52
+ launchReport,
53
+ launchRun,
49
54
  ownsRun,
50
55
  readRun,
56
+ waitForStart,
51
57
  } from './core.js'
52
58
 
53
59
  const KEY = 'ci-local'
@@ -56,51 +62,51 @@ const START_KEY = 'ci-local-start'
56
62
  /** The hidden wake message (see createDelivery). */
57
63
  const WAKE_KEY = 'ci-local-wake'
58
64
  const POLL_MS = 1000
59
- /** Set on globalThis while the package is loaded: how the bridge knows to step aside. */
65
+ /**
66
+ * Set on globalThis while this package is loaded. A CLI 0.7.x still installed in
67
+ * Pi loads a bridge that steps aside when it sees this flag, so a user with both
68
+ * sees every card once. The CLI 0.8.0 has no bridge; the flag stays for 0.7.x.
69
+ */
60
70
  const FLAG = Symbol.for('@jals1212/ci-local-pi')
61
- const MOVED =
62
- "ci-local's Pi extension moved to @jals1212/ci-local-pi: pi install npm:@jals1212/ci-local-pi, then pi remove npm:@jals1212/ci-local"
63
-
64
- const packageLoaded = () => (globalThis as Record<symbol, unknown>)[FLAG] === true
65
71
 
66
72
  type Card = { state: string; lines: string[] }
67
73
  /** A start card: the run, its stage ('starting', then 'started'), the --status block. */
68
74
  type StartCard = { key: string; phase: 'starting' | 'started'; lines: string[] }
69
75
 
70
- type Role = 'package' | 'bridge'
71
-
72
- export function createCiLocalExtension({ role }: { role: Role }) {
73
- return (pi: ExtensionAPI) => ciLocal(pi, role)
76
+ /** launchWaitMs: how long ci_local_run waits for the run's first event (LAUNCH_WAIT_MS). */
77
+ export function createCiLocalExtension({ launchWaitMs = LAUNCH_WAIT_MS }: { launchWaitMs?: number } = {}) {
78
+ return (pi: ExtensionAPI) => ciLocal(pi, { launchWaitMs })
74
79
  }
75
80
 
76
- function ciLocal(pi: ExtensionAPI, role: Role) {
77
- // Said at load, before any session starts, so the bridge can see it whatever
78
- // the load order; session_shutdown takes it back (a reload without the
79
- // package must not find it still there).
80
- if (role === 'package') (globalThis as Record<symbol, unknown>)[FLAG] = true
81
- // Pi loads the package's extension first or last, we cannot know: the bridge
82
- // looks at session_start, and its handlers look again.
83
- const aside = () => role === 'bridge' && packageLoaded()
81
+ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
82
+ // Said at load, before any session starts, so a CLI 0.7.x bridge can see it
83
+ // whatever the load order; session_shutdown takes it back (a reload without
84
+ // this package must not find it still there).
85
+ ;(globalThis as Record<symbol, unknown>)[FLAG] = true
84
86
 
85
87
  let timer: ReturnType<typeof setInterval> | undefined
86
88
  // The session's end cards, and whether an agent run is under way: it spans
87
89
  // agent_start to agent_settled (in-run compaction and retries included).
88
90
  let delivery: ReturnType<typeof createDelivery> | undefined
89
91
  let runActive = false
92
+ // The runs ci_local_run started, by pid, until their run shows in the events
93
+ // file (seen) or they end. One that ends unseen, after the tool returned,
94
+ // gets an end card of its own (exitCard): no event will ever name it. Each
95
+ // belongs to the session that started it; another session drops it.
96
+ type Launch = ReturnType<typeof launchRun> & { seen: boolean; waiting: boolean; sessionId: string }
97
+ const launches = new Map<number, Launch>()
90
98
 
91
99
  pi.on('agent_start', async () => {
92
- if (aside()) return
93
100
  runActive = true
94
101
  delivery?.boundary()
95
102
  })
96
103
  pi.on('agent_settled', async () => {
97
- if (aside()) return
98
104
  runActive = false
99
105
  delivery?.boundary()
100
106
  })
101
- pi.on('turn_end', async () => (aside() ? undefined : delivery?.boundary()))
102
- pi.on('session_compact', async () => (aside() ? undefined : delivery?.boundary()))
103
- pi.on('session_compact_failed', async () => (aside() ? undefined : delivery?.boundary()))
107
+ pi.on('turn_end', async () => delivery?.boundary())
108
+ pi.on('session_compact', async () => delivery?.boundary())
109
+ pi.on('session_compact_failed', async () => delivery?.boundary())
104
110
 
105
111
  pi.registerEntryRenderer<StartCard>(START_KEY, (entry, _options, theme) => {
106
112
  const lines = (entry.data?.lines ?? []).map((line) => line.replace(/^> ?/, '').replaceAll('**', ''))
@@ -145,6 +151,44 @@ function ciLocal(pi: ExtensionAPI, role: Role) {
145
151
  },
146
152
  })
147
153
 
154
+ pi.registerTool({
155
+ name: 'ci_local_run',
156
+ label: 'ci-local run',
157
+ description:
158
+ "Start this repository's ci-local (its own node_modules/.bin/ci-local, else the one on PATH) detached, with optional arguments " +
159
+ '(e.g. ["--only", "lint"]; never --live), and return once it is queued or started. ' +
160
+ 'Use it instead of bash or bash_background: no job notice, the footer follows the run and its end card ' +
161
+ 'reaches you when it ends. Do not poll, sleep or start a second run to check it. ' +
162
+ 'Commands that end at once (--list, --print-plan, --down) return their output.',
163
+ promptSnippet: 'ci_local_run: start a ci-local run of this repository; its end card reaches you when it ends',
164
+ parameters: Type.Object({
165
+ args: Type.Optional(Type.Array(Type.String(), { description: 'ci-local arguments, one per item' })),
166
+ }),
167
+ async execute(_id, params, signal, _onUpdate, ctx) {
168
+ const found = findProject(ctx.cwd)
169
+ if (!found) throw new Error(`no ci-local.config.sh with a literal CI_PROJECT at or above ${ctx.cwd}`)
170
+ const sessionId = ctx.sessionManager.getSessionId()
171
+ const launch = launchRun({ repo: found.repo, args: params.args ?? [], sessionId })
172
+ const tracked: Launch = Object.assign(launch, { seen: false, waiting: true, sessionId })
173
+ if (launch.pid !== undefined) launches.set(launch.pid, tracked)
174
+ const result = await waitForStart(launch, eventsFileFor(found.project, process.env, found.repo), { timeoutMs: launchWaitMs, signal })
175
+ tracked.waiting = false
176
+ if (result.kind === 'started') tracked.seen = true
177
+ if (result.kind === 'exited') {
178
+ if (launch.pid !== undefined) launches.delete(launch.pid)
179
+ if (launch.error) throw new Error(`cannot start ${launch.bin}: ${launch.error}`)
180
+ }
181
+ return { content: [{ type: 'text', text: launchReport(result, launch, found.project) }], details: { kind: result.kind, pid: launch.pid } }
182
+ },
183
+ })
184
+
185
+ /** Launches of an earlier session are that session's: not this one's to report. */
186
+ function dropLaunchesOfOtherSessions(sessionId: string) {
187
+ for (const [pid, launch] of launches) {
188
+ if (launch.sessionId !== sessionId) launches.delete(pid)
189
+ }
190
+ }
191
+
148
192
  function stop() {
149
193
  if (timer) clearInterval(timer)
150
194
  timer = undefined
@@ -154,12 +198,6 @@ function ciLocal(pi: ExtensionAPI, role: Role) {
154
198
  stop()
155
199
  runActive = false
156
200
  if (!ctx.hasUI) return
157
- if (aside()) {
158
- delivery = undefined // the package does it all; this one shows and sends nothing
159
- return
160
- }
161
- // Not on a reload: the session was told when it started.
162
- if (role === 'bridge' && event.reason !== 'reload') ctx.ui.notify(MOVED, 'warning')
163
201
  const found = findProject(ctx.cwd)
164
202
  if (!found) return
165
203
  const file = eventsFileFor(found.project, process.env, found.repo)
@@ -178,6 +216,7 @@ function ciLocal(pi: ExtensionAPI, role: Role) {
178
216
  // Whether each run seen in flight is this session's own (decided once: by the
179
217
  // session the run names, else by its process ancestry; see ownsRun).
180
218
  const sessionId = ctx.sessionManager.getSessionId()
219
+ dropLaunchesOfOtherSessions(sessionId)
181
220
  const owned = new Map<string, boolean>()
182
221
  const isOwn = (run: NonNullable<ReturnType<typeof readRun>>) => {
183
222
  if (!owned.has(run.key)) owned.set(run.key, ownsRun(run, { sessionId }))
@@ -198,6 +237,7 @@ function ciLocal(pi: ExtensionAPI, role: Role) {
198
237
  }
199
238
  const { footer, changed } = statusLine.update(run)
200
239
  if (changed) ctx.ui.setStatus(KEY, footer)
240
+ settleLaunches(run)
201
241
  // Only this session's own runs get cards and reach the agent, as Gentle
202
242
  // AI's job notices go only to the session that started the job. Another
203
243
  // session's run in this repository shows in the status line alone. What
@@ -223,6 +263,21 @@ function ciLocal(pi: ExtensionAPI, role: Role) {
223
263
  deliver()
224
264
  }
225
265
 
266
+ // The launches of ci_local_run: one whose run shows in the events is
267
+ // followed like any run; one that ended without its run ever showing here
268
+ // gets an end card of its own (exitCard), as no event will name it.
269
+ const settleLaunches = (run: ReturnType<typeof readRun>) => {
270
+ for (const [pid, launch] of launches) {
271
+ if (run?.state.pid === pid) launch.seen = true
272
+ if (launch.waiting || !launch.ended) continue
273
+ launches.delete(pid)
274
+ if (launch.seen) continue
275
+ const card = exitCard(launch)
276
+ ctx.ui.notify(`ci-local ended before this session saw its run · ${found.project}`, card.state.state === 'passed' ? 'info' : 'error')
277
+ ends.ended(card)
278
+ }
279
+ }
280
+
226
281
  // At most one delivery per tick: a failure is retried later (see createDelivery).
227
282
  const deliver = () => {
228
283
  let action
@@ -258,8 +313,8 @@ function ciLocal(pi: ExtensionAPI, role: Role) {
258
313
 
259
314
  pi.on('session_shutdown', async () => {
260
315
  stop()
261
- if (role === 'package') delete (globalThis as Record<symbol, unknown>)[FLAG]
316
+ delete (globalThis as Record<symbol, unknown>)[FLAG]
262
317
  })
263
318
  }
264
319
 
265
- export default createCiLocalExtension({ role: 'package' })
320
+ export default createCiLocalExtension()
@@ -2,8 +2,24 @@
2
2
  // follows, the footer line, and when a run it watched has ended. The
3
3
  // rendering and the states are those of `ci-local --status` (status.js).
4
4
 
5
- import { spawnSync } from 'node:child_process'
6
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
+ import { spawn, spawnSync } from 'node:child_process'
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ fstatSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ openSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ readSync,
17
+ renameSync,
18
+ rmSync,
19
+ statSync,
20
+ writeFileSync,
21
+ } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
7
23
  import { dirname, isAbsolute, join } from 'node:path'
8
24
 
9
25
  import { BRAND, GLYPHS, alive, duration, lastRun, render, runState } from '@jals1212/ci-local/status'
@@ -268,27 +284,34 @@ export function createStatusLine({ file, sessionId, reason }) {
268
284
  }
269
285
  }
270
286
 
271
- /** One short line for the footer. */
287
+ /** One short line for the footer: `🚦 ci-local ✓ passed · 4m12s`. */
272
288
  export function footerText(state) {
289
+ // The state's glyph sits by the state, after the name.
290
+ return `${BRAND} ci-local ${GLYPHS[state.state] ?? GLYPHS.stopped} ${stateText(state)}`
291
+ }
292
+
293
+ /** The state of a run in a few words, as the footer says it after its glyph. */
294
+ export function stateText(state) {
273
295
  const time = duration(state.elapsedMs)
274
296
  switch (state.state) {
275
297
  case 'running':
276
- return state.step
277
- ? `${BRAND} ${GLYPHS.running} ci-local [${state.step.index}/${state.step.total}] ${state.step.step} · ${time}`
278
- : `${BRAND} ${GLYPHS.running} ci-local running · ${time}`
298
+ return state.step ? `[${state.step.index}/${state.step.total}] ${state.step.step} · ${time}` : `running · ${time}`
279
299
  case 'starting':
280
- return `${BRAND} ${GLYPHS.starting} ci-local starting · ${time}`
300
+ return `starting · ${time}`
281
301
  case 'queued': {
282
- const behind = state.blockedBy?.project
283
- const queued = `${BRAND} ${GLYPHS.queued} ci-local queued`
284
- return behind ? `${queued} · behind ${behind}` : queued
302
+ // blockedBy names the run at the head of the queue (the one running), the
303
+ // same for every run waiting: how many are ahead tells them apart.
304
+ const running = state.blockedBy?.project ? `${state.blockedBy.project} running` : ''
305
+ if (!(state.ahead > 0)) return running ? `queued · ${running}` : 'queued'
306
+ const ahead = `queued · ${state.ahead} ahead`
307
+ return running ? `${ahead} (${running})` : ahead
285
308
  }
286
309
  case 'passed':
287
- return `${BRAND} ${GLYPHS.passed} ci-local passed · ${time}`
310
+ return `passed · ${time}`
288
311
  case 'failed':
289
- return state.fail ? `${BRAND} ${GLYPHS.failed} ci-local failed at ${state.fail.step} · ${time}` : `${BRAND} ${GLYPHS.failed} ci-local failed · ${time}`
312
+ return state.fail ? `failed at ${state.fail.step} · ${time}` : `failed · ${time}`
290
313
  default:
291
- return `${BRAND} ${GLYPHS.stopped} ci-local stopped`
314
+ return 'stopped'
292
315
  }
293
316
  }
294
317
 
@@ -449,3 +472,187 @@ export function createDelivery({ isAlive = alive, now = Date.now } = {}) {
449
472
  },
450
473
  }
451
474
  }
475
+
476
+ // ---------------------------------------------------------------- ci_local_run
477
+ //
478
+ // The ci_local_run tool starts a run itself, detached from Pi's tools, so no
479
+ // background job announces its end: the footer follows it and the end card
480
+ // (with its wake) reports it, as for any run this session starts. A run that
481
+ // ends before its first event (a usage or config error, `--list`, a busy
482
+ // machine with --no-wait) leaves no card: the tool reports it when it ends
483
+ // while the tool waits, exitCard when it ends later.
484
+
485
+ /** The directories of launch outputs, in the temporary directory. */
486
+ const LAUNCH_PREFIX = 'ci-local-run-'
487
+
488
+ /** How long a launch's output is kept: past it, the next launch removes it. */
489
+ export const LAUNCH_OUTPUT_TTL_MS = 7 * 24 * 3600 * 1000
490
+
491
+ /** Removes the launch outputs in `dir` older than `maxAgeMs`; never fails. */
492
+ export function pruneLaunchOutputs({ dir = tmpdir(), maxAgeMs = LAUNCH_OUTPUT_TTL_MS, now = Date.now() } = {}) {
493
+ let names = []
494
+ try {
495
+ names = readdirSync(dir)
496
+ } catch {
497
+ return
498
+ }
499
+ for (const name of names) {
500
+ if (!name.startsWith(LAUNCH_PREFIX)) continue
501
+ const path = join(dir, name)
502
+ try {
503
+ const stat = lstatSync(path)
504
+ if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) rmSync(path, { recursive: true, force: true })
505
+ } catch {
506
+ // Gone already, or not ours to remove: left alone.
507
+ }
508
+ }
509
+ }
510
+
511
+ /** The ci-local a repository runs: its own (node_modules/.bin), else the one on PATH. */
512
+ export function ciLocalBin(repo) {
513
+ const local = join(repo, 'node_modules', '.bin', 'ci-local')
514
+ return existsSync(local) ? local : 'ci-local'
515
+ }
516
+
517
+ /**
518
+ * Starts ci-local in `repo` with `args`, detached (its own process group, no
519
+ * terminal, nothing Pi waits on), naming the session in PI_SESSION_ID so the
520
+ * run is this session's (ownsRun). Its stdout and stderr go to a private file
521
+ * (`output`). Once it ends, `ended` is true, with its exit status in `code`
522
+ * or the signal that killed it in `signal`; when it could not start at all,
523
+ * `error` says why and both are null. `exited` settles then.
524
+ */
525
+ export function launchRun({ repo, args = [], sessionId, env = process.env, bin = ciLocalBin(repo) }) {
526
+ pruneLaunchOutputs()
527
+ const dir = mkdtempSync(join(tmpdir(), LAUNCH_PREFIX))
528
+ const output = join(dir, 'output.log')
529
+ const launch = { bin, output, pid: undefined, ended: false, code: null, signal: null, error: undefined, exited: undefined }
530
+ let child
531
+ try {
532
+ const fd = openSync(output, 'a', 0o600)
533
+ try {
534
+ child = spawn(bin, args, { cwd: repo, detached: true, stdio: ['ignore', fd, fd], env: { ...env, PI_SESSION_ID: sessionId } })
535
+ } finally {
536
+ closeSync(fd) // the child has its own copy
537
+ }
538
+ } catch (error) {
539
+ rmSync(dir, { recursive: true, force: true }) // nothing ran: no output to keep
540
+ throw error
541
+ }
542
+ launch.pid = child.pid
543
+ launch.exited = new Promise((resolve) => {
544
+ child.once('error', (error) => {
545
+ launch.error = error.message
546
+ launch.ended = true
547
+ resolve(launch)
548
+ })
549
+ child.once('exit', (code, signal) => {
550
+ Object.assign(launch, { code, signal, ended: true })
551
+ resolve(launch)
552
+ })
553
+ })
554
+ child.unref()
555
+ return launch
556
+ }
557
+
558
+ /** How long ci_local_run waits for the run's first event. */
559
+ export const LAUNCH_WAIT_MS = 15_000
560
+
561
+ /**
562
+ * Waits until the launched run shows in the events file (its pid in the last
563
+ * run: `started`, with the run), ends first (`exited`), or `timeoutMs` passes
564
+ * or `signal` aborts (`waiting`: a run of the same project may still own the
565
+ * events file, and this one writes nothing before its turn). The run itself
566
+ * is never stopped here.
567
+ */
568
+ export async function waitForStart(launch, file, { timeoutMs = LAUNCH_WAIT_MS, pollMs = 250, signal } = {}) {
569
+ const deadline = Date.now() + timeoutMs
570
+ const aborted = new Promise((resolve) => signal?.addEventListener('abort', resolve, { once: true }))
571
+ for (;;) {
572
+ // Snapshot "has it exited?" first, then read the events. A run that wrote
573
+ // its events and exited in between is then found by its events (started),
574
+ // never taken for one that exited before writing any.
575
+ const ended = launch.ended
576
+ let run = null
577
+ try {
578
+ run = file ? readRun(file) : null
579
+ } catch {
580
+ // A half-written file: the next look reads it whole.
581
+ }
582
+ if (run && launch.pid !== undefined && run.state.pid === launch.pid) return { kind: 'started', run }
583
+ if (ended) return { kind: 'exited' }
584
+ if (Date.now() >= deadline || signal?.aborted) return { kind: 'waiting' }
585
+ await Promise.race([launch.exited, aborted, new Promise((resolve) => setTimeout(resolve, pollMs))])
586
+ }
587
+ }
588
+
589
+ /** How much of the end of a launch's output outputTail reads. */
590
+ const TAIL_BYTES = 64 * 1024
591
+
592
+ /** The last `count` lines of a launch's output, from its last TAIL_BYTES (none when it cannot be read). */
593
+ export function outputTail(output, count = 20) {
594
+ let fd
595
+ try {
596
+ fd = openSync(output, 'r')
597
+ const size = fstatSync(fd).size
598
+ const length = Math.min(size, TAIL_BYTES)
599
+ const buffer = Buffer.alloc(length)
600
+ readSync(fd, buffer, 0, length, size - length)
601
+ const lines = buffer.toString('utf8').trimEnd().split('\n')
602
+ if (length < size) lines.shift() // cut mid-line
603
+ return lines.slice(-count)
604
+ } catch {
605
+ return []
606
+ } finally {
607
+ if (fd !== undefined) closeSync(fd)
608
+ }
609
+ }
610
+
611
+ /** How a launch ended, in a few words: `status 2`, or `killed by SIGTERM`. */
612
+ const howEnded = ({ code, signal }) => (signal ? `killed by ${signal}` : `status ${code}`)
613
+
614
+ /** The first line of ci_local_run's answer for a launch that ended before its run showed. */
615
+ function endedLine(launch) {
616
+ if (launch.code === 0) return 'ci-local finished (status 0) without a run'
617
+ const verb = launch.signal ? 'was' : 'exited with'
618
+ return `ci-local ${verb} ${howEnded(launch)} before a run started`
619
+ }
620
+
621
+ /** What ci_local_run tells the agent of a launch and how it stands. */
622
+ export function launchReport(result, launch, project) {
623
+ const where = `Output: ${launch.output}.`
624
+ const follow = 'The footer follows it and its end card reaches you when it ends: do not poll, sleep or run `ci-local --status`.'
625
+ if (result.kind === 'started')
626
+ return `ci-local started (pid ${launch.pid}) for ${project}: ${stateText(result.run.state)}. ${follow} ${where}`
627
+ if (result.kind === 'waiting')
628
+ return `ci-local launched (pid ${launch.pid}) for ${project}, no event yet: another run of ${project} may hold its events file until it ends. ${follow} ${where}`
629
+ const tail = outputTail(launch.output)
630
+ return [
631
+ `${endedLine(launch)}. ${where}`,
632
+ ...(tail.length ? ['', '```', ...tail, '```'] : []),
633
+ ].join('\n')
634
+ }
635
+
636
+ /**
637
+ * A launch that ended after ci_local_run returned without the session ever
638
+ * seeing its run (it wrote no event, or one the session never read before a
639
+ * later run took the events file), as a run for the end card
640
+ * (createDelivery): failed, with the end of its output, which says what
641
+ * happened either way.
642
+ */
643
+ export function exitCard({ pid, code, signal = null, output, tail = outputTail(output, 10) }) {
644
+ // Status 0: its run passed, or it had no run to make; anything else failed.
645
+ const state = code === 0 ? 'passed' : 'failed'
646
+ return {
647
+ key: `launch:${pid}`,
648
+ state: { state, pid, pidStart: null },
649
+ block: {
650
+ code: code === 0 ? 0 : 1,
651
+ lines: [
652
+ `> ${BRAND} ${GLYPHS[state]} **ci-local** · ended before this session saw its run`,
653
+ `> ${howEnded({ code, signal })} · pid ${pid} · output \`${output}\``,
654
+ ...tail.map((line) => `> ${line}`),
655
+ ],
656
+ },
657
+ }
658
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/ci-local-pi",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension and skill for @jals1212/ci-local: the state of this repository's ci-local run in Pi's status line, cards and a read-only tool for the agent.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",
@@ -4,7 +4,7 @@ description: "Trigger: ci-local, run CI locally, ci-local.config.sh, CI_PROJECT,
4
4
  license: MIT
5
5
  metadata:
6
6
  author: jals1212
7
- version: "0.1.0"
7
+ version: "0.2.0"
8
8
  ---
9
9
 
10
10
  ## Activation Contract
@@ -14,7 +14,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
14
14
  ## Hard Rules
15
15
 
16
16
  - Run the repository's own ci-local (`npx ci-local`, `pnpm exec ci-local`), never a global install: the version is the repository's.
17
- - Launch a run in the background (`bash_background` or equivalent) and wait for its exit notice; never poll, never `sleep`.
17
+ - Start a run with the `ci_local_run` tool when present: it returns once the run is queued or started, and its end card reaches you when it ends. Without it, launch in the background (`bash_background` or equivalent) and wait for its exit notice. Never poll, never `sleep`.
18
18
  - Unwatched runs are quiet: milestones and the summary on stdout, each step's output in its own log. From 0.5.0 that is the default without a terminal; before 0.5.0, add `--quiet`. Never add `--live` to a background run.
19
19
  - Do not touch the checkout while a run is in flight (no edits, no branch switch): the steps read it.
20
20
  - Never start a second run to "check" the first. Runs queue machine-wide by themselves (one Docker user at a time); a waiting run is normal, not a hang.
@@ -28,6 +28,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
28
28
  | --- | --- |
29
29
  | State of a run (queued, running, result) | `ci_local_status` tool if present; else `ci-local --status` (3 lines, no Docker) |
30
30
  | Pi extension active (footer shows `ci-local`) | Do not print waiting or completion blocks: the footer and the end card already do |
31
+ | `ci_local_run` ended at once (`exited`) | Its output is the result: a usage or config error, `--list`, or a busy machine with `--no-wait` |
31
32
  | No extension | Paste the `ci-local --status` output once when the run starts and once when it ends |
32
33
  | Only some jobs | `--only JOBS` / `--skip TAGS`; a partial run never stands for the full one |
33
34
  | Inspect containers after a run | `--keep`, then `ci-local --down` |
@@ -37,7 +38,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
37
38
  ## Execution Steps
38
39
 
39
40
  1. Check the plan: `ci-local --list`.
40
- 2. Start: `ci-local` in the background (`ci-local --quiet` before 0.5.0).
41
+ 2. Start: `ci_local_run` (arguments in `args`); without it, `ci-local` in the background (`ci-local --quiet` before 0.5.0).
41
42
  3. While waiting, keep working on something that does not touch the checkout, or report the `--status` block.
42
43
  4. On exit: status 0 passed, 1 failed, 2 usage/config error, 3 no turn (busy with `--no-wait`, or `CI_LOCAL_QUEUE_TIMEOUT` reached).
43
44
  5. Failed: open the failing step's log, fix, and run again; partial `--only` runs are fine while fixing, the final check is a full run.