// ci: pipeline runs on the workspace repos' github/gitlab remotes import { z } from "zod"; import { AgentRunPickSchema } from "./agent.js"; // The daemon maps each workspace repo to its CI project via a connected github/gitlab capability, registers a webhook // to fire `ci` listener automations instantly, and serves the Pipelines rail from a webhook-freshened, poll-backfilled // cache. `host` names which provider API serves a repo; the listener provider is always `ci`, since a trigger narrows // by repo, not by vendor. export const CiHostSchema = z.enum(["github", "gitlab"]); export type CiHost = z.infer; // Both vendors' vocabularies collapsed onto six states. `queued` is its own state, not a flavor of `running`: a job // waiting on an offline runner must read as "go look at your runners", not as work in progress with a ticking duration. export const PipelineStatusSchema = z.enum(["queued", "running", "success", "failed", "canceled", "skipped"]); export type PipelineStatus = z.infer; // Whether a run has said anything yet, against the three ways it can stop; shared here since the daemon (for duration // math) and the board (in-flight count, Cancel button, verdict walk) must split the same way. export const isPipelineInFlight = (status: PipelineStatus): boolean => status === "queued" || status === "running"; export const PipelineRunSchema = z.object({ // The workspace repo dir (the panels `repo` convention), the join key back to the tree and to triggers. repo: z.string().describe("Which workspace repository it belongs to."), host: CiHostSchema.describe("Which forge is running it."), // owner/name (github) or the full project path (gitlab). project: z.string().describe("The project there, as that forge names it."), // The vendor's numeric run/pipeline id, what rerun/cancel address. runId: z.number().describe("The forge's own id for the run, which is what re-running and cancelling take."), // github's display_title (commit subject, or PR title); gitlab's pipeline name or head commit subject. title: z .string() .optional() .describe("The run's headline, usually the commit subject or the pull request's title. Absent means falling back to the branch and commit."), // The actor the vendor credits, matching what its own UI shows; the avatar is vendor-hosted. authorName: z.string().optional().describe("Who the forge credits for setting it off."), authorAvatarUrl: z.string().optional().describe("Their picture, hosted by the forge. Absent means drawing their initials instead."), // What set it off, in the vendor's own word (gitlab's `source`, github's `event`), left raw rather than flattened // into a shared enum. trigger: z .string() .optional() .describe( "What set it off, in the forge's own word rather than flattened into a shared vocabulary, because the forge's word is the precise one.", ), // github's workflow name: several workflows run on one push there, and one passing says nothing of another. Absent on // gitlab, where a commit's pipeline is the project's one. workflow: z.string().optional().describe("Which workflow it is a run of, where a push starts several. Absent where a commit has one pipeline."), branch: z.string().describe("Which branch."), sha: z.string().describe("Which commit."), status: PipelineStatusSchema.describe( "How it is going. Queued means the forge has accepted it and nothing is executing it yet, which is a different thing to wait on than a run actually in progress.", ), // The vendor's run page, the deep link out. url: z.string().describe("Its page on the forge."), createdAt: z.number().describe("When it started, in milliseconds."), durationSeconds: z.number().optional().describe("How long it took."), // Names of the failed jobs, fetched only for failed runs (one extra call), so a wake or a view names what broke. failedJobs: z .array(z.string()) .optional() .describe( "What broke, by name. Fetched only for failed runs, so that a notification or a screen can say what went wrong rather than just that something did.", ), }); export type PipelineRun = z.infer; // Jobs fetched lazily per visible run so the list endpoint stays cheap; both vendors normalize onto these fields. Run // shape is read in descending order of truth: `needs` (the real declared graph), then `stage` (gitlab's own grouping), // then overlapping timestamps as a last resort. A `queued` job carries neither timestamp, an invariant the normalizers // enforce since github reports a fake `started_at` on a job that hasn't started. export const PipelineJobSchema = z.object({ name: z.string().describe("The job's name."), status: PipelineStatusSchema.describe("How it went."), stage: z.string().optional().describe("Which stage it belongs to, where the pipeline groups its jobs that way."), // Absent means nothing could be resolved (fall back); an empty array is the different claim that this job is a // root. needs: z .array(z.string()) .optional() .describe( "Which jobs in this run it declared it waits on: the real shape of the pipeline. Absent means nothing could be read, which is different from an empty list, which is the claim that it waits on nothing.", ), declaredId: z .string() .optional() .describe( "The job's id in its workflow file, with a called workflow's job under its caller as `caller.job`. Present where the workflow could be read; it is what the forge orders its own drawing of the run by.", ), matrix: z .string() .optional() .describe("The matrix job this is one leg of, by that job's name. Legs of one matrix are drawn together, apart from every other job."), startedAt: z.number().optional().describe("When it began, in milliseconds. Absent while it is queued."), finishedAt: z.number().optional().describe("When it ended, in milliseconds."), durationSeconds: z.number().optional().describe("How long it took."), // The job's page on its host, the shortest path from "this step failed" to the log that says why. webUrl: z.string().optional().describe("Its page on the forge, which is the shortest path from this step failed to the log that says why."), }); export type PipelineJob = z.infer; export const CiJobsResponseSchema = z.object({ jobs: z.array(PipelineJobSchema).describe("The steps inside one run. Fetched separately from the run list, so that list stays cheap."), }); export type CiJobsResponse = z.infer; // One mapped repo's CI wiring: `hookWarning` explains why webhook registration was refused or impossible, visible to // everyone. `hookRecipe` is the URL and secret to paste manually, shown to a maintainer or owner only, since the secret // signs every delivery this sandbox trusts. export const CiRepoSchema = z.object({ repo: z.string().describe("Which workspace repository."), host: CiHostSchema.describe("Which forge it lives on."), project: z.string().describe("The project there."), // The project's home page on its host. url: z.string().describe("Its page on the forge."), hookWarning: z .string() .optional() .describe( "Present when the sandbox could not register for instant notifications, with what happened. Without them the sandbox polls instead, so this costs a couple of minutes' delay rather than the feature.", ), hookRecipe: z .string() .optional() .describe("What to paste into the repository's webhook settings by hand, secret included. Shown to a maintainer or the owner only."), }); export type CiRepo = z.infer; // Fallback poll interval when a repo's webhook couldn't register; lives here since both the poller and the automation // editor need it to tell the owner what a `hookWarning` actually costs (minutes' delay, not the feature). export const CI_POLL_INTERVAL_MS = 2 * 60_000; // What was decided about a main-line branch while its CI fails, in the order it was decided (ci/main-fixer.ts). export const MainFailureDecisionKindSchema = z.enum([ // Its one fix agent was put on it: by the sandbox with the first run that failed, or by a person's Fix press, which // gives the agent its turns back. "fix-up", // Nobody was sent: repairs are switched off, or nothing could take it. "reported", // The fix agent had its turns, or stopped without a fix: it waits for a person. "spent", ]); export type MainFailureDecisionKind = z.infer; // Why the fix agent handed the failure back (a `spent` decision), as a closed set the editor words for itself, so what a // reader sees stays a few words in every language rather than whatever the turn's error happened to say. export const MainFailureHandBackSchema = z.enum([ // It had every turn the sandbox gives one failure, and main still fails. "turns", // Its turn ended without changing anything, so the failure is likely not in the code. "no-change", // A person stopped it. "stopped", // Its turn was cut off. "interrupted", // Its turn failed. "turn-failed", // Its conversation is gone or archived. "gone", // It could not be told about the newest failure. "refused", ]); export type MainFailureHandBack = z.infer; export const MainFailureDecisionSchema = z.object({ kind: MainFailureDecisionKindSchema.describe("What was decided."), reason: MainFailureHandBackSchema.optional().describe("Why the fix agent handed it back, on a `spent` decision."), conversationId: z.string().optional().describe("The conversation working on it, when one is."), at: z.number().describe("When that was decided, in milliseconds."), detail: z.string().optional().describe("One short sentence on why, in the sandbox's words."), }); export type MainFailureDecision = z.infer; // A main-line branch while its CI fails, as the daemon's one fix agent on it sees it (ci/main-fixer.ts): from the first // job that failed until a later run of every workflow that failed on it passes, whatever else failed along the way. // One workflow failing on a main-line branch, where a push starts several (GitHub): a later run of it passing takes it // off the failure, so a pass of any other says nothing of it. A scheduled one (a nightly) is the case that needs // spelling out: a push does not run it, and the board's own runs may all pass while it still fails. export const CiMainFailureWorkflowSchema = z.object({ name: z.string().describe("The workflow, by the name the forge gives it. `ci` on a forge that runs one pipeline per commit."), runId: z.number().describe("Its newest run that failed."), jobs: z.array(z.string()).describe("Its jobs failing now, by name."), }); export type CiMainFailureWorkflow = z.infer; export const CiMainFailureSchema = z.object({ repo: z.string().describe("Which workspace repository."), branch: z.string().describe("Which main-line branch."), since: z.number().describe("When its first job failed, in milliseconds."), runId: z.number().describe("The newest run that failed on it."), jobs: z.array(z.string()).describe("The jobs failing on it now, by name: the newest failed run's failures, and any that failed since."), workflows: z .array(CiMainFailureWorkflowSchema) .optional() .describe( "Each workflow failing on it, in the order they began failing, with its newest failed run and its jobs: the branch passes again only once a later run of every one of them passes. Absent from a daemon from before workflows were told apart.", ), fixer: z .string() .optional() .describe("The one conversation working on it, which every failure goes to until the branch passes. Absent while nobody is on it."), decision: MainFailureDecisionSchema.optional().describe( "The latest thing decided about it: `fix-up` while the fix agent has it, `spent` once it waits for you (its turns are used up, or it stopped without a fix), `reported` when repairs are off.", ), }); export type CiMainFailure = z.infer; export const CiRunsResponseSchema = z.object({ repos: z.array(CiRepoSchema).describe("Which workspace repositories are wired to a forge, and how each one's notifications are set up."), // Newest first, across all mapped repos. runs: z.array(PipelineRunSchema).describe("Runs across all of them, newest first."), failures: z .array(CiMainFailureSchema) .optional() .describe("Every main-line branch failing right now, with the fix agent on it. Absent from a daemon that keeps none."), }); export type CiRunsResponse = z.infer; // Re-resolves repo → project + token on every call, so a stale card can't act on a mapping the workspace no longer has. export const CiRunParamSchema = z.object({ repo: z .string() .describe( "Which workspace repository. The project behind it is resolved fresh each call, so a stale screen cannot act on one the workspace no longer maps to.", ), runId: z.number().describe("Which run, by the forge's own id."), }); export type CiRunParam = z.infer; // Which model to open the session on, when the user reached for the caret instead of pressing the button; absent is // ordinary. export const CiFixParamSchema = CiRunParamSchema.extend({ pick: AgentRunPickSchema.describe( "Which model to open the conversation on, when somebody chose one. Leave it out for the sandbox's own choice, which is the ordinary path.", ), // The browser's own chat pick, which the daemon never holds: without it an unpinned fix opened on a provider the // owner never chose. Absent (an older app, or nobody pressed) keeps the sandbox's own fallback. fallback: AgentRunPickSchema.describe( "The model a new chat opens on for whoever pressed, for when nobody chose one and no model set for fixing pipelines can run. Used while this sandbox can serve its provider; left out, or not servable, the sandbox takes its default provider when connected, else the first one connected.", ), // The verb the picker's bar was ended with, when an attempt already exists (state/fix-attempt-plan.ts). Left out, // an ended attempt is continued and a fresh press opens attempt 1; one still in play answers CONFLICT. mode: z .enum(["continue", "start-over"]) .optional() .describe( "What to do about the attempt already made at this run, when there is one. `continue` carries on in that conversation; `start-over` stops it if running, files it away, and opens the next attempt on a clean worktree. Leave it out for the plain press: an attempt that ended is continued, a fresh failure gets attempt 1, and one still in play answers CONFLICT with why.", ), force: z .boolean() .optional() .describe( "Open the conversation even when every failed job died in its runner's own setup, which is the fleet's fault and nothing an agent on the code can repair. Left out, such a run is refused with that sentence.", ), }); export type CiFixParam = z.infer; // The fix route opens an isolated conversation (fleet card + chat tab) seeded with the failure context. export const CiFixResponseSchema = z.object({ conversationId: z.string().describe("The conversation that was opened, already holding the failure. Open it to watch, or attach to its turn."), }); export type CiFixResponse = z.infer; // One shape for any command the daemon runs on a click with a verdict and a quotable tail (the push via PushRunSchema in // schemas/git/git.ts, which runs the repository's own pre-push hook), so the fields the browser reads (terminal, tail, // kill vs timeout) have one definition. // idle: nothing has run yet, or the last run was cleared // running: live; its output is the terminal's, not this object's // passed: exited 0 // failed: exited non-zero, or killed by its ceiling (`timedOut`) // error: not spawnable at all; not fixable, since nothing is wrong with the code // cancelled: the user stopped it export const CommandRunStatusSchema = z.enum(["idle", "running", "passed", "failed", "error", "cancelled"]); export type CommandRunStatus = z.infer; export const CommandRunSchema = z.object({ status: CommandRunStatusSchema.describe( "Where the run is. Failed and error are deliberately different: failed means the code is wrong, error means the command could not be run at all, and calling the second one a test failure would send an agent hunting a bug that is not there.", ), // Echoed rather than read from settings, so a result seen after the setting changed still says what produced it. command: z .string() .describe( "What actually ran, echoed here rather than read back from the settings, so a result looked at after the setting changed still says what produced it.", ), startedAt: z.number().optional().describe("When it began, in milliseconds."), finishedAt: z.number().optional().describe("When it ended, in milliseconds."), exitCode: z.number().optional().describe("How the command exited."), timedOut: z.boolean().optional().describe("It was killed for taking too long rather than finishing."), // The tmux session to open, since watching a run is the terminal panel's job, not this object's; absent where the // sandbox has no tmux wrapper. session: z .string() .optional() .describe( "The terminal it runs in, which is where to watch it. Absent where the sandbox has no terminals, in which case there is nothing to attach to.", ), // Tail-capped plain text, colour codes and redrawn progress lines stripped, so a fix prompt quotes a verdict, not // terminal litter; empty while running or for a killed run. output: z .string() .describe( "The end of what it printed, as plain text with the colour codes and redrawn progress lines resolved away. The end rather than the beginning, because a suite's verdict is at the end. Empty while it runs, and for one that was killed.", ), }); export type CommandRun = z.infer;