@jals1212/ci-local-pi 0.3.1 → 0.5.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,25 @@ add or change behaviour and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.5.0
10
+
11
+ ### Added
12
+
13
+ - The status line of a finished run says when it ended, so an old result is
14
+ not taken for a new one: how long ago for its first hour
15
+ (`🚦 ci-local · ✗ failed at lint · 32s · 10m ago`), then the local time it
16
+ ended (`· 13:10`). (#58)
17
+
18
+ ## 0.4.0
19
+
20
+ ### Added
21
+
22
+ - Herdr sidebar: in a Herdr pane, the status line is also the pane's `$ci`
23
+ metadata token while a run is in flight and for 5 minutes after it ends, so
24
+ a `["$ci"]` row in Herdr's Agents sidebar shows ci-local next to the
25
+ agent. Display only, refreshed with a 30-second TTL, and cleared when the
26
+ session ends. (#54)
27
+
9
28
  ## 0.3.1
10
29
 
11
30
  ### Fixed
package/README.md CHANGED
@@ -24,8 +24,9 @@ literal `CI_PROJECT=`), it follows the events file of that project; it never
24
24
  runs ci-local or Docker itself:
25
25
 
26
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
- the next run. A run already over when a session starts shows only while its
27
+ then `🚦 ci-local · ✓ passed · 4m12s · 10m ago` or `🚦 ci-local · ✗ failed at lint · 2m10s · just now`
28
+ until the next run. A result says how long ago it ended for its first hour,
29
+ then the local time it ended (`· 13:10`). A run already over when a session starts shows only while its
29
30
  result is undelivered, like an unread message: it ended with no session of
30
31
  the repository open. Once a session has shown it finished, a later session
31
32
  (new or resumed) leaves it out, and a reload keeps it out unless that
@@ -35,6 +36,26 @@ runs ci-local or Docker itself:
35
36
  shows it where it lists extension statuses: Gentle Shell, for one, under
36
37
  **Integrations** in the sidebar's Status card (or in its bottom bar on a
37
38
  narrow terminal).
