Make run actors and provenance total; remove Principal::Anonymous (#463)

## Summary

Removes `Principal::Anonymous` as an actor variant and makes run
creator/provenance non-optional across the full stack — Rust types,
OpenAPI schema, generated TypeScript client, and frontend components.
Every persisted run now has a mandatory creator; there are no nullable
`created_by`, `provenance`, or `subject` fields anywhere in the system.

As a separate cleanup, all `cargo` invocations in CI and dev tooling
gain `--locked` to prevent unintended dependency resolution drift.

### Plan Summary

- **Commit 1 – Remove `Principal::Anonymous`**:
`RequestAuthContext.principal` becomes `Option<Principal>`; `initial()`
and `rejected()` set `None`; all auth gate helpers match on
`Option<Principal>`; HTTP log computes `principal_kind` as
`principal.as_ref().map_or("none", Principal::kind)`;
`PrincipalAnonymous` removed from the OpenAPI schema and generated
client; frontend `principalDisplay` drops the `"anonymous"` case.

- **Commit 2 – Total provenance**: `RunProvenance.subject`,
`RunSpec.provenance`, `Run.created_by`, and `RunCreatedProps.provenance`
all become non-optional. `Default` derive dropped from `RunProvenance`.
Demo mode gets a synthetic `DEMO_PRINCIPAL` via `AuthMethod::DevToken`.
A new `fabro-types` `test-support` feature exports `test_principal()`
and `test_run_provenance()` used across all affected crates and TS
tests. All `provenance: None` / `created_by: null` literals in tests are
replaced with the test fixture.

- **`--locked` CI hardening**: Every `cargo` invocation in GitHub
Actions workflows and `fabro-dev` tooling commands now passes
`--locked`.

### Key design decisions

- `None` principal (unauthenticated / rejected requests) is now
structurally distinct from any actor variant — no sentinel value that
could accidentally pass actor checks.
- `RunProvenance` no longer implements `Default` because a provenance
with no subject is incoherent; callers must supply a real actor at
construction time.
- The `cancel_run` handler was refactored as part of this change to
correctly handle in-process blocked runs: a new test
(`cancel_durably_blocked_in_process_run_cancels_pending_interview_without_abort_signal`)
covers the cancellation path that should let the workflow unwind rather
than aborting it.
- Test fixtures live under the existing `test-support` feature flag
pattern; no fake-auth helpers were added to the `fixtures` module.


### Fabro Details

<details>
<summary>Ran 9 stages in 129m 57s for $53.54</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 10s | – | 0 |
| preflight_lint | 2m 22s | – | 0 |
| implement | 76m 55s | $47.53 | 0 |
| simplify_opus | 14m 46s | $3.21 | 0 |
| simplify_gpt | 2m 26s | $1.04 | 0 |
| verify | 14m 44s | – | 0 |
| fixup | 15m 43s | $1.76 | 0 |
| **Total** | **129m 57s** | **$53.54** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <19+brynary@users.noreply.github.com>
Co-authored-by: Release Repro <release-repro@example.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-31 11:18:01 -04:00 • committed by GitHub
parent c4dbbb4c27
commit 59a4afa188
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 577 additions and 431 deletions

View file

@ -67,4 +67,4 @@ jobs:
git remote set-url origin \
"https://x-access-token:${release_token}@github.com/${GITHUB_REPOSITORY}.git"
unset release_token
cargo dev release --nightly
cargo --locked dev release --nightly

View file

@ -84,7 +84,7 @@ jobs:
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- name: Refresh embedded SPA
run: cargo dev spa refresh
run: cargo --locked dev spa refresh
- name: Test (x86_64-musl)
# nextest still shells through cargo test for this target, so
@ -93,22 +93,22 @@ jobs:
env:
CC_x86_64_unknown_linux_musl: musl-gcc
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
run: cargo nextest run --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci
run: cargo nextest run --locked --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci
- name: Test
# aarch64-musl test runs have not been validated on the compile
# runner yet; shipping binary is exercised via Docker smoke tests.
# Re-enable after verifying the workspace passes on this target.
if: matrix.target != 'aarch64-unknown-linux-musl' && matrix.target != 'x86_64-unknown-linux-musl'
run: cargo nextest run --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci
run: cargo nextest run --locked --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci
- name: Build (musl via cargo-zigbuild)
if: matrix.musl
run: cargo zigbuild --target ${{ matrix.target }} --release -p fabro-cli
run: cargo zigbuild --locked --target ${{ matrix.target }} --release -p fabro-cli
- name: Build
if: ${{ !matrix.musl }}
run: cargo build --target ${{ matrix.target }} --release -p fabro-cli
run: cargo build --locked --target ${{ matrix.target }} --release -p fabro-cli
- name: Package
run: |

View file

@ -76,7 +76,7 @@ jobs:
run: |
! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*"disabled"' \
lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml
- run: cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
- run: cargo +nightly-2026-04-14 clippy --locked --workspace --all-targets -- -D warnings
generated-docs:
name: Generated Docs
@ -91,7 +91,7 @@ jobs:
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- run: cargo dev docs check
- run: cargo --locked dev docs check
test:
name: Test (Linux)
@ -107,7 +107,7 @@ jobs:
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: cargo nextest run --workspace --status-level slow --profile ci
- run: cargo nextest run --locked --workspace --status-level slow --profile ci
test-macos:
name: Test (macOS)
@ -124,4 +124,4 @@ jobs:
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: cargo nextest run --workspace --status-level slow --profile ci
- run: cargo nextest run --locked --workspace --status-level slow --profile ci

View file

@ -73,5 +73,5 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- run: cargo dev build -- -p fabro-cli --release
- run: cargo --locked dev build -- --locked -p fabro-cli --release
- run: wc -c < target/release/fabro

1
Cargo.lock generated
View file

@ -2609,6 +2609,7 @@ dependencies = [
"clap",
"dirs",
"fabro-model",
"fabro-types",
"fabro-util",
"hex",
"serde",

View file

@ -6,6 +6,7 @@ import {
RunSummaryPanelView,
type RunSummaryPanelViewProps,
} from "./run-summary-panel";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
function instanceText(instance: TestRenderer.ReactTestInstance): string {
const parts: string[] = [];
@ -53,7 +54,7 @@ function cellAfterLabel(
function makeRun(overrides: Record<string, any> = {}) {
return {
id: "run_1",
created_by: null,
created_by: TEST_PRINCIPAL,
diff: null,
billing: null,
...overrides,
@ -71,9 +72,9 @@ describe("RunSummaryPanelView", () => {
}
});
test("shows unavailable copy for missing run fields after load", () => {
test("shows creator and unavailable copy for optional missing run fields after load", () => {
const tree = render({ run: makeRun() });
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe(EMPTY_VALUE);
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Ttest");
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(EMPTY_VALUE);
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe(EMPTY_VALUE);
});
@ -226,7 +227,7 @@ describe("RunSummaryPanelView", () => {
kind: "user",
identity: { issuer: "github", subject: "1" },
login: "brynary",
auth_method: "oauth",
auth_method: "github",
},
}),
});
@ -240,7 +241,7 @@ describe("RunSummaryPanelView", () => {
kind: "user",
identity: { issuer: "github", subject: "1" },
login: "brynary",
auth_method: "oauth",
auth_method: "github",
avatar_url: "https://example.com/brynary.png",
},
}),
@ -252,7 +253,7 @@ describe("RunSummaryPanelView", () => {
});
test("renders non-user actor with kind label", () => {
for (const kind of ["agent", "system", "slack", "webhook", "worker", "anonymous"]) {
for (const kind of ["agent", "system", "slack", "webhook", "worker"]) {
const tree = render({ run: makeRun({ created_by: { kind } as any }) });
expect(instanceText(cellAfterLabel(tree, "Created by"))).toContain(kind);
}

View file

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type {
Principal,
Run,
SandboxResources,
SandboxState,
@ -50,6 +51,16 @@ function Cell({ label, children }: { label: string; children: ReactNode }) {
);
}
function CreatedByValue({ actor }: { actor: Principal }) {
const created = principalDisplay(actor);
return (
<div className="flex items-center gap-2">
{created.glyph}
<span className={VALUE_CLASS}>{created.label}</span>
</div>
);
}
export interface RunSummaryPanelViewProps {
run: Run | null;
runLoading: boolean;
@ -116,7 +127,6 @@ export function RunSummaryPanelView({
artifactsCount,
artifactsLoading,
}: RunSummaryPanelViewProps) {
const created = run?.created_by ? principalDisplay(run.created_by) : null;
const diff = run?.diff ?? null;
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
const sandboxKind = sandboxLifecycleKind(run?.sandbox);
@ -127,11 +137,8 @@ export function RunSummaryPanelView({
<Cell label="Created by">
{runLoading ? (
<Skeleton widthClass="w-20" />
) : created ? (
<div className="flex items-center gap-2">
{created.glyph}
<span className={VALUE_CLASS}>{created.label}</span>
</div>
) : run ? (
<CreatedByValue actor={run.created_by} />
) : (
<EmptyValue />
)}

View file

@ -35,6 +35,7 @@ export function RunTableRow({
}) {
const lifecycleLabel = listLifecycleStatusLabel(run);
const statusDisplay = columnStatusDisplay[run.status];
const creator = principalDisplay(run.createdBy);
const show = (col: ToggleableColumn) => !hiddenColumns.has(col);
return (
@ -54,14 +55,9 @@ export function RunTableRow({
</td>
{show("created_by") && (
<td className="relative z-10 w-8 whitespace-nowrap px-3 py-2.5">
{run.createdBy && (() => {
const display = principalDisplay(run.createdBy);
return (
<Tooltip label={display.label}>
<span aria-label={`Created by ${display.label}`}>{display.glyph}</span>
</Tooltip>
);
})()}
<Tooltip label={creator.label}>
<span aria-label={`Created by ${creator.label}`}>{creator.glyph}</span>
</Tooltip>
</td>
)}
{show("repo") && (

View file

@ -8,6 +8,7 @@ import {
mapRunToRunItem,
runStatusDisplay,
} from "./runs";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
function makeRun(overrides: Partial<Run> = {}): Run {
return {
@ -17,7 +18,7 @@ function makeRun(overrides: Partial<Run> = {}): Run {
workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild", node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "myrepo", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -41,7 +41,7 @@ export interface RunItem {
sandboxWorkingDirectory?: string;
sourceDirectory?: string;
createdAt?: string;
createdBy?: Principal | null;
createdBy: Principal;
lastEventAt?: string;
size?: RunSize;
}

View file

@ -4,7 +4,6 @@ import {
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
CpuChipIcon,
QuestionMarkCircleIcon,
ServerIcon,
} from "@heroicons/react/20/solid";
import type { Principal } from "@qltysh/fabro-api-client";
@ -57,10 +56,5 @@ export function principalDisplay(actor: Principal): PrincipalDisplay {
return { glyph: principalIconGlyph(<BoltIcon className="size-3" />), label: "webhook" };
case "worker":
return { glyph: principalIconGlyph(<ServerIcon className="size-3" />), label: "worker" };
case "anonymous":
return {
glyph: principalIconGlyph(<QuestionMarkCircleIcon className="size-3" />),
label: "anonymous",
};
}
}

View file

@ -24,6 +24,7 @@ import {
unarchiveRuns,
} from "./run-actions";
import { generatedAxios } from "./api-client";
import { TEST_PRINCIPAL } from "./test-fixtures";
type StubResponseInit = {
status: number;
@ -47,7 +48,7 @@ function makeRun(status: RunStatus, archived = false): Run {
workflow: { slug: "fix_build", name: "Fix Build", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: null,
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -0,0 +1,8 @@
import type { Principal } from "@qltysh/fabro-api-client";
export const TEST_PRINCIPAL: Principal = {
kind: "user",
identity: { issuer: "fabro:test", subject: "test-user" },
login: "test",
auth_method: "dev_token",
};

View file

@ -4,6 +4,7 @@ import TestRenderer, { act } from "react-test-renderer";
import { createMemoryRouter, RouterProvider } from "react-router";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { setupReactTestEnv } from "../lib/test-utils";
let currentRun: any = null;
@ -120,7 +121,7 @@ function makeRun(overrides: Record<string, unknown> = {}) {
origin_url: "https://github.com/fallback/repo.git",
provider: "github",
},
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -115,7 +115,7 @@ function AutomationCard({
onDelete: () => void;
}) {
const Icon = automation.icon;
const runDisabled = busy || !automation.apiEnabled;
const runDisabled = busy || running || !automation.apiEnabled;
return (
<div className="group flex items-center gap-4 rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
<Link to={`/automations/${automation.id}`} className="flex min-w-0 flex-1 items-center gap-4">
@ -153,7 +153,7 @@ function AutomationCard({
<button
type="button"
onClick={onRun}
disabled={running || runDisabled}
disabled={runDisabled}
aria-label={running ? "Starting run…" : "Run automation"}
title={
running

View file

@ -13,6 +13,7 @@ import {
import { ToastProvider } from "../components/toast";
import { DemoModeProvider } from "../lib/demo-mode";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
let currentRunSummary: any = null;
let currentRunState: any = null;
@ -221,7 +222,7 @@ function makeRunSummary({
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
automation,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router";
import { toast as sonnerToast } from "sonner";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
let currentFilesPayload: any = null;
let currentCommitsPayload: any = null;
@ -51,7 +52,7 @@ mock.module("../lib/queries", () => ({
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -5,6 +5,7 @@ import type { PaginatedRunList, Run } from "@qltysh/fabro-api-client";
import { ToastProvider } from "../components/toast";
import { CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY } from "../components/runs-list/preferences";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { setupReactTestEnv } from "../lib/test-utils";
class MemoryStorage {
@ -35,7 +36,7 @@ function run(id: string, repo = "qlty/fabro", workflow = "release"): Run {
workflow: { slug: workflow, name: workflow, graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: repo, origin_url: null, provider: "github" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -11,6 +11,7 @@ import {
shouldRefreshBoardForEvent,
} from "./runs";
import { summarizeBatchLifecycleAction } from "../components/runs-list/batch-lifecycle";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
const status =
@ -34,7 +35,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
workflow: { slug: "test", name: "Test", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "repo", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -80,7 +80,10 @@ Important rules:
- Optional envelope fields are omitted, not serialized as `null`.
- Event-specific fields do not get flattened into the top level.
- Actor identity lives only in top-level `actor: Principal`; never duplicate it in event-specific properties.
- Actor identity normally lives only in top-level `actor: Principal`; do not duplicate it in
event-specific properties. The exception is `run.created`, whose
`properties.provenance.subject` is the durable run creator stored in `RunSpec`; its envelope
`actor` is derived from the same principal.
- User actors must carry canonical IdP identity through `Principal::User { identity, login, auth_method }`, not a login-only string.
- `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`.

View file

@ -58,7 +58,18 @@ Emitted when the run record is created.
}
},
"fork_source_ref": null,
"in_place": false
"in_place": false,
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "octocat",
"auth_method": "github"
}
}
}
}
```
@ -76,7 +87,7 @@ Emitted when the run record is created.
| `base_branch` | string? | Submitter-side base branch |
| `workflow_slug` | string? | Workflow slug |
| `db_prefix` | string? | Store prefix used for the run |
| `provenance` | object? | Actor and request provenance |
| `provenance` | object | Actor and request provenance |
| `manifest_blob` | string? | Blob id for the submitted manifest |
| `pre_run_git` | object? | Submitter-side pre-run git context and push outcome |
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |

View file

@ -118,11 +118,11 @@ Fields are key-value pairs that make events queryable. Include enough context th
| `error` | Error value on failure |
| `path` | File system path |
| `duration_ms` | Elapsed time in milliseconds |
| `principal_kind` | HTTP caller category (`user`, `worker`, `webhook`, `anonymous`, etc.) |
| `principal_kind` | HTTP caller category (`user`, `worker`, `webhook`, `none`, etc.) |
| `auth_status` | HTTP authentication result (`missing`, `invalid`, `expired`, `authenticated`) |
| `idp_issuer`, `idp_subject` | Canonical user identity for authenticated user requests |
For HTTP request logs, use the request `Principal` projection rather than hand-assembled auth strings. User identity fields are present only for `Principal::User`; worker and webhook requests use their variant-specific fields (`run_id`, `delivery_id`).
For HTTP request logs, use the request `Principal` projection rather than hand-assembled auth strings. User identity fields are present only for `Principal::User`; worker and webhook requests use their variant-specific fields (`run_id`, `delivery_id`). Requests without a principal use `principal_kind="none"`; `auth_status` distinguishes missing, invalid, expired, and authenticated auth state.
Server auth intentionally exposes a mutable `RequestAuth` context slot for public auth routes and guard extractors such as `RequiredUser` / `RequireRunScoped` for protected routes. There is no loose `RequestPrincipal` extractor; route-facing extractors should enforce the route's auth contract while the slot supplies the final HTTP log fields.
| `input_tokens` | Token count for LLM input |

View file

@ -8988,6 +8988,8 @@ components:
RunProvenance:
type: object
required:
- subject
properties:
server:
oneOf:
@ -8998,9 +9000,7 @@ components:
- $ref: "#/components/schemas/RunClientProvenance"
- type: "null"
subject:
oneOf:
- $ref: "#/components/schemas/Principal"
- type: "null"
$ref: "#/components/schemas/Principal"
Principal:
oneOf:
@ -9010,7 +9010,6 @@ components:
- $ref: "#/components/schemas/PrincipalSlack"
- $ref: "#/components/schemas/PrincipalAgent"
- $ref: "#/components/schemas/PrincipalSystem"
- $ref: "#/components/schemas/PrincipalAnonymous"
discriminator:
propertyName: kind
mapping:
@ -9020,7 +9019,6 @@ components:
slack: "#/components/schemas/PrincipalSlack"
agent: "#/components/schemas/PrincipalAgent"
system: "#/components/schemas/PrincipalSystem"
anonymous: "#/components/schemas/PrincipalAnonymous"
PrincipalUser:
type: object
@ -9110,15 +9108,6 @@ components:
system_kind:
$ref: "#/components/schemas/SystemActorKind"
PrincipalAnonymous:
type: object
required:
- kind
properties:
kind:
type: string
enum: [anonymous]
RunEvent:
description: >
Internal RunEvent-compatible JSON payload. The server validates this
@ -10246,6 +10235,7 @@ components:
- run_id
- settings
- graph
- provenance
properties:
run_id:
type: string
@ -10269,9 +10259,7 @@ components:
additionalProperties:
type: string
provenance:
oneOf:
- $ref: "#/components/schemas/RunProvenance"
- type: "null"
$ref: "#/components/schemas/RunProvenance"
manifest_blob:
type: ["string", "null"]
definition_blob:
@ -10583,9 +10571,7 @@ components:
- $ref: "#/components/schemas/RepositoryRef"
- type: "null"
created_by:
oneOf:
- $ref: "#/components/schemas/Principal"
- type: "null"
$ref: "#/components/schemas/Principal"
origin:
$ref: "#/components/schemas/RunOrigin"
labels:

View file

@ -11,7 +11,7 @@ The dock listens to interview events and refreshes as questions arrive, so a par
## Principal attribution and auth routing
Run events and run creation now carry clearer principal information for users, workers, systems, Slack interactions, webhooks, agents, and anonymous actors. API clients get explicit provenance objects instead of older actor-shaped fields that could lose where a run came from.
Run events and run creation now carry clearer principal information for users, workers, systems, Slack interactions, webhooks, and agents. API clients get explicit provenance objects instead of older actor-shaped fields that could lose where a run came from.
This also closes attribution gaps across web, CLI, worker-token, Slack, and human-interview paths. Runs created or advanced through different surfaces now preserve who or what took the action more consistently.
@ -19,7 +19,7 @@ This also closes attribution gaps across web, CLI, worker-token, Slack, and huma
<Accordion title="API">
- Run specs now include client and server provenance shapes
- Run events use unified principal shapes for user, worker, system, Slack, webhook, agent, and anonymous subjects
- Run events use unified principal shapes for user, worker, system, Slack, webhook, and agent subjects
</Accordion>
<Accordion title="CLI">

View file

@ -34,3 +34,6 @@ serde_json = "1"
serde_yaml = "0.9"
prettyplease = "0.2"
syn = "2"
[dev-dependencies]
fabro-types = { path = "../fabro-types", features = ["test-support"] }

View file

@ -118,7 +118,6 @@ fn principal_round_trips_every_variant_through_api_type() {
Principal::System {
system_kind: SystemActorKind::Watchdog,
},
Principal::Anonymous,
];
for principal in variants {
@ -140,9 +139,9 @@ fn run_provenance_subject_round_trips_as_principal() {
name: Some("fabro-cli".to_string()),
version: Some("0.1.0".to_string()),
}),
subject: Some(Principal::Worker {
subject: Principal::Worker {
run_id: fixtures::RUN_1,
}),
},
};
let json = serde_json::to_value(&provenance).unwrap();

View file

@ -1,7 +1,7 @@
use std::any::{TypeId, type_name};
use fabro_api::types::RunEvent as ApiRunEvent;
use fabro_types::{Graph, RunEvent, WorkflowSettings, fixtures};
use fabro_types::{Graph, RunEvent, WorkflowSettings, fixtures, test_support};
use serde_json::{Value, json};
#[test]
@ -20,7 +20,8 @@ fn run_event_round_trips_run_created() {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/fabro/run-1",
"source_directory": "/tmp/fabro/run-1"
"source_directory": "/tmp/fabro/run-1",
"provenance": test_support::test_run_provenance()
}
});
@ -39,7 +40,8 @@ fn run_event_round_trips_run_created_with_web_url() {
"graph": Graph::new("test"),
"run_dir": "/tmp/fabro/run-1",
"source_directory": "/tmp/fabro/run-1",
"web_url": format!("http://localhost:3000/runs/{}", fixtures::RUN_1)
"web_url": format!("http://localhost:3000/runs/{}", fixtures::RUN_1),
"provenance": test_support::test_run_provenance()
}
});

View file

@ -1,9 +1,8 @@
use std::any::{TypeId, type_name};
use fabro_api::types::RunProjection as ApiRunProjection;
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings};
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, test_support};
use serde_json::json;
#[test]
fn run_projection_reuses_canonical_type() {
assert_same_type::<ApiRunProjection, RunProjection>();
@ -137,7 +136,7 @@ fn run_spec_json() -> serde_json::Value {
automation: None,
source_directory: None,
labels: std::collections::HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -12,7 +12,7 @@ use fabro_types::{
AskFabro, AskFabroUnavailableReason, AutomationRef, DiffSummary, PullRequestLink,
RepositoryProvider, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary,
RunId, RunLifecycle, RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming,
WorkflowRef, fixtures,
WorkflowRef, fixtures, test_support,
};
use serde_json::json;
@ -88,7 +88,7 @@ fn run_summary_json_matches_openapi_shape() {
origin_url: None,
provider: RepositoryProvider::Unknown,
}),
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::from([("team".to_string(), "core".to_string())]),
lifecycle: RunLifecycle {
@ -161,7 +161,15 @@ fn run_summary_json_matches_openapi_shape() {
"origin_url": null,
"provider": "unknown"
},
"created_by": null,
"created_by": {
"kind": "user",
"identity": {
"issuer": "fabro:test",
"subject": "test-user"
},
"login": "test",
"auth_method": "dev_token"
},
"origin": {
"kind": "api"
},
@ -253,6 +261,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
"origin_url": null,
"provider": "unknown"
},
"created_by": test_support::test_principal(),
"models": [],
"timestamps": {
"created_at": "2026-04-20T12:00:00Z",
@ -275,6 +284,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.workflow.edge_count, 0);
assert_eq!(summary.goal, "ship it");
assert_eq!(summary.title, "ship it");
assert_eq!(summary.created_by, test_support::test_principal());
assert_eq!(summary.labels, HashMap::new());
assert_eq!(summary.source_directory, None);
assert_eq!(

View file

@ -119,6 +119,7 @@ assert_cmd = "2"
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-build-support = { path = "../build-support" }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
fabro-types = { path = "../fabro-types", features = ["clap", "test-support"] }
insta = { workspace = true, features = ["filters"] }
paste = "1"
predicates = "3"

View file

@ -822,6 +822,7 @@ mod tests {
)]
use fabro_interview::{Answer, AnswerValue};
use fabro_types::test_support;
use fabro_util::terminal::Styles;
use httpmock::MockServer;
@ -841,7 +842,7 @@ mod tests {
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -221,7 +221,18 @@ fn inspect_resolves_selector_via_server_endpoint() {
"attrs": {}
},
"workflow_slug": "remote-workflow",
"source_directory": "/srv/repo"
"source_directory": "/srv/repo",
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "fabro:test",
"subject": "test-user"
},
"login": "test",
"auth_method": "dev_token"
}
}
},
"start_record": null,
"conclusion": null,

View file

@ -21,6 +21,7 @@ use fabro_config::daemon::ServerDaemon;
use fabro_config::{Storage, envfile};
use fabro_store::EventEnvelope;
use fabro_test::{TestContext, expect_reqwest_status};
use fabro_types::test_support::test_principal;
use fabro_types::{RunId, StageId};
use httpmock::{Mock, MockServer};
use serde_json::Value;
@ -177,6 +178,8 @@ pub(crate) fn remote_run_summary_json(
"origin_url": null,
"provider": "unknown"
},
"created_by": serde_json::to_value(test_principal())
.expect("test principal should serialize"),
"origin": {
"kind": "api"
},

View file

@ -1,3 +1,4 @@
use fabro_types::test_support;
mod auth_harness;
mod auth_tokens;
@ -49,7 +50,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
automation: None,
source_directory: Some("/srv/repo".to_string()),
labels: std::collections::HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -148,6 +148,7 @@ impl DockerBuildPlan {
fn spa_refresh_command() -> PlannedCommand {
PlannedCommand::new("cargo")
.arg("--locked")
.arg("dev")
.arg("spa")
.arg("refresh")
@ -235,6 +236,6 @@ fn build_script(target: &str, zig_arch: &str) -> String {
cargo install --locked --root /opt/cargo-tools cargo-zigbuild; \
fi; \
rustup target add {target}; \
cargo zigbuild --release -p fabro-cli --target {target}"
cargo zigbuild --locked --release -p fabro-cli --target {target}"
)
}

View file

@ -44,6 +44,7 @@ pub(crate) fn docs_cli_reference_root(root: &Path, check: bool) -> Result<()> {
fn render_cli_reference() -> Result<String> {
let command = PlannedCommand::new("cargo")
.arg("run")
.arg("--locked")
.arg("-p")
.arg("fabro-cli")
.arg("--")

View file

@ -237,6 +237,7 @@ impl ReleasePlan {
fn spa_refresh_command() -> PlannedCommand {
PlannedCommand::new("cargo")
.arg("--locked")
.arg("dev")
.arg("spa")
.arg("refresh")
@ -249,6 +250,7 @@ impl ReleasePlan {
.env("SEGMENT_WRITE_KEY", RELEASE_TEST_SEGMENT_WRITE_KEY)
.arg("nextest")
.arg("run")
.arg("--locked")
.arg("--workspace")
.arg("--release")
.arg("--profile")

View file

@ -53,7 +53,7 @@ fn dry_run_prints_equivalent_build_commands() {
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("cargo dev spa refresh"),
stdout.contains("cargo --locked dev spa refresh"),
"dry-run should print SPA refresh command:\n{stdout}"
);
assert!(
@ -61,7 +61,9 @@ fn dry_run_prints_equivalent_build_commands() {
"dry-run should print builder docker run:\n{stdout}"
);
assert!(
stdout.contains("cargo zigbuild --release -p fabro-cli --target x86_64-unknown-linux-musl"),
stdout.contains(
"cargo zigbuild --locked --release -p fabro-cli --target x86_64-unknown-linux-musl"
),
"dry-run should print cargo-zigbuild target:\n{stdout}"
);
assert!(

View file

@ -96,7 +96,7 @@ fn dry_run_computes_stable_version_from_date() {
"dry-run should compute base version from date:\n{stdout}"
);
assert!(
stdout.contains("cargo dev spa refresh"),
stdout.contains("cargo --locked dev spa refresh"),
"dry-run should print one SPA refresh command:\n{stdout}"
);
assert!(
@ -109,7 +109,7 @@ fn dry_run_computes_stable_version_from_date() {
);
assert!(
stdout.contains(
"unset GH_TOKEN GITHUB_TOKEN && SEGMENT_WRITE_KEY=fake-for-local-smoke cargo nextest run"
"unset GH_TOKEN GITHUB_TOKEN && SEGMENT_WRITE_KEY=fake-for-local-smoke cargo nextest run --locked"
),
"dry-run should show release tests without inherited GitHub tokens:\n{stdout}"
);

View file

@ -476,6 +476,7 @@ mod tests {
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance,
RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
test_support,
};
use futures::executor;
@ -498,7 +499,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,

View file

@ -1671,11 +1671,11 @@ client_id = "github-client-id"
let [first, second, third] = <[RequestAuthContext; 3]>::try_from(contexts)
.expect("expected three captured auth contexts");
assert_eq!(first.auth_status, AuthStatus::Authenticated);
assert_eq!(first.principal.display(), "octocat");
assert_eq!(first.principal.expect("principal").display(), "octocat");
assert_eq!(second.auth_status, AuthStatus::Authenticated);
assert_eq!(second.principal.display(), "octocat");
assert_eq!(second.principal.expect("principal").display(), "octocat");
assert_eq!(third.auth_status, AuthStatus::Authenticated);
assert_eq!(third.principal.display(), "octocat");
assert_eq!(third.principal.expect("principal").display(), "octocat");
}
#[tokio::test]
@ -2080,7 +2080,10 @@ client_id = "github-client-id"
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert_eq!(contexts[0].principal.display(), "octocat");
assert_eq!(
contexts[0].principal.as_ref().expect("principal").display(),
"octocat"
);
assert_eq!(contexts[1].auth_status, AuthStatus::Invalid);
assert_eq!(
contexts[1].auth_error_code,
@ -2273,8 +2276,11 @@ client_id = "github-client-id"
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert_eq!(contexts[0].principal.display(), "octocat");
let Principal::User(user) = &contexts[0].principal else {
assert_eq!(
contexts[0].principal.as_ref().expect("principal").display(),
"octocat"
);
let Some(Principal::User(user)) = &contexts[0].principal else {
panic!("expected user principal");
};
assert_eq!(

View file

@ -1081,7 +1081,7 @@ fn ts(s: &str) -> DateTime<Utc> {
mod runs {
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::{LazyLock, OnceLock};
use std::time::Duration;
use fabro_api::types::*;
@ -1092,13 +1092,22 @@ mod runs {
};
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
use fabro_types::{
PendingReason, RepositoryRef, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin,
RunSize, RunTimestamps, StageId, WorkflowRef, WorkflowSettings,
AuthMethod, IdpIdentity, PendingReason, Principal, RepositoryRef, RunBillingSummary, RunId,
RunLifecycle, RunLinks, RunOrigin, RunSize, RunTimestamps, StageId, WorkflowRef,
WorkflowSettings,
};
use super::ts;
use crate::server::run_stage_from_stage_id;
static DEMO_PRINCIPAL: LazyLock<Principal> = LazyLock::new(|| {
Principal::user(
IdpIdentity::new("fabro:demo", "demo").expect("demo identity should be valid"),
"demo".to_string(),
AuthMethod::DevToken,
)
});
fn labels(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
@ -1171,7 +1180,7 @@ mod runs {
repo_origin_url,
source_directory.as_deref(),
)),
created_by: None,
created_by: DEMO_PRINCIPAL.clone(),
origin: RunOrigin::default(),
labels: labels(entries),
lifecycle: RunLifecycle {

View file

@ -19,7 +19,7 @@ use crate::worker_token::{self, WORKER_TOKEN_KID, WorkerScopeSet};
#[derive(Clone, Debug)]
pub(crate) struct RequestAuthContext {
pub principal: Principal,
pub principal: Option<Principal>,
pub auth_status: AuthStatus,
pub auth_error_code: Option<AuthErrorCode>,
pub user_profile: Option<UserProfile>,
@ -76,7 +76,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn initial() -> Self {
Self {
principal: Principal::Anonymous,
principal: None,
auth_status: AuthStatus::Missing,
auth_error_code: None,
user_profile: None,
@ -87,7 +87,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn authenticated(principal: Principal, user_profile: Option<UserProfile>) -> Self {
Self {
principal,
principal: Some(principal),
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile,
@ -98,7 +98,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn authenticated_worker(run_id: RunId, scopes: WorkerScopeSet) -> Self {
Self {
principal: Principal::Worker { run_id },
principal: Some(Principal::Worker { run_id }),
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile: None,
@ -125,7 +125,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn rejected(status: AuthStatus, code: Option<AuthErrorCode>) -> Self {
Self {
principal: Principal::Anonymous,
principal: None,
auth_status: status,
auth_error_code: code,
user_profile: None,
@ -148,7 +148,7 @@ impl AuthStatus {
#[derive(Clone, Debug)]
pub(crate) struct RequestAuthLogContext {
pub principal: Principal,
pub principal: Option<Principal>,
pub auth_status: AuthStatus,
pub auth_error_code: Option<AuthErrorCode>,
}
@ -172,25 +172,13 @@ impl AuthContextSlot {
pub(crate) fn log_snapshot(&self) -> RequestAuthLogContext {
let context = self.0.lock().expect("auth context lock poisoned");
RequestAuthLogContext {
principal: principal_without_log_unused_fields(&context.principal),
principal: context.principal.clone(),
auth_status: context.auth_status,
auth_error_code: context.auth_error_code,
}
}
}
fn principal_without_log_unused_fields(principal: &Principal) -> Principal {
match principal {
Principal::User(user) => Principal::User(UserPrincipal {
identity: user.identity.clone(),
login: user.login.clone(),
auth_method: user.auth_method,
avatar_url: None,
}),
principal => principal.clone(),
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequestAuth {
type Rejection = Infallible;
@ -402,8 +390,8 @@ fn auth_slot_from_parts(parts: &Parts) -> AuthContextSlot {
pub(crate) fn require_user(slot: &AuthContextSlot) -> Result<UserPrincipal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(user.clone()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::User(user)) => Ok(user.clone()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -412,7 +400,7 @@ pub(crate) fn require_authenticated_user(
) -> Result<AuthenticatedUser, ApiError> {
let context = slot.snapshot();
match context.principal {
Principal::User(principal) => {
Some(Principal::User(principal)) => {
let Some(profile) = context.user_profile else {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
@ -421,19 +409,19 @@ pub(crate) fn require_authenticated_user(
};
Ok(AuthenticatedUser { principal, profile })
}
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
pub(crate) fn require_run_management_actor(slot: &AuthContextSlot) -> Result<Principal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(Principal::User(user.clone())),
Principal::Worker { run_id } if context.worker_scopes.has_agent_run_tools() => {
Some(Principal::User(user)) => Ok(Principal::User(user.clone())),
Some(Principal::Worker { run_id }) if context.worker_scopes.has_agent_run_tools() => {
Ok(Principal::Worker { run_id: *run_id })
}
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::Worker { .. }) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -443,19 +431,19 @@ fn require_worker_or_user_for_run(
) -> Result<(), ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(_) => Ok(()),
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::User(_)) => Ok(()),
Some(Principal::Worker { run_id }) if run_id == route_run_id => Ok(()),
Some(Principal::Worker { .. }) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
fn require_worker_for_run(slot: &AuthContextSlot, route_run_id: &RunId) -> Result<(), ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } | Principal::User(_) => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::Worker { run_id }) if run_id == route_run_id => Ok(()),
Some(Principal::Worker { .. } | Principal::User(_)) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -465,14 +453,14 @@ fn require_run_management_target(
) -> Result<Principal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(Principal::User(user.clone())),
Principal::Worker { run_id }
Some(Principal::User(user)) => Ok(Principal::User(user.clone())),
Some(Principal::Worker { run_id })
if run_id == route_run_id || context.worker_scopes.has_agent_run_tools() =>
{
Ok(Principal::Worker { run_id: *run_id })
}
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::Worker { .. }) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -687,7 +675,7 @@ mod tests {
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert!(matches!(context.principal, Principal::User(_)));
assert!(matches!(context.principal, Some(Principal::User(_))));
assert!(context.user_profile.is_some());
}
@ -727,7 +715,7 @@ mod tests {
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert_eq!(context.principal, Principal::Worker { run_id });
assert_eq!(context.principal, Some(Principal::Worker { run_id }));
assert!(!context.worker_scopes.has_agent_run_tools());
}
@ -746,7 +734,7 @@ mod tests {
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert_eq!(context.principal, Principal::Worker { run_id });
assert_eq!(context.principal, Some(Principal::Worker { run_id }));
assert!(context.worker_scopes.has_agent_run_tools());
}
@ -825,7 +813,7 @@ mod tests {
assert_eq!(context.auth_status, AuthStatus::Missing);
assert_eq!(context.auth_error_code, None);
assert_eq!(context.principal, Principal::Anonymous);
assert_eq!(context.principal, None);
}
#[test]

View file

@ -1717,7 +1717,7 @@ fn count_flags(data: &[FileDiff]) -> (u64, u64, u64, u64) {
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use fabro_types::{CommandTermination, RunId};
use fabro_types::{CommandTermination, RunId, test_support};
use tokio::time::{Duration, sleep};
use super::*;
@ -2389,7 +2389,7 @@ index 1111111..2222222 160000
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -27,7 +27,9 @@ use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunNamespace};
use fabro_types::{ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings};
use fabro_types::{
ManifestPath, RunId, RunProvenance, SandboxProviderKind, ServerSettings, WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
@ -193,6 +195,7 @@ pub(crate) fn validate_prepared_manifest(
pub(crate) fn create_run_input(
prepared: PreparedManifest,
configured_providers: Vec<ProviderId>,
provenance: RunProvenance,
web_url: Option<String>,
) -> CreateRunInput {
CreateRunInput {
@ -209,7 +212,7 @@ pub(crate) fn create_run_input(
git: prepared.git,
fork_source_ref: None,
parent_id: prepared.parent_id,
provenance: None,
provenance,
configured_providers,
web_url,
}

View file

@ -1876,7 +1876,10 @@ async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Resp
let status = response.status().as_u16();
let latency_ms = start.elapsed().as_millis();
let auth_context = auth_slot.log_snapshot();
let principal_kind = auth_context.principal.kind();
let principal_kind = auth_context
.principal
.as_ref()
.map_or("none", Principal::kind);
let auth_status = auth_context.auth_status.as_str();
macro_rules! emit_http_log {
@ -1914,27 +1917,27 @@ async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Resp
macro_rules! emit_principal_http_log {
($level:ident) => {{
match &auth_context.principal {
Principal::User(user) => emit_http_log!(
Some(Principal::User(user)) => emit_http_log!(
$level,
user_auth_method = user.auth_method.as_str(),
idp_issuer = user.identity.issuer(),
idp_subject = user.identity.subject(),
login = user.login.as_str(),
),
Principal::Worker { run_id } => {
Some(Principal::Worker { run_id }) => {
emit_http_log!($level, run_id = run_id.to_string().as_str(),)
}
Principal::Webhook { delivery_id } => {
Some(Principal::Webhook { delivery_id }) => {
emit_http_log!($level, delivery_id = delivery_id.as_str(),)
}
Principal::Slack {
Some(Principal::Slack {
team_id, user_id, ..
} => emit_http_log!(
}) => emit_http_log!(
$level,
team_id = team_id.as_str(),
user_id = user_id.as_str(),
),
Principal::Agent { .. } | Principal::System { .. } | Principal::Anonymous => {
None | Some(Principal::Agent { .. } | Principal::System { .. }) => {
emit_http_log!($level)
}
}

View file

@ -535,7 +535,7 @@ mod stage_events_tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use fabro_store::EventPayload;
use fabro_types::{Graph, RunId, WorkflowSettings};
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use fabro_workflow::event as workflow_event;
use http_body_util::BodyExt;
use serde_json::json;
@ -570,7 +570,7 @@ mod stage_events_tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -371,46 +371,67 @@ async fn cancel_run(
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let pending_control = match load_pending_control(state.as_ref(), id).await {
Ok(pending_control) => pending_control,
let durable_summary = match state.store.runs().find(&id).await {
Ok(summary) => summary,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let pending_control = durable_summary
.as_ref()
.and_then(|summary| summary.lifecycle.pending_control);
let durable_status = durable_summary
.as_ref()
.map(|summary| summary.lifecycle.status);
let cancel_target = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Submitted
| RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. } => {
let persist_cancelled_status = matches!(
managed_run.status,
RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable
);
if persist_cancelled_status {
managed_run.status = RunStatus::Failed {
reason: FailureReason::Cancelled,
Some(managed_run) => {
let managed_status = managed_run.status;
match managed_status {
RunStatus::Submitted
| RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. } => {
let answer_transport = managed_run.answer_transport.clone();
let should_cancel_pending_interview =
matches!(
&answer_transport,
Some(RunAnswerTransport::InProcess { .. })
) && (matches!(managed_status, RunStatus::Blocked { .. })
|| matches!(durable_status, Some(RunStatus::Blocked { .. })));
let persist_cancelled_status = matches!(
managed_status,
RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable
) && !should_cancel_pending_interview;
if persist_cancelled_status {
managed_run.status = RunStatus::Failed {
reason: FailureReason::Cancelled,
};
}
let cancel_tx = if should_cancel_pending_interview {
None
} else {
managed_run.cancel_tx.take()
};
Some((
persist_cancelled_status,
answer_transport,
managed_run.cancel_token.clone(),
cancel_tx,
managed_run.worker_ref.clone(),
))
}
_ => {
return ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.")
.into_response();
}
Some((
persist_cancelled_status,
managed_run.answer_transport.clone(),
managed_run.cancel_token.clone(),
managed_run.cancel_tx.take(),
managed_run.worker_ref.clone(),
))
}
_ => {
return ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.")
.into_response();
}
},
}
None => None,
}
};
@ -873,7 +894,7 @@ async fn retry_run(
let input = operations::RetryRunInput {
source_run_id: id,
new_run_id,
provenance: Some(run_provenance(&headers, &actor)),
provenance: run_provenance(&headers, &actor),
web_url: state.run_web_url(&new_run_id),
};
match Box::pin(operations::retry_run(&state.store, &input)).await {

View file

@ -850,7 +850,7 @@ mod tests {
use fabro_types::run_event::AgentMessageProps;
use fabro_types::{
BilledTokenCounts, EventEnvelope, Graph, PairMessageId, RunEvent, StageId,
WorkflowSettings, fixtures,
WorkflowSettings, fixtures, test_support,
};
use fabro_workflow::event as workflow_event;
use tower::ServiceExt;
@ -1024,7 +1024,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -687,13 +687,14 @@ pub(crate) async fn create_run_from_manifest(
.as_ref()
.map(LlmClientResult::provider_ids)
.unwrap_or_default();
let provenance = run_provenance(&headers, &actor);
let mut create_input = run_manifest::create_run_input(
prepared.clone(),
ready_provider_ids.clone(),
provenance,
web_url.clone(),
);
create_input.run_id = Some(run_id);
create_input.provenance = Some(run_provenance(&headers, &actor));
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
create_input.automation = automation;
@ -864,7 +865,7 @@ pub(super) fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunPro
version: FABRO_VERSION.to_string(),
}),
client: run_client_provenance(headers),
subject: Some(subject.clone()),
subject: subject.clone(),
}
}

View file

@ -1299,7 +1299,7 @@ FABRO_PROC_NET_TCP /proc/net/tcp6
mod retrieve_sandbox_tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_types::{Graph, RunId, WorkflowSettings};
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use serde_json::{Value, json};
use tower::ServiceExt;
@ -1339,6 +1339,7 @@ mod retrieve_sandbox_tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/test",
"provenance": test_support::test_run_provenance(),
},
}),
run_id,

View file

@ -1506,6 +1506,7 @@ mod tests {
use fabro_agent::config::ToolAccess;
use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use fabro_llm::types::{ToolCall, ToolDefinition};
use fabro_types::test_support;
use super::*;
@ -1700,7 +1701,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -29,7 +29,7 @@ use fabro_types::{
SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
WorkflowSettings, fixtures,
WorkflowSettings, fixtures, test_support,
};
use fabro_util::check_report::CheckStatus;
use fabro_workflow::records::CheckpointExt;
@ -3978,7 +3978,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -4032,7 +4032,7 @@ async fn create_slack_notification_run(
workflow_slug: workflow_slug.map(str::to_string),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -5039,7 +5039,7 @@ async fn list_run_stages_distinguishes_visits() {
workflow_slug: Some("test".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -6096,7 +6096,7 @@ async fn create_completed_run_ready_for_pull_request(
source_directory: Some("/tmp/project".to_string()),
git: git.clone(),
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -9899,15 +9899,10 @@ async fn run_tool_worker_token_can_use_client_backend_routes_across_runs() {
.unwrap()
.expect("created run should be cached");
assert_eq!(
cached
.projection
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.as_ref()),
Some(&Principal::Worker {
cached.projection.spec.provenance.subject,
Principal::Worker {
run_id: parent_run_id,
}),
},
);
let response = app
@ -12266,7 +12261,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
workflow_slug: Some("test".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -13018,7 +13013,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
workflow_slug: Some("test".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -13626,6 +13621,72 @@ async fn cancel_run_requests_worker_runtime_stop_when_control_unavailable() {
assert_eq!(runtime.requested_refs(), vec![worker_ref]);
}
#[tokio::test]
async fn cancel_durably_blocked_in_process_run_cancels_pending_interview_without_abort_signal() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunRunning,
workflow_event::Event::RunBlocked {
blocked_reason: BlockedReason::HumanInputRequired,
},
])
.await;
let interviewer = Arc::new(ControlInterviewer::new());
let mut question = Question::new("approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let ask_interviewer = Arc::clone(&interviewer);
let ask = tokio::spawn(async move { ask_interviewer.ask(question).await });
tokio::task::yield_now().await;
let (cancel_tx, mut cancel_rx) = oneshot::channel();
let cancel_token = CancellationToken::new();
let temp_dir = tempfile::tempdir().unwrap();
let mut run = managed_run(
MINIMAL_DOT.to_string(),
RunStatus::Running,
Utc::now(),
temp_dir.path().join(run_id.to_string()),
RunExecutionMode::Start,
);
run.answer_transport = Some(RunAnswerTransport::InProcess {
interviewer,
steering_hub: Arc::new(fabro_workflow::SteeringHub::new(Arc::new(
fabro_workflow::event::Emitter::new(run_id),
))),
});
run.cancel_token = Some(cancel_token);
run.cancel_tx = Some(cancel_tx);
state
.runs
.lock()
.expect("runs lock poisoned")
.insert(run_id, run);
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/cancel")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::OK).await;
let submission = tokio::time::timeout(std::time::Duration::from_millis(100), ask)
.await
.expect("cancel should resolve the pending in-process interview")
.expect("interview task should not panic");
assert_eq!(submission.answer.value, AnswerValue::Cancelled);
assert!(
matches!(
cancel_rx.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
),
"blocked in-process cancellation should let the workflow unwind instead of aborting it"
);
}
#[tokio::test]
async fn pause_run_rejects_when_control_is_already_pending() {
let state = test_app_state();

View file

@ -1365,7 +1365,7 @@ client_id = "github-client-id"
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert!(matches!(contexts[0].principal, Principal::User(_)));
assert!(matches!(contexts[0].principal, Some(Principal::User(_))));
assert_eq!(contexts[1].auth_status, AuthStatus::Invalid);
assert_eq!(
contexts[1].auth_error_code,

View file

@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::test_support::test_app_state_with_store;
use fabro_store::{ArtifactStore, Database};
use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings};
use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings, test_support};
use fabro_workflow::event as workflow_event;
use fabro_workflow::run_status::SuccessReason;
use object_store::memory::InMemory as MemoryObjectStore;
@ -69,7 +69,7 @@ async fn append_completed_run_with_final_patch(
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -32,6 +32,7 @@ futures.workspace = true
uuid.workspace = true
[dev-dependencies]
fabro-types = { path = "../fabro-types", features = ["test-support"] }
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"
ulid.workspace = true

View file

@ -922,11 +922,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
})
.map(|(_, record)| record.question.clone());
let models = run_models(state);
let created_by = state
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.clone());
let created_by = state.spec.provenance.subject.clone();
let source_directory = state.spec.source_directory.clone();
let repo_origin_url = state.spec.git.as_ref().map(|git| git.origin_url.clone());
let start_time = state.start.as_ref().map(|start| start.start_time);
@ -1276,7 +1272,7 @@ mod tests {
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
StageModelUsage, StageOutcome, StageState, SubAgentStatus, SuccessReason, WorkflowSettings,
first_event_seq, fixtures,
first_event_seq, fixtures, test_support,
};
use serde_json::json;
@ -1358,7 +1354,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,
@ -1439,7 +1435,7 @@ mod tests {
}
#[test]
fn legacy_run_created_projects_retried_from_none() {
fn run_created_without_retried_from_projects_retried_from_none() {
let event = test_raw_event(
1,
"run.created",
@ -1447,7 +1443,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -1475,7 +1472,8 @@ mod tests {
"graph": Graph::new("test"),
"automation": automation,
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -1498,7 +1496,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
)])
@ -1520,7 +1519,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
)])
@ -1597,7 +1597,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
),
@ -1822,7 +1823,7 @@ mod tests {
"repo_origin_url": null,
"base_branch": null,
"labels": {},
"provenance": null,
"provenance": test_support::test_run_provenance(),
"manifest_blob": null,
"definition_blob": null,
"git": null,
@ -2851,7 +2852,7 @@ mod tests {
source_directory: Some("/tmp/repo".to_string()),
git: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -2877,7 +2878,7 @@ mod tests {
source_directory: Some("/tmp/repo".to_string()),
git: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -2916,7 +2917,8 @@ mod tests {
"attrs": { "goal": { "String": "Goal title" } }
},
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -2930,7 +2932,7 @@ mod tests {
}
#[test]
fn legacy_run_created_without_title_infers_projection_title() {
fn run_created_without_title_infers_projection_title() {
let event = test_raw_event(
1,
"run.created",
@ -2943,7 +2945,8 @@ mod tests {
"attrs": { "goal": { "String": "## Plan: Legacy title\n\nDetails" } }
},
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -2972,7 +2975,8 @@ mod tests {
"attrs": { "goal": { "String": "Goal title" } }
},
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
),
@ -3016,6 +3020,7 @@ mod tests {
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/tmp/run",
"provenance": test_support::test_run_provenance(),
"manifest_blob": manifest_blob
}
}))

View file

@ -472,7 +472,7 @@ mod tests {
use chrono::{DateTime, Utc};
use fabro_types::{
AttrValue, FailureReason, Graph, RunControlAction, RunSpec, RunStatus, StageId,
SuccessReason, WorkflowSettings,
SuccessReason, WorkflowSettings, test_support,
};
use futures::TryStreamExt;
use object_store::memory::InMemory;
@ -542,7 +542,7 @@ mod tests {
automation: None,
source_directory: Some(format!("/tmp/{label}")),
labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: Some(fabro_types::GitContext {
@ -601,6 +601,7 @@ mod tests {
"run_dir": format!("/tmp/{label}"),
"git": run_spec.git,
"labels": run_spec.labels,
"provenance": run_spec.provenance,
}),
))
.await
@ -627,6 +628,7 @@ mod tests {
"git": run_spec.git,
"labels": run_spec.labels,
"parent_id": parent_id,
"provenance": run_spec.provenance,
}),
))
.await

View file

@ -667,7 +667,7 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_types::{Graph, RunId, SessionId, StageId, WorkflowSettings};
use fabro_types::{Graph, RunId, SessionId, StageId, WorkflowSettings, test_support};
use object_store::memory::InMemory;
use serde_json::json;
@ -723,6 +723,7 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/test",
"provenance": test_support::test_run_provenance(),
},
}),
run_id,

View file

@ -8,7 +8,7 @@ use fabro_types::{
BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime,
RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,
WorkflowSettings, first_event_seq, fixtures,
WorkflowSettings, first_event_seq, fixtures, test_support,
};
use serde_json::json;
@ -22,7 +22,7 @@ fn sample_run_spec() -> RunSpec {
automation: None,
source_directory: Some("/tmp/project".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: Some(fabro_types::GitContext {

View file

@ -29,4 +29,5 @@ tokio.workspace = true
toml.workspace = true
[dev-dependencies]
fabro-types = { path = "../fabro-types", features = ["test-support"] }
tempfile = "3"

View file

@ -307,7 +307,9 @@ fn format_tool_error(err: &anyhow::Error) -> String {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef};
use fabro_types::{
RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use super::*;
@ -413,7 +415,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -508,7 +508,7 @@ mod tests {
use fabro_api::types;
use fabro_types::{
EventEnvelope, Run, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus,
RunTimestamps, WorkflowRef,
RunTimestamps, WorkflowRef, test_support,
};
use schemars::SchemaGenerator;
use serde_json::json;
@ -902,7 +902,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -453,7 +453,7 @@ mod tests {
use chrono::{TimeZone, Utc};
use fabro_types::{
EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection,
RunStatus, RunTimestamps, WorkflowRef,
RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use serde_json::json;
@ -690,7 +690,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -293,7 +293,9 @@ mod tests {
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef};
use fabro_types::{
RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use super::*;
@ -444,7 +446,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::from([("group".to_string(), group.to_string())]),
lifecycle: RunLifecycle {

View file

@ -33,4 +33,5 @@ ulid.workspace = true
url.workspace = true
[dev-dependencies]
fabro-types = { path = ".", features = ["test-support"] }
tempfile = "3"

View file

@ -44,6 +44,8 @@ pub mod start;
pub mod status;
pub mod steering;
pub mod system_integrations;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod timing;
pub mod todo;
pub mod transcript;

View file

@ -12,8 +12,9 @@ pub struct UserPrincipal {
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, IntoStaticStr)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Principal {
User(UserPrincipal),
Worker {
@ -39,7 +40,6 @@ pub enum Principal {
System {
system_kind: SystemActorKind,
},
Anonymous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, IntoStaticStr)]
@ -90,15 +90,7 @@ impl Principal {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::User(_) => "user",
Self::Worker { .. } => "worker",
Self::Webhook { .. } => "webhook",
Self::Slack { .. } => "slack",
Self::Agent { .. } => "agent",
Self::System { .. } => "system",
Self::Anonymous => "anonymous",
}
self.into()
}
#[must_use]
@ -123,7 +115,6 @@ impl Principal {
} => session_id.clone(),
Self::Agent { .. } => "agent".to_string(),
Self::System { system_kind } => format!("system:{system_kind}"),
Self::Anonymous => "anonymous".to_string(),
}
}
}
@ -291,11 +282,6 @@ mod tests {
});
}
#[test]
fn round_trips_anonymous_variant() {
assert_round_trip(&Principal::Anonymous);
}
#[test]
fn auth_method_as_str_matches_serde() {
assert_eq!(AuthMethod::Github.as_str(), "github");

View file

@ -24,14 +24,13 @@ pub struct RunClientProvenance {
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<RunServerProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<RunClientProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<Principal>,
pub subject: Principal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -93,8 +92,7 @@ pub struct RunSpec {
pub source_directory: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
pub provenance: RunProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -933,7 +933,7 @@ mod tests {
use super::*;
use crate::{
AuthMethod, Edge, Graph, IdpIdentity, Node, PendingReason, RunBlobId, WorkflowSettings,
fixtures,
fixtures, test_support,
};
fn user_principal(login: &str) -> Principal {
@ -1017,7 +1017,8 @@ mod tests {
"graph": graph,
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/tmp/run"
"source_directory": "/tmp/run",
"provenance": test_support::test_run_provenance()
}
});
@ -1038,6 +1039,7 @@ mod tests {
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/tmp/run",
"provenance": test_support::test_run_provenance(),
"manifest_blob": RunBlobId::new(br#"{"version":1}"#).to_string()
}
});

View file

@ -30,8 +30,7 @@ pub struct RunCreatedProps {
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
pub provenance: RunProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -681,7 +681,7 @@ mod title_tests {
use chrono::Utc;
use crate::{AttrValue, Graph, RunId, RunProjection, RunSpec, WorkflowSettings};
use crate::{AttrValue, Graph, RunId, RunProjection, RunSpec, WorkflowSettings, test_support};
fn projection_with_goal(goal: Option<&str>) -> RunProjection {
let mut graph = Graph::new("test");
@ -700,7 +700,7 @@ mod title_tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,
@ -752,7 +752,7 @@ mod iter_stages_tests {
use serde_json::json;
use super::RunProjection;
use crate::{Graph, RunId, RunSpec, StageProjection, WorkflowSettings};
use crate::{Graph, RunId, RunSpec, StageProjection, WorkflowSettings, test_support};
fn seq(n: u32) -> NonZeroU32 {
NonZeroU32::new(n).unwrap()
@ -770,7 +770,7 @@ mod iter_stages_tests {
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -52,8 +52,7 @@ pub struct Run {
pub automation: Option<AutomationRef>,
#[serde(default)]
pub repository: Option<RepositoryRef>,
#[serde(default)]
pub created_by: Option<Principal>,
pub created_by: Principal,
pub origin: RunOrigin,
pub labels: HashMap<String, String>,
pub lifecycle: RunLifecycle,

View file

@ -0,0 +1,19 @@
use crate::{AuthMethod, IdpIdentity, Principal, RunProvenance};
#[must_use]
pub fn test_principal() -> Principal {
Principal::user(
IdpIdentity::new("fabro:test", "test-user").expect("test identity should be valid"),
"test".to_string(),
AuthMethod::DevToken,
)
}
#[must_use]
pub fn test_run_provenance() -> RunProvenance {
RunProvenance {
server: None,
client: None,
subject: test_principal(),
}
}

View file

@ -6,6 +6,7 @@ use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunPare
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;
use fabro_types::{AutomationRef, EventBody, TurnId, WorkflowSettings, fixtures};
fn templated_settings() -> WorkflowSettings {
@ -32,7 +33,7 @@ fn run_created_props_round_trip_templated_settings() {
trigger_id: Some("schedule_1".to_string()),
}),
db_prefix: Some("run_".to_string()),
provenance: None,
provenance: test_run_provenance(),
manifest_blob: None,
git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
@ -97,7 +98,7 @@ fn run_created_props_omits_web_url_when_absent() {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -134,7 +135,8 @@ fn run_created_props_defaults_additive_fields_for_legacy_events() {
"settings": WorkflowSettings::default(),
"graph": Graph::new("ship"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_run_provenance()
});
let props: RunCreatedProps =

View file

@ -3,6 +3,7 @@ use std::collections::HashMap;
use fabro_types::graph::Graph;
use fabro_types::run::{DirtyStatus, GitContext, PreRunPushOutcome, RunSpec};
use fabro_types::settings::{ProjectNamespace, WorkflowNamespace};
use fabro_types::test_support::test_run_provenance;
use fabro_types::{WorkflowSettings, fixtures};
fn sample_run_spec() -> RunSpec {
@ -27,7 +28,7 @@ fn sample_run_spec() -> RunSpec {
automation: None,
source_directory: Some("/Users/client/project".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
provenance: test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: Some(GitContext {

View file

@ -4,6 +4,7 @@ use fabro_types::graph::Graph;
use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunSpec};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::test_support::test_run_provenance;
use fabro_types::{AutomationRef, WorkflowSettings, fixtures};
fn templated_settings() -> WorkflowSettings {
@ -27,7 +28,7 @@ fn run_spec_round_trips_templated_settings() {
}),
source_directory: Some("/Users/client/project".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
provenance: test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: Some(GitContext {
@ -80,7 +81,8 @@ fn run_spec_defaults_automation_for_legacy_specs() {
"run_id": fixtures::RUN_1,
"settings": WorkflowSettings::default(),
"graph": Graph::new("ship"),
"labels": {}
"labels": {},
"provenance": test_run_provenance()
});
let record: RunSpec = serde_json::from_value(json).expect("legacy spec should deserialize");

View file

@ -165,7 +165,7 @@ mod tests {
use fabro_model::{Catalog, ModelRef, ProviderId};
use fabro_types::{
AttrValue, BilledTokenCounts, Graph, Node, RunProjection, RunSpec, StageCompletion,
StageOutcome, WorkflowSettings, first_event_seq, fixtures,
StageOutcome, WorkflowSettings, first_event_seq, fixtures, test_support,
};
use super::billing_rollup_from_projection;
@ -353,7 +353,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -2339,7 +2339,7 @@ mod tests {
let provenance = RunProvenance {
server: None,
client: None,
subject: Some(user_principal("alice")),
subject: user_principal("alice"),
};
let automation = AutomationRef {
id: "nightly".to_string(),
@ -2348,25 +2348,25 @@ mod tests {
};
let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
workflow_config: None,
labels: BTreeMap::default(),
run_dir: "/tmp/run".to_string(),
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
workflow_config: None,
labels: BTreeMap::default(),
run_dir: "/tmp/run".to_string(),
source_directory: Some("/tmp/run".to_string()),
workflow_slug: None,
automation: Some(automation.clone()),
db_prefix: None,
provenance: Some(provenance),
manifest_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
workflow_slug: None,
automation: Some(automation.clone()),
db_prefix: None,
provenance,
manifest_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
});
let actor = stored.actor.as_ref().expect("actor set");
assert_eq!(actor, &user_principal("alice"));

View file

@ -41,8 +41,7 @@ pub enum Event {
automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<RunProvenance>,
provenance: RunProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -213,6 +213,7 @@ mod tests {
use std::sync::Arc;
use ::fabro_types::{Graph, RunNoticeLevel, WorkflowSettings, fixtures};
use fabro_types::test_support;
use tokio::sync::Mutex as AsyncMutex;
use super::*;
@ -244,7 +245,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -57,7 +57,7 @@ pub(super) fn stored_event_fields(event: &Event, scope: Option<&StageScope>) ->
fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
match event {
Event::RunCreated { provenance, .. } => StoredEventFields {
actor: provenance.as_ref().and_then(|p| p.subject.clone()),
actor: Some(provenance.subject.clone()),
..StoredEventFields::default()
},
Event::RunCancelRequested { actor }

View file

@ -343,7 +343,7 @@ mod tests {
use fabro_dump::RunDump;
use fabro_store::Database;
use fabro_types::{CommandTermination, StageModelUsage, fixtures};
use fabro_types::{CommandTermination, StageModelUsage, fixtures, test_support};
use object_store::memory::InMemory;
use super::*;
@ -469,7 +469,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -429,7 +429,7 @@ mod tests {
use fabro_graphviz::graph::AttrValue;
use fabro_model::{ReasoningEffort, Speed};
use fabro_store::{Database, RunDatabase, StageId};
use fabro_types::fixtures;
use fabro_types::{fixtures, test_support};
use object_store::memory::InMemory;
use tempfile::TempDir;
@ -484,7 +484,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -228,7 +228,7 @@ mod tests {
use bytes::Bytes;
use fabro_graphviz::graph::AttrValue;
use fabro_store::{Database, RunDatabase, StageId};
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, fixtures};
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, fixtures, test_support};
use object_store::memory::InMemory;
use tokio::sync::Mutex;
@ -256,7 +256,7 @@ mod tests {
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,
@ -357,7 +357,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -1600,6 +1600,7 @@ mod tests {
use fabro_types::{
EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin,
RunPairStatusResponse, RunProjection, RunStatus, RunTimestamps, SuccessReason, WorkflowRef,
test_support,
};
use fabro_vault::{SecretType, Vault};
use futures::stream;
@ -2133,7 +2134,7 @@ reasoning = false
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -692,7 +692,7 @@ mod tests {
use fabro_graphviz::graph::{AttrValue, Edge};
use fabro_store::{Database, StageId};
use fabro_types::fixtures;
use fabro_types::{fixtures, test_support};
use object_store::memory::InMemory;
use super::*;
@ -728,7 +728,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -225,7 +225,7 @@ mod tests {
use fabro_graphviz::graph::AttrValue;
use fabro_model::{ReasoningEffort, Speed};
use fabro_store::{Database, RunDatabase, StageId};
use fabro_types::fixtures;
use fabro_types::{fixtures, test_support};
use object_store::memory::InMemory;
use tempfile::TempDir;
@ -283,7 +283,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -598,7 +598,7 @@ mod tests {
use fabro_model::Catalog;
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
use fabro_types::{EventBody, RunBlobId, RunEvent, WorkflowSettings, fixtures};
use fabro_types::{EventBody, RunBlobId, RunEvent, WorkflowSettings, fixtures, test_support};
use object_store::memory::InMemory;
use super::*;
@ -736,7 +736,7 @@ mod tests {
workflow_slug: Some("metadata".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -136,7 +136,9 @@ mod tests {
use std::time::Duration;
use fabro_store::Database;
use fabro_types::{FailureReason, RunId, SuccessReason, TerminalStatus, fixtures};
use fabro_types::{
FailureReason, RunId, SuccessReason, TerminalStatus, fixtures, test_support,
};
use object_store::memory::InMemory;
use super::*;
@ -226,7 +228,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -45,7 +45,7 @@ pub struct CreateRunInput {
pub git: Option<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub parent_id: Option<RunId>,
pub provenance: Option<RunProvenance>,
pub provenance: RunProvenance,
pub configured_providers: Vec<ProviderId>,
/// Public URL where this run can be viewed in the web UI, when the server
/// has the web UI enabled. Recorded on the `run.created` event so attach
@ -72,7 +72,7 @@ struct PersistCreateOptions {
automation: Option<AutomationRef>,
git: Option<GitContext>,
fork_source_ref: Option<ForkSourceRef>,
provenance: Option<RunProvenance>,
provenance: RunProvenance,
configured_providers: Vec<ProviderId>,
catalog: Arc<Catalog>,
}
@ -422,7 +422,7 @@ mod tests {
use fabro_store::Database;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunMode;
use fabro_types::{WorkflowSettings, fixtures};
use fabro_types::{WorkflowSettings, fixtures, test_support};
use fabro_util::error::collect_chain;
use fabro_validate::Severity;
use object_store::local::LocalFileSystem;
@ -1115,7 +1115,7 @@ mod tests {
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},
@ -1183,7 +1183,7 @@ mod tests {
}),
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},
@ -1295,7 +1295,7 @@ mod tests {
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},
@ -1341,7 +1341,7 @@ mod tests {
}),
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},
@ -1414,7 +1414,7 @@ mod tests {
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},
@ -1467,7 +1467,7 @@ mod tests {
git: None,
fork_source_ref: None,
parent_id: None,
provenance: Some(fabro_types::RunProvenance {
provenance: fabro_types::RunProvenance {
server: Some(fabro_types::RunServerProvenance {
version: "0.9.0".to_string(),
}),
@ -1476,12 +1476,12 @@ mod tests {
name: Some("fabro-cli".to_string()),
version: Some("0.9.0".to_string()),
}),
subject: Some(fabro_types::Principal::user(
subject: fabro_types::Principal::user(
fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
"octocat".to_string(),
fabro_types::AuthMethod::Github,
)),
}),
),
},
configured_providers: Vec::new(),
web_url: None,
},
@ -1494,7 +1494,7 @@ mod tests {
let run_store = store.open_run_reader(&created.run_id).await.unwrap();
let state = run_store.state().await.unwrap();
let run = state.spec;
let provenance = run.provenance.expect("provenance should be projected");
let provenance = run.provenance;
assert_eq!(provenance.server.unwrap().version, "0.9.0");
assert_eq!(
@ -1502,7 +1502,7 @@ mod tests {
Some("fabro-cli")
);
assert_eq!(
provenance.subject.unwrap(),
provenance.subject,
fabro_types::Principal::user(
fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
"octocat".to_string(),

View file

@ -284,7 +284,7 @@ mod tests {
use fabro_graphviz::graph::Graph;
use fabro_store::{Database, RunProjectionReducer};
use fabro_types::{StageId, WorkflowSettings, fixtures};
use fabro_types::{StageId, WorkflowSettings, fixtures, test_support};
use object_store::memory::InMemory;
use super::*;
@ -383,7 +383,7 @@ mod tests {
workflow_slug: Some("fork-source".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: Some(fabro_types::GitContext {
origin_url: "https://github.com/example/repo.git".to_string(),

View file

@ -12,7 +12,7 @@ use crate::event::{self, Event};
pub struct RetryRunInput {
pub source_run_id: RunId,
pub new_run_id: RunId,
pub provenance: Option<RunProvenance>,
pub provenance: RunProvenance,
pub web_url: Option<String>,
}
@ -122,7 +122,7 @@ mod tests {
use fabro_types::{
AuthMethod, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph, IdpIdentity,
PreRunPushOutcome, Principal, PullRequestLink, RunBlobId, RunRunnableSource,
RunServerProvenance, RunTiming, UserPrincipal, WorkflowSettings, fixtures,
RunServerProvenance, RunTiming, WorkflowSettings, fixtures,
};
use object_store::memory::InMemory;
@ -138,12 +138,11 @@ mod tests {
}
fn actor(login: &str) -> Principal {
Principal::User(UserPrincipal {
identity: IdpIdentity::new("github", format!("user:{login}")).unwrap(),
login: login.to_string(),
auth_method: AuthMethod::DevToken,
avatar_url: None,
})
Principal::user(
IdpIdentity::new("github", format!("user:{login}")).unwrap(),
login.to_string(),
AuthMethod::DevToken,
)
}
fn provenance(login: &str) -> RunProvenance {
@ -152,7 +151,7 @@ mod tests {
version: "test".to_string(),
}),
client: None,
subject: Some(actor(login)),
subject: actor(login),
}
}
@ -191,7 +190,7 @@ mod tests {
workflow_slug: Some("retry-source".to_string()),
automation: None,
db_prefix: None,
provenance: Some(provenance("source-user")),
provenance: provenance("source-user"),
manifest_blob,
git: Some(git_context()),
fork_source_ref,
@ -368,7 +367,7 @@ mod tests {
let outcome = retry_run(&store, &RetryRunInput {
source_run_id,
new_run_id: RunId::new(),
provenance: Some(provenance("retry-user")),
provenance: provenance("retry-user"),
web_url: Some("http://localhost:3000/runs/retry".to_string()),
})
.await
@ -402,14 +401,7 @@ mod tests {
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));
assert_eq!(
retry_state
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.as_ref()),
Some(&actor("retry-user"))
);
assert_eq!(retry_state.spec.provenance.subject, actor("retry-user"));
assert_eq!(
retry_state.web_url.as_deref(),
Some("http://localhost:3000/runs/retry")
@ -463,7 +455,7 @@ mod tests {
let outcome = retry_run(&store, &RetryRunInput {
source_run_id,
new_run_id: RunId::new(),
provenance: Some(provenance("retry-user")),
provenance: provenance("retry-user"),
web_url: None,
})
.await
@ -517,7 +509,7 @@ mod tests {
let err = retry_run(&store, &RetryRunInput {
source_run_id: run_id,
new_run_id: RunId::new(),
provenance: None,
provenance: provenance("retry-user"),
web_url: None,
})
.await
@ -535,7 +527,7 @@ mod tests {
let err = retry_run(&store, &RetryRunInput {
source_run_id: fixtures::RUN_1,
new_run_id: RunId::new(),
provenance: None,
provenance: provenance("retry-user"),
web_url: None,
})
.await

View file

@ -1129,7 +1129,9 @@ mod tests {
use fabro_store::Database;
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, ModelRef};
use fabro_types::{BilledModelUsage, ManifestPath, StageTiming, WorkflowSettings, fixtures};
use fabro_types::{
BilledModelUsage, ManifestPath, StageTiming, WorkflowSettings, fixtures, test_support,
};
use object_store::memory::InMemory;
use super::*;
@ -1438,7 +1440,7 @@ reasoning = false
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},
@ -1860,7 +1862,7 @@ reasoning = false
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
provenance: test_support::test_run_provenance(),
configured_providers: Vec::new(),
web_url: None,
},

View file

@ -204,6 +204,7 @@ mod tests {
use chrono::Utc;
use fabro_types::{
Checkpoint, CheckpointRecord, Graph, RunDiff, RunSpec, WorkflowSettings, fixtures,
test_support,
};
use super::*;
@ -248,7 +249,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -18,7 +18,9 @@ use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::Database;
use fabro_types::settings::run::RunModelControls;
use fabro_types::{Principal, RunId, SystemActorKind, WorkflowSettings, fixtures, format_blob_ref};
use fabro_types::{
Principal, RunId, SystemActorKind, WorkflowSettings, fixtures, format_blob_ref, test_support,
};
use object_store::memory::InMemory;
use super::*;
@ -165,7 +167,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -208,7 +210,7 @@ async fn seed_created_and_starting(
workflow_slug: run_options.workflow_slug.clone(),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),

View file

@ -651,7 +651,7 @@ mod tests {
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
use fabro_types::{
BilledTokenCounts, EventBody, RunBlobId, RunEvent, RunId, RunSpec, StageCompletion,
WorkflowSettings, first_event_seq, fixtures,
WorkflowSettings, first_event_seq, fixtures, test_support,
};
use object_store::memory::InMemory;
@ -739,7 +739,7 @@ mod tests {
workflow_slug: Some("metadata".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -856,7 +856,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -648,7 +648,7 @@ mod tests {
use fabro_sandbox::SandboxSpec;
use fabro_store::Database;
use fabro_types::settings::run::RunModelControls;
use fabro_types::{EventBody, RunEvent, RunId, WorkflowSettings, fixtures};
use fabro_types::{EventBody, RunEvent, RunId, WorkflowSettings, fixtures, test_support};
use fabro_vault::{SecretType, Vault};
use object_store::memory::InMemory;
use tokio::fs::{create_dir_all, write};
@ -773,7 +773,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,

View file

@ -59,7 +59,7 @@ mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_store::{Database, RunDatabase};
use fabro_types::fixtures;
use fabro_types::{fixtures, test_support};
use object_store::memory::InMemory;
use super::*;
@ -148,7 +148,7 @@ mod tests {
("env".to_string(), "test".to_string()),
("team".to_string(), "workflow".to_string()),
]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,

View file

@ -680,7 +680,7 @@ mod tests {
use fabro_store::Database;
use fabro_types::{
BilledTokenCounts, RunProjection, RunSpec, SuccessReason, WorkflowSettings,
first_event_seq, fixtures,
first_event_seq, fixtures, test_support,
};
use fabro_vault::{SecretType, Vault};
use futures::stream;
@ -823,7 +823,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,
@ -1148,7 +1148,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -1219,7 +1219,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -1575,7 +1575,7 @@ mod tests {
source_directory: Some(tmp.path().display().to_string()),
git: None,
labels: std::collections::HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -1704,7 +1704,7 @@ mod tests {
source_directory: Some("/tmp/project".to_string()),
git: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -1722,7 +1722,7 @@ mod tests {
workflow_slug: run_spec.workflow_slug.clone(),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -1875,7 +1875,7 @@ mod tests {
source_directory: None,
git: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -1893,7 +1893,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

Some files were not shown because too many files have changed in this diff Show more