Artifact 75d3fcf8ecc8dbeffb71236f05ac7d7af63ef8046820d8565f35d3496fa3edd5:
- File
internal/zitcli/zitcli.go
— part of check-in
[e326831901]
at
2026-08-17 13:13:43
on branch trunk
— ci: wire the trigger, poll runs, record the verdict as a tag
Completes the loop 0001 §6 describes. The trigger runs after each ingest fold — the moment a check-in becomes visible whatever brought it in, a local commit, a push or a sync. A poller refreshes runs quarry still thinks are going, and a terminal run becomes a tag artifact on the check-in, so CI history clones with the repository instead of living in the forge.
The verdict tag is --raw, and that is not a detail: without it the name enters the symbolic namespace where it resolves to a check-in like a release name, and the SECOND check-in to pass would make 'ci-pass' ambiguous for both. zit#80 landed exactly this last night.
It is written through the lease holder, like every artifact quarry mints, so two nodes cannot both record a verdict.
Proven end to end against the live dagnats: a commit to synthetic launched ci/build.json read AT that check-in, the run was recorded, cancelling it drove the poller to write ci-fail with the run id as the tag's value, and the tag came back through ingest into the index. (user: dmestas, size: 14526) [annotate] [blame] [check-ins using]
// Package zitcli is the subprocess boundary to zit (0001 §2): the zit // binary is the only store interface — never linked code, never direct // file reads. This package is a data plane; the arch gate restricts who // may import it. package zitcli import ( "bufio" "bytes" "errors" "fmt" "io" "os/exec" "sort" "strconv" "strings" ) // ErrCursorInvalid reports that the store was rewritten (pack, shun // reclaim) under a consumer's cursor. The remedy is a re-read from zero; // derived indexes rebuild, so the loss is detectable, never silent. var ErrCursorInvalid = errors.New("zitcli: cursor invalid: store was rewritten; re-read from zero") // Cursor is an opaque resume position from a prior Frames call. The zero // Cursor reads from the beginning (bootstrap). Only round-tripping the // values is supported — their content belongs to zit. type Cursor struct { Since string Verify string } // Artifact is one whole artifact as streamed by zit — delta storage is // resolved before emission, so Bytes hash to Hash. type Artifact struct { Hash string Bytes []byte } // Batch is one Frames result: the artifacts appended since the request // cursor, and the cursor to pass next time. type Batch struct { Artifacts []Artifact Cursor Cursor } // Client runs zit against one repository path (a checkout directory or // the .zit file itself, per zit -R). type Client struct { repo string } func New(repo string) *Client { return &Client{repo: repo} } // exit codes documented by `zit help frames`. const ( exitNoNew = 3 exitCursorInvalid = 4 ) // Frames streams artifacts appended after c. A current cursor returns an // empty Batch with the same cursor; a dead cursor returns ErrCursorInvalid. func (c *Client) Frames(cur Cursor) (Batch, error) { args := []string{"frames", "-R", c.repo} if cur.Since != "" { args = append(args, "--since="+cur.Since, "--verify="+cur.Verify) } cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr stdout, err := cmd.StdoutPipe() if err != nil { return Batch{}, err } if err := cmd.Start(); err != nil { return Batch{}, err } batch, parseErr := parseFrames(bufio.NewReader(stdout)) waitErr := cmd.Wait() switch code := exitCode(waitErr); { case code == exitCursorInvalid: return Batch{}, ErrCursorInvalid case code == exitNoNew: // Trailer-only output; the parsed cursor is authoritative. case waitErr != nil: return Batch{}, fmt.Errorf("zitcli: frames: %w: %s", waitErr, strings.TrimSpace(stderr.String())) } if parseErr != nil { return Batch{}, fmt.Errorf("zitcli: frames: %w", parseErr) } return batch, nil } // LsEntry is one file of a check-in as listed by `zit ls`. type LsEntry struct { Blob string `json:"blob"` Name string `json:"name"` } // Ls lists the effective file set of a check-in (an on-demand read, // 0001 §2: ~ms subprocess cost, off the hot path, cache above here). func (c *Client) Ls(version string) ([]LsEntry, error) { cmd := exec.Command("zit", "ls", "-R", c.repo, version) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("zitcli: ls %s: %w: %s", version, err, strings.TrimSpace(stderr.String())) } var files []LsEntry for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { if line == "" { continue } blob, name, found := strings.Cut(line, " ") if !found { return nil, fmt.Errorf("zitcli: ls: unparseable line %q", line) } files = append(files, LsEntry{Blob: blob, Name: name}) } return files, nil } // ErrNoEntry reports a read of a file that is not available at the // requested check-in — absent from its file set, or content shunned. var ErrNoEntry = errors.New("file not available at this check-in") // Cat emits a file's exact bytes at a check-in (an on-demand read, // 0001 §2). The emitted bytes are hash-verified by zit itself. A file // zit cannot serve (untracked at that check-in, shunned content) comes // back wrapping ErrNoEntry. func (c *Client) Cat(path, version string) ([]byte, error) { cmd := exec.Command("zit", "cat", "-R", c.repo, path, version) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return nil, fmt.Errorf("%w (%s@%.12s): %s", ErrNoEntry, path, version, strings.TrimSpace(stderr.String())) } if err != nil { return nil, fmt.Errorf("zitcli: cat %s@%s: %w: %s", path, version, err, strings.TrimSpace(stderr.String())) } return out, nil } // DiffEntry is one file in a name-status diff. type DiffEntry struct { Status string `json:"status"` // A, M, D Name string `json:"name"` } // Diff compares two check-ins: unified hunks plus the name-status // summary (two zit invocations; both on-demand reads, 0001 §2). func (c *Client) Diff(from, to string) (string, []DiffEntry, error) { unified, err := c.diffOut(from, to) if err != nil { return "", nil, err } brief, err := c.diffOut(from, to, "--brief") if err != nil { return "", nil, err } var entries []DiffEntry for _, line := range strings.Split(strings.TrimSuffix(brief, "\n"), "\n") { if line == "" { continue } status, name, found := strings.Cut(line, " ") if !found { return "", nil, fmt.Errorf("zitcli: diff --brief: unparseable line %q", line) } entries = append(entries, DiffEntry{Status: status, Name: name}) } return unified, entries, nil } func (c *Client) diffOut(from, to string, extra ...string) (string, error) { // -R diffs from --from/--to work against a bare store now (zit#69), // so quarry never needs a checkout on disk. args := append([]string{"diff", "-R", c.repo, "--from", from, "--to", to}, extra...) cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: diff %s..%s: %w: %s", from, to, err, strings.TrimSpace(stderr.String())) } return string(out), nil } func exitCode(err error) int { var ee *exec.ExitError if errors.As(err, &ee) { return ee.ExitCode() } return 0 } // parseFrames reads the frames wire format: zero or more // "artifact HASH SIZE\n" + SIZE raw bytes + "\n", then "cursor SINCE TOKEN\n". func parseFrames(r *bufio.Reader) (Batch, error) { var b Batch for { line, err := r.ReadString('\n') if err != nil { return b, fmt.Errorf("reading frame header: %w", err) } fields := strings.Fields(line) switch { case len(fields) == 3 && fields[0] == "artifact": size, err := strconv.Atoi(fields[2]) if err != nil { return b, fmt.Errorf("bad size in %q", strings.TrimSpace(line)) } raw := make([]byte, size) if _, err := io.ReadFull(r, raw); err != nil { return b, fmt.Errorf("artifact %s: %w", fields[1], err) } if nl, err := r.ReadByte(); err != nil || nl != '\n' { return b, fmt.Errorf("artifact %s: missing frame terminator", fields[1]) } b.Artifacts = append(b.Artifacts, Artifact{Hash: fields[1], Bytes: raw}) case len(fields) == 3 && fields[0] == "cursor": b.Cursor = Cursor{Since: fields[1], Verify: fields[2]} return b, nil default: return b, fmt.Errorf("unexpected frame line %q", strings.TrimSpace(line)) } } } // WikiCommit records a wiki artifact for title with body, returning the // new artifact hash (zit#71: a machine verb, hash-only stdout). func (c *Client) WikiCommit(title, user, mimetype string, body []byte) (string, error) { args := []string{"wiki", "commit", title, "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } cmd := exec.Command("zit", args...) cmd.Stdin = bytes.NewReader(body) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: wiki commit %q: %w: %s", title, err, strings.TrimSpace(stderr.String())) } return strings.TrimSpace(string(out)), nil } // TicketNew records a fresh ticket's first change and returns its // minted ticket id and the artifact hash. func (c *Client) TicketNew(user string, fields map[string]string) (id, hash string, err error) { out, err := c.ticketRun(append([]string{"ticket", "new", "-R", c.repo, "--user", user}, fieldArgs(fields)...)) if err != nil { return "", "", err } id, hash, found := strings.Cut(strings.TrimSpace(out), " ") if !found { return "", "", fmt.Errorf("zitcli: ticket new: unparseable result %q", out) } return id, hash, nil } // TicketSet records a change to an existing ticket, returning the // artifact hash. ErrNoEntry reports an unknown ticket id. func (c *Client) TicketSet(id, user string, fields map[string]string) (string, error) { out, err := c.ticketRun(append([]string{"ticket", "set", id, "-R", c.repo, "--user", user}, fieldArgs(fields)...)) if err != nil { return "", err } return strings.TrimSpace(out), nil } func (c *Client) ticketRun(args []string) (string, error) { cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String())) } if err != nil { return "", fmt.Errorf("zitcli: %s: %w: %s", strings.Join(args[:2], " "), err, strings.TrimSpace(stderr.String())) } return string(out), nil } func fieldArgs(fields map[string]string) []string { keys := make([]string, 0, len(fields)) for k := range fields { keys = append(keys, k) } sort.Strings(keys) args := make([]string, 0, len(keys)) for _, k := range keys { args = append(args, k+"="+fields[k]) } return args } // ForumPost opens a new forum thread (zit#76): title is the H card, // body the W card, mimetype the N card (zit#77); returns the root // post's artifact hash. An empty mimetype omits the card, which Fossil // reads as text/x-fossil-wiki — see markup's dispatch. func (c *Client) ForumPost(title, user, mimetype string, body []byte) (string, error) { args := []string{"forum", "post", "-R", c.repo, "--user", user} if title != "" { args = append(args, "--title", title) } if mimetype != "" { args = append(args, "--mimetype", mimetype) } return c.stdinHash(args, body) } // ForumReply replies to postHash within its thread (zit resolves the // thread root transitively); returns the reply's artifact hash. // ErrNoEntry reports an unknown or non-forum post. func (c *Client) ForumReply(postHash, user, mimetype string, body []byte) (string, error) { args := []string{"forum", "reply", postHash, "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } return c.stdinHash(args, body) } // TechnoteAdd records a technote (zit#76). event reuses an id to record // an edit (latest wins); empty mints a fresh one. Returns the event id // and the artifact hash. // // comment is the C card — the title a timeline renders for the note. // It can only be written here: zit documents that no later verb can // attach the card, so a note recorded without one shows its event id // forever (zit#77). func (c *Client) TechnoteAdd(user, event, mimetype, comment string, body []byte) (id, hash string, err error) { args := []string{"technote", "add", "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } if comment != "" { args = append(args, "--comment", comment) } if event != "" { args = append(args, "--event", event) } out, err := c.stdinHash(args, body) if err != nil { return "", "", err } id, hash, found := strings.Cut(out, " ") if !found { return "", "", fmt.Errorf("zitcli: technote add: unparseable result %q", out) } return id, hash, nil } // stdinHash runs a machine verb with body on stdin, returning trimmed // stdout (the hash line). Exit 1 is a user error (unknown target, empty // body/author) and maps to ErrNoEntry. func (c *Client) stdinHash(args []string, body []byte) (string, error) { cmd := exec.Command("zit", args...) cmd.Stdin = bytes.NewReader(body) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String())) } if err != nil { return "", fmt.Errorf("zitcli: %s %s: %w: %s", args[0], args[1], err, strings.TrimSpace(stderr.String())) } return strings.TrimSpace(string(out)), nil } // ArtifactRaw returns one stored artifact's exact bytes, addressed by // hash. This is how attachment content reaches a reader: an attachment's // source is a plain blob, not a file within a check-in, so Cat (which // resolves a path at a version) cannot fetch it. // // max caps the response — an attachment is arbitrary user-supplied // content, and quarry buffers it to hand to the op layer, so an // unbounded read would be a memory denial of service. Exceeding the cap // is an error, never a truncated artifact: silently serving a prefix of // a binary is worse than refusing it. func (c *Client) ArtifactRaw(hash string, max int) ([]byte, error) { cmd := exec.Command("zit", "artifact", hash, "--raw", "-R", c.repo) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if exitCode(err) == 1 { return nil, fmt.Errorf("zitcli: artifact %s: %w", hash, ErrNoEntry) } return nil, fmt.Errorf("zitcli: artifact %s: %w: %s", hash, err, strings.TrimSpace(stderr.String())) } if len(out) > max { return nil, fmt.Errorf("zitcli: artifact %s: %d bytes exceeds the %d-byte limit", hash, len(out), max) } return out, nil } // TagAdd attaches a raw-namespace tag with a value to a check-in // (zit#80). Raw is not optional for a verdict: without --raw the name // enters the symbolic namespace, where it resolves to a check-in like a // release name does — and the SECOND check-in to pass CI would make // "ci-pass" an ambiguous name for both. // // propagate carries the tag to descendants; a CI verdict does not // propagate, because passing says something about one check-in. func (c *Client) TagAdd(name, checkin, value string, propagate bool) error { args := []string{"tag", "add", "--raw"} if propagate { args = append(args, "--propagate") } args = append(args, name, checkin) if value != "" { args = append(args, value) } args = append(args, "-R", c.repo) cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr if _, err := cmd.Output(); err != nil { return fmt.Errorf("zitcli: tag add %s %s: %w: %s", name, checkin, err, strings.TrimSpace(stderr.String())) } return nil }