@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 +34 -0
- package/README.md +21 -8
- package/extensions/ci-local.ts +92 -37
- package/extensions/core.js +220 -13
- package/package.json +1 -1
- package/skills/ci-local/SKILL.md +4 -3
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`.
|
|
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**: `🚦
|
|
29
|
-
then `🚦
|
|
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
|
-
- **
|
|
79
|
-
|
|
80
|
-
|
|
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
|
package/extensions/ci-local.ts
CHANGED
|
@@ -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: `🚦
|
|
5
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
77
|
-
// Said at load, before any session starts, so
|
|
78
|
-
// the load order; session_shutdown takes it back (a reload without
|
|
79
|
-
// package must not find it still there).
|
|
80
|
-
|
|
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 () =>
|
|
102
|
-
pi.on('session_compact', async () =>
|
|
103
|
-
pi.on('session_compact_failed', async () =>
|
|
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
|
-
|
|
316
|
+
delete (globalThis as Record<symbol, unknown>)[FLAG]
|
|
262
317
|
})
|
|
263
318
|
}
|
|
264
319
|
|
|
265
|
-
export default createCiLocalExtension(
|
|
320
|
+
export default createCiLocalExtension()
|
package/extensions/core.js
CHANGED
|
@@ -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 {
|
|
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
|
|
300
|
+
return `starting · ${time}`
|
|
281
301
|
case 'queued': {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
310
|
+
return `passed · ${time}`
|
|
288
311
|
case 'failed':
|
|
289
|
-
return state.fail ?
|
|
312
|
+
return state.fail ? `failed at ${state.fail.step} · ${time}` : `failed · ${time}`
|
|
290
313
|
default:
|
|
291
|
-
return
|
|
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.
|
|
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",
|
package/skills/ci-local/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
-
-
|
|
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.
|