@jals1212/ci-local-pi 0.1.1 → 0.3.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.3.0
10
+
11
+ ### Changed
12
+
13
+ - Status line: a `·` separates the name from the state, as other footer
14
+ integrations do (`🚦 ci-local · ✓ passed · 4m12s`, was
15
+ `🚦 ci-local ✓ passed · 4m12s`). (#45)
16
+
17
+ ## 0.2.0
18
+
19
+ ### Added
20
+
21
+ - Tool `ci_local_run`: starts a run of the repository's own ci-local
22
+ (`node_modules/.bin`, else `PATH`) detached from Pi, for this session.
23
+ (#42, #43)
24
+ - It returns once the run is queued or started, or with ci-local's output
25
+ when it ends first (`--list`, a usage or config error, `--no-wait` on a
26
+ busy machine).
27
+ - No background job, so no job notice: the footer and the end card report
28
+ the run.
29
+ - A launch that ends after the tool returned, without the session ever
30
+ seeing its run, gets an end card of its own with the end of its output.
31
+ - Launch outputs live under `$TMPDIR` and are removed after a week.
32
+ - Skill: starts runs with `ci_local_run` when present. (#42)
33
+
34
+ ### Changed
35
+
36
+ - Status line: the state's glyph sits by the state, after the name
37
+ (`🚦 ci-local ✓ passed · 4m12s`, was `🚦 ✓ ci-local passed`). (#40)
38
+ - Status line: a queued run says how many runs are ahead of it and which one
39
+ is running (`🚦 ci-local ◌ queued · 2 ahead (erp-ci running)`). It said
40
+ only `behind erp-ci`, the run at the head of the queue, so every waiting
41
+ session read the same. (#40)
42
+
9
43
  ## 0.1.1
10
44
 
11
45
  ### Changed
package/README.md CHANGED
@@ -23,8 +23,8 @@ In a session opened inside a repository with `ci-local.config.sh` (with a
23
23
  literal `CI_PROJECT=`), it follows the events file of that project; it never
24
24
  runs ci-local or Docker itself:
25
25
 
26
- - **Status line**: `🚦 ◌ ci-local queued · behind erp-ci`, `🚦 ⧗ ci-local [3/8] lint · 2m05s`,
27
- 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
28
28
  the next run. A run already over when a session starts shows only while its
29
29
  result is undelivered, like an unread message: it ended with no session of
30
30
  the repository open. Once a session has shown it finished, a later session
@@ -73,9 +73,24 @@ runs ci-local or Docker itself:
73
73
  session starts is not announced.
74
74
  - **Tool `ci_local_status`**: the same three lines for the agent, without a
75
75
  shell.
76
- - **Skill `ci-local`**: how an agent runs and waits for ci-local (in the
77
- background (quiet without a terminal), never two runs, read only the failing
78
- 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).
79
94
  Branch and merge policy stay the project's.
80
95
 
81
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,6 +24,11 @@
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.
@@ -34,6 +39,7 @@ import { Box, Text } from '@earendil-works/pi-tui'
34
39
  import { Type } from 'typebox'
35
40
 
36
41
  import {
42
+ LAUNCH_WAIT_MS,
37
43
  WAKE_TEXT,
38
44
  cardContent,
39
45
  createDelivery,
@@ -41,9 +47,13 @@ import {
41
47
  createTracker,
42
48
  deliveryOptions,
43
49
  eventsFileFor,
50
+ exitCard,
44
51
  findProject,
52
+ launchReport,
53
+ launchRun,
45
54
  ownsRun,
46
55
  readRun,
56
+ waitForStart,
47
57
  } from './core.js'
48
58
 
49
59
  const KEY = 'ci-local'
@@ -63,11 +73,12 @@ type Card = { state: string; lines: string[] }
63
73
  /** A start card: the run, its stage ('starting', then 'started'), the --status block. */
64
74
  type StartCard = { key: string; phase: 'starting' | 'started'; lines: string[] }
65
75
 
66
- export function createCiLocalExtension() {
67
- return (pi: ExtensionAPI) => ciLocal(pi)
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 })
68
79
  }
69
80
 
70
- function ciLocal(pi: ExtensionAPI) {
81
+ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
71
82
  // Said at load, before any session starts, so a CLI 0.7.x bridge can see it
72
83
  // whatever the load order; session_shutdown takes it back (a reload without
73
84
  // this package must not find it still there).
@@ -78,6 +89,12 @@ function ciLocal(pi: ExtensionAPI) {
78
89
  // agent_start to agent_settled (in-run compaction and retries included).
79
90
  let delivery: ReturnType<typeof createDelivery> | undefined
80
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>()
81
98
 
82
99
  pi.on('agent_start', async () => {
83
100
  runActive = true
@@ -134,6 +151,44 @@ function ciLocal(pi: ExtensionAPI) {
134
151
  },
135
152
  })
136
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
+
137
192
  function stop() {
138
193
  if (timer) clearInterval(timer)
139
194
  timer = undefined
@@ -161,6 +216,7 @@ function ciLocal(pi: ExtensionAPI) {
161
216
  // Whether each run seen in flight is this session's own (decided once: by the
162
217
  // session the run names, else by its process ancestry; see ownsRun).
163
218
  const sessionId = ctx.sessionManager.getSessionId()
219
+ dropLaunchesOfOtherSessions(sessionId)
164
220
  const owned = new Map<string, boolean>()
165
221
  const isOwn = (run: NonNullable<ReturnType<typeof readRun>>) => {
166
222
  if (!owned.has(run.key)) owned.set(run.key, ownsRun(run, { sessionId }))
@@ -181,6 +237,7 @@ function ciLocal(pi: ExtensionAPI) {
181
237
  }
182
238
  const { footer, changed } = statusLine.update(run)
183
239
  if (changed) ctx.ui.setStatus(KEY, footer)
240
+ settleLaunches(run)
184
241
  // Only this session's own runs get cards and reach the agent, as Gentle
185
242
  // AI's job notices go only to the session that started the job. Another
186
243
  // session's run in this repository shows in the status line alone. What
@@ -206,6 +263,21 @@ function ciLocal(pi: ExtensionAPI) {
206
263
  deliver()
207
264
  }
208
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
+
209
281
  // At most one delivery per tick: a failure is retried later (see createDelivery).
210
282
  const deliver = () => {
211
283
  let action
@@ -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.1",
3
+ "version": "0.3.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.1"
7
+ version: "0.3.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.