From 040bc6c0433d0eb98c29597446512f9ba50dd258 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Fri, 21 Aug 2026 19:08:55 -0400 Subject: [PATCH] Add version-backed run intent creation --- AGENTS.md | 2 +- docs/internal/events.md | 13 +- docs/public/api-reference/fabro-api.yaml | 148 +++- docs/public/changelog/2026-08-21.mdx | 24 +- docs/public/integrations/github.mdx | 13 + lib/apps/fabro-server/src/lib.rs | 1 + lib/apps/fabro-server/src/run_compiler.rs | 113 ++- lib/apps/fabro-server/src/run_files.rs | 1 + lib/apps/fabro-server/src/run_intent.rs | 644 ++++++++++++++++++ .../fabro-server/src/server/handler/events.rs | 1 + .../fabro-server/src/server/handler/pair.rs | 1 + .../fabro-server/src/server/handler/runs.rs | 460 ++++++++++++- .../src/server/handler/sessions.rs | 1 + lib/apps/fabro-server/src/server/tests.rs | 386 +++++++++++ .../fabro-server/tests/it/api/run_files.rs | 1 + lib/components/fabro-environment/src/lib.rs | 4 +- lib/components/fabro-environment/src/store.rs | 2 +- .../fabro-sandbox/src/clone_source.rs | 20 +- lib/components/fabro-store/src/run_state.rs | 1 + .../fabro-workflow/src/event/convert.rs | 3 + .../fabro-workflow/src/event/events.rs | 8 +- .../fabro-workflow/src/event/sink.rs | 1 + lib/components/fabro-workflow/src/git.rs | 1 + .../fabro-workflow/src/handler/agent.rs | 1 + .../fabro-workflow/src/handler/command.rs | 2 + .../fabro-workflow/src/handler/parallel.rs | 1 + .../fabro-workflow/src/handler/prompt.rs | 1 + .../fabro-workflow/src/lifecycle/git.rs | 1 + .../fabro-workflow/src/operations/archive.rs | 1 + .../fabro-workflow/src/operations/create.rs | 23 +- .../fabro-workflow/src/operations/fork.rs | 16 +- .../fabro-workflow/src/operations/retry.rs | 20 +- .../fabro-workflow/src/operations/start.rs | 174 ++++- .../fabro-workflow/src/operations/timeline.rs | 1 + .../src/pipeline/execute/tests.rs | 2 + .../fabro-workflow/src/pipeline/finalize.rs | 2 + .../fabro-workflow/src/pipeline/initialize.rs | 2 + .../fabro-workflow/src/pipeline/persist.rs | 2 + .../src/pipeline/pull_request.rs | 9 + .../fabro-workflow/src/run_lookup.rs | 1 + .../fabro-workflow/src/run_metadata.rs | 1 + .../fabro-workflow/src/runtime_store.rs | 1 + .../fabro-workflow/src/stage_execution.rs | 1 + .../fabro-workflow/src/test_support.rs | 1 + lib/foundation/fabro-api/build.rs | 3 + lib/foundation/fabro-api/src/lib.rs | 16 +- .../fabro-api/tests/run_intent_round_trip.rs | 53 ++ lib/foundation/fabro-client/src/client.rs | 8 + lib/foundation/fabro-types/src/lib.rs | 6 +- lib/foundation/fabro-types/src/repository.rs | 11 + lib/foundation/fabro-types/src/run.rs | 3 + .../fabro-types/src/run_event/run.rs | 4 +- lib/foundation/fabro-types/src/run_intent.rs | 49 ++ .../fabro-types/src/test_support.rs | 1 + .../fabro-types/tests/run_event_serde.rs | 8 +- .../fabro-types/tests/run_intent.rs | 99 +++ .../fabro-types/tests/run_spec_serde.rs | 9 +- .../src/.openapi-generator/FILES | 5 + .../fabro-api-client/src/api/runs-api.ts | 38 +- .../src/models/create-run-request.ts | 42 ++ .../src/models/git-run-target.ts | 40 ++ .../fabro-api-client/src/models/index.ts | 5 + .../models/run-intent-args-inputs-value.ts | 18 + .../src/models/run-intent-args.ts | 31 + .../fabro-api-client/src/models/run-intent.ts | 49 ++ .../fabro-api-client/src/models/run-spec.ts | 4 + 66 files changed, 2508 insertions(+), 106 deletions(-) create mode 100644 lib/apps/fabro-server/src/run_intent.rs create mode 100644 lib/foundation/fabro-api/tests/run_intent_round_trip.rs create mode 100644 lib/foundation/fabro-types/src/run_intent.rs create mode 100644 lib/foundation/fabro-types/tests/run_intent.rs create mode 100644 lib/packages/fabro-api-client/src/models/create-run-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/git-run-target.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-intent-args-inputs-value.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-intent-args.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-intent.ts diff --git a/AGENTS.md b/AGENTS.md index acc2f2dec..411d07e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) ### Docker sandbox provider - Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities). - The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs. -- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. +- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted. - The sandbox layer also accepts an optional exact commit for future admitted runs. An exact commit always requires a non-empty branch. Docker initializes an empty repository, shallow-fetches the SHA at the same depth as a branch diff --git a/docs/internal/events.md b/docs/internal/events.md index dfc7403b4..b2f9d7ad0 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -44,11 +44,18 @@ Emitted when the run record is created. "event": "run.created", "properties": { "workflow_slug": "my-workflow", + "workflow_version_id": "wv_...", + "target": { + "kind": "git", + "repo": "acme/my-project", + "branch": "main", + "sha": "0123456789abcdef0123456789abcdef01234567" + }, "source_directory": "/home/user/src/my-project", "git": { "origin_url": "https://github.com/acme/my-project", "branch": "main", - "sha": "abc123", + "sha": "0123456789abcdef0123456789abcdef01234567", "dirty": "clean" }, "fork_source_ref": null, @@ -76,9 +83,11 @@ Emitted when the run record is created. | `labels` | object | Run labels | | `source_directory` | string? | Submitter-side source directory | | `workflow_slug` | string? | Workflow slug | +| `workflow_version_id` | string? | Exact immutable root workflow version used for admission | +| `target` | object? | Canonical accepted workspace target. Version-backed Git intent runs persist `kind`, `repo`, required `branch`, and optional normalized `sha`; legacy manifest runs omit it | | `provenance` | object | Actor and request provenance | | `manifest_blob` | string? | Blob hash for the submitted manifest | -| `git` | object? | Git provenance observed before the run: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status | +| `git` | object? | Operational Git projection: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status. For Git intent runs, `branch` is the submitted working branch and `sha` is the optional lowercase-normalized submitted commit; admission does not resolve it or prove branch ancestry. Legacy runs retain their observed optional-SHA semantics | | `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked | | `in_place` | boolean | Whether the run was created with `--in-place` (no git checkpoints) | diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 89016b90a..ec5574d63 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -1172,13 +1172,16 @@ paths: operationId: createRun tags: [Runs] summary: Create Run - description: Creates a new workflow run in `submitted` status from a self-contained manifest. + description: >- + Creates a new workflow run in `submitted` status from either a + self-contained legacy manifest or an immutable workflow-version intent. + Creation does not start or schedule the run. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RunManifest" + $ref: "#/components/schemas/CreateRunRequest" responses: "201": description: Run created @@ -1187,7 +1190,43 @@ paths: schema: $ref: "#/components/schemas/Run" "400": - description: Invalid Graphviz source + description: Invalid JSON or legacy manifest + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Workflow version or selected environment not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "422": + description: Invalid intent, target, environment, workflow version, or compiled run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + description: Required sandbox integration is unavailable + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Workflow-version storage or run persistence failed headers: x-request-id: $ref: "#/components/headers/XRequestId" @@ -9214,6 +9253,104 @@ components: workflow_version_id: $ref: "#/components/schemas/WorkflowVersionId" + CreateRunRequest: + description: >- + Transitional create body used while callers migrate independently from + self-contained manifests to immutable workflow-version intents. + oneOf: + - $ref: "#/components/schemas/RunManifest" + - $ref: "#/components/schemas/RunIntent" + + RunIntent: + description: >- + A request to create, but not start, one run from an immutable workflow + version and an explicit workspace target. + type: object + additionalProperties: false + required: + - workflow_version_id + - target + - args + properties: + workflow_version_id: + $ref: "#/components/schemas/WorkflowVersionId" + target: + $ref: "#/components/schemas/RunTarget" + args: + $ref: "#/components/schemas/RunIntentArgs" + environment_id: + type: string + description: Server environment catalog ID. Omission selects `default`. + parent_id: + type: string + description: Optional orchestration parent run ID. + title: + type: string + maxLength: 100 + description: Optional explicit run title, normalized by the server. + goal: + type: string + description: Optional inline goal override. + + RunIntentArgs: + description: Structured run overrides accepted by workflow-version creation. + type: object + additionalProperties: false + properties: + model: + type: string + provider: + type: string + description: LLM provider; this does not select the sandbox environment. + inputs: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + labels: + type: object + additionalProperties: + type: string + + RunTarget: + description: Workspace content and location requested for a run. + oneOf: + - $ref: "#/components/schemas/GitRunTarget" + discriminator: + propertyName: kind + mapping: + git: "#/components/schemas/GitRunTarget" + + GitRunTarget: + description: Public github.com repository target. + type: object + additionalProperties: false + required: + - kind + - repo + - branch + properties: + kind: + type: string + enum: [git] + repo: + type: string + description: GitHub repository slug in `owner/name` form. + example: acme/my-app + branch: + type: string + description: Required branch name, preserved exactly. + example: feature/foo + sha: + type: string + pattern: "^[0-9A-Fa-f]{40}$" + description: >- + Optional exact commit. The server lowercase-normalizes its syntax + but does not resolve it or prove branch ancestry. + RunManifest: description: Self-contained workflow run manifest. type: object @@ -11439,6 +11576,11 @@ components: oneOf: - $ref: "#/components/schemas/WorkflowVersionId" - type: "null" + target: + description: Canonical workspace target accepted for a version-backed run. Absent for legacy manifest runs. + oneOf: + - $ref: "#/components/schemas/RunTarget" + - type: "null" automation: oneOf: - $ref: "#/components/schemas/AutomationRef" diff --git a/docs/public/changelog/2026-08-21.mdx b/docs/public/changelog/2026-08-21.mdx index 6d1e9ee55..161c0e9ab 100644 --- a/docs/public/changelog/2026-08-21.mdx +++ b/docs/public/changelog/2026-08-21.mdx @@ -1,8 +1,30 @@ --- -title: "Additional GitHub repositories and Venice search" +title: "Run intents, additional repositories, and Venice search" date: "2026-08-21" --- +## Version-backed run creation + +Fabro's canonical `POST /api/v1/runs` endpoint now also accepts a strict +`RunIntent` body that creates a submitted run from an immutable workflow +version, a named server environment, and an explicit public GitHub repository +target. Creation remains separate from execution; call the existing start +endpoint when the run should begin. + +Git targets require a branch and may pin a full commit SHA. Fabro normalizes a +submitted SHA to lowercase and uses that exact commit during sandbox setup, +without resolving it during admission or falling back to a newer branch HEAD. +The environment ID defaults to `default`; this first target slice supports +clone-enabled Docker and Daytona environments. + +Existing manifest callers continue to work unchanged and can migrate +independently. Intent runs do not consume repository `project.toml` settings: +the selected server environment owns provider, working directory, and image, +while workflow versions may still overlay resources, network, lifecycle, +labels, and environment variables. Project metadata therefore no longer adds +run labels on this path, and repository-wide settings must move into workflow +versions or server defaults before a caller migrates. + ## One token for the whole repository set A run can now declare additional GitHub repositories that its stages may access through the managed `GITHUB_TOKEN`: diff --git a/docs/public/integrations/github.mdx b/docs/public/integrations/github.mdx index 9550c436c..9b6347b95 100644 --- a/docs/public/integrations/github.mdx +++ b/docs/public/integrations/github.mdx @@ -227,6 +227,19 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints. +#### Exact commits for run intents + +The `RunIntent` create body names a required Git branch and may also pin a full +40-character commit SHA. Creating the run validates and lowercase-normalizes +the SHA, but it does not contact GitHub, resolve the commit, or prove that the +commit belongs to the submitted branch. + +At sandbox setup, Docker fetches the submitted commit directly and Daytona +receives it as `commit_id`; the submitted branch remains the working branch. +If the exact commit is unavailable, setup fails. Fabro never substitutes the +branch's newer HEAD. When the request omits `sha`, the sandbox resolves the +branch at materialization time instead. + ### GITHUB_TOKEN injection When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls and pushes within the granted permissions. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`. diff --git a/lib/apps/fabro-server/src/lib.rs b/lib/apps/fabro-server/src/lib.rs index 9a7e12978..d58c92f51 100644 --- a/lib/apps/fabro-server/src/lib.rs +++ b/lib/apps/fabro-server/src/lib.rs @@ -37,6 +37,7 @@ mod request_id; mod run_compiler; mod run_files; mod run_files_security; +mod run_intent; mod run_manifest; mod run_selector; mod run_title_generation; diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index b5358f1a3..24ce7b36f 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -37,7 +37,7 @@ use fabro_model::{Catalog, ProviderId}; use fabro_types::settings::interp::{InterpString, ResolveError}; use fabro_types::settings::run::{McpServerSettings, RunGoal}; use fabro_types::{ - AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, + AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, RunTarget, WorkflowSettings, WorkflowVersionId, }; use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError}; @@ -80,7 +80,7 @@ pub(crate) struct RawRunCompilerInput { pub(crate) server_run_defaults: RunLayer, pub(crate) server_environment_defaults: MergeMap, pub(crate) server_mcp_catalog: HashMap, - pub(crate) project_settings: Vec, + pub(crate) settings_input: RunCompilerSettingsInput, pub(crate) user_toml: Vec, pub(crate) run_overrides: Option, pub(crate) cli_overrides: Option, @@ -93,12 +93,25 @@ pub(crate) struct RawRunCompilerInput { pub(crate) storage_root: PathBuf, pub(crate) workflow_slug: Option, pub(crate) workflow_version_id: Option, + pub(crate) target: Option, pub(crate) provenance: RunProvenance, pub(crate) web_url: Option, pub(crate) submitted_manifest_bytes: Option>, pub(crate) automation: Option, } +/// Settings already admitted by the caller, or the unchanged legacy manifest +/// inputs that still need their historical parsing and lookup behavior. +#[derive(Debug)] +pub(crate) enum RunCompilerSettingsInput { + LegacyManifest { + project_settings: Vec, + }, + Admitted { + workflow_layer: Option>, + }, +} + /// Stage-one output: the selected bundled workflow and all client settings /// sources have been parsed and normalized, but no settings have been layered. pub(crate) struct NormalizedRun { @@ -124,6 +137,7 @@ struct RunMetadata { storage_root: PathBuf, workflow_slug: Option, workflow_version_id: Option, + target: Option, submitted_manifest_bytes: Option>, title: Option, automation: Option, @@ -198,6 +212,23 @@ pub(crate) struct PinnedRun { metadata: RunMetadata, } +impl PinnedRun { + pub(crate) fn parent_id(&self) -> Option { + self.metadata.parent_id + } + + pub(crate) fn resolve_run_id(mut self) -> (Self, RunId) { + let run_id = self.metadata.run_id.unwrap_or_default(); + self.metadata.run_id = Some(run_id); + (self, run_id) + } + + pub(crate) fn with_web_url(mut self, web_url: Option) -> Self { + self.metadata.web_url = web_url; + self + } +} + #[derive(Debug, thiserror::Error)] pub(crate) enum RunCompilerError { /// The acquired source bundle is invalid: missing entrypoint or broken @@ -305,7 +336,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result Result Result>>()?; + let (workflow_layer, project_layers) = match settings_input { + RunCompilerSettingsInput::LegacyManifest { project_settings } => { + let workflow_layer = workflow + .config + .as_ref() + .map(|config| { + settings_layer_with_resolved_dockerfiles( + &config.source, + &config.path, + &workflow.files, + SettingsSource::Workflow, + ) + }) + .transpose()?; + let project_layers = project_settings + .into_iter() + .map(|project| { + let path = project.path.map_err(|source| { + invalid_settings(InvalidSettingsError::ProjectPath { source }) + })?; + settings_layer_with_resolved_dockerfiles( + &project.toml, + &path, + &workflow.files, + SettingsSource::Project, + ) + }) + .collect::>>()?; + (workflow_layer, project_layers) + } + RunCompilerSettingsInput::Admitted { workflow_layer } => { + (workflow_layer.map(|layer| *layer), Vec::new()) + } + }; Ok(NormalizedRun { workflow_bundle, @@ -378,6 +418,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result CreateRunPersistenceInput { storage_root, workflow_slug, workflow_version_id, + target, submitted_manifest_bytes, title, automation, @@ -552,6 +594,7 @@ pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput { storage_root, workflow_slug, workflow_version_id, + target, submitted_manifest_bytes, title, automation, @@ -706,7 +749,9 @@ mod tests { server_run_defaults: RunLayer::default(), server_environment_defaults: fabro_environment::seeded_catalog_layer(), server_mcp_catalog: HashMap::new(), - project_settings: Vec::new(), + settings_input: RunCompilerSettingsInput::LegacyManifest { + project_settings: Vec::new(), + }, user_toml: Vec::new(), run_overrides: None, cli_overrides: None, @@ -719,6 +764,7 @@ mod tests { storage_root: PathBuf::from("/tmp/fabro-storage"), workflow_slug: None, workflow_version_id: None, + target: None, provenance: provenance(), web_url: None, submitted_manifest_bytes: None, @@ -843,7 +889,12 @@ target = "workflow" include = ["reports/{{ vars.owner }}/*.json"] "#; let mut input = raw_input(Some(workflow_toml), HashMap::new()); - input.project_settings.push(ProjectSettingsSource { + let RunCompilerSettingsInput::LegacyManifest { project_settings } = + &mut input.settings_input + else { + panic!("test fixture should use legacy manifest settings"); + }; + project_settings.push(ProjectSettingsSource { path: Ok(manifest_path(".fabro/project.toml")), toml: r#" _version = 1 diff --git a/lib/apps/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs index 78bc85ff8..7fb9cc2a8 100644 --- a/lib/apps/fabro-server/src/run_files.rs +++ b/lib/apps/fabro-server/src/run_files.rs @@ -2382,6 +2382,7 @@ index 1111111..2222222 160000 graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: HashMap::default(), diff --git a/lib/apps/fabro-server/src/run_intent.rs b/lib/apps/fabro-server/src/run_intent.rs new file mode 100644 index 000000000..0c22ccd6b --- /dev/null +++ b/lib/apps/fabro-server/src/run_intent.rs @@ -0,0 +1,644 @@ +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; + +use fabro_config::parse::SettingsSource; +use fabro_config::{RunGoalLayer, SettingsLayer}; +use fabro_environment::{EnvironmentId, EnvironmentValidationError}; +use fabro_types::settings::InterpString; +use fabro_types::{ + DirtyStatus, GitContext, GitHubRepositorySlug, ManifestPath, RunTarget, SandboxProviderKind, + WorkflowPath, WorkflowVersionId, normalize_git_commit_sha, repository, +}; +use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle}; +use fabro_workflow_version::LoadedWorkflowVersionClosure; +use thiserror::Error; + +use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles}; + +#[derive(Debug, Error)] +pub(crate) enum RunIntentAdmissionError { + #[error("workflow-version storage could not be opened")] + StoreOpen { + #[source] + source: fabro_store::Error, + }, + #[error("workflow-version closure could not be loaded")] + VersionStore { + #[source] + source: fabro_workflow_version::WorkflowVersionStoreError, + }, + #[error(transparent)] + Lowering(#[from] WorkflowClosureLoweringError), + #[error(transparent)] + Target(#[from] TargetValidationError), + #[error(transparent)] + Environment(#[from] EnvironmentSelectionError), + #[error(transparent)] + Compiler(#[from] RunCompilerError), + #[error("run variables could not be loaded")] + VariableSnapshot { + #[source] + source: fabro_variable::Error, + }, +} + +#[derive(Debug, Error)] +pub(crate) enum EnvironmentSelectionError { + #[error("invalid environment ID `{value}`")] + InvalidId { + value: String, + #[source] + source: EnvironmentValidationError, + }, + #[error("environment `{id}` not found")] + NotFound { id: EnvironmentId }, + #[error("Git targets require a compatible clone-enabled Docker or Daytona environment")] + TargetUnsupported, + #[error("{detail}")] + ProviderDisabled { + provider: SandboxProviderKind, + detail: String, + }, + #[error("{name} is not configured for sandbox provider `{provider}`")] + MissingCredential { + provider: SandboxProviderKind, + name: &'static str, + }, + #[error("failed to read sandbox credential `{name}`")] + CredentialStore { + name: &'static str, + #[source] + source: fabro_vault::SecretStoreError, + }, +} + +#[derive(Debug)] +pub(crate) struct LoweredWorkflowClosure { + pub(crate) workflow_bundle: WorkflowBundle, + pub(crate) entrypoint: ManifestPath, + pub(crate) workflow_layer: Option, +} + +#[derive(Debug, Error)] +pub(crate) enum WorkflowClosureLoweringError { + #[error("workflow version `{id}` is missing from the loaded closure")] + MissingVersion { id: WorkflowVersionId }, + #[error("workflow version `{id}` has no entrypoint content")] + MissingEntrypoint { id: WorkflowVersionId }, + #[error("workflow path `{path}` cannot be mounted at `{mount}`")] + InvalidMount { + path: WorkflowPath, + mount: ManifestPath, + }, + #[error("workflow mount `{path}` resolves to two different workflow versions")] + ConflictingMount { path: ManifestPath }, + #[error("workflow config goal file `{reference}` cannot be resolved")] + InvalidGoalReference { reference: String }, + #[error("workflow config goal file `{path}` is missing from the version")] + MissingGoalFile { path: ManifestPath }, + #[error("workflow-version settings are unusable")] + Settings { + #[source] + source: Box, + }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum TargetValidationError { + #[error("target repository must be a valid GitHub owner/name slug")] + Repository, + #[error("target branch must be a non-empty branch name, not a ref or commit selector")] + Branch, + #[error("target SHA must be exactly 40 ASCII hexadecimal characters")] + Sha, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ValidatedGitTarget { + pub(crate) target: RunTarget, + pub(crate) git: GitContext, +} + +pub(crate) fn validate_target( + target: RunTarget, +) -> Result { + match target { + RunTarget::Git { repo, branch, sha } => { + let slug = + GitHubRepositorySlug::try_new(&repo).ok_or(TargetValidationError::Repository)?; + let selector = format!("heads/{branch}"); + if branch.is_empty() + || branch.starts_with("heads/") + || branch.starts_with("tags/") + || branch.starts_with("refs/") + || normalize_git_commit_sha(&branch).is_some() + || !repository::is_valid_github_ref_selector(&selector) + { + return Err(TargetValidationError::Branch); + } + let sha = sha + .map(|sha| normalize_git_commit_sha(&sha).ok_or(TargetValidationError::Sha)) + .transpose()?; + let repo = format!("{}/{}", slug.owner(), slug.repo()); + let origin_url = format!("https://github.com/{repo}"); + Ok(ValidatedGitTarget { + target: RunTarget::Git { + repo, + branch: branch.clone(), + sha: sha.clone(), + }, + git: GitContext { + origin_url, + branch, + sha, + dirty: DirtyStatus::Clean, + }, + }) + } + } +} + +pub(crate) fn lower_workflow_closure( + closure: &LoadedWorkflowVersionClosure, +) -> Result { + let entrypoint = manifest_path(closure.root().entrypoint(), closure.root().entrypoint())?; + let mut mounts = HashMap::new(); + let mut workflows = HashMap::new(); + mount_version( + closure, + closure.root_id(), + entrypoint.clone(), + &mut mounts, + &mut workflows, + )?; + + let root_workflow = workflows + .get(&entrypoint) + .expect("root workflow should be mounted"); + let workflow_layer = root_workflow + .config + .as_ref() + .map(|config| { + settings_layer_with_resolved_dockerfiles( + &config.source, + &config.path, + &root_workflow.files, + SettingsSource::Workflow, + ) + .map_err(|source| WorkflowClosureLoweringError::Settings { + source: Box::new(source), + }) + .and_then(|mut layer| { + inline_goal_file(&mut layer, &config.path, &root_workflow.files)?; + Ok(layer) + }) + }) + .transpose()?; + + Ok(LoweredWorkflowClosure { + workflow_bundle: WorkflowBundle::new(workflows), + entrypoint, + workflow_layer, + }) +} + +pub(crate) fn pin_workflow_environment_authority(layer: &mut SettingsLayer, environment_id: &str) { + if let Some(environment) = layer.environments.get_mut(environment_id) { + environment.provider = None; + environment.cwd = None; + environment.image = None; + } + if let Some(environment) = layer.run.as_mut().and_then(|run| run.environment.as_mut()) { + environment.image = None; + } +} + +fn mount_version( + closure: &LoadedWorkflowVersionClosure, + id: WorkflowVersionId, + mounted_entrypoint: ManifestPath, + mounts: &mut HashMap, + workflows: &mut HashMap, +) -> Result<(), WorkflowClosureLoweringError> { + if let Some(existing) = mounts.get(&mounted_entrypoint) { + return if *existing == id { + Ok(()) + } else { + Err(WorkflowClosureLoweringError::ConflictingMount { + path: mounted_entrypoint, + }) + }; + } + mounts.insert(mounted_entrypoint.clone(), id); + + let version = closure + .get(&id) + .ok_or(WorkflowClosureLoweringError::MissingVersion { id })?; + let mut files = HashMap::new(); + for (path, content) in version.files() { + files.insert( + manifest_path(version.entrypoint(), path).and_then(|local| { + rebase_path(version.entrypoint(), &mounted_entrypoint, &local, path) + })?, + content.clone(), + ); + } + let source = version + .files() + .get(version.entrypoint()) + .cloned() + .ok_or(WorkflowClosureLoweringError::MissingEntrypoint { id })?; + let config_local = WorkflowPath::new("workflow.toml") + .expect("the static workflow config path should be valid"); + let config_path = version.files().get(&config_local).map(|source| { + rebase_path( + version.entrypoint(), + &mounted_entrypoint, + &ManifestPath::from_wire(config_local.as_str()) + .expect("validated workflow path should be a manifest path"), + &config_local, + ) + .map(|path| ParsedWorkflowConfig { + path, + source: source.clone(), + }) + }); + let config = config_path.transpose()?; + + workflows.insert(mounted_entrypoint.clone(), BundledWorkflow { + path: mounted_entrypoint.clone(), + source, + config, + files, + }); + + for (binding, dependency_id) in version.workflow_dependencies() { + let local = ManifestPath::from_wire(binding.as_str()) + .expect("validated workflow path should be a manifest path"); + let dependency_mount = + rebase_path(version.entrypoint(), &mounted_entrypoint, &local, binding)?; + mount_version(closure, *dependency_id, dependency_mount, mounts, workflows)?; + } + Ok(()) +} + +fn manifest_path( + entrypoint: &WorkflowPath, + path: &WorkflowPath, +) -> Result { + ManifestPath::from_wire(path.as_str()).ok_or_else(|| { + WorkflowClosureLoweringError::InvalidMount { + path: path.clone(), + mount: ManifestPath::from_wire(entrypoint.as_str()) + .expect("validated entrypoint should be a manifest path"), + } + }) +} + +fn rebase_path( + local_entrypoint: &WorkflowPath, + mounted_entrypoint: &ManifestPath, + local_path: &ManifestPath, + workflow_path: &WorkflowPath, +) -> Result { + let relative = relative_path( + local_entrypoint_parent(local_entrypoint), + local_path.as_path(), + ); + let mapped = ManifestPath::from_reference( + mounted_entrypoint.parent_or_dot(), + &relative.to_string_lossy(), + ) + .filter(|path| !path.as_path().starts_with("..")) + .ok_or_else(|| WorkflowClosureLoweringError::InvalidMount { + path: workflow_path.clone(), + mount: mounted_entrypoint.clone(), + })?; + Ok(mapped) +} + +fn local_entrypoint_parent(entrypoint: &WorkflowPath) -> &Path { + Path::new(entrypoint.as_str()) + .parent() + .unwrap_or_else(|| Path::new(".")) +} + +fn relative_path(base: &Path, path: &Path) -> PathBuf { + let base = base + .components() + .filter_map(normal_component) + .collect::>(); + let path = path + .components() + .filter_map(normal_component) + .collect::>(); + let common = base + .iter() + .zip(&path) + .take_while(|(left, right)| left == right) + .count(); + let mut relative = PathBuf::new(); + for _ in &base[common..] { + relative.push(".."); + } + for component in &path[common..] { + relative.push(component); + } + relative +} + +fn normal_component(component: Component<'_>) -> Option<&std::ffi::OsStr> { + match component { + Component::Normal(value) => Some(value), + Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + None + } + } +} + +fn inline_goal_file( + layer: &mut SettingsLayer, + config_path: &ManifestPath, + files: &HashMap, +) -> Result<(), WorkflowClosureLoweringError> { + let Some(RunGoalLayer::File { file }) = layer.run.as_mut().and_then(|run| run.goal.as_mut()) + else { + return Ok(()); + }; + let reference = unresolved_source(file); + let path = + ManifestPath::from_reference(config_path.parent_or_dot(), &reference).ok_or_else(|| { + WorkflowClosureLoweringError::InvalidGoalReference { + reference: reference.clone(), + } + })?; + let content = files + .get(&path) + .ok_or_else(|| WorkflowClosureLoweringError::MissingGoalFile { path: path.clone() })?; + *layer + .run + .as_mut() + .and_then(|run| run.goal.as_mut()) + .expect("goal file should still be present") = + RunGoalLayer::Inline(InterpString::parse(content)); + Ok(()) +} + +#[expect( + clippy::disallowed_methods, + reason = "workflow-version lowering preserves authored goal-file references for validated lookup" +)] +fn unresolved_source(value: &InterpString) -> String { + value.as_source() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use fabro_types::{WorkflowVersion, WorkflowVersionId}; + use fabro_workflow_version::{ValidatedWorkflowVersion, WorkflowVersionStore}; + + use super::*; + + fn workflow_path(value: &str) -> WorkflowPath { + WorkflowPath::new(value).unwrap() + } + + fn version( + entrypoint: &str, + files: impl IntoIterator, + dependencies: impl IntoIterator, + ) -> ValidatedWorkflowVersion { + let files = files + .into_iter() + .map(|(path, source)| (workflow_path(path), source.to_string())) + .collect(); + let dependencies = dependencies + .into_iter() + .map(|(path, id)| (workflow_path(path), id)) + .collect::>(); + ValidatedWorkflowVersion::new( + WorkflowVersion::new(workflow_path(entrypoint), files, dependencies).unwrap(), + ) + .unwrap() + } + + #[tokio::test] + async fn lowers_nested_entrypoints_and_inlines_goal_files() { + let (database, _) = crate::test_support::test_store_bundle(); + let blobs = database.blobs().await.unwrap(); + let store = WorkflowVersionStore::new(blobs); + let grandchild = version( + "deep/leaf.fabro", + [("deep/leaf.fabro", "digraph Leaf {}")], + [], + ); + let grandchild_id = store.put(&grandchild).await.unwrap(); + let child = version( + "pkg/child.fabro", + [( + "pkg/child.fabro", + "digraph Child { leaf [stack.child_workflow=\"../nested/leaf.fabro\"] }", + )], + [("nested/leaf.fabro", grandchild_id)], + ); + let child_id = store.put(&child).await.unwrap(); + let root = version( + "flows/root.fabro", + [ + ( + "flows/root.fabro", + "digraph Root { child [stack.child_workflow=\"../deps/run.fabro\"] }", + ), + ( + "workflow.toml", + "_version = 1\n[run.goal]\nfile = \"goal.md\"\n", + ), + ("goal.md", "Ship {{ vars.owner }}"), + ], + [("deps/run.fabro", child_id)], + ); + let root_id = store.put(&root).await.unwrap(); + let closure = store.get_closure(&root_id).await.unwrap().unwrap(); + + let lowered = lower_workflow_closure(&closure).unwrap(); + + assert!( + lowered + .workflow_bundle + .workflow(&lowered.entrypoint) + .is_some() + ); + assert!( + lowered + .workflow_bundle + .workflow(&ManifestPath::from_wire("deps/run.fabro").unwrap()) + .is_some() + ); + assert!( + lowered + .workflow_bundle + .workflow(&ManifestPath::from_wire("nested/leaf.fabro").unwrap()) + .is_some() + ); + assert!(matches!( + lowered + .workflow_layer + .as_ref() + .and_then(|layer| layer.run.as_ref()) + .and_then(|run| run.goal.as_ref()), + Some(RunGoalLayer::Inline(_)) + )); + } + + #[tokio::test] + async fn lowers_same_version_at_distinct_mount_paths() { + let (database, _) = crate::test_support::test_store_bundle(); + let blobs = database.blobs().await.unwrap(); + let store = WorkflowVersionStore::new(blobs); + let child = version( + "pkg/child.fabro", + [("pkg/child.fabro", "digraph Child {}")], + [], + ); + let child_id = store.put(&child).await.unwrap(); + let root = version( + "flows/root.fabro", + [( + "flows/root.fabro", + "digraph Root { one [stack.child_workflow=\"../children/one.fabro\"] two [stack.child_workflow=\"../children/two.fabro\"] }", + )], + [ + ("children/one.fabro", child_id), + ("children/two.fabro", child_id), + ], + ); + let root_id = store.put(&root).await.unwrap(); + let closure = store.get_closure(&root_id).await.unwrap().unwrap(); + + let lowered = lower_workflow_closure(&closure).unwrap(); + + assert!( + lowered + .workflow_bundle + .workflow(&ManifestPath::from_wire("children/one.fabro").unwrap()) + .is_some() + ); + assert!( + lowered + .workflow_bundle + .workflow(&ManifestPath::from_wire("children/two.fabro").unwrap()) + .is_some() + ); + } + + #[tokio::test] + async fn rejects_distinct_versions_that_converge_on_one_mount_path() { + let (database, _) = crate::test_support::test_store_bundle(); + let blobs = database.blobs().await.unwrap(); + let store = WorkflowVersionStore::new(blobs); + let first_leaf = version( + "leaf/first.fabro", + [("leaf/first.fabro", "digraph FirstLeaf {}")], + [], + ); + let first_leaf_id = store.put(&first_leaf).await.unwrap(); + let second_leaf = version( + "leaf/second.fabro", + [("leaf/second.fabro", "digraph SecondLeaf {}")], + [], + ); + let second_leaf_id = store.put(&second_leaf).await.unwrap(); + let first_parent = version( + "a/first.fabro", + [( + "a/first.fabro", + "digraph FirstParent { child [stack.child_workflow=\"../shared/collision.fabro\"] }", + )], + [("shared/collision.fabro", first_leaf_id)], + ); + let first_parent_id = store.put(&first_parent).await.unwrap(); + let second_parent = version( + "b/second.fabro", + [( + "b/second.fabro", + "digraph SecondParent { child [stack.child_workflow=\"../shared/collision.fabro\"] }", + )], + [("shared/collision.fabro", second_leaf_id)], + ); + let second_parent_id = store.put(&second_parent).await.unwrap(); + let root = version( + "flows/root.fabro", + [( + "flows/root.fabro", + "digraph Root { first [stack.child_workflow=\"../left/first.fabro\"] second [stack.child_workflow=\"../right/second.fabro\"] }", + )], + [ + ("left/first.fabro", first_parent_id), + ("right/second.fabro", second_parent_id), + ], + ); + let root_id = store.put(&root).await.unwrap(); + let closure = store.get_closure(&root_id).await.unwrap().unwrap(); + + let error = lower_workflow_closure(&closure).unwrap_err(); + + assert!(matches!( + error, + WorkflowClosureLoweringError::ConflictingMount { .. } + )); + } + + #[tokio::test] + async fn rejects_rebased_files_that_escape_the_runtime_root() { + let (database, _) = crate::test_support::test_store_bundle(); + let blobs = database.blobs().await.unwrap(); + let store = WorkflowVersionStore::new(blobs); + let child = version( + "nested/child.fabro", + [ + ("nested/child.fabro", "digraph Child {}"), + ("workflow.toml", "_version = 1"), + ], + [], + ); + let child_id = store.put(&child).await.unwrap(); + let root = version( + "root.fabro", + [( + "root.fabro", + "digraph Root { child [stack.child_workflow=\"child.fabro\"] }", + )], + [("child.fabro", child_id)], + ); + let root_id = store.put(&root).await.unwrap(); + let closure = store.get_closure(&root_id).await.unwrap().unwrap(); + + let error = lower_workflow_closure(&closure).unwrap_err(); + + assert!(matches!( + error, + WorkflowClosureLoweringError::InvalidMount { .. } + )); + } + + #[test] + fn target_validation_normalizes_sha_without_network_resolution() { + let validated = validate_target(RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()), + }) + .unwrap(); + + assert_eq!( + validated.git.sha.as_deref(), + Some("abcdef0123456789abcdef0123456789abcdef01") + ); + assert_eq!( + validated.git.origin_url, + "https://github.com/fabro-sh/fabro" + ); + } +} diff --git a/lib/apps/fabro-server/src/server/handler/events.rs b/lib/apps/fabro-server/src/server/handler/events.rs index 926fefdca..fb9ff4c1d 100644 --- a/lib/apps/fabro-server/src/server/handler/events.rs +++ b/lib/apps/fabro-server/src/server/handler/events.rs @@ -625,6 +625,7 @@ mod stage_events_tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/apps/fabro-server/src/server/handler/pair.rs b/lib/apps/fabro-server/src/server/handler/pair.rs index c3c3166ee..6a8bf55f7 100644 --- a/lib/apps/fabro-server/src/server/handler/pair.rs +++ b/lib/apps/fabro-server/src/server/handler/pair.rs @@ -1025,6 +1025,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 8a8efc259..2c0ccc9c5 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -15,21 +15,24 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use bytes::Bytes; use chrono::{DateTime, Utc}; use fabro_api::types::{ - BoardColumn, ManifestConfigType, ManifestGoalType, RunManifest, SubmitAnswerRequest, + BoardColumn, ManifestConfigType, ManifestGoalType, RunIntent, RunManifest, SubmitAnswerRequest, UpdateRunParentRequest, UpdateRunRequest, }; -use fabro_config::{CliLayer, RunLayer, Storage}; +use fabro_config::{CliLayer, ReplaceMap, RunEnvironmentLayer, RunLayer, RunModelLayer, Storage}; +use fabro_environment::{DEFAULT_ENVIRONMENT_ID, EnvironmentId}; use fabro_interview::AnswerSubmission; use fabro_llm::client::Client as LlmClient; +use fabro_static::EnvVars; use fabro_store::{ RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility, }; use fabro_types::{ AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance, - RunServerProvenance, RunStatusKind, StageContextWindow, StageContextWindowStaleness, - StageContextWindowUnavailableReason, StageHandler, StageModelUsage, StageProjection, - SystemActorKind, parse_blob_ref, + RunServerProvenance, RunStatusKind, SandboxProviderKind, StageContextWindow, + StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler, + StageModelUsage, StageProjection, SystemActorKind, json_scalar_to_toml_value, parse_blob_ref, }; +use fabro_util::error as error_util; use fabro_util::version::FABRO_VERSION; use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice}; use fabro_workflow::run_status::RunStatus; @@ -52,8 +55,13 @@ use crate::principal_middleware::{ }; use crate::run_compiler::{ self, ProjectSettingsPathError, ProjectSettingsSource, RawRunCompilerInput, RunCompilerError, + RunCompilerSettingsInput, }; use crate::run_files::{list_run_commits, list_run_files}; +use crate::run_intent::{ + EnvironmentSelectionError, RunIntentAdmissionError, lower_workflow_closure, + pin_workflow_environment_authority, validate_target, +}; use crate::run_manifest; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary}; @@ -527,9 +535,35 @@ async fn create_run( headers: HeaderMap, body: Bytes, ) -> Response { - let req = match serde_json::from_slice::(&body) { + let value = match serde_json::from_slice::(&body) { + Ok(value) => value, + Err(err) => { + return ApiError::with_code(StatusCode::BAD_REQUEST, err.to_string(), "invalid_json") + .into_response(); + } + }; + let intent_error = match serde_json::from_value::(value.clone()) { + Ok(intent) => { + return Box::pin(create_run_from_intent(state, intent, actor, headers)).await; + } + Err(err) => err, + }; + let req = match serde_json::from_value::(value.clone()) { Ok(req) => req, - Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + Err(manifest_error) => { + if value + .as_object() + .is_some_and(|object| object.contains_key("workflow_version_id")) + { + return ApiError::with_code( + StatusCode::UNPROCESSABLE_ENTITY, + intent_error.to_string(), + "run_intent_invalid", + ) + .into_response(); + } + return ApiError::bad_request(manifest_error.to_string()).into_response(); + } }; let explicit_title_supplied = req.title.is_some(); Box::pin(create_run_from_manifest( @@ -547,6 +581,413 @@ async fn create_run( .await } +async fn create_run_from_intent( + state: Arc, + intent: RunIntent, + actor: Principal, + headers: HeaderMap, +) -> Response { + let blobs = match state.store_ref().blobs().await { + Ok(blobs) => blobs, + Err(source) => { + return run_intent_admission_error(RunIntentAdmissionError::StoreOpen { source }); + } + }; + let version_store = fabro_workflow_version::WorkflowVersionStore::new(blobs); + let closure = match version_store.get_closure(&intent.workflow_version_id).await { + Ok(Some(closure)) => closure, + Ok(None) => { + return intent_error( + StatusCode::NOT_FOUND, + "workflow version not found", + "workflow_version_not_found", + ); + } + Err(source) => { + return run_intent_admission_error(RunIntentAdmissionError::VersionStore { source }); + } + }; + let mut lowered = match lower_workflow_closure(&closure) { + Ok(lowered) => lowered, + Err(error) => return run_intent_admission_error(error.into()), + }; + let validated_target = match validate_target(intent.target) { + Ok(target) => target, + Err(error) => return run_intent_admission_error(error.into()), + }; + let environment_id = match select_intent_environment_id( + &state, + intent + .environment_id + .as_deref() + .unwrap_or(DEFAULT_ENVIRONMENT_ID), + ) { + Ok(id) => id, + Err(error) => return run_intent_admission_error(error.into()), + }; + if let Some(layer) = lowered.workflow_layer.as_mut() { + pin_workflow_environment_authority(layer, environment_id.as_str()); + } + + let title = match intent + .title + .as_deref() + .map(fabro_types::normalize_explicit_run_title) + .transpose() + { + Ok(title) => title, + Err(err) => { + return intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + err.to_string(), + "run_intent_invalid", + ); + } + }; + let mut input_overrides = HashMap::new(); + for (name, value) in &intent.args.inputs { + let value = match json_scalar_to_toml_value(value) { + Ok(value) => value, + Err(err) => { + return intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + format!("args.inputs.{name}: {err}"), + "run_intent_invalid", + ); + } + }; + input_overrides.insert(name.clone(), value); + } + let mut run_overrides = RunLayer { + environment: Some(RunEnvironmentLayer { + id: Some(environment_id.to_string()), + ..RunEnvironmentLayer::default() + }), + metadata: ReplaceMap::from(intent.args.labels), + ..RunLayer::default() + }; + if intent.args.model.is_some() || intent.args.provider.is_some() { + run_overrides.model = Some(RunModelLayer { + name: intent.args.model, + provider: intent.args.provider, + ..RunModelLayer::default() + }); + } + + let entrypoint = lowered.entrypoint.clone(); + let raw_compiler_input = RawRunCompilerInput { + workflow_bundle: lowered.workflow_bundle, + entrypoint: lowered.entrypoint, + cwd: PathBuf::from("/workspace"), + server_run_defaults: state.manifest_run_defaults().as_ref().clone(), + server_environment_defaults: state.environment_store().catalog_layer().as_ref().clone(), + server_mcp_catalog: state.mcp_server_store().catalog_settings(), + settings_input: RunCompilerSettingsInput::Admitted { + workflow_layer: lowered.workflow_layer.map(Box::new), + }, + user_toml: Vec::new(), + run_overrides: Some(run_overrides), + cli_overrides: None, + input_overrides, + inline_goal_override: intent.goal, + run_id: None, + title, + parent_id: intent.parent_id, + git: Some(validated_target.git), + storage_root: state.server_storage_dir(), + workflow_slug: None, + workflow_version_id: Some(intent.workflow_version_id), + target: Some(validated_target.target), + provenance: run_provenance(&headers, &actor), + web_url: None, + submitted_manifest_bytes: None, + automation: None, + }; + let normalized = match run_compiler::normalize_source(raw_compiler_input) { + Ok(normalized) => normalized, + Err(error) => return run_intent_admission_error(error.into()), + }; + let layered = match run_compiler::layer_settings(normalized) { + Ok(layered) => layered, + Err(error) => return run_intent_admission_error(error.into()), + }; + let vars = match snapshot_run_variables(&state).await { + Ok(vars) => vars, + Err(source) => { + return run_intent_admission_error(RunIntentAdmissionError::VariableSnapshot { + source, + }); + } + }; + let prepared = match run_compiler::apply_run_variables(layered, vars) { + Ok(prepared) => prepared, + Err(error) => return run_intent_admission_error(error.into()), + }; + if let Err(error) = validate_intent_environment(&state, prepared.settings()).await { + return run_intent_admission_error(error.into()); + } + let catalog = state.catalog(); + let (llm_result, ready_provider_ids) = state.resolve_llm_client_with_ready_ids().await; + let llm_client_for_title = llm_result.ok(); + let run_materialization_provider_ids = { + #[cfg(any(test, feature = "test-support"))] + { + server_test_support::test_run_materialization_provider_ids( + catalog.as_ref(), + &ready_provider_ids, + ) + } + #[cfg(not(any(test, feature = "test-support")))] + { + ready_provider_ids.clone() + } + }; + let pinned = + match run_compiler::compile_and_pin(prepared, run_materialization_provider_ids, catalog) + .await + { + Ok(pinned) => pinned, + Err(error) => return run_intent_admission_error(error.into()), + }; + let (pinned, run_id) = pinned.resolve_run_id(); + if let Some(parent_id) = pinned.parent_id() { + if parent_id == run_id { + return ApiError::bad_request("A run cannot be its own parent.").into_response(); + } + if let Err(err) = validate_parent_link(&state, run_id, parent_id).await { + return err.into_response(); + } + } + let pinned = pinned.with_web_url(state.run_web_url(&run_id)); + let persistence_input = run_compiler::assemble_run(pinned); + let created = match Box::pin(operations::persist_create_run( + state.stores.runs.as_ref(), + persistence_input, + )) + .await + { + Ok(created) => created, + Err(err) => { + tracing::error!( + error = %err, + error_chain = ?error_util::collect_chain(&err), + "Failed to persist admitted run intent" + ); + return intent_error( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to persist run", + "run_persistence_failed", + ); + } + }; + let summary = match state + .stores + .runs + .get_cached_summary(&created.run_id, Utc::now()) + .await + { + Ok(Some(summary)) => summary, + Ok(None) => { + return intent_error( + StatusCode::INTERNAL_SERVER_ERROR, + "created run summary is unavailable", + "run_persistence_failed", + ); + } + Err(err) => { + tracing::error!(error = %err, "Failed to read admitted run summary"); + return intent_error( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to read created run", + "run_persistence_failed", + ); + } + }; + let deterministic_title = summary.title.clone(); + let created_at = created.run_id.created_at(); + { + let mut runs = state.runs.lock().expect("runs lock poisoned"); + runs.insert( + created.run_id, + managed_run( + created.persisted.source().to_string(), + RunStatus::Submitted, + created_at, + created.run_dir, + RunExecutionMode::Start, + ), + ); + } + if intent.title.is_none() && !ready_provider_ids.is_empty() { + if let Some(client) = llm_client_for_title { + let run_spec = created.persisted.run_spec(); + let workflow = run_title_generation::workflow_summary(&run_spec.graph); + let run_inputs = run_spec.settings.run.inputs.clone(); + let title_catalog = state.catalog(); + let title_model = title_catalog.small_default_for_configured_ids(&ready_provider_ids); + spawn_generated_title_task(GeneratedTitleTask { + state: Arc::clone(&state), + run_id: created.run_id, + deterministic_title, + workflow_target: entrypoint.to_string(), + workflow, + run_inputs, + client: client.client, + model_id: title_model.id.to_string(), + provider_id: title_model.provider.clone(), + }); + } + } + info!(run_id = %created.run_id, "Run created from intent"); + ( + StatusCode::CREATED, + Json(state.decorate_run_summary(summary).await), + ) + .into_response() +} + +fn intent_error(status: StatusCode, detail: impl Into, code: &'static str) -> Response { + ApiError::with_code(status, detail, code).into_response() +} + +fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response { + match &error { + RunIntentAdmissionError::StoreOpen { .. } + | RunIntentAdmissionError::VersionStore { .. } + | RunIntentAdmissionError::VariableSnapshot { .. } + | RunIntentAdmissionError::Environment(EnvironmentSelectionError::CredentialStore { + .. + }) => { + tracing::error!( + error = %error, + error_chain = ?error_util::collect_chain(&error), + "Run intent admission failed" + ); + } + RunIntentAdmissionError::Lowering(_) | RunIntentAdmissionError::Compiler(_) => { + tracing::warn!( + error = %error, + error_chain = ?error_util::collect_chain(&error), + "Run intent admission rejected" + ); + } + RunIntentAdmissionError::Target(_) | RunIntentAdmissionError::Environment(_) => {} + } + + match error { + RunIntentAdmissionError::StoreOpen { .. } + | RunIntentAdmissionError::VersionStore { .. } => intent_error( + StatusCode::INTERNAL_SERVER_ERROR, + "workflow version store operation failed", + "workflow_version_store_error", + ), + RunIntentAdmissionError::Lowering(_) => intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + "workflow version cannot be used for run creation", + "workflow_version_unusable", + ), + RunIntentAdmissionError::Target(error) => intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + error.to_string(), + "target_invalid", + ), + RunIntentAdmissionError::Environment(error) => match error { + EnvironmentSelectionError::InvalidId { source, .. } => intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + source.to_string(), + "run_intent_invalid", + ), + EnvironmentSelectionError::NotFound { .. } => intent_error( + StatusCode::NOT_FOUND, + error.to_string(), + "environment_not_found", + ), + EnvironmentSelectionError::TargetUnsupported => intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + error.to_string(), + "target_environment_unsupported", + ), + EnvironmentSelectionError::ProviderDisabled { .. } + | EnvironmentSelectionError::MissingCredential { .. } => intent_error( + StatusCode::SERVICE_UNAVAILABLE, + error.to_string(), + "integration_unavailable", + ), + EnvironmentSelectionError::CredentialStore { .. } => intent_error( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to read sandbox credentials", + "run_persistence_failed", + ), + }, + RunIntentAdmissionError::Compiler(_) => intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + "run intent could not be compiled", + "run_compile_invalid", + ), + RunIntentAdmissionError::VariableSnapshot { .. } => intent_error( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to prepare run", + "run_persistence_failed", + ), + } +} + +fn select_intent_environment_id( + state: &AppState, + value: &str, +) -> Result { + let id = + value + .parse::() + .map_err(|source| EnvironmentSelectionError::InvalidId { + value: value.to_string(), + source, + })?; + if state.environment_store().get(&id).is_none() { + return Err(EnvironmentSelectionError::NotFound { id }); + } + Ok(id) +} + +async fn validate_intent_environment( + state: &AppState, + settings: &fabro_types::WorkflowSettings, +) -> Result<(), EnvironmentSelectionError> { + let provider = run_manifest::effective_sandbox_provider(&settings.run); + let image = &settings.run.environment.image; + let incompatible = match provider { + SandboxProviderKind::Local => true, + SandboxProviderKind::Docker => image.docker.is_none() && image.dockerfile.is_some(), + SandboxProviderKind::Daytona => image.docker.is_some(), + }; + if incompatible || !settings.run.clone.enabled { + return Err(EnvironmentSelectionError::TargetUnsupported); + } + if let Some(detail) = + run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider) + { + return Err(EnvironmentSelectionError::ProviderDisabled { provider, detail }); + } + if provider == SandboxProviderKind::Daytona { + match state.vault_secret(EnvVars::DAYTONA_API_KEY).await { + Ok(Some(key)) if !key.trim().is_empty() => {} + Ok(_) => { + return Err(EnvironmentSelectionError::MissingCredential { + provider, + name: EnvVars::DAYTONA_API_KEY, + }); + } + Err(source) => { + return Err(EnvironmentSelectionError::CredentialStore { + name: EnvVars::DAYTONA_API_KEY, + source, + }); + } + } + } + Ok(()) +} + pub(crate) struct CreateRunFromManifestRequest { pub(crate) manifest: RunManifest, pub(crate) submitted_manifest_bytes: Vec, @@ -718,7 +1159,9 @@ pub(crate) async fn create_run_from_manifest( server_run_defaults: manifest_run_defaults.as_ref().clone(), server_environment_defaults: manifest_environment_defaults.as_ref().clone(), server_mcp_catalog: manifest_mcp_server_catalog, - project_settings: manifest_adapter.project_settings, + settings_input: RunCompilerSettingsInput::LegacyManifest { + project_settings: manifest_adapter.project_settings, + }, user_toml: manifest_adapter.user_toml, run_overrides: manifest_adapter.run_overrides, cli_overrides: manifest_adapter.cli_overrides, @@ -731,6 +1174,7 @@ pub(crate) async fn create_run_from_manifest( storage_root: state.server_storage_dir(), workflow_slug: None, workflow_version_id: None, + target: None, provenance: run_provenance(&headers, &actor), web_url: None, submitted_manifest_bytes: Some(submitted_manifest_bytes), diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 835b9ed51..773b2edf6 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -1918,6 +1918,7 @@ reasoning = false graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: HashMap::default(), diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index dc21a12e6..70ec20465 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -3544,6 +3544,384 @@ async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_j response_json!(response, StatusCode::CREATED).await } +async fn store_workflow_version( + state: &AppState, + graph: &str, + workflow_toml: Option<&str>, +) -> fabro_types::WorkflowVersionId { + let mut files = std::collections::BTreeMap::from([( + fabro_types::WorkflowPath::new("workflow.fabro").unwrap(), + graph.to_string(), + )]); + if let Some(workflow_toml) = workflow_toml { + files.insert( + fabro_types::WorkflowPath::new("workflow.toml").unwrap(), + workflow_toml.to_string(), + ); + files.insert( + fabro_types::WorkflowPath::new("goal.md").unwrap(), + "Goal loaded from immutable version bytes".to_string(), + ); + files.insert( + fabro_types::WorkflowPath::new("Dockerfile").unwrap(), + "FROM alpine:3".to_string(), + ); + } + let version = fabro_types::WorkflowVersion::new( + fabro_types::WorkflowPath::new("workflow.fabro").unwrap(), + files, + std::collections::BTreeMap::new(), + ) + .unwrap(); + let version = fabro_workflow_version::ValidatedWorkflowVersion::new(version).unwrap(); + let blobs = state.store_ref().blobs().await.unwrap(); + fabro_workflow_version::WorkflowVersionStore::new(blobs) + .put(&version) + .await + .unwrap() +} + +#[tokio::test] +async fn post_runs_run_intent_creates_submitted_version_backed_git_target_without_starting() { + let state = test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let workflow_version_id = store_workflow_version( + &state, + MINIMAL_DOT, + Some( + r#" +_version = 1 + +[environments.default] +provider = "local" + +[environments.default.image] +dockerfile = { path = "Dockerfile" } + +[environments.default.resources] +cpu = 7 + +[environments.default.env] +WORKFLOW_OVERLAY = "present" + +[run.goal] +file = "goal.md" + +[run.environment.image] +docker = "workflow-owned:latest" +"#, + ), + ) + .await; + let submitted_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; + let body = post_run_manifest( + &app, + json!({ + "workflow_version_id": workflow_version_id, + "target": { + "kind": "git", + "repo": "fabro-sh/fabro", + "branch": "feature/run-intent", + "sha": submitted_sha + }, + "args": { + "inputs": { "ship": true }, + "labels": { "team": "platform" } + }, + "title": "Intent run" + }), + ) + .await; + let run_id = body["id"].as_str().unwrap().parse::().unwrap(); + + assert_eq!(body["lifecycle"]["status"]["kind"], "submitted"); + let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); + let events = run_store.list_events().await.unwrap(); + assert_eq!( + events + .iter() + .map(|event| event.event.event_name()) + .collect::>(), + vec!["run.created", "run.submitted"] + ); + let projection = run_store.state().await.unwrap(); + assert_eq!( + projection.spec.workflow_version_id, + Some(workflow_version_id) + ); + assert_eq!( + projection.spec.graph.goal(), + "Goal loaded from immutable version bytes" + ); + assert_eq!( + projection.spec.target, + Some(fabro_types::RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()), + }) + ); + assert_eq!( + projection + .spec + .git + .as_ref() + .and_then(|git| git.sha.as_deref()), + Some("abcdef0123456789abcdef0123456789abcdef01") + ); + assert_eq!( + projection.spec.settings.run.inputs["ship"], + toml::Value::Boolean(true) + ); + assert_eq!( + projection.spec.labels.get("team").map(String::as_str), + Some("platform") + ); + assert_eq!( + projection.spec.settings.run.environment.provider, + EnvironmentProvider::Docker + ); + assert_eq!( + projection + .spec + .settings + .run + .environment + .image + .docker + .as_deref(), + Some("buildpack-deps:noble") + ); + assert!( + projection + .spec + .settings + .run + .environment + .image + .dockerfile + .is_none() + ); + assert_eq!( + projection.spec.settings.run.environment.resources.cpu, + Some(7) + ); + assert!( + projection + .spec + .settings + .run + .environment + .env + .contains_key("WORKFLOW_OVERLAY") + ); +} + +#[tokio::test] +async fn post_runs_run_intent_dispatches_errors_without_changing_legacy_lane() { + let state = test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let malformed = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from("{")) + .unwrap(), + ) + .await + .unwrap(); + let malformed = response_json!(malformed, StatusCode::BAD_REQUEST).await; + assert_eq!(malformed["errors"][0]["code"], "invalid_json"); + + let intent_shaped = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from(json!({ "workflow_version_id": fabro_types::test_support::test_workflow_version_id() }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let intent_shaped = response_json!(intent_shaped, StatusCode::UNPROCESSABLE_ENTITY).await; + assert_eq!(intent_shaped["errors"][0]["code"], "run_intent_invalid"); + + let mut legacy = minimal_manifest_json(MINIMAL_DOT); + legacy["workflow_version_id"] = json!(fabro_types::test_support::test_workflow_version_id()); + let legacy = post_run_manifest(&app, legacy).await; + assert_eq!(legacy["lifecycle"]["status"]["kind"], "submitted"); +} + +#[tokio::test] +async fn post_runs_run_intent_maps_missing_version_environment_and_target_errors() { + let state = test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let missing_version_id = fabro_types::test_support::test_workflow_version_id(); + let base_intent = json!({ + "workflow_version_id": missing_version_id, + "target": { + "kind": "git", + "repo": "fabro-sh/fabro", + "branch": "feature/run-intent" + }, + "args": {} + }); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from(base_intent.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::NOT_FOUND).await; + assert_eq!(body["errors"][0]["code"], "workflow_version_not_found"); + + let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await; + for (patch, expected_status, expected_code) in [ + ( + json!({ "environment_id": "missing-environment" }), + StatusCode::NOT_FOUND, + "environment_not_found", + ), + ( + json!({ "environment_id": "not valid" }), + StatusCode::UNPROCESSABLE_ENTITY, + "run_intent_invalid", + ), + ( + json!({ "target": { "kind": "git", "repo": "fabro-sh/fabro", "branch": "heads/main" } }), + StatusCode::UNPROCESSABLE_ENTITY, + "target_invalid", + ), + ] { + let mut intent = json!({ + "workflow_version_id": workflow_version_id, + "target": { + "kind": "git", + "repo": "fabro-sh/fabro", + "branch": "feature/run-intent" + }, + "args": {} + }); + for (key, value) in patch.as_object().unwrap() { + intent[key] = value.clone(); + } + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from(intent.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, expected_status).await; + assert_eq!(body["errors"][0]["code"], expected_code); + } + + assert!(state.runs.lock().expect("runs lock poisoned").is_empty()); +} + +#[tokio::test] +async fn post_runs_run_intent_rejects_disabled_or_unready_sandbox_integrations() { + let disabled_state = test_app_state_with_options( + server_settings_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.sandbox.providers.docker] +enabled = false +"#, + ), + RunLayer::default(), + 5, + ); + let disabled_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await; + let disabled_app = crate::test_support::build_test_router(Arc::clone(&disabled_state)); + let intent = json!({ + "workflow_version_id": disabled_version_id, + "target": { + "kind": "git", + "repo": "fabro-sh/fabro", + "branch": "feature/run-intent" + }, + "args": {} + }); + let response = disabled_app + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from(intent.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!(body["errors"][0]["code"], "integration_unavailable"); + assert!( + disabled_state + .runs + .lock() + .expect("runs lock poisoned") + .is_empty() + ); + + let daytona_state = TestAppStateBuilder::new() + .default_environment_provider(Some(EnvironmentProvider::Daytona)) + .vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); + let daytona_version_id = store_workflow_version(&daytona_state, MINIMAL_DOT, None).await; + let daytona_app = crate::test_support::build_test_router(Arc::clone(&daytona_state)); + let intent = json!({ + "workflow_version_id": daytona_version_id, + "target": { + "kind": "git", + "repo": "fabro-sh/fabro", + "branch": "feature/run-intent" + }, + "args": {} + }); + let response = daytona_app + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from(intent.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!(body["errors"][0]["code"], "integration_unavailable"); + assert!( + daytona_state + .runs + .lock() + .expect("runs lock poisoned") + .is_empty() + ); +} + #[tokio::test] async fn post_runs_ignores_removed_run_id_input() { let app = crate::test_support::build_test_router(test_app_state()); @@ -4650,6 +5028,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -4703,6 +5082,7 @@ async fn create_slack_notification_run( source_directory: None, workflow_slug: workflow_slug.map(str::to_string), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -5778,6 +6158,7 @@ async fn list_run_stages_distinguishes_visits() { source_directory: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -5916,6 +6297,7 @@ async fn list_run_stages_exposes_execution_identity_for_resumed_stage() { source_directory: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -7101,6 +7483,7 @@ async fn create_completed_run_ready_for_pull_request( graph_source: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some("/tmp/project".to_string()), git: git.clone(), @@ -7123,6 +7506,7 @@ async fn create_completed_run_ready_for_pull_request( source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), workflow_version_id: run_spec.workflow_version_id, + target: run_spec.target.clone(), automation: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -14097,6 +14481,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc, run_id: RunId source_directory: Some("/tmp/fabro-run".to_string()), workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -14848,6 +15233,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() { source_directory: Some("/tmp/fabro-run".to_string()), workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/apps/fabro-server/tests/it/api/run_files.rs b/lib/apps/fabro-server/tests/it/api/run_files.rs index b8aad6410..e30d09582 100644 --- a/lib/apps/fabro-server/tests/it/api/run_files.rs +++ b/lib/apps/fabro-server/tests/it/api/run_files.rs @@ -67,6 +67,7 @@ async fn append_completed_run_with_final_patch( source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-environment/src/lib.rs b/lib/components/fabro-environment/src/lib.rs index bd6029bd8..2c4f008fb 100644 --- a/lib/components/fabro-environment/src/lib.rs +++ b/lib/components/fabro-environment/src/lib.rs @@ -7,6 +7,6 @@ pub use error::{EnvironmentStoreError, EnvironmentValidationError}; pub use id::{EnvironmentId, EnvironmentRevision, EnvironmentRevisionParseError}; pub use model::{Environment, EnvironmentDraft}; pub use store::{ - EnvironmentStore, ImportReport, import_legacy_directory_once, seed_default_environment, - seed_environments, seeded_catalog_layer, + DEFAULT_ENVIRONMENT_ID, EnvironmentStore, ImportReport, import_legacy_directory_once, + seed_default_environment, seed_environments, seeded_catalog_layer, }; diff --git a/lib/components/fabro-environment/src/store.rs b/lib/components/fabro-environment/src/store.rs index 441906fb3..203348f22 100644 --- a/lib/components/fabro-environment/src/store.rs +++ b/lib/components/fabro-environment/src/store.rs @@ -28,7 +28,7 @@ use crate::{ /// managed environments, and a run that selects an absent environment fails /// explicitly. `local` is intentionally absent from SQLite because it is a /// reserved, in-memory environment. -const DEFAULT_ENVIRONMENT_ID: &str = "default"; +pub const DEFAULT_ENVIRONMENT_ID: &str = "default"; /// `local` is a reserved environment: it is synthesized in memory only when the /// local sandbox provider is enabled, is never persisted, and cannot be diff --git a/lib/components/fabro-sandbox/src/clone_source.rs b/lib/components/fabro-sandbox/src/clone_source.rs index 9f03a93e3..b3c5c9dd5 100644 --- a/lib/components/fabro-sandbox/src/clone_source.rs +++ b/lib/components/fabro-sandbox/src/clone_source.rs @@ -84,9 +84,9 @@ pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str /// Fetch a single admitted commit with the same history depth a branch clone /// gets, so both paths can reach the same number of parent commits. /// -/// The fetch names the commit directly rather than the branch: reachability of -/// the commit from the admitted branch is an admission-time invariant, not -/// something this layer re-verifies. +/// The fetch names the commit directly rather than the branch. No layer proves +/// that the submitted commit belongs to the submitted branch: the branch names +/// the working branch, while a fetchable exact commit is checked out as-is. pub(crate) fn exact_fetch_command( checkout_path: &str, fetch_source: &str, @@ -208,8 +208,9 @@ pub(crate) fn decide_clone( )); } // The branch names the checkout the run works on; it is not used to - // constrain which commits may be fetched. Admission is responsible for - // proving the commit belongs to the branch before it reaches here. + // constrain which commits may be fetched. No layer proves branch/SHA + // ancestry, and an unavailable exact commit fails without falling back + // to branch HEAD. if clone_branch.is_none_or(|branch| branch.trim().is_empty()) { return Err(crate::Error::message( "Exact commit checkout requires a repository branch", @@ -246,12 +247,9 @@ pub(crate) fn decide_clone( } fn normalize_exact_commit_sha(commit_sha: &str) -> crate::Result { - if commit_sha.len() != 40 || !commit_sha.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(crate::Error::message( - "Exact commit SHA must be exactly 40 ASCII hexadecimal characters", - )); - } - Ok(commit_sha.to_ascii_lowercase()) + fabro_types::normalize_git_commit_sha(commit_sha).ok_or_else(|| { + crate::Error::message("Exact commit SHA must be exactly 40 ASCII hexadecimal characters") + }) } pub(crate) fn clean_clone_origin_for_record(clone_origin_url: Option<&str>) -> Option { diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index da54a2d29..3407ab92f 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -1041,6 +1041,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result { graph_source: props.workflow_source.clone(), workflow_slug: props.workflow_slug.clone(), workflow_version_id: props.workflow_version_id, + target: props.target.clone(), automation: props.automation.clone(), source_directory: props.source_directory.clone(), labels, diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index 7d9af8f79..8d0827835 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -72,6 +72,7 @@ fn event_body_from_event(event: &Event) -> EventBody { source_directory, workflow_slug, workflow_version_id, + target, automation, provenance, manifest_blob, @@ -92,6 +93,7 @@ fn event_body_from_event(event: &Event) -> EventBody { source_directory: source_directory.clone(), workflow_slug: workflow_slug.clone(), workflow_version_id: *workflow_version_id, + target: target.clone(), automation: automation.clone(), provenance: provenance.clone(), manifest_blob: *manifest_blob, @@ -2842,6 +2844,7 @@ mod tests { source_directory: Some("/tmp/run".to_string()), workflow_slug: None, workflow_version_id: Some(workflow_version_id), + target: None, automation: Some(automation.clone()), provenance, manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index f916f7985..f23bc5c35 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -5,9 +5,9 @@ use ::fabro_types::{ FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal, PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, RunNoticeLevel, - RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTiming, - SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason, WorkflowVersionId, - run_event as fabro_types, + RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTarget, + RunTiming, SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason, + WorkflowVersionId, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_model::{ReasoningEffort, Speed}; @@ -38,6 +38,8 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] workflow_version_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] automation: Option, provenance: RunProvenance, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/components/fabro-workflow/src/event/sink.rs b/lib/components/fabro-workflow/src/event/sink.rs index a82f2e20f..dfd8e3b4e 100644 --- a/lib/components/fabro-workflow/src/event/sink.rs +++ b/lib/components/fabro-workflow/src/event/sink.rs @@ -310,6 +310,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/git.rs b/lib/components/fabro-workflow/src/git.rs index 5d72aa43d..cb7b08a3f 100644 --- a/lib/components/fabro-workflow/src/git.rs +++ b/lib/components/fabro-workflow/src/git.rs @@ -362,6 +362,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/handler/agent.rs b/lib/components/fabro-workflow/src/handler/agent.rs index b262cb976..572de6429 100644 --- a/lib/components/fabro-workflow/src/handler/agent.rs +++ b/lib/components/fabro-workflow/src/handler/agent.rs @@ -499,6 +499,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs index 49c43230c..8d08d17b1 100644 --- a/lib/components/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -369,6 +369,7 @@ mod tests { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: std::collections::HashMap::default(), @@ -474,6 +475,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/handler/parallel.rs b/lib/components/fabro-workflow/src/handler/parallel.rs index 9b3ef2f14..4e9d0d90f 100644 --- a/lib/components/fabro-workflow/src/handler/parallel.rs +++ b/lib/components/fabro-workflow/src/handler/parallel.rs @@ -992,6 +992,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index f2d3df535..940f1711d 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -277,6 +277,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs index 691690b21..540c895fc 100644 --- a/lib/components/fabro-workflow/src/lifecycle/git.rs +++ b/lib/components/fabro-workflow/src/lifecycle/git.rs @@ -783,6 +783,7 @@ mod tests { source_directory: Some("/tmp/project".to_string()), workflow_slug: Some("metadata".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/operations/archive.rs b/lib/components/fabro-workflow/src/operations/archive.rs index 28e28ae77..188f625b2 100644 --- a/lib/components/fabro-workflow/src/operations/archive.rs +++ b/lib/components/fabro-workflow/src/operations/archive.rs @@ -225,6 +225,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index 54f095bd2..c14f48632 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -17,7 +17,7 @@ use fabro_store::{Database, RunDatabase}; use fabro_template::TemplateContext; use fabro_types::{ AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, - WorkflowSettings, WorkflowVersionId, + RunTarget, WorkflowSettings, WorkflowVersionId, }; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -44,6 +44,7 @@ pub struct CreateRunInput { pub workflow_slug: Option, pub workflow_path: Option, pub workflow_bundle: Option, + pub target: Option, pub submitted_manifest_bytes: Option>, pub run_id: Option, pub title: Option, @@ -75,6 +76,7 @@ impl CreateRunInput { workflow_slug, workflow_path, workflow_bundle, + target, submitted_manifest_bytes, run_id: _, title, @@ -101,6 +103,7 @@ impl CreateRunInput { storage_root, workflow_slug, workflow_version_id: None, + target, submitted_manifest_bytes, title, automation, @@ -135,6 +138,7 @@ pub struct CreateRunPersistenceMetadata { pub storage_root: PathBuf, pub workflow_slug: Option, pub workflow_version_id: Option, + pub target: Option, pub submitted_manifest_bytes: Option>, pub title: Option, pub automation: Option, @@ -205,6 +209,7 @@ pub struct CreateRunPersistenceInput { run_dir: PathBuf, workflow_slug: Option, workflow_version_id: Option, + target: Option, submitted_manifest_bytes: Option>, title: Option, automation: Option, @@ -405,6 +410,7 @@ pub fn assemble_create_run_persistence_input( storage_root, workflow_slug, workflow_version_id, + target, submitted_manifest_bytes, title, automation, @@ -426,6 +432,7 @@ pub fn assemble_create_run_persistence_input( run_dir, workflow_slug, workflow_version_id, + target, submitted_manifest_bytes, title, automation, @@ -448,6 +455,7 @@ pub async fn persist_create_run( run_dir, workflow_slug, workflow_version_id, + target, submitted_manifest_bytes, title, automation, @@ -476,6 +484,7 @@ pub async fn persist_create_run( graph_source: Some(validated.source().to_string()), workflow_slug, workflow_version_id, + target, automation, source_directory: Some(source_directory), labels, @@ -564,6 +573,7 @@ async fn persist_created_run( source_directory: record.source_directory.clone(), workflow_slug: record.workflow_slug.clone(), workflow_version_id: record.workflow_version_id, + target: record.target.clone(), automation: record.automation.clone(), provenance: record.provenance.clone(), manifest_blob, @@ -1616,6 +1626,7 @@ reasoning = false workflow_slug: Some("request-slug".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: Some(b"submitted manifest".to_vec()), run_id: Some(fixtures::RUN_1), title: Some("Assembled run".to_string()), @@ -1722,6 +1733,7 @@ reasoning = false storage_root: PathBuf::from("/tmp/storage"), workflow_slug: None, workflow_version_id: None, + target: None, submitted_manifest_bytes: None, title: None, automation: None, @@ -1757,6 +1769,7 @@ reasoning = false workflow_slug: Some("compiled-slug".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: Some(b"submitted manifest".to_vec()), run_id: Some(fixtures::RUN_2), title: Some("Compiled run".to_string()), @@ -1844,6 +1857,7 @@ reasoning = false workflow_slug: None, workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: None, title: None, @@ -1911,6 +1925,7 @@ reasoning = false workflow_slug: Some("slug".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, @@ -2044,6 +2059,7 @@ reasoning = false workflow_slug: None, workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: None, title: None, @@ -2153,6 +2169,7 @@ reasoning = false workflow_slug: Some("secret-source".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, @@ -2230,6 +2247,7 @@ reasoning = false workflow_slug: None, workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_2), title: None, @@ -2271,6 +2289,7 @@ reasoning = false workflow_slug: None, workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_2), title: None, @@ -2350,6 +2369,7 @@ reasoning = false workflow_slug: Some("slug".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_3), title: None, @@ -2404,6 +2424,7 @@ reasoning = false workflow_slug: Some("slug".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_64), title: None, diff --git a/lib/components/fabro-workflow/src/operations/fork.rs b/lib/components/fabro-workflow/src/operations/fork.rs index 1439ab471..5b827dbe8 100644 --- a/lib/components/fabro-workflow/src/operations/fork.rs +++ b/lib/components/fabro-workflow/src/operations/fork.rs @@ -160,6 +160,7 @@ async fn persist_forked_run( source_directory: spec.source_directory.clone(), workflow_slug: spec.workflow_slug.clone(), workflow_version_id: spec.workflow_version_id, + target: spec.target.clone(), automation: spec.automation.clone(), provenance: spec.provenance.clone(), manifest_blob: spec.manifest_blob, @@ -384,12 +385,17 @@ mod tests { source_directory: Some("/client/source".to_string()), workflow_slug: Some("fork-source".to_string()), workflow_version_id: Some(workflow_version_id), + target: Some(fabro_types::RunTarget::Git { + repo: "example/repo".to_string(), + branch: "main".to_string(), + sha: None, + }), automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, spec_blob: None, git: Some(fabro_types::GitContext { - origin_url: "https://github.com/example/repo.git".to_string(), + origin_url: "https://github.com/example/repo".to_string(), branch: "main".to_string(), sha: None, dirty: fabro_types::DirtyStatus::Clean, @@ -470,6 +476,14 @@ mod tests { forked_state.spec.workflow_version_id, Some(workflow_version_id) ); + assert_eq!( + forked_state.spec.target, + Some(fabro_types::RunTarget::Git { + repo: "example/repo".to_string(), + branch: "main".to_string(), + sha: None, + }) + ); assert_eq!( forked_state.spec.fork_source_ref.unwrap().source_run_id, source_run_id diff --git a/lib/components/fabro-workflow/src/operations/retry.rs b/lib/components/fabro-workflow/src/operations/retry.rs index 8acde0e43..8fdd1dda4 100644 --- a/lib/components/fabro-workflow/src/operations/retry.rs +++ b/lib/components/fabro-workflow/src/operations/retry.rs @@ -49,6 +49,7 @@ pub async fn retry_run( graph_source, workflow_slug, workflow_version_id, + target, automation, source_directory, labels, @@ -78,6 +79,7 @@ pub async fn retry_run( source_directory, workflow_slug, workflow_version_id, + target, automation, provenance: input.provenance.clone(), manifest_blob, @@ -124,8 +126,8 @@ mod tests { use fabro_store::{Database, RunProjectionReducer}; use fabro_types::{ AuthMethod, BlobHash, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph, - IdpIdentity, Principal, PullRequestLink, RunRunnableSource, RunServerProvenance, RunTiming, - WorkflowSettings, fixtures, test_support, + IdpIdentity, Principal, PullRequestLink, RunRunnableSource, RunServerProvenance, RunTarget, + RunTiming, WorkflowSettings, fixtures, test_support, }; use object_store::memory::InMemory; @@ -160,13 +162,21 @@ mod tests { fn git_context() -> GitContext { GitContext { - origin_url: "https://github.com/fabro-sh/fabro.git".to_string(), + origin_url: "https://github.com/fabro-sh/fabro".to_string(), branch: "main".to_string(), - sha: Some("abc123".to_string()), + sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()), dirty: DirtyStatus::Clean, } } + fn run_target() -> RunTarget { + RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "main".to_string(), + sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()), + } + } + async fn append_created( store: &fabro_store::RunDatabase, run_id: RunId, @@ -189,6 +199,7 @@ mod tests { source_directory: Some("/workspace/source".to_string()), workflow_slug: Some("retry-source".to_string()), workflow_version_id: Some(test_support::test_workflow_version_id()), + target: Some(run_target()), automation: None, provenance: provenance("source-user"), manifest_blob, @@ -405,6 +416,7 @@ mod tests { Some("digraph retry_source { start -> exit }") ); assert_eq!(retry_state.spec.git, Some(git_context())); + assert_eq!(retry_state.spec.target, Some(run_target())); assert_eq!(retry_state.spec.manifest_blob, manifest_blob); assert_eq!(retry_state.spec.definition_blob, definition_blob); assert_eq!(retry_state.spec.fork_source_ref, Some(fork_source_ref)); diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 897ee6039..668132211 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -21,7 +21,10 @@ use fabro_types::settings::run::{ ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings, RunPrepareSettings as ResolvedRunPrepareSettings, }; -use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind}; +use fabro_types::{ + GitHubRepositorySlug, ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget, + SandboxProviderKind, normalize_git_commit_sha, repository, +}; use fabro_util::error::collect_chain; use fabro_vault::Vault; use tokio::runtime::Handle; @@ -415,6 +418,7 @@ impl RunSession { let resolved = &settings.run; let sandbox_provider = resolve_sandbox_provider(resolved).effective_for(resolved.execution.mode); + let clone_source = clone_source_for_run(record)?; let catalog = Arc::clone(&services.catalog); let configured = configured_providers_for_start(&services.vault, Arc::clone(&catalog)).await; @@ -454,6 +458,11 @@ impl RunSession { let sandbox = match sandbox_provider { SandboxProviderKind::Local => { + if record.target.is_some() { + return Err(Error::engine( + "persisted Git run targets require a clone-based sandbox provider", + )); + } let working_directory = local_working_directory_from_environment( &resolved.environment, record.source_directory.as_deref().map(Path::new), @@ -470,9 +479,9 @@ impl RunSession { config: resolve_docker_config(resolved, secret_lookup)?, github_app: services.github_app.clone(), run_id: Some(record.run_id), - clone_origin_url: record.repo_origin_url().map(str::to_string), - clone_branch: record.base_branch().map(str::to_string), - clone_commit_sha: None, + clone_origin_url: clone_source.origin_url.clone(), + clone_branch: clone_source.branch.clone(), + clone_commit_sha: clone_source.commit_sha.clone(), }, SandboxProviderKind::Daytona => { let api_key = vault_guard @@ -482,9 +491,9 @@ impl RunSession { config: Box::new(resolve_daytona_config(resolved)), github_app: services.github_app.clone(), run_id: Some(record.run_id), - clone_origin_url: record.repo_origin_url().map(str::to_string), - clone_branch: record.base_branch().map(str::to_string), - clone_commit_sha: None, + clone_origin_url: clone_source.origin_url.clone(), + clone_branch: clone_source.branch.clone(), + clone_commit_sha: clone_source.commit_sha.clone(), api_key, } } @@ -562,6 +571,71 @@ impl RunSession { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CloneSourceForRun { + origin_url: Option, + branch: Option, + commit_sha: Option, +} + +fn clone_source_for_run(record: &RunSpec) -> Result { + let Some(target) = &record.target else { + return Ok(CloneSourceForRun { + origin_url: record.repo_origin_url().map(str::to_string), + branch: record.base_branch().map(str::to_string), + commit_sha: None, + }); + }; + + match target { + RunTarget::Git { repo, branch, sha } => { + let slug = GitHubRepositorySlug::try_new(repo).ok_or_else(|| { + Error::engine("persisted Git run target has an invalid repository slug") + })?; + let selector = format!("heads/{branch}"); + if branch.starts_with("heads/") + || branch.starts_with("tags/") + || branch.starts_with("refs/") + || normalize_git_commit_sha(branch).is_some() + || !repository::is_valid_github_ref_selector(&selector) + { + return Err(Error::engine( + "persisted Git run target has an invalid branch", + )); + } + let sha = sha + .as_deref() + .map(|value| { + normalize_git_commit_sha(value) + .ok_or_else(|| Error::engine("persisted Git run target has an invalid SHA")) + }) + .transpose()?; + let expected_origin = format!("https://github.com/{}/{}", slug.owner(), slug.repo()); + let git = record.git.as_ref().ok_or_else(|| { + Error::engine("persisted Git run target is missing its Git projection") + })?; + let projected_sha = git + .sha + .as_deref() + .map(|value| { + normalize_git_commit_sha(value) + .ok_or_else(|| Error::engine("persisted Git projection has an invalid SHA")) + }) + .transpose()?; + if git.origin_url != expected_origin || git.branch != *branch || projected_sha != sha { + return Err(Error::engine( + "persisted Git run target disagrees with its Git projection", + )); + } + Ok(CloneSourceForRun { + origin_url: Some(expected_origin), + branch: Some(branch.clone()), + commit_sha: sha, + }) + } + } +} + async fn configured_providers_for_start( vault: &Arc>, catalog: Arc, @@ -1810,6 +1884,7 @@ reasoning = false workflow_slug: Some("test".to_string()), workflow_path: None, workflow_bundle: None, + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, @@ -2366,6 +2441,7 @@ reasoning = false workflow_slug: Some("bundle-child".to_string()), workflow_path: Some(ManifestPath::from_wire("workflow.fabro").unwrap()), workflow_bundle: Some(workflow_bundle), + target: None, submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, @@ -2612,4 +2688,88 @@ reasoning = false result = result.as_ref().map(|_| "Ok"), ); } + + #[test] + fn clone_commit_legacy_run_never_activates_an_observed_git_sha() { + let mut spec = test_support::test_run_spec(); + spec.git = Some(fabro_types::GitContext { + origin_url: "https://github.com/fabro-sh/fabro".to_string(), + branch: "main".to_string(), + sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()), + dirty: fabro_types::DirtyStatus::Clean, + }); + + let source = clone_source_for_run(&spec).unwrap(); + + assert_eq!(source.commit_sha, None); + assert_eq!(source.branch.as_deref(), Some("main")); + } + + #[test] + fn clone_commit_persisted_git_target_activates_exact_branch_and_sha() { + let mut spec = test_support::test_run_spec(); + let submitted_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; + let normalized_sha = "abcdef0123456789abcdef0123456789abcdef01"; + spec.target = Some(RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: Some(submitted_sha.to_string()), + }); + spec.git = Some(fabro_types::GitContext { + origin_url: "https://github.com/fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: Some(submitted_sha.to_string()), + dirty: fabro_types::DirtyStatus::Clean, + }); + + let source = clone_source_for_run(&spec).unwrap(); + + assert_eq!( + source.origin_url.as_deref(), + Some("https://github.com/fabro-sh/fabro") + ); + assert_eq!(source.branch.as_deref(), Some("feature/run-intent")); + assert_eq!(source.commit_sha.as_deref(), Some(normalized_sha)); + } + + #[test] + fn clone_commit_persisted_git_target_without_sha_keeps_branch_unpinned() { + let mut spec = test_support::test_run_spec(); + spec.target = Some(RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: None, + }); + spec.git = Some(fabro_types::GitContext { + origin_url: "https://github.com/fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: None, + dirty: fabro_types::DirtyStatus::Clean, + }); + + let source = clone_source_for_run(&spec).unwrap(); + + assert_eq!(source.branch.as_deref(), Some("feature/run-intent")); + assert_eq!(source.commit_sha, None); + } + + #[test] + fn clone_commit_persisted_git_target_rejects_projection_drift() { + let mut spec = test_support::test_run_spec(); + spec.target = Some(RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "main".to_string(), + sha: None, + }); + spec.git = Some(fabro_types::GitContext { + origin_url: "https://github.com/fabro-sh/fabro".to_string(), + branch: "other".to_string(), + sha: None, + dirty: fabro_types::DirtyStatus::Clean, + }); + + let error = clone_source_for_run(&spec).unwrap_err(); + + assert!(error.to_string().contains("disagrees")); + } } diff --git a/lib/components/fabro-workflow/src/operations/timeline.rs b/lib/components/fabro-workflow/src/operations/timeline.rs index b3f51350e..ab0383c79 100644 --- a/lib/components/fabro-workflow/src/operations/timeline.rs +++ b/lib/components/fabro-workflow/src/operations/timeline.rs @@ -247,6 +247,7 @@ mod tests { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: HashMap::new(), diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index 6a2f63078..916b5883b 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -157,6 +157,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI graph_source: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some( std::env::current_dir() @@ -218,6 +219,7 @@ async fn seed_created_and_starting( source_directory: Some(std::env::current_dir().unwrap().display().to_string()), workflow_slug: run_options.workflow_slug.clone(), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs index 76dde359f..5190bc4db 100644 --- a/lib/components/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -796,6 +796,7 @@ mod tests { source_directory: Some("/tmp/project".to_string()), workflow_slug: Some("metadata".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -913,6 +914,7 @@ mod tests { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: HashMap::new(), diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index d1e864e88..39f3629ef 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -810,6 +810,7 @@ mod tests { source_directory, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -966,6 +967,7 @@ mod tests { graph_source: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some(std::env::current_dir().unwrap().display().to_string()), git: Some(fabro_types::GitContext { diff --git a/lib/components/fabro-workflow/src/pipeline/persist.rs b/lib/components/fabro-workflow/src/pipeline/persist.rs index 95cab3c32..ef6bf4df4 100644 --- a/lib/components/fabro-workflow/src/pipeline/persist.rs +++ b/lib/components/fabro-workflow/src/pipeline/persist.rs @@ -172,6 +172,7 @@ mod tests { graph_source: None, workflow_slug: Some("ship".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { @@ -222,6 +223,7 @@ mod tests { source_directory: record.source_directory.clone(), workflow_slug: record.workflow_slug.clone(), workflow_version_id: None, + target: record.target.clone(), automation: record.automation.clone(), provenance: record.provenance.clone(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs index c0e35acfc..4de3f2ec4 100644 --- a/lib/components/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -825,6 +825,7 @@ mod tests { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: HashMap::new(), @@ -1103,6 +1104,7 @@ mod tests { graph_source: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { @@ -1128,6 +1130,7 @@ mod tests { source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), workflow_version_id: run_spec.workflow_version_id, + target: run_spec.target.clone(), automation: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -1174,6 +1177,7 @@ mod tests { graph_source: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { @@ -1199,6 +1203,7 @@ mod tests { source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), workflow_version_id: run_spec.workflow_version_id, + target: run_spec.target.clone(), automation: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -1600,6 +1605,7 @@ mod tests { graph_source: None, workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some("/tmp/project".to_string()), git: None, @@ -1620,6 +1626,7 @@ mod tests { source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), workflow_version_id: run_spec.workflow_version_id, + target: run_spec.target.clone(), automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, @@ -1821,6 +1828,7 @@ mod tests { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, git: None, @@ -1841,6 +1849,7 @@ mod tests { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/run_lookup.rs b/lib/components/fabro-workflow/src/run_lookup.rs index 2b37861cf..cff4f1657 100644 --- a/lib/components/fabro-workflow/src/run_lookup.rs +++ b/lib/components/fabro-workflow/src/run_lookup.rs @@ -499,6 +499,7 @@ mod tests { source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), workflow_version_id: None, + target: None, automation: None, provenance: run_spec.provenance.clone(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/run_metadata.rs b/lib/components/fabro-workflow/src/run_metadata.rs index c4ae9951a..73d03c634 100644 --- a/lib/components/fabro-workflow/src/run_metadata.rs +++ b/lib/components/fabro-workflow/src/run_metadata.rs @@ -686,6 +686,7 @@ mod tests { graph_source: None, workflow_slug: Some("metadata".to_string()), workflow_version_id: None, + target: None, automation: None, source_directory: Some("/Users/client/project".to_string()), git: Some(GitContext { diff --git a/lib/components/fabro-workflow/src/runtime_store.rs b/lib/components/fabro-workflow/src/runtime_store.rs index b68caa27a..3217741d2 100644 --- a/lib/components/fabro-workflow/src/runtime_store.rs +++ b/lib/components/fabro-workflow/src/runtime_store.rs @@ -155,6 +155,7 @@ mod tests { source_directory: Some("/tmp/test".to_string()), workflow_slug: Some("test".to_string()), workflow_version_id: None, + target: None, automation: None, provenance: test_support::test_run_provenance(), manifest_blob: None, diff --git a/lib/components/fabro-workflow/src/stage_execution.rs b/lib/components/fabro-workflow/src/stage_execution.rs index dfdf8a3ba..d479a83d1 100644 --- a/lib/components/fabro-workflow/src/stage_execution.rs +++ b/lib/components/fabro-workflow/src/stage_execution.rs @@ -204,6 +204,7 @@ mod tests { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: std::collections::HashMap::new(), diff --git a/lib/components/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs index 6c9f55b99..a4b128c5d 100644 --- a/lib/components/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -204,6 +204,7 @@ async fn initialized( source_directory: Some(sandbox.working_directory().to_string()), workflow_slug: run_options.workflow_slug.clone(), workflow_version_id: None, + target: None, automation: None, provenance: fabro_types::RunProvenance { server: None, diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 73f0bfd19..7a79fbfb2 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -736,6 +736,9 @@ fn main() { ("CompletionMessageRole", "fabro_types::Role", &[]), ("CompletionContentPart", "fabro_types::ContentPart", &[]), ("WorkflowVersion", "fabro_types::WorkflowVersion", &[]), + ("RunIntent", "fabro_types::RunIntent", &[]), + ("RunIntentArgs", "fabro_types::RunIntentArgs", &[]), + ("RunTarget", "fabro_types::RunTarget", &[]), ("WorkflowPath", "fabro_types::WorkflowPath", &[]), ("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]), ("BlobHash", "fabro_types::BlobHash", &[]), diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index a845a8252..59a72d5e6 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -61,14 +61,14 @@ pub mod types { PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, ReasoningOutput, RepositoryRef, ReviewTarget, ReviewTargetKind, Role, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind, - RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, - RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, - RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, - SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, - SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError, - SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, - SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, - SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, + RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, RunPairStatusResponse, + RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure, + RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, + RunSize, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, + SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind, + SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse, + SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, + SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, diff --git a/lib/foundation/fabro-api/tests/run_intent_round_trip.rs b/lib/foundation/fabro-api/tests/run_intent_round_trip.rs new file mode 100644 index 000000000..ba02cf50a --- /dev/null +++ b/lib/foundation/fabro-api/tests/run_intent_round_trip.rs @@ -0,0 +1,53 @@ +use std::any::{TypeId, type_name}; +use std::collections::HashMap; + +use fabro_api::types::{RunIntent as ApiRunIntent, RunIntentArgs as ApiRunIntentArgs}; +use fabro_types::{RunIntent, RunIntentArgs, RunTarget, test_support}; +use serde_json::json; + +#[test] +fn run_intent_schemas_reuse_canonical_types() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn run_intent_round_trips_the_openapi_shape() { + let intent = RunIntent { + workflow_version_id: test_support::test_workflow_version_id(), + target: RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()), + }, + args: RunIntentArgs { + model: Some("gpt-5.6-sol".to_string()), + provider: Some("openai".to_string()), + inputs: HashMap::from([ + ("attempts".to_string(), json!(3)), + ("ship".to_string(), json!(true)), + ]), + labels: HashMap::from([("team".to_string(), "platform".to_string())]), + }, + environment_id: Some("default".to_string()), + parent_id: None, + title: Some("Ship RunIntent".to_string()), + goal: Some("Create the run without starting it".to_string()), + }; + + let value = serde_json::to_value(&intent).unwrap(); + let api: ApiRunIntent = serde_json::from_value(value.clone()).unwrap(); + + assert_eq!(serde_json::to_value(api).unwrap(), value); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/foundation/fabro-client/src/client.rs b/lib/foundation/fabro-client/src/client.rs index 9547dddce..62c300861 100644 --- a/lib/foundation/fabro-client/src/client.rs +++ b/lib/foundation/fabro-client/src/client.rs @@ -706,6 +706,14 @@ impl Client { Ok(status.id) } + pub async fn create_run_from_intent(&self, intent: types::RunIntent) -> Result { + let response = self + .send_api(|client| async move { client.create_run().body(intent.clone()).send().await }) + .await?; + let status = response.into_inner(); + Ok(status.id) + } + pub async fn list_secrets(&self) -> Result> { let response = self .send_api(|client| async move { client.list_secrets().send().await }) diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index bb3c86620..9e33bcec6 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -30,6 +30,7 @@ pub mod run; pub mod run_event; pub mod run_failure; pub mod run_id; +pub mod run_intent; pub mod run_projection; pub mod run_sandbox; pub mod run_summary; @@ -112,7 +113,9 @@ pub use pull_request::{ PullRequestRef, PullRequestResponse, PullRequestTimestamps, PullRequestUser, }; pub use reasoning::ReasoningOutput; -pub use repository::{GitHubRepositorySlug, RepositoryProvider, RepositoryRef}; +pub use repository::{ + GitHubRepositorySlug, RepositoryProvider, RepositoryRef, normalize_git_commit_sha, +}; pub use run::{ DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance, RunServerProvenance, RunSpec, @@ -127,6 +130,7 @@ pub use run_event::{ }; pub use run_failure::RunFailure; pub use run_id::{RunId, fixtures}; +pub use run_intent::{RunIntent, RunIntentArgs, RunTarget}; pub use run_projection::{ ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus, PendingInterviewRecord, RunProjection, SkillsProjection, StageContextWindow, diff --git a/lib/foundation/fabro-types/src/repository.rs b/lib/foundation/fabro-types/src/repository.rs index fcddc9bea..7eb431b92 100644 --- a/lib/foundation/fabro-types/src/repository.rs +++ b/lib/foundation/fabro-types/src/repository.rs @@ -212,6 +212,17 @@ pub fn is_valid_github_ref_selector(value: &str) -> bool { .all(|part| !part.is_empty() && !part.starts_with('.') && !has_lock_suffix(part)) } +/// Validates and canonicalizes an exact Git commit SHA. +/// +/// The grammar accepts exactly 40 untrimmed ASCII hexadecimal bytes. It does +/// not resolve abbreviations or verify that the object exists or belongs to a +/// branch. +#[must_use] +pub fn normalize_git_commit_sha(value: &str) -> Option { + (value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| value.to_ascii_lowercase()) +} + fn has_lock_suffix(value: &str) -> bool { value .rsplit_once('.') diff --git a/lib/foundation/fabro-types/src/run.rs b/lib/foundation/fabro-types/src/run.rs index 34e56ffaf..ee1718505 100644 --- a/lib/foundation/fabro-types/src/run.rs +++ b/lib/foundation/fabro-types/src/run.rs @@ -7,6 +7,7 @@ use crate::blob_hash::BlobHash; use crate::graph::Graph; use crate::principal::Principal; use crate::run_id::RunId; +use crate::run_intent::RunTarget; use crate::run_summary::AutomationRef; use crate::workflow_version_id::WorkflowVersionId; @@ -69,6 +70,8 @@ pub struct RunSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_version_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub automation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_directory: Option, diff --git a/lib/foundation/fabro-types/src/run_event/run.rs b/lib/foundation/fabro-types/src/run_event/run.rs index 8ef5a361e..5585111bc 100644 --- a/lib/foundation/fabro-types/src/run_event/run.rs +++ b/lib/foundation/fabro-types/src/run_event/run.rs @@ -6,7 +6,7 @@ use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel}; use crate::status::{BlockedReason, PendingReason, SuccessReason}; use crate::{ AutomationRef, BlobHash, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, - RunControlAction, RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings, + RunControlAction, RunFailure, RunId, RunProvenance, RunTarget, RunTiming, WorkflowSettings, WorkflowVersionId, }; @@ -27,6 +27,8 @@ pub struct RunCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_version_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub automation: Option, pub provenance: RunProvenance, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/foundation/fabro-types/src/run_intent.rs b/lib/foundation/fabro-types/src/run_intent.rs new file mode 100644 index 000000000..7ab81e84c --- /dev/null +++ b/lib/foundation/fabro-types/src/run_intent.rs @@ -0,0 +1,49 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{RunId, WorkflowVersionId}; + +/// A request to create a run from an immutable workflow version. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunIntent { + pub workflow_version_id: WorkflowVersionId, + pub target: RunTarget, + pub args: RunIntentArgs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, +} + +/// Structured run-setting overrides accepted by [`RunIntent`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunIntentArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub inputs: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, +} + +/// Requested workspace content, independent of sandbox placement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RunTarget { + Git { + repo: String, + branch: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha: Option, + }, +} diff --git a/lib/foundation/fabro-types/src/test_support.rs b/lib/foundation/fabro-types/src/test_support.rs index c5065cb76..c32a989c3 100644 --- a/lib/foundation/fabro-types/src/test_support.rs +++ b/lib/foundation/fabro-types/src/test_support.rs @@ -45,6 +45,7 @@ pub fn test_run_spec() -> RunSpec { graph_source: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, source_directory: None, labels: HashMap::new(), diff --git a/lib/foundation/fabro-types/tests/run_event_serde.rs b/lib/foundation/fabro-types/tests/run_event_serde.rs index 2645b9ca4..f90b0f70a 100644 --- a/lib/foundation/fabro-types/tests/run_event_serde.rs +++ b/lib/foundation/fabro-types/tests/run_event_serde.rs @@ -7,7 +7,7 @@ use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; use fabro_types::test_support::{test_run_provenance, test_workflow_version_id}; -use fabro_types::{AutomationRef, EventBody, TurnId, WorkflowSettings, fixtures}; +use fabro_types::{AutomationRef, EventBody, RunTarget, TurnId, WorkflowSettings, fixtures}; fn templated_settings() -> WorkflowSettings { let mut settings = WorkflowSettings::default(); @@ -26,6 +26,11 @@ fn run_created_props_round_trip_templated_settings() { source_directory: Some("/Users/client/project".to_string()), workflow_slug: Some("demo".to_string()), workflow_version_id: Some(test_workflow_version_id()), + target: Some(RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "main".to_string(), + sha: None, + }), automation: Some(AutomationRef { id: "nightly".to_string(), name: Some("Nightly".to_string()), @@ -99,6 +104,7 @@ fn run_created_props_omits_web_url_when_absent() { source_directory: None, workflow_slug: None, workflow_version_id: None, + target: None, automation: None, provenance: test_run_provenance(), manifest_blob: None, diff --git a/lib/foundation/fabro-types/tests/run_intent.rs b/lib/foundation/fabro-types/tests/run_intent.rs new file mode 100644 index 000000000..7cdc03d21 --- /dev/null +++ b/lib/foundation/fabro-types/tests/run_intent.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; + +use fabro_types::{ + RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha, +}; +use serde_json::json; + +fn version_id() -> WorkflowVersionId { + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .parse() + .expect("fixture version ID should be valid") +} + +fn intent() -> RunIntent { + RunIntent { + workflow_version_id: version_id(), + target: RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "feature/run-intent".to_string(), + sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()), + }, + args: RunIntentArgs { + model: Some("gpt-5.6".to_string()), + provider: Some("openai".to_string()), + inputs: HashMap::from([ + ("attempts".to_string(), json!(3)), + ("enabled".to_string(), json!(true)), + ]), + labels: HashMap::from([("team".to_string(), "platform".to_string())]), + }, + environment_id: Some("production".to_string()), + parent_id: None, + title: Some("Add RunIntent".to_string()), + goal: Some("Implement the endpoint".to_string()), + } +} + +#[test] +fn run_intent_round_trips_the_strict_git_shape() { + let intent = intent(); + let value = serde_json::to_value(&intent).expect("intent should serialize"); + + assert_eq!(value["target"]["kind"], "git"); + assert_eq!(value["target"]["repo"], "fabro-sh/fabro"); + assert_eq!(value["target"]["branch"], "feature/run-intent"); + assert_eq!( + serde_json::from_value::(value).expect("intent should deserialize"), + intent + ); +} + +#[test] +fn run_intent_rejects_unknown_fields_at_every_object_boundary() { + let value = serde_json::to_value(intent()).expect("intent should serialize"); + + for path in ["root", "args", "target"] { + let mut candidate = value.clone(); + match path { + "root" => candidate["unexpected"] = json!(true), + "args" => candidate["args"]["unexpected"] = json!(true), + "target" => candidate["target"]["unexpected"] = json!(true), + _ => unreachable!(), + } + assert!( + serde_json::from_value::(candidate).is_err(), + "{path} should reject unknown fields" + ); + } +} + +#[test] +fn run_intent_requires_args_and_git_branch() { + let mut missing_args = serde_json::to_value(intent()).expect("intent should serialize"); + missing_args.as_object_mut().unwrap().remove("args"); + assert!(serde_json::from_value::(missing_args).is_err()); + + let mut missing_branch = serde_json::to_value(intent()).expect("intent should serialize"); + missing_branch["target"] + .as_object_mut() + .unwrap() + .remove("branch"); + assert!(serde_json::from_value::(missing_branch).is_err()); +} + +#[test] +fn git_commit_sha_normalization_is_exact_and_pure() { + assert_eq!( + normalize_git_commit_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01"), + Some("abcdef0123456789abcdef0123456789abcdef01".to_string()) + ); + for invalid in [ + "abcdef0123456789abcdef0123456789abcdef0", + "abcdef0123456789abcdef0123456789abcdef012", + "abcdef0123456789abcdef0123456789abcdef0g", + " abcdef0123456789abcdef0123456789abcdef01", + ] { + assert_eq!(normalize_git_commit_sha(invalid), None); + } +} diff --git a/lib/foundation/fabro-types/tests/run_spec_serde.rs b/lib/foundation/fabro-types/tests/run_spec_serde.rs index 49432940c..c2519deaa 100644 --- a/lib/foundation/fabro-types/tests/run_spec_serde.rs +++ b/lib/foundation/fabro-types/tests/run_spec_serde.rs @@ -5,7 +5,7 @@ use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec}; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; use fabro_types::test_support::{test_run_provenance, test_workflow_version_id}; -use fabro_types::{AutomationRef, WorkflowSettings, fixtures}; +use fabro_types::{AutomationRef, RunTarget, WorkflowSettings, fixtures}; fn templated_settings() -> WorkflowSettings { let mut settings = WorkflowSettings::default(); @@ -22,6 +22,11 @@ fn run_spec_round_trips_templated_settings() { graph_source: None, workflow_slug: Some("demo".to_string()), workflow_version_id: Some(test_workflow_version_id()), + target: Some(RunTarget::Git { + repo: "fabro-sh/fabro".to_string(), + branch: "main".to_string(), + sha: Some("abc123".to_string()), + }), automation: Some(AutomationRef { id: "nightly".to_string(), name: Some("Nightly".to_string()), @@ -64,6 +69,7 @@ fn run_spec_round_trips_templated_settings() { json["workflow_version_id"], test_workflow_version_id().to_string() ); + assert_eq!(json["target"]["kind"], "git"); let round_trip: RunSpec = serde_json::from_value(json.clone()).expect("record should deserialize"); @@ -91,6 +97,7 @@ fn run_spec_defaults_automation_for_legacy_specs() { assert_eq!(record.automation, None); assert_eq!(record.workflow_version_id, None); + assert_eq!(record.target, None); let round_trip = serde_json::to_value(&record).expect("record should serialize"); assert!(round_trip.get("workflow_version_id").is_none()); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 4f8d5f871..832e7ff8c 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -103,6 +103,7 @@ models/create-environment-request.ts models/create-mcp-server-request.ts models/create-playground-chat-request.ts models/create-run-pull-request-request.ts +models/create-run-request.ts models/create-run-session-request.ts models/create-secret-request.ts models/create-variable-request.ts @@ -157,6 +158,7 @@ models/fork-response.ts models/fork-source-ref.ts models/git-author-settings.ts models/git-context.ts +models/git-run-target.ts models/github-integration-settings.ts models/github-integration-strategy.ts models/health-response.ts @@ -373,6 +375,9 @@ models/run-goal-inline.ts models/run-goal.ts models/run-integrations-github-settings.ts models/run-integrations-settings.ts +models/run-intent-args-inputs-value.ts +models/run-intent-args.ts +models/run-intent.ts models/run-interviews-settings.ts models/run-lifecycle.ts models/run-links.ts diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index f61c0fe8f..adb629b32 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -36,6 +36,8 @@ import type { CloseRunPullRequestResponse } from '../models'; // @ts-ignore import type { CreateRunPullRequestRequest } from '../models'; // @ts-ignore +import type { CreateRunRequest } from '../models'; +// @ts-ignore import type { DeleteRunResponse } from '../models'; // @ts-ignore import type { DenyRunRequest } from '../models'; @@ -370,15 +372,15 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Creates a new workflow run in `submitted` status from a self-contained manifest. + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. * @summary Create Run - * @param {RunManifest} runManifest + * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createRun: async (runManifest: RunManifest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'runManifest' is not null or undefined - assertParamExists('createRun', 'runManifest', runManifest) + createRun: async (createRunRequest: CreateRunRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'createRunRequest' is not null or undefined + assertParamExists('createRun', 'createRunRequest', createRunRequest) const localVarPath = `/api/v1/runs`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -403,7 +405,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(runManifest, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(createRunRequest, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -1675,14 +1677,14 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Creates a new workflow run in `submitted` status from a self-contained manifest. + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. * @summary Create Run - * @param {RunManifest} runManifest + * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createRun(runManifest, options); + async createRun(createRunRequest: CreateRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createRun(createRunRequest, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.createRun']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -2135,14 +2137,14 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.closeRunPullRequest(id, options).then((request) => request(axios, basePath)); }, /** - * Creates a new workflow run in `submitted` status from a self-contained manifest. + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. * @summary Create Run - * @param {RunManifest} runManifest + * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.createRun(runManifest, options).then((request) => request(axios, basePath)); + createRun(createRunRequest: CreateRunRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createRun(createRunRequest, options).then((request) => request(axios, basePath)); }, /** * Durably requests creation of a pull request for a completed run. The server generates the pull request content and creates the GitHub pull request after this request returns. Poll the URL in the Location response header until the creation succeeds or fails. If a creation is already pending for the run, the response returns that creation unchanged; any different `model` or `force` values in the new request are ignored. @@ -2516,14 +2518,14 @@ export class RunsApi extends BaseAPI { } /** - * Creates a new workflow run in `submitted` status from a self-contained manifest. + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. * @summary Create Run - * @param {RunManifest} runManifest + * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig) { - return RunsApiFp(this.configuration).createRun(runManifest, options).then((request) => request(this.axios, this.basePath)); + public createRun(createRunRequest: CreateRunRequest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).createRun(createRunRequest, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/lib/packages/fabro-api-client/src/models/create-run-request.ts b/lib/packages/fabro-api-client/src/models/create-run-request.ts new file mode 100644 index 000000000..d362f85f0 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/create-run-request.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { GitContext } from './git-context'; +// May contain unused imports in some cases +// @ts-ignore +import type { GitRunTarget } from './git-run-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestConfig } from './manifest-config'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestWorkflow } from './manifest-workflow'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunIntent } from './run-intent'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunIntentArgs } from './run-intent-args'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunManifest } from './run-manifest'; + +/** + * @type CreateRunRequest + * Transitional create body used while callers migrate independently from self-contained manifests to immutable workflow-version intents. + */ +export type CreateRunRequest = RunIntent | RunManifest; diff --git a/lib/packages/fabro-api-client/src/models/git-run-target.ts b/lib/packages/fabro-api-client/src/models/git-run-target.ts new file mode 100644 index 000000000..4805feff4 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/git-run-target.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Public github.com repository target. + */ +export interface GitRunTarget { + 'kind': GitRunTargetKindEnum; + /** + * GitHub repository slug in `owner/name` form. + */ + 'repo': string; + /** + * Required branch name, preserved exactly. + */ + 'branch': string; + /** + * Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it or prove branch ancestry. + */ + 'sha'?: string; +} + +export const GitRunTargetKindEnum = { + GIT: 'git' +} as const; + +export type GitRunTargetKindEnum = typeof GitRunTargetKindEnum[keyof typeof GitRunTargetKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 04df237be..5d05c73d9 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -73,6 +73,7 @@ export * from './create-environment-request'; export * from './create-mcp-server-request'; export * from './create-playground-chat-request'; export * from './create-run-pull-request-request'; +export * from './create-run-request'; export * from './create-run-session-request'; export * from './create-secret-request'; export * from './create-variable-request'; @@ -127,6 +128,7 @@ export * from './fork-response'; export * from './fork-source-ref'; export * from './git-author-settings'; export * from './git-context'; +export * from './git-run-target'; export * from './github-integration-settings'; export * from './github-integration-strategy'; export * from './health-response'; @@ -343,6 +345,9 @@ export * from './run-goal-file'; export * from './run-goal-inline'; export * from './run-integrations-github-settings'; export * from './run-integrations-settings'; +export * from './run-intent'; +export * from './run-intent-args'; +export * from './run-intent-args-inputs-value'; export * from './run-interviews-settings'; export * from './run-lifecycle'; export * from './run-links'; diff --git a/lib/packages/fabro-api-client/src/models/run-intent-args-inputs-value.ts b/lib/packages/fabro-api-client/src/models/run-intent-args-inputs-value.ts new file mode 100644 index 000000000..3bbba83f5 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-intent-args-inputs-value.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface RunIntentArgsInputsValue { +} diff --git a/lib/packages/fabro-api-client/src/models/run-intent-args.ts b/lib/packages/fabro-api-client/src/models/run-intent-args.ts new file mode 100644 index 000000000..012ef85a6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-intent-args.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { RunIntentArgsInputsValue } from './run-intent-args-inputs-value'; + +/** + * Structured run overrides accepted by workflow-version creation. + */ +export interface RunIntentArgs { + 'model'?: string; + /** + * LLM provider; this does not select the sandbox environment. + */ + 'provider'?: string; + 'inputs'?: { [key: string]: RunIntentArgsInputsValue; }; + 'labels'?: { [key: string]: string; }; +} diff --git a/lib/packages/fabro-api-client/src/models/run-intent.ts b/lib/packages/fabro-api-client/src/models/run-intent.ts new file mode 100644 index 000000000..3cc1637cb --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-intent.ts @@ -0,0 +1,49 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { GitRunTarget } from './git-run-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunIntentArgs } from './run-intent-args'; + +/** + * A request to create, but not start, one run from an immutable workflow version and an explicit workspace target. + */ +export interface RunIntent { + /** + * SHA-256 identity of validated canonical workflow-version bytes. Hex input is case-insensitive; Fabro emits the canonical lowercase form. + */ + 'workflow_version_id': string; + 'target': GitRunTarget; + 'args': RunIntentArgs; + /** + * Server environment catalog ID. Omission selects `default`. + */ + 'environment_id'?: string; + /** + * Optional orchestration parent run ID. + */ + 'parent_id'?: string; + /** + * Optional explicit run title, normalized by the server. + */ + 'title'?: string; + /** + * Optional inline goal override. + */ + 'goal'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/run-spec.ts b/lib/packages/fabro-api-client/src/models/run-spec.ts index a9e41bec6..b05916427 100644 --- a/lib/packages/fabro-api-client/src/models/run-spec.ts +++ b/lib/packages/fabro-api-client/src/models/run-spec.ts @@ -24,6 +24,9 @@ import type { ForkSourceRef } from './fork-source-ref'; import type { GitContext } from './git-context'; // May contain unused imports in some cases // @ts-ignore +import type { GitRunTarget } from './git-run-target'; +// May contain unused imports in some cases +// @ts-ignore import type { RunProvenance } from './run-provenance'; // May contain unused imports in some cases // @ts-ignore @@ -42,6 +45,7 @@ export interface RunSpec { * SHA-256 identity of validated canonical workflow-version bytes. Hex input is case-insensitive; Fabro emits the canonical lowercase form. */ 'workflow_version_id'?: string | null; + 'target'?: GitRunTarget | null; 'automation'?: AutomationRef | null; 'source_directory'?: string | null; 'labels'?: { [key: string]: string; };