39
+ - **Herdr sidebar**: in a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`,
40
+ `HERDR_PANE_ID`, `HERDR_SOCKET_PATH`), the status line is also the pane's
41
+ `ci` metadata token while a run is queued or running, and its result for 5
42
+ minutes after it ends. The token is display only: the agent's state in
43
+ Herdr does not change. It is reported again every 10 seconds with a
44
+ 30-second TTL, so it goes away when Pi does. To show it, add a `$ci` row to
45
+ the Agents sidebar in Herdr's `config.toml`, then run `herdr server reload-config`:
46
+
47
+ ```toml
48
+ [ui.sidebar.agents]
49
+ rows = [
50
+ ["state_icon", "machine", "workspace", "tab"],
51
+ ["agent"],
52
+ [{ token = "$ci", rules = [
53
+ { contains = "✗", fg = "#f38ba8" },
54
+ { contains = "✓", fg = "#a6e3a1" },
55
+ { contains = "⧗", fg = "#f9e2af" },
56
+ ] }],
57
+ ]
58
+ ```
38
59
  - **Only the session's own runs** get the cards and the notification, and
39
60
  reach the agent: runs this session started. A run started from Pi's bash
40
61
  tool names its session (ci-local records Pi's `PI_SESSION_ID` as `session`
@@ -2,8 +2,9 @@
2
2
  // ci-local run in Pi's own UI, so neither the agent nor you write it by hand.
3
3
  //
4
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).
5
+ // 2 ahead (erp-ci running)`, then `🚦 ci-local · ✓ passed · 4m12s · 10m ago` / `🚦 ci-local ·
6
+ // ✗ failed at lint · 2m10s · 13:10` (how long ago it ended, then from an
7
+ // hour on the time it ended) until the next run (🚦 is ci-local's own emoji, see BRAND).
7
8
  // A run already over when a session starts is shown only if no session
8
9
  // showed its result yet, like a message not yet delivered (createStatusLine).
9
10
  // - Cards and the agent: only for runs this session started, as Gentle AI's job
@@ -13,6 +14,9 @@
13
14
  // this session's process (a background job, even orphaned). Another
14
15
  // session's run in the same repository, or one started in a terminal, shows
15
16
  // in the status line alone.
17
+ // - Herdr: in a pane Herdr runs, the status line is also the pane's `$ci`
18
+ // metadata token while a run is in flight and for 5 minutes after it ends,
19
+ // so a `["$ci"]` row in Herdr's Agents sidebar shows it (display only).
16
20
  // - When a run starts: a card in the transcript when it is starting (or
17
21
  // waiting for its turn), another when its steps start; for you only (session
18
22
  // entries, never in the model's context, never repeated after a reload).
@@ -42,6 +46,8 @@ import {
42
46
  LAUNCH_WAIT_MS,
43
47
  WAKE_TEXT,
44
48
  cardContent,
49
+ createHerdrLine,
50
+ createHerdrPublisher,
45
51
  createDelivery,
46
52
  createStatusLine,
47
53
  createTracker,
@@ -49,6 +55,8 @@ import {
49
55
  eventsFileFor,
50
56
  exitCard,
51
57
  findProject,
58
+ herdrPane,
59
+ herdrSend,
52
60
  launchReport,
53
61
  launchRun,
54
62
  ownsRun,
@@ -98,6 +106,10 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
98
106
  // belongs to the session that started it; another session drops it.
99
107
  type Launch = ReturnType<typeof launchRun> & { seen: boolean; waiting: boolean; sessionId: string }
100
108
  const launches = new Map<number, Launch>()
109
+ // In a Herdr pane, the footer line is also the pane's `$ci` sidebar token
110
+ // (see createHerdrLine in core.js); null elsewhere. Read when a session
111
+ // starts following, so a test or a moved pane gets its own.
112
+ let herdr: { pane: string; socket: string; publisher: ReturnType<typeof createHerdrPublisher> } | undefined
101
113
 
102
114
  pi.on('agent_start', async () => {
103
115
  runActive = true
@@ -249,6 +261,11 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
249
261
  // already showed, and records each finished run it shows (see
250
262
  // createStatusLine in core.js).
251
263
  const statusLine = createStatusLine({ file, sessionId, reason })
264
+ const pane = herdrPane(process.env)
265
+ if (pane && (herdr?.pane !== pane.pane || herdr.socket !== pane.socket)) {
266
+ herdr = { ...pane, publisher: createHerdrPublisher({ send: herdrSend(pane) }) }
267
+ }
268
+ const herdrLine = pane ? createHerdrLine() : undefined
252
269
 
253
270
  const tick = () => {
254
271
  let run
@@ -259,6 +276,7 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
259
276
  }
260
277
  const { footer, changed } = statusLine.update(run)
261
278
  if (changed) ctx.ui.setStatus(KEY, footer)
279
+ if (herdrLine) herdr?.publisher.update(herdrLine.text(run))
262
280
  settleLaunches(run)
263
281
  // Only this session's own runs get cards and reach the agent, as Gentle
264
282
  // AI's job notices go only to the session that started the job. Another
@@ -336,6 +354,11 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
336
354
 
337
355
  pi.on('session_shutdown', async () => {
338
356
  stop()
357
+ // The sidebar token goes with the session (its TTL would end it anyway).
358
+ if (herdr) {
359
+ herdr.publisher.update(null)
360
+ await herdr.publisher.idle()
361
+ }
339
362
  startReason = undefined
340
363
  delete (globalThis as Record<symbol, unknown>)[FLAG]
341
364
  })
@@ -19,6 +19,7 @@ import {
19
19
  statSync,
20
20
  writeFileSync,
21
21
  } from 'node:fs'
22
+ import { createConnection } from 'node:net'
22
23
  import { tmpdir } from 'node:os'
23
24
  import { dirname, isAbsolute, join } from 'node:path'
24
25
 
@@ -284,10 +285,30 @@ export function createStatusLine({ file, sessionId, reason }) {
284
285
  }
285
286
  }
286
287
 
287
- /** One short line for the footer: `🚦 ci-local · ✓ passed · 4m12s`. */
288
- export function footerText(state) {
288
+ /**
289
+ * One short line for the footer: `🚦 ci-local · ✓ passed · 4m12s · 10m ago`. A
290
+ * finished run says when it ended (from its end event), so an old result is
291
+ * not taken for a new one: how long ago within the first hour, then the local
292
+ * HH:MM, which no longer changes. `now` is the time it is said at.
293
+ */
294
+ export function footerText(state, now = Date.now()) {
289
295
  // The state's glyph sits by the state, after the name.
290
- return `${BRAND} ci-local · ${GLYPHS[state.state] ?? GLYPHS.stopped} ${stateText(state)}`
296
+ const line = `${BRAND} ci-local · ${GLYPHS[state.state] ?? GLYPHS.stopped} ${stateText(state)}`
297
+ const at = FINISHED.has(state.state) ? endedText(state.end?.ts, now) : null
298
+ return at ? `${line} · ${at}` : line
299
+ }
300
+
301
+ /** Until then a result says how long ago it ended; after, the time it ended. */
302
+ export const RELATIVE_END_MS = 60 * 60_000
303
+
304
+ /** When a run ended, as the footer says it, or null without an end time. */
305
+ function endedText(ts, now) {
306
+ const date = new Date(ts ?? '')
307
+ if (Number.isNaN(date.getTime())) return null
308
+ const age = now - date.getTime()
309
+ if (age < 60_000) return 'just now' // a clock a little ahead says so too
310
+ if (age < RELATIVE_END_MS) return `${Math.floor(age / 60_000)}m ago`
311
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
291
312
  }
292
313
 
293
314
  /** The state of a run in a few words, as the footer says it after its glyph. */
@@ -663,3 +684,153 @@ export function exitCard({ pid, code, signal = null, output, tail = outputTail(o
663
684
  },
664
685
  }
665
686
  }
687
+
688
+ // Herdr (https://herdr.dev): in a pane Herdr runs, the footer line is also the
689
+ // pane's `$ci` metadata token, so a row `["$ci"]` in Herdr's Agents sidebar
690
+ // shows the run. A token is display only: it never touches the agent's state.
691
+
692
+ /** The token's name, as a sidebar row names it: `$ci`. */
693
+ export const HERDR_TOKEN = 'ci'
694
+ /** Who reports the token. No sequence numbers: one sender, one report at a time. */
695
+ export const HERDR_SOURCE = 'ci-local'
696
+ /** Herdr drops the token this long after the last report: a Pi gone leaves nothing behind. */
697
+ export const HERDR_TTL_MS = 30_000
698
+ /** A token unchanged is reported again this often, well inside its TTL. */
699
+ export const HERDR_REFRESH_MS = 10_000
700
+ /** How long a run's result stays in the sidebar after the run ends. */
701
+ export const HERDR_RESULT_MS = 5 * 60_000
702
+ /** Herdr keeps at most this many characters of a token. */
703
+ export const HERDR_TOKEN_MAX = 80
704
+
705
+ /** The pane and socket of the Herdr pane this process runs in, or null outside one. */
706
+ export function herdrPane(env = process.env) {
707
+ const pane = env.HERDR_PANE_ID?.trim()
708
+ const socket = env.HERDR_SOCKET_PATH?.trim()
709
+ return env.HERDR_ENV === '1' && pane && socket ? { pane, socket } : null
710
+ }
711
+
712
+ function clip(text) {
713
+ const chars = [...text]
714
+ return chars.length <= HERDR_TOKEN_MAX ? text : chars.slice(0, HERDR_TOKEN_MAX - 1).join('') + '…'
715
+ }
716
+
717
+ /**
718
+ * What the token says for a run: the footer line while it is in flight, and
719
+ * its result for HERDR_RESULT_MS after it ended (by its end event; a run that
720
+ * stopped without one, from when this session saw it stop). Null otherwise:
721
+ * no run, or a result older than that, or a run found already stopped.
722
+ */
723
+ export function createHerdrLine({ now = Date.now } = {}) {
724
+ let flying // the key of the last run seen in flight
725
+ const endedAt = new Map()
726
+ return {
727
+ text(run) {
728
+ if (!run) return null
729
+ if (!FINISHED.has(run.state.state)) {
730
+ flying = run.key
731
+ return clip(footerText(run.state))
732
+ }
733
+ if (!endedAt.has(run.key)) {
734
+ const ended = Date.parse(run.state.end?.ts ?? '')
735
+ endedAt.clear()
736
+ endedAt.set(run.key, Number.isFinite(ended) ? ended : run.key === flying ? now() : null)
737
+ }
738
+ const ended = endedAt.get(run.key)
739
+ return ended !== null && now() - ended < HERDR_RESULT_MS ? clip(footerText(run.state, now())) : null
740
+ },
741
+ }
742
+ }
743
+
744
+ /** The socket request that sets the token (with its TTL), or clears it (null). */
745
+ export function herdrReport(pane, value) {
746
+ const params = { pane_id: pane, source: HERDR_SOURCE, tokens: { [HERDR_TOKEN]: value } }
747
+ if (value !== null) params.ttl_ms = HERDR_TTL_MS
748
+ return { method: 'pane.report_metadata', params }
749
+ }
750
+
751
+ let herdrRequests = 0
752
+
753
+ /**
754
+ * A sender of the token to Herdr's socket (one JSON line, one reply): resolves
755
+ * true when Herdr accepted it, false on any error or after `timeoutMs`.
756
+ */
757
+ export function herdrSend({ pane, socket }, { timeoutMs = 2000 } = {}) {
758
+ return (value) =>
759
+ new Promise((resolve) => {
760
+ const id = `ci-local-${process.pid}-${++herdrRequests}`
761
+ let reply = ''
762
+ let done = false
763
+ const finish = (ok) => {
764
+ if (done) return
765
+ done = true
766
+ clearTimeout(timer)
767
+ conn.destroy()
768
+ resolve(ok)
769
+ }
770
+ const conn = createConnection(socket)
771
+ const timer = setTimeout(() => finish(false), timeoutMs)
772
+ timer.unref?.()
773
+ conn.on('connect', () => conn.write(JSON.stringify({ id, ...herdrReport(pane, value) }) + '\n'))
774
+ conn.on('data', (data) => {
775
+ reply += data
776
+ const end = reply.indexOf('\n')
777
+ if (end < 0) return
778
+ try {
779
+ const answer = JSON.parse(reply.slice(0, end))
780
+ finish(answer.id === id && answer.result?.type === 'ok')
781
+ } catch {
782
+ finish(false)
783
+ }
784
+ })
785
+ conn.on('error', () => finish(false))
786
+ conn.on('close', () => finish(false))
787
+ })
788
+ }
789
+
790
+ /**
791
+ * Keeps Herdr's token in step with `update(value)` (null clears it): a report
792
+ * when the value changes, again every HERDR_REFRESH_MS while it stays, one
793
+ * report at a time (the latest value waits for the one on its way). A failed
794
+ * report is tried again at the next update. `idle()` resolves once nothing is
795
+ * on its way.
796
+ */
797
+ export function createHerdrPublisher({ send, now = Date.now, refreshMs = HERDR_REFRESH_MS }) {
798
+ let shown = null // what Herdr has
799
+ let sentAt = 0
800
+ let inFlight = null
801
+ let pending // the value to report once inFlight is done
802
+ const update = (value) => {
803
+ if (inFlight) {
804
+ pending = value
805
+ return
806
+ }
807
+ const due = value !== shown || (value !== null && now() - sentAt >= refreshMs)
808
+ if (!due) return
809
+ let sending
810
+ try {
811
+ sending = Promise.resolve(send(value))
812
+ } catch {
813
+ sending = Promise.resolve(false)
814
+ }
815
+ inFlight = sending
816
+ .catch(() => false)
817
+ .then((ok) => {
818
+ if (ok) {
819
+ shown = value
820
+ sentAt = now()
821
+ }
822
+ inFlight = null
823
+ if (pending !== undefined) {
824
+ const next = pending
825
+ pending = undefined
826
+ update(next)
827
+ }
828
+ })
829
+ }
830
+ return {
831
+ update,
832
+ async idle() {
833
+ while (inFlight) await inFlight
834
+ },
835
+ }
836
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/ci-local-pi",
3
- "version": "0.3.1",
3
+ "version": "0.5.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.3.1"
7
+ version: "0.5.0"
8
8
  ---
9
9
 
10
10
  ## Activation Contract
@@ -18,7 +18,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
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.
21
- - Read the result from the run itself: `PASSED:` / `FAILED at step:` and `Development stack untouched`. A changed development stack is a failure.
21
+ - Read the result from the run itself: `PASSED:` / `FAILED at step:` / `STOPPED` (stopped on purpose, not a failure) and `Development stack untouched`. A changed development stack is a failure.
22
22
  - On failure, read only the failing step's log (named in the failure output or `--status`), not every log.
23
23
  - Everything a run creates carries the `$CI_PROJECT` prefix and the `$CI_LABEL` label; never hard-code another name, publish a port, or reuse a development container name. Local compose smokes use `-p <own-name>`.
24
24
 
@@ -45,7 +45,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
45
45
 
46
46
  ## Output Contract
47
47
 
48
- Report: project, commit, result line (`PASSED` / `FAILED at step`), total time, any `DEVIATES` lines, and whether the development stack was untouched.
48
+ Report: project, commit, result line (`PASSED` / `FAILED at step` / `STOPPED`), total time, any `DEVIATES` lines, and whether the development stack was untouched.
49
49
 
50
50
  ## References
51
51