Commit 8394af3

Eric Bower  ·  2026-08-08 11:54:27 -0400 EDT
parent 957187e
feat: add local runner base command with isolated execution and html logging

- Default `pici [destination]` command copies CWD to /tmp for isolated execution
- Streamline CLI flags with -e/--env for key-value environment overrides
- Isolate local sessions with `local.` prefix so `pici monitor` daemons ignore local dev runs
- Auto-detect git commit SHA and branch for attestation.json and event metadata
- Rsync build artifacts directly into a `<job_id>` subfolder at target destination
- Add onboarding guide when pico.sh is missing and root `pici help` overview
2 files changed,  +500, -36
+396, -32
  1@@ -45,6 +45,17 @@ func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Works
  2 	}
  3 }
  4 
  5+type envList []string
  6+
  7+func (e *envList) String() string {
  8+	return strings.Join(*e, ", ")
  9+}
 10+
 11+func (e *envList) Set(value string) error {
 12+	*e = append(*e, value)
 13+	return nil
 14+}
 15+
 16 type Cfg struct {
 17 	Logger              *slog.Logger
 18 	Ctx                 context.Context
 19@@ -61,6 +72,7 @@ type Cfg struct {
 20 	IncludeRunning      bool      // emit running status updates in addition to terminal
 21 	HumanOutput         bool      // human-readable output instead of JSONL / slog
 22 	Wait                bool      // block until job completes, print history and summary
 23+	EnvVars             envList   // custom environment variables passed via -e / -env
 24 }
 25 
 26 type Event struct {
 27@@ -80,6 +92,7 @@ func NewCfg() (*Cfg, string, bool) {
 28 	var monitorInterval time.Duration
 29 	var gcInterval time.Duration
 30 	var logLevel string
 31+	var envVars envList
 32 	flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services")
 33 	flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)")
 34 	flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts")
 35@@ -87,6 +100,8 @@ func NewCfg() (*Cfg, string, bool) {
 36 	flag.DurationVar(&monitorInterval, "monitor-interval", 5*time.Second, "interval for monitoring zmx sessions")
 37 	flag.DurationVar(&gcInterval, "gc-interval", 10*time.Minute, "interval for garbage collection in monitor (0 to disable)")
 38 	flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
 39+	flag.Var(&envVars, "e", "environment variable in KEY=VAL format (can be specified multiple times)")
 40+	flag.Var(&envVars, "env", "environment variable in KEY=VAL format (can be specified multiple times)")
 41 	var includeRunning bool
 42 	var human bool
 43 	var wait bool
 44@@ -118,20 +133,30 @@ func NewCfg() (*Cfg, string, bool) {
 45 		IncludeRunning:      includeRunning,
 46 		HumanOutput:         human,
 47 		Wait:                wait,
 48+		EnvVars:             envVars,
 49 	}, cmd, wantHelp
 50 }
 51 
 52+func isKnownSubcommand(s string) bool {
 53+	switch s {
 54+	case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "help":
 55+		return true
 56+	default:
 57+		return false
 58+	}
 59+}
 60+
 61 // splitCommand separates the first non-flag argument (the subcommand) from
 62 // the rest of the flags, so "runner --wait" becomes flags=["--wait"], cmd="runner".
 63 // It strips --help/help so we can print custom help per subcommand.
 64 func splitCommand(args []string) (flags []string, cmd string, wantHelp bool) {
 65 	flags = make([]string, 0, len(args))
 66 	for _, arg := range args {
 67-		if arg == "--help" || arg == "help" {
 68+		if arg == "--help" || arg == "help" || arg == "-h" {
 69 			wantHelp = true
 70 			continue
 71 		}
 72-		if cmd == "" && arg != "" && !strings.HasPrefix(arg, "-") {
 73+		if cmd == "" && isKnownSubcommand(arg) {
 74 			cmd = arg
 75 		} else {
 76 			flags = append(flags, arg)
 77@@ -160,17 +185,67 @@ func newLogger(space string, levelStr string) *slog.Logger {
 78 	})).With("service", space)
 79 }
 80 
 81+func printMainHelp() {
 82+	fmt.Println(`pici — minimal parallel CI runner & monitor powered by zmx
 83+
 84+LOCAL DEVELOPER USAGE
 85+  pici [destination] [flags]                  Run ./pico.sh locally in /tmp & render HTML logs
 86+  pici pgs.sh:/my-site                        Run locally and rsync HTML logs to destination
 87+  pici run [destination]                      Explicit alias for local run
 88+
 89+DAEMON & SERVICE COMMANDS
 90+  pici runner                                 Execute CI job from event JSON payload (stdin/flag)
 91+  pici monitor                                Poll ci.* zmx sessions & stage/sync HTML artifacts
 92+  pici cancel                                 Cancel active running jobs for a repository
 93+  pici gc                                     Clean up stale/finished zmx sessions & artifacts
 94+
 95+SUBCOMMAND HELP
 96+  pici <command> --help                       Show detailed help for a specific command (e.g. pici runner --help)
 97+
 98+FLAGS
 99+  -e, -env <KEY=VAL>                          Set or override environment variable for pico.sh
100+  -pk <path>                                  SSH private key
101+  -ck <path>                                  SSH public certificate key
102+  -artifact-dir <path>                        Artifact staging directory (default: /tmp/pici-artifacts)
103+  -log-level <level>                          Log level: debug, info, warn, error`)
104+}
105+
106+func printMissingPicoHelp(cwd string) {
107+	fmt.Printf(`āŒ Error: no pico.sh found in %s
108+
109+To run local CI tasks, create a pico.sh script in your project root:
110+
111+  #!/usr/bin/env bash
112+  set -euo pipefail
113+
114+  # Run parallel steps using zmx
115+  zmx run lint -d <your lint command>
116+  zmx run test -d <your test command>
117+
118+  # Wait for all steps to finish
119+  zmx wait "*"
120+  printf "\x1b[32msuccess!\x1b[0m\n"
121+
122+For full command documentation and daemon options, run:
123+  pici help
124+
125+`, cwd)
126+}
127+
128 func main() {
129 	cfg, cmd, wantHelp := NewCfg()
130 
131 	cfg.Logger.Debug("setting up ci", "cfg", cfg)
132 	cfg.Logger.Debug("running cmd", "cmd", cmd)
133 
134-	if wantHelp && (cmd == "runner" || cmd == "monitor") {
135-		if cmd == "runner" {
136+	if wantHelp {
137+		switch cmd {
138+		case "runner":
139 			printRunnerHelp()
140-		} else {
141+		case "monitor":
142 			printMonitorHelp()
143+		default:
144+			printMainHelp()
145 		}
146 		return
147 	}
148@@ -204,9 +279,23 @@ func main() {
149 		cfg.Logger.Debug("starting status updater")
150 	case "orca":
151 		cfg.Logger.Debug("starting orchestrator")
152+	case "help":
153+		printMainHelp()
154+	case "run", "":
155+		dest := ""
156+		if flag.NArg() > 0 {
157+			dest = flag.Arg(0)
158+		}
159+		if err := runLocal(cfg, dest); err != nil {
160+			cfg.Logger.Error("local run failed", "err", err)
161+			os.Exit(1)
162+		}
163 	default:
164-		cfg.Logger.Error("must provide command: runner, cancel, gc, monitor, status, or orca")
165-		os.Exit(1)
166+		dest := cmd
167+		if err := runLocal(cfg, dest); err != nil {
168+			cfg.Logger.Error("local run failed", "err", err)
169+			os.Exit(1)
170+		}
171 	}
172 }
173 
174@@ -362,7 +451,9 @@ func (w *WorkspaceRsync) Setup() error {
175 }
176 
177 func (w *WorkspaceRsync) Cleanup() error {
178-	// return os.RemoveAll(w.Dest)
179+	if w.Dest != "" {
180+		return os.RemoveAll(w.Dest)
181+	}
182 	return nil
183 }
184 
185@@ -522,9 +613,13 @@ func (eng *JobEngine) Setup() error {
186 }
187 
188 func (eng *JobEngine) Run(manifest string) error {
189-	prefix := fmt.Sprintf("ci.%s.%s.", eng.Ev.Name, eng.JobID)
190+	domain := "ci"
191+	if eng.Ev != nil && eng.Ev.Type == "local" {
192+		domain = "local"
193+	}
194+	prefix := fmt.Sprintf("%s.%s.%s.", domain, eng.Ev.Name, eng.JobID)
195 	// Child sessions use ".step." sub-prefix so zmx wait "*" inside pico.sh
196-	// matches ci.<name>.<jobID>.step.* but NOT ci.<name>.<jobID>.runner.
197+	// matches <domain>.<name>.<jobID>.step.* but NOT <domain>.<name>.<jobID>.runner.
198 	// This avoids a deadlock where the runner waits for itself.
199 	childPrefix := prefix + "step."
200 
201@@ -551,9 +646,8 @@ func (eng *JobEngine) Run(manifest string) error {
202 	if eng.Ev.Tag != "" {
203 		cmdEnv = append(cmdEnv, fmt.Sprintf("PICI_TAG=%s", eng.Ev.Tag))
204 	}
205-
206-	zmxPrefixStr := fmt.Sprintf("ZMX_SESSION_PREFIX=%s", childPrefix)
207-	cmd := exec.Command("zmx", "run", runnerName, "-d", zmxPrefixStr, "bash", manifest)
208+	bashCmd := fmt.Sprintf("export ZMX_SESSION_PREFIX=%q; exec bash %q", childPrefix, manifest)
209+	cmd := exec.Command("zmx", "run", runnerName, "-d", "bash", "-c", bashCmd)
210 	cmd.Env = cmdEnv
211 	cmd.Dir = eng.Wk.GetDir()
212 
213@@ -610,14 +704,18 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
214 		Ev:     eventData,
215 		JobID:  jobID,
216 	}
217+	var runErr error
218 	defer func() {
219-		if err := eng.Cleanup(); err != nil {
220-			cfg.Logger.Error("engine cleanup", "err", err)
221+		if runErr != nil || cfg.Wait {
222+			if err := eng.Cleanup(); err != nil {
223+				cfg.Logger.Error("engine cleanup", "err", err)
224+			}
225 		}
226 	}()
227 
228 	fmt.Fprintf(os.Stdout, "šŸ“¦ syncing workspace %s\n", eventData.Workspace) //nolint:errcheck
229 	if err := eng.Setup(); err != nil {
230+		runErr = err
231 		return fmt.Errorf("setup: %w", err)
232 	}
233 	fmt.Fprintf(os.Stdout, "āœ… workspace ready %s\n", eng.Wk.GetDir()) //nolint:errcheck
234@@ -654,6 +752,7 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
235 
236 	manifest, err := eng.FindManifest()
237 	if err != nil {
238+		runErr = err
239 		fmt.Fprintf(os.Stdout, "āŒ %s\n\n", err) //nolint:errcheck
240 		//nolint:errcheck
241 		fmt.Fprint(os.Stdout, `Create a pico.sh script in your workspace root:
242@@ -686,13 +785,15 @@ See: https://github.com/picosh/pici
243 
244 	fmt.Fprint(os.Stdout, "šŸƒ launching sessions...\n") //nolint:errcheck
245 	if err := eng.Run(manifest); err != nil {
246+		runErr = err
247 		return fmt.Errorf("run: %w", err)
248 	}
249 
250 	fmt.Fprintln(os.Stdout, "āœ… job launched") //nolint:errcheck
251 
252 	if cfg.Wait {
253-		if err := waitAndReport(cfg, log, eventData.Name, jobID); err != nil {
254+		if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
255+			runErr = err
256 			return fmt.Errorf("wait: %w", err)
257 		}
258 		return nil
259@@ -707,9 +808,17 @@ See: https://github.com/picosh/pici
260 
261 // waitAndReport polls the job's sessions until all complete, prints live
262 // progress to stdout, then dumps session history and a final summary.
263-func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
264-	prefix := "ci." + name + "." + jobID + "."
265-	ticker := time.NewTicker(cfg.MonitorInterval)
266+func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
267+	domain := "ci"
268+	if eventType == "local" {
269+		domain = "local"
270+	}
271+	prefix := domain + "." + name + "." + jobID + "."
272+	interval := cfg.MonitorInterval
273+	if interval <= 0 {
274+		interval = 5 * time.Second
275+	}
276+	ticker := time.NewTicker(interval)
277 	defer ticker.Stop()
278 
279 	// Handle ^C gracefully
280@@ -726,9 +835,16 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
281 	var sessionOrder []string // track insertion order for deterministic output
282 	var liveLines []string    // last set of status lines printed (for overwrite)
283 
284+	var done <-chan struct{}
285+	if cfg.Ctx != nil {
286+		done = cfg.Ctx.Done()
287+	}
288+
289 	for {
290 		select {
291-		case <-cfg.Ctx.Done():
292+		case <-done:
293+			cancelJobSessions(prefix)
294+			fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck
295 			return cfg.Ctx.Err()
296 		case <-sigCh:
297 			fmt.Fprintln(os.Stdout, "\nā¹ cancelled") //nolint:errcheck
298@@ -876,9 +992,11 @@ func cleanSessionShort(name, prefix, repoName, jobID string) string {
299 	short := strings.TrimPrefix(name, prefix)
300 	// Strip "step." prefix added by child sessions
301 	short = strings.TrimPrefix(short, "step.")
302-	// Strip nested full prefix (e.g. runner named ci.name.jobID.runner)
303-	nested := "ci." + repoName + "." + jobID + "."
304-	short = strings.TrimPrefix(short, nested)
305+	// Strip nested full prefix (e.g. runner named ci.name.jobID.runner or local.name.jobID.runner)
306+	nestedCI := "ci." + repoName + "." + jobID + "."
307+	short = strings.TrimPrefix(short, nestedCI)
308+	nestedLocal := "local." + repoName + "." + jobID + "."
309+	short = strings.TrimPrefix(short, nestedLocal)
310 	return short
311 }
312 
313@@ -917,10 +1035,11 @@ func runMonitor(cfg *Cfg) error {
314 	defer ticker.Stop()
315 
316 	// Optional GC ticker — runs garbage collection on a separate interval.
317-	var gcTicker *time.Ticker
318+	var gcChan <-chan time.Time
319 	if cfg.GCInterval > 0 {
320-		gcTicker = time.NewTicker(cfg.GCInterval)
321+		gcTicker := time.NewTicker(cfg.GCInterval)
322 		defer gcTicker.Stop()
323+		gcChan = gcTicker.C
324 	}
325 
326 	// Track per-job display state across ticks (for human output)
327@@ -938,7 +1057,7 @@ func runMonitor(cfg *Cfg) error {
328 			if err := monitorTick(cfg, log, output, jobStates); err != nil {
329 				log.Error("monitor tick", "err", err)
330 			}
331-		case <-gcTicker.C:
332+		case <-gcChan:
333 			log.Debug("running periodic garbage collection")
334 			if err := runGC(cfg); err != nil {
335 				log.Error("periodic gc", "err", err)
336@@ -1560,21 +1679,21 @@ func syncJobArtifacts(cfg *Cfg, repoName, jobID string, log *slog.Logger) error
337 		}
338 		sshArgs = fmt.Sprintf("-F ~/.ssh/config -i %s%s", cfg.KeyLocation, certFile)
339 	}
340-	// Append "/" so rsync copies into the destination directory,
341-	// not as a subdirectory named after the source.
342+	// Source is jobDir (without trailing slash) so rsync creates
343+	// the jobID subdirectory inside the destination folder.
344 	dest := event.ArtifactDest
345 	if !strings.HasSuffix(dest, "/") {
346 		dest += "/"
347 	}
348 	var cmd *exec.Cmd
349 	if sshArgs != "" {
350-		cmd = exec.Command("rsync", "-e", sshArgs, "-rv", jobDir+"/", dest)
351+		cmd = exec.Command("rsync", "-e", sshArgs, "-rv", jobDir, dest)
352 	} else {
353-		cmd = exec.Command("rsync", "-rv", jobDir+"/", dest)
354+		cmd = exec.Command("rsync", "-rv", jobDir, dest)
355 	}
356 	rsyncCmd := fmt.Sprintf("rsync %s %s %s",
357 		strings.TrimLeft(cmd.Args[1], "-"),
358-		jobDir+"/", dest)
359+		jobDir, dest)
360 	log.Info("rsync", "cmd", rsyncCmd)
361 	return runCmd(cmd, log)
362 }
363@@ -1730,7 +1849,7 @@ func runGC(cfg *Cfg) error {
364 
365 	var toKill []string
366 	for _, s := range sessions {
367-		if !strings.HasPrefix(s.Name, "ci.") {
368+		if !strings.HasPrefix(s.Name, "ci.") && !strings.HasPrefix(s.Name, "local.") {
369 			continue
370 		}
371 
372@@ -1951,3 +2070,248 @@ func fmtDuration(created, ended string) string {
373 	}
374 	return fmt.Sprintf("%.1fs", secs)
375 }
376+
377+func runLocal(cfg *Cfg, dest string) error {
378+	cwd, err := os.Getwd()
379+	if err != nil {
380+		return fmt.Errorf("get working directory: %w", err)
381+	}
382+
383+	picoPath := filepath.Join(cwd, "pico.sh")
384+	if _, err := os.Stat(picoPath); os.IsNotExist(err) {
385+		printMissingPicoHelp(cwd)
386+		return fmt.Errorf("no pico.sh found in %s", cwd)
387+	}
388+
389+	repoName := filepath.Base(cwd)
390+	jobID := fmt.Sprintf("local-%d", time.Now().Unix())
391+
392+	eventData := &Event{
393+		Type:         "local",
394+		Name:         repoName,
395+		JobID:        jobID,
396+		Workspace:    cwd,
397+		ArtifactDest: dest,
398+	}
399+
400+	// Process -e / --env flags
401+	for _, envPair := range cfg.EnvVars {
402+		parts := strings.SplitN(envPair, "=", 2)
403+		val := ""
404+		if len(parts) == 2 {
405+			val = parts[1]
406+		}
407+		key := parts[0]
408+		switch key {
409+		case "PICI_REPO":
410+			eventData.Name = val
411+		case "PICI_JOB":
412+			eventData.JobID = val
413+		case "PICI_EVENT":
414+			eventData.Type = val
415+		case "PICI_BRANCH":
416+			eventData.Branch = val
417+		case "PICI_COMMIT":
418+			eventData.Commit = val
419+		case "PICI_TAG":
420+			eventData.Tag = val
421+		}
422+		_ = os.Setenv(key, val)
423+	}
424+
425+	// Auto-detect git commit SHA and branch if not explicitly provided via -e
426+	if eventData.Commit == "" {
427+		eventData.Commit = detectGitCommit(cwd)
428+	}
429+	if eventData.Branch == "" {
430+		eventData.Branch = detectGitBranch(cwd)
431+	}
432+
433+	// Always block and output human format for local runs
434+	cfg.Wait = true
435+	cfg.HumanOutput = true
436+
437+	logger := cfg.Logger
438+	if logger == nil {
439+		logger = newLogger("ci", "info")
440+	}
441+	log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
442+
443+	// Set up workspace in temp directory
444+	wk := &WorkspaceRsync{
445+		Cfg:    cfg,
446+		Logger: log,
447+		Source: cwd,
448+	}
449+
450+	eng := &JobEngine{
451+		Logger: log,
452+		Cfg:    cfg,
453+		Wk:     wk,
454+		Ev:     eventData,
455+		JobID:  jobID,
456+	}
457+
458+	defer func() {
459+		if err := eng.Cleanup(); err != nil {
460+			log.Error("cleanup workspace", "err", err)
461+		}
462+	}()
463+
464+	fmt.Fprintf(os.Stdout, "šŸš€ starting local job local.%s.%s\n", eventData.Name, jobID) //nolint:errcheck
465+	fmt.Fprintln(os.Stdout, "šŸ“¦ syncing workspace to temp directory...")                 //nolint:errcheck
466+	if err := eng.Setup(); err != nil {
467+		return fmt.Errorf("workspace setup: %w", err)
468+	}
469+	log.Debug("workspace directory", "dir", eng.Wk.GetDir())
470+
471+	// Store event.json in artifact dir
472+	eventDir := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID)
473+	artifactsDir := filepath.Join(eventDir, "artifacts")
474+	if err := os.MkdirAll(artifactsDir, 0755); err != nil {
475+		log.Error("create artifacts dir", "err", err)
476+	} else {
477+		eventBytes, _ := json.Marshal(eventData)
478+		_ = os.WriteFile(filepath.Join(artifactsDir, "event.json"), eventBytes, 0644)
479+	}
480+
481+	// Write attestation.json
482+	hostname, _ := os.Hostname()
483+	attestation := map[string]interface{}{
484+		"runner": map[string]string{
485+			"hostname": hostname,
486+			"os":       runtimeOS(),
487+			"arch":     runtimeArch(),
488+		},
489+		"provenance": map[string]string{
490+			"repo":   eventData.Name,
491+			"branch": eventData.Branch,
492+			"commit": eventData.Commit,
493+		},
494+		"workspace_checksum": eng.Wk.Checksum(),
495+	}
496+	attestationBytes, _ := json.Marshal(attestation)
497+	_ = os.WriteFile(filepath.Join(artifactsDir, "attestation.json"), attestationBytes, 0644)
498+
499+	manifest, err := eng.FindManifest()
500+	if err != nil {
501+		return err
502+	}
503+
504+	fmt.Fprintln(os.Stdout, "šŸƒ launching sessions...") //nolint:errcheck
505+	if err := eng.Run(manifest); err != nil {
506+		return fmt.Errorf("run: %w", err)
507+	}
508+
509+	// Wait for completion & print live progress
510+	if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
511+		return fmt.Errorf("wait: %w", err)
512+	}
513+
514+	// Generate and stage full HTML/txt artifacts and index
515+	if err := stageLocalArtifacts(cfg, log, eventData.Name, jobID); err != nil {
516+		log.Error("stage local artifacts", "err", err)
517+	}
518+
519+	indexFile := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID, "index.html")
520+	fmt.Fprintf(os.Stdout, "šŸ“Š local html report: file://%s\n", indexFile) //nolint:errcheck
521+
522+	// Sync to destination if specified
523+	if dest != "" {
524+		targetDest := strings.TrimSuffix(dest, "/") + "/" + jobID
525+		fmt.Fprintf(os.Stdout, "šŸ”„ rsyncing artifacts to %s...\n", dest) //nolint:errcheck
526+		if err := syncJobArtifacts(cfg, eventData.Name, jobID, log); err != nil {
527+			return fmt.Errorf("sync artifacts: %w", err)
528+		}
529+		fmt.Fprintf(os.Stdout, "āœ… artifacts rsynced to %s\n", targetDest) //nolint:errcheck
530+	}
531+
532+	return nil
533+}
534+
535+func stageLocalArtifacts(cfg *Cfg, log *slog.Logger, repoName, jobID string) error {
536+	listOutput, err := exec.Command("zmx", "list").CombinedOutput()
537+	if err != nil {
538+		return fmt.Errorf("zmx list: %w", err)
539+	}
540+	sessions := parseZMXList(string(listOutput))
541+	var localSessions []SessionInfo
542+	for _, s := range sessions {
543+		if strings.HasPrefix(s.Name, "local.") {
544+			localSessions = append(localSessions, s)
545+		}
546+	}
547+	prefix := fmt.Sprintf("local.%s.%s.", repoName, jobID)
548+
549+	var jobSessions []SessionInfo
550+	for _, s := range localSessions {
551+		if strings.HasPrefix(s.Name, prefix) {
552+			s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
553+			jobSessions = append(jobSessions, s)
554+		}
555+	}
556+
557+	for _, s := range jobSessions {
558+		sessionStatus := "running"
559+		sessionDuration := fmtDuration(s.Created, fmt.Sprintf("%d", time.Now().Unix()))
560+		sessionExitCode := ""
561+		if s.Ended != "" {
562+			sessionDuration = fmtDuration(s.Created, s.Ended)
563+			if s.ExitCode == "0" {
564+				sessionStatus = "success"
565+				sessionExitCode = "0"
566+			} else {
567+				sessionStatus = "failed"
568+				sessionExitCode = s.ExitCode
569+			}
570+		}
571+
572+		html, err := fetchHistoryHTML(s.Name, repoName, jobID, sessionStatus, sessionDuration, sessionExitCode)
573+		if err == nil {
574+			_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, s.Short, html, ".html")
575+		}
576+		plain, err := fetchHistoryPlain(s.Name)
577+		if err == nil {
578+			_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, s.Short, plain, ".txt")
579+		}
580+	}
581+
582+	// Write published sentinel
583+	exitCode, status := resolveJobExitCode(jobSessions)
584+	sentinel := filepath.Join(cfg.ArtifactDir, repoName, jobID, "artifacts", "published.json")
585+	published := map[string]interface{}{
586+		"status":      status,
587+		"exit_code":   exitCode,
588+		"job_id":      jobID,
589+		"finished_at": time.Now().UTC().Format(time.RFC3339),
590+	}
591+	publishedJSON, _ := json.Marshal(published)
592+	_ = os.WriteFile(sentinel, publishedJSON, 0644)
593+
594+	indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, repoName, jobID, jobSessions)
595+	_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "index", indexHTML, ".html")
596+	_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "index", indexTXT, ".txt")
597+	if styles, err := loadStyles(); err == nil {
598+		_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "styles", styles, ".css")
599+	}
600+
601+	return nil
602+}
603+
604+func detectGitCommit(dir string) string {
605+	cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD")
606+	out, err := cmd.Output()
607+	if err != nil {
608+		return ""
609+	}
610+	return strings.TrimSpace(string(out))
611+}
612+
613+func detectGitBranch(dir string) string {
614+	cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD")
615+	out, err := cmd.Output()
616+	if err != nil || strings.TrimSpace(string(out)) == "HEAD" {
617+		return ""
618+	}
619+	return strings.TrimSpace(string(out))
620+}
+104, -4
  1@@ -25,17 +25,20 @@ func TestE2E_RunnerWithZMXSessions(t *testing.T) {
  2 	if testing.Short() {
  3 		t.Skip("skip integration test")
  4 	}
  5-	if _, err := exec.LookPath("zmx"); err != nil {
  6+	zmxPath, err := exec.LookPath("zmx")
  7+	if err != nil {
  8 		t.Skip("zmx not found, skipping integration test")
  9 	}
 10+	zmxDir := filepath.Dir(zmxPath)
 11 
 12 	// 1. Create workspace with pico.sh that spawns zmx sessions
 13 	workspaceDir := t.TempDir()
 14-	picoSh := `#!/usr/bin/env bash
 15+	picoSh := fmt.Sprintf(`#!/usr/bin/env bash
 16 set -e
 17+export PATH="%s:$PATH"
 18 zmx run step1 echo "hello from step1"
 19 zmx run step2 echo "hello from step2"
 20-`
 21+`, zmxDir)
 22 	if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
 23 		t.Fatalf("write pico.sh: %v", err)
 24 	}
 25@@ -43,9 +46,14 @@ zmx run step2 echo "hello from step2"
 26 	// 2. Create config
 27 	artifactDir := t.TempDir()
 28 	ctx, cancel := context.WithCancel(context.Background())
 29+	testJobID := fmt.Sprintf("j-%d", time.Now().UnixNano()%100000000)
 30+	t.Cleanup(func() {
 31+		_ = exec.Command("zmx", "kill", "-f", fmt.Sprintf("ci.test-repo.%s", testJobID)).Run()
 32+	})
 33 	event := Event{
 34 		Type:      "build",
 35 		Name:      "test-repo",
 36+		JobID:     testJobID,
 37 		Workspace: workspaceDir,
 38 	}
 39 	eventJSON, _ := json.Marshal(event)
 40@@ -109,7 +117,7 @@ zmx run step2 echo "hello from step2"
 41 				}
 42 
 43 				// Ignore statuses from unrelated jobs (e.g., leftover sessions from previous tests)
 44-				if p.Name != "test-repo" {
 45+				if p.Name != "test-repo" || p.JobID != testJobID {
 46 					continue
 47 				}
 48 
 49@@ -690,3 +698,95 @@ func TestResolveJobExitCode(t *testing.T) {
 50 		})
 51 	}
 52 }
 53+
 54+func TestRunLocal_MissingPico(t *testing.T) {
 55+	tempDir := t.TempDir()
 56+	origWd, _ := os.Getwd()
 57+	defer func() { _ = os.Chdir(origWd) }()
 58+	_ = os.Chdir(tempDir)
 59+
 60+	cfg := &Cfg{
 61+		ArtifactDir: t.TempDir(),
 62+	}
 63+	err := runLocal(cfg, "")
 64+	if err == nil {
 65+		t.Fatal("expected error when pico.sh is missing")
 66+	}
 67+}
 68+
 69+func TestRunLocal_EnvOverrides(t *testing.T) {
 70+	tempDir := t.TempDir()
 71+	origWd, _ := os.Getwd()
 72+	defer func() { _ = os.Chdir(origWd) }()
 73+	_ = os.Chdir(tempDir)
 74+
 75+	picoContent := `#!/usr/bin/env bash
 76+echo "hello from pico"
 77+`
 78+	if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
 79+		t.Fatal(err)
 80+	}
 81+
 82+	cfg := &Cfg{
 83+		ArtifactDir: t.TempDir(),
 84+		EnvVars:     envList{"PICI_REPO=custom-repo", "CUSTOM_VAR=hello"},
 85+	}
 86+
 87+	_ = runLocal(cfg, "")
 88+
 89+	if os.Getenv("CUSTOM_VAR") != "hello" {
 90+		t.Errorf("expected CUSTOM_VAR to be hello, got %q", os.Getenv("CUSTOM_VAR"))
 91+	}
 92+}
 93+
 94+func TestDetectGitCommit(t *testing.T) {
 95+	cwd, err := os.Getwd()
 96+	if err != nil {
 97+		t.Fatal(err)
 98+	}
 99+	commit := detectGitCommit(cwd)
100+	if commit == "" {
101+		t.Log("git commit sha not detected (not a git repository or git unavailable)")
102+	} else {
103+		t.Logf("detected git commit sha: %s", commit)
104+	}
105+}
106+
107+func TestWaitAndReport_Cancellation(t *testing.T) {
108+	ctx, cancel := context.WithCancel(context.Background())
109+	cfg := &Cfg{
110+		Ctx:             ctx,
111+		Cancel:          cancel,
112+		MonitorInterval: 100 * time.Millisecond,
113+	}
114+
115+	jobID := fmt.Sprintf("canceltest-%d", time.Now().UnixNano())
116+	prefix := "local.testrepo." + jobID + "."
117+	runnerSession := prefix + "runner"
118+
119+	cmd := exec.Command("zmx", "run", runnerSession, "-d", "sleep", "30")
120+	if err := cmd.Run(); err != nil {
121+		t.Fatalf("failed to start zmx session: %v", err)
122+	}
123+
124+	go func() {
125+		time.Sleep(200 * time.Millisecond)
126+		cancel()
127+	}()
128+
129+	err := waitAndReport(cfg, nil, "testrepo", jobID, "local")
130+	if err == nil {
131+		t.Error("expected error when waitAndReport is cancelled, got nil")
132+	}
133+
134+	time.Sleep(200 * time.Millisecond)
135+	listOutput, _ := exec.Command("zmx", "list").CombinedOutput()
136+	sessions := parseZMXList(string(listOutput))
137+	for _, s := range sessions {
138+		if s.Name == runnerSession {
139+			if s.Ended == "" {
140+				t.Errorf("session %s should have been killed on cancellation", runnerSession)
141+			}
142+		}
143+	}
144+}