mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # .claude/skills/docs/SKILL.md # docs/public/.mintignore
This commit is contained in:
commit
43197729d1
446 changed files with 1026 additions and 5678 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Mintlify Changelog MDX Format
|
||||
|
||||
Each changelog entry is a separate `.mdx` file in `docs/changelog/`.
|
||||
Each changelog entry is a separate `.mdx` file in `docs/public/changelog/`.
|
||||
|
||||
## Template
|
||||
|
||||
|
|
|
|||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
|
|
@ -13,25 +13,8 @@ env:
|
|||
SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }}
|
||||
|
||||
jobs:
|
||||
verify-spa:
|
||||
name: Verify SPA assets
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
no-cache: true
|
||||
- run: bun install --frozen-lockfile
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
- run: cargo dev spa check
|
||||
|
||||
compile:
|
||||
name: Compile (${{ matrix.target }})
|
||||
needs: verify-spa
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -60,6 +43,13 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
no-cache: true
|
||||
|
||||
- name: Install bun deps
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Linux build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
|
|
@ -89,6 +79,9 @@ jobs:
|
|||
|
||||
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
|
||||
|
||||
- name: Refresh embedded SPA
|
||||
run: cargo dev spa refresh
|
||||
|
||||
- name: Test (x86_64-musl)
|
||||
# nextest still shells through cargo test for this target, so
|
||||
# build.rs C code needs an explicit musl compiler/linker.
|
||||
|
|
|
|||
3
.github/workflows/typescript.yml
vendored
3
.github/workflows/typescript.yml
vendored
|
|
@ -72,6 +72,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 spa check
|
||||
- run: cargo build -p fabro-cli --release
|
||||
- run: cargo dev build -- -p fabro-cli --release
|
||||
- run: wc -c < target/release/fabro
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,6 +3,8 @@ target
|
|||
.entire
|
||||
node_modules
|
||||
apps/fabro-web/dist/
|
||||
lib/crates/fabro-spa/assets/*
|
||||
!lib/crates/fabro-spa/assets/.gitkeep
|
||||
tmp
|
||||
evals/swe-bench/repos/
|
||||
evals/swe-bench/results/
|
||||
|
|
|
|||
|
|
@ -22,11 +22,10 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
|
|||
- `cd apps/fabro-web && bun test` — run tests
|
||||
- `cd apps/fabro-web && bun run typecheck` — type check
|
||||
- `cd apps/fabro-web && bun run build` — production build (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary)
|
||||
- `cargo dev spa refresh` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build, verifies SPA asset budgets, and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job runs `cargo dev spa check` — if the committed bundle drifts from source or exceeds budgets, the check fails. `bun run build` on its own is not enough.
|
||||
- `cargo dev build [-- <cargo args>]` — refreshes the embedded SPA assets from the production build, verifies SPA asset budgets, and then runs `cargo build` with forwarded args. The embedded assets are gitignored except for `.gitkeep`; use this when building a Rust binary that should include a populated SPA bundle. `bun run dev` for local development is unchanged because debug builds prefer `apps/fabro-web/dist/` on disk via the server fallback.
|
||||
|
||||
### Docker image
|
||||
- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag <name>` (default `fabro-sh/fabro`), `--compile-only` (stages `tmp/docker-context/<arch>/fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout.
|
||||
- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `cargo dev spa refresh` runs the bun build, verifies budgets, and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle.
|
||||
|
||||
### Docker sandbox provider
|
||||
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
|
||||
|
|
@ -46,7 +45,7 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
|
|||
2. `cd apps/fabro-web && bun run dev` — rebuilds web assets on change; refresh the browser manually
|
||||
3. Mintlify docs dev server (requires Docker — `mintlify dev` needs Node LTS which may not match the host):
|
||||
```
|
||||
docker run --rm -d -p 3333:3333 -v $(pwd)/docs:/docs -w /docs --name mintlify-dev node:22-slim \
|
||||
docker run --rm -d -p 3333:3333 -v $(pwd)/docs/public:/docs -w /docs --name mintlify-dev node:22-slim \
|
||||
bash -c "npx mintlify dev --host 0.0.0.0 --port 3333"
|
||||
```
|
||||
Then open http://localhost:3333. Stop with `docker stop mintlify-dev`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
|
|||
import type {
|
||||
RunListItem,
|
||||
RunStatus as ApiRunStatus,
|
||||
StoreRunSummary,
|
||||
RunSummary,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
export type CiStatus = "passing" | "failing" | "pending";
|
||||
|
|
@ -84,9 +84,9 @@ export function mapRunListItem(item: RunListItem): RunItem {
|
|||
};
|
||||
}
|
||||
|
||||
export type RunSummaryResponse = StoreRunSummary;
|
||||
export type { RunSummary };
|
||||
|
||||
export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
|
||||
export function mapRunSummaryToRunItem(summary: RunSummary): RunItem {
|
||||
const lifecycleStatus = runStatusKind(summary.status);
|
||||
return {
|
||||
id: summary.run_id,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
PaginatedStageTurnList,
|
||||
RunBilling,
|
||||
ServerSettings,
|
||||
RunSummary,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import type { PaginatedWorkflowListResponse, WorkflowDetailResponse } from "./workflow-api";
|
||||
|
|
@ -20,7 +21,6 @@ import {
|
|||
type PaginatedEnvelope,
|
||||
} from "./api-client";
|
||||
import { queryKeys } from "./query-keys";
|
||||
import type { RunSummaryResponse } from "../data/runs";
|
||||
|
||||
const immutableOptions: SWRConfiguration = {
|
||||
revalidateIfStale: false,
|
||||
|
|
@ -63,7 +63,7 @@ export function useBoardsRuns() {
|
|||
}
|
||||
|
||||
export function useRun(id: string | undefined) {
|
||||
return useSWR<RunSummaryResponse | null>(
|
||||
return useSWR<RunSummary | null>(
|
||||
id ? queryKeys.runs.detail(id) : null,
|
||||
apiNullableFetcher,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
isRunStatus,
|
||||
mapRunSummaryToRunItem,
|
||||
runStatusDisplay,
|
||||
type RunSummaryResponse,
|
||||
type RunSummary,
|
||||
} from "../data/runs";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import {
|
||||
|
|
@ -78,7 +78,7 @@ export function lifecycleActionVisibility(status: string | null | undefined) {
|
|||
};
|
||||
}
|
||||
|
||||
function buildRunDetailRun(summary: RunSummaryResponse): RunDetailRun {
|
||||
function buildRunDetailRun(summary: RunSummary): RunDetailRun {
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
const rawStatus = summary.status;
|
||||
const statusKind = rawStatus.kind;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ With the SPA baked into the binary, there's no in-container edit path. Commit th
|
|||
|
||||
## Updating logos
|
||||
|
||||
Logos live at `apps/fabro-web/public/logotype.svg` (dark) and `apps/fabro-web/public/logotype-light.svg` (light). These are bundled into the SPA at build time — rebuilding the image picks up the changes. The source-of-truth logos are in `docs/logo/dark.svg` and `docs/logo/light.svg`.
|
||||
Logos live at `apps/fabro-web/public/logotype.svg` (dark) and `apps/fabro-web/public/logotype-light.svg` (light). These are bundled into the SPA at build time — rebuilding the image picks up the changes. The source-of-truth logos are in `docs/public/logo/dark.svg` and `docs/public/logo/light.svg`.
|
||||
|
||||
## Browser setup
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ If the nav bar is too crowded at this width, hide low-priority items (Start, Set
|
|||
|
||||
## Taking screenshots
|
||||
|
||||
Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL (served from `http://localhost/` with the `X-Fabro-Demo: 1` header):
|
||||
Screenshots live in `docs/public/images/web/`. Each screenshot maps to a specific URL (served from `http://localhost/` with the `X-Fabro-Demo: 1` header):
|
||||
|
||||
| File | URL |
|
||||
|---|---|
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "Client SDKs"
|
|||
description: "Language-specific clients generated from the Fabro OpenAPI spec"
|
||||
---
|
||||
|
||||
The Fabro API is defined by an OpenAPI 3.1 specification (`docs/api-reference/fabro-api.yaml` in the repository) that serves as the single source of truth for all endpoints, request/response schemas, and parameter definitions. The spec is also available at runtime from the server at `GET /api/v1/openapi.json`. Both client SDKs below are generated directly from this spec.
|
||||
The Fabro API is defined by an OpenAPI 3.1 specification (`docs/public/api-reference/fabro-api.yaml` in the repository) that serves as the single source of truth for all endpoints, request/response schemas, and parameter definitions. The spec is also available at runtime from the server at `GET /api/v1/openapi.json`. Both client SDKs below are generated directly from this spec.
|
||||
|
||||
## TypeScript (Axios)
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ cd lib/packages/fabro-api-client
|
|||
bun run generate
|
||||
```
|
||||
|
||||
This runs `openapi-generator-cli` against `docs/api-reference/fabro-api.yaml` and writes the generated source into `lib/packages/fabro-api-client/src/`.
|
||||
This runs `openapi-generator-cli` against `docs/public/api-reference/fabro-api.yaml` and writes the generated source into `lib/packages/fabro-api-client/src/`.
|
||||
|
||||
### Usage
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ The `fabro-api` crate generates Rust structs, enums, and a `reqwest`-based HTTP
|
|||
|
||||
### How It Works
|
||||
|
||||
A `build.rs` script reads `docs/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via:
|
||||
A `build.rs` script reads `docs/public/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via:
|
||||
|
||||
```rust
|
||||
// lib/crates/fabro-api/src/lib.rs
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StoreRunSummary"
|
||||
$ref: "#/components/schemas/RunSummary"
|
||||
"400":
|
||||
description: Selector is invalid or ambiguous
|
||||
content:
|
||||
|
|
@ -617,7 +617,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StoreRunSummary"
|
||||
$ref: "#/components/schemas/RunSummary"
|
||||
"404":
|
||||
description: Run not found
|
||||
content:
|
||||
|
|
@ -2858,7 +2858,7 @@ components:
|
|||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/StoreRunSummary"
|
||||
$ref: "#/components/schemas/RunSummary"
|
||||
meta:
|
||||
$ref: "#/components/schemas/PaginationMeta"
|
||||
|
||||
|
|
@ -4374,7 +4374,7 @@ components:
|
|||
additionalProperties:
|
||||
$ref: "#/components/schemas/NodeState"
|
||||
|
||||
StoreRunSummary:
|
||||
RunSummary:
|
||||
description: Durable run summary derived from the backing store.
|
||||
type: object
|
||||
required:
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ fn ensure_provider_registered(client: &Client, provider: Provider) -> anyhow::Re
|
|||
if client
|
||||
.provider_names()
|
||||
.iter()
|
||||
.any(|name| *name == provider.as_str())
|
||||
.any(|name| *name == <&'static str>::from(provider))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa
|
|||
"Here is the conversation to summarize:\n\n{rendered}"
|
||||
)),
|
||||
],
|
||||
provider: Some(provider_profile.provider().as_str().to_string()),
|
||||
provider: Some(provider_profile.provider().to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
response_format: None,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ impl OpenAiProfile {
|
|||
Provider::Zai => "Zhipu AI",
|
||||
Provider::Minimax => "MiniMax",
|
||||
Provider::Inception => "Inception",
|
||||
other => other.as_str(),
|
||||
other => <&'static str>::from(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -726,7 +726,7 @@ impl Session {
|
|||
// Call LLM (streaming) with retry for transient errors
|
||||
let retry_emitter = self.event_emitter.clone();
|
||||
let retry_session_id = self.id.clone();
|
||||
let retry_provider = self.provider_profile.provider().as_str().to_string();
|
||||
let retry_provider = self.provider_profile.provider().to_string();
|
||||
let retry_model = self.provider_profile.model().to_string();
|
||||
let retry_policy = RetryPolicy {
|
||||
max_retries: 3,
|
||||
|
|
@ -991,7 +991,7 @@ impl Session {
|
|||
Request {
|
||||
model: self.provider_profile.model().to_string(),
|
||||
messages,
|
||||
provider: Some(self.provider_profile.provider().as_str().to_string()),
|
||||
provider: Some(self.provider_profile.provider().to_string()),
|
||||
tools: if has_tools { Some(tools) } else { None },
|
||||
tool_choice: if has_tools {
|
||||
Some(ToolChoice::Auto)
|
||||
|
|
|
|||
|
|
@ -605,7 +605,7 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option<WebFetchSummarizer>) -> Reg
|
|||
let request = Request {
|
||||
model: s.model_id.model_id().to_string(),
|
||||
messages: vec![Message::user(summarization_prompt)],
|
||||
provider: Some(s.model_id.provider().as_str().to_string()),
|
||||
provider: Some(s.model_id.provider().to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
response_format: None,
|
||||
|
|
|
|||
|
|
@ -171,9 +171,10 @@ fn main() {
|
|||
"fabro_types::status::RunControlAction",
|
||||
&[],
|
||||
),
|
||||
("RunSummary", "fabro_types::RunSummary", &[]),
|
||||
(
|
||||
"RunStatusRecord",
|
||||
"fabro_types::status::RunStatusRecord",
|
||||
"RepositoryReference",
|
||||
"fabro_types::RepositoryReference",
|
||||
&[],
|
||||
),
|
||||
("WorkflowSettings", "fabro_types::WorkflowSettings", &[]),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pub mod types {
|
|||
pub use fabro_types::status::{
|
||||
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
|
||||
};
|
||||
pub use fabro_types::{ServerSettings, WorkflowSettings};
|
||||
pub use fabro_types::{RepositoryReference, RunSummary, ServerSettings, WorkflowSettings};
|
||||
|
||||
pub use crate::generated::types::*;
|
||||
}
|
||||
|
|
|
|||
122
lib/crates/fabro-api/tests/run_summary_round_trip.rs
Normal file
122
lib/crates/fabro-api/tests/run_summary_round_trip.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_api::types::{
|
||||
RepositoryReference as ApiRepositoryReference, RunSummary as ApiRunSummary,
|
||||
};
|
||||
use fabro_types::status::{RunStatus, SuccessReason, TerminalStatus};
|
||||
use fabro_types::{RepositoryReference, RunId, RunSummary};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn run_summary_reuses_domain_types() {
|
||||
assert_same_type::<ApiRunSummary, RunSummary>();
|
||||
assert_same_type::<ApiRepositoryReference, RepositoryReference>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_summary_json_matches_openapi_shape() {
|
||||
let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap();
|
||||
let run_id = RunId::with_timestamp(created_at, 7);
|
||||
let superseded_by = RunId::with_timestamp(created_at, 8);
|
||||
let summary = RunSummary::new(
|
||||
run_id,
|
||||
Some("workflow".to_string()),
|
||||
Some("workflow".to_string()),
|
||||
String::new(),
|
||||
HashMap::from([("team".to_string(), "core".to_string())]),
|
||||
Some("/tmp/fabro".to_string()),
|
||||
Some(created_at),
|
||||
RunStatus::Archived {
|
||||
prior: TerminalStatus::Succeeded {
|
||||
reason: SuccessReason::PartialSuccess,
|
||||
},
|
||||
},
|
||||
None,
|
||||
Some(42_000),
|
||||
Some(123),
|
||||
Some(superseded_by),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&summary).unwrap(),
|
||||
json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"workflow_name": "workflow",
|
||||
"workflow_slug": "workflow",
|
||||
"goal": "",
|
||||
"title": "",
|
||||
"labels": {
|
||||
"team": "core"
|
||||
},
|
||||
"host_repo_path": "/tmp/fabro",
|
||||
"repository": {
|
||||
"name": "fabro"
|
||||
},
|
||||
"start_time": "2026-04-20T12:00:00Z",
|
||||
"created_at": "2026-04-20T12:00:00Z",
|
||||
"status": {
|
||||
"kind": "archived",
|
||||
"prior": {
|
||||
"kind": "succeeded",
|
||||
"reason": "partial_success"
|
||||
}
|
||||
},
|
||||
"pending_control": null,
|
||||
"duration_ms": 42000,
|
||||
"elapsed_secs": 42.0,
|
||||
"total_usd_micros": 123,
|
||||
"superseded_by": superseded_by.to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_summary_deserializes_when_optional_fields_are_absent() {
|
||||
let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap();
|
||||
let run_id = RunId::with_timestamp(created_at, 7);
|
||||
let summary: RunSummary = serde_json::from_value(json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"goal": "ship it",
|
||||
"title": "ship it",
|
||||
"labels": {},
|
||||
"status": {
|
||||
"kind": "running"
|
||||
},
|
||||
"repository": {
|
||||
"name": "fabro"
|
||||
},
|
||||
"created_at": "2026-04-20T12:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summary.run_id, run_id);
|
||||
assert_eq!(summary.workflow_name, None);
|
||||
assert_eq!(summary.workflow_slug, None);
|
||||
assert_eq!(summary.goal, "ship it");
|
||||
assert_eq!(summary.title, "ship it");
|
||||
assert_eq!(summary.labels, HashMap::new());
|
||||
assert_eq!(summary.host_repo_path, None);
|
||||
assert_eq!(summary.repository, RepositoryReference {
|
||||
name: "fabro".to_string(),
|
||||
});
|
||||
assert_eq!(summary.start_time, None);
|
||||
assert_eq!(summary.created_at, created_at);
|
||||
assert_eq!(summary.status, RunStatus::Running);
|
||||
assert_eq!(summary.pending_control, None);
|
||||
assert_eq!(summary.duration_ms, None);
|
||||
assert_eq!(summary.elapsed_secs, None);
|
||||
assert_eq!(summary.total_usd_micros, None);
|
||||
assert_eq!(summary.superseded_by, None);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ pub fn credential_id_for(credential: &AuthCredential) -> Result<String, String>
|
|||
"codex_oauth credentials are only valid for OpenAI, got {}",
|
||||
credential.provider
|
||||
)),
|
||||
(provider, AuthDetails::ApiKey { .. }) => Ok(provider.as_str().to_string()),
|
||||
(provider, AuthDetails::ApiKey { .. }) => Ok(provider.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, bail};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
|
||||
|
|
@ -15,6 +15,7 @@ use fabro_config::bind::{self, Bind, BindRequest};
|
|||
use fabro_config::user::{active_settings_path, default_storage_dir};
|
||||
use fabro_server::install::{self, InstallAppState, InstallFinishHook, InstallFinishInfo};
|
||||
use fabro_server::serve::{self, ServeArgs};
|
||||
use fabro_server::static_files;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_util::browser;
|
||||
use fabro_util::printer::Printer;
|
||||
|
|
@ -171,6 +172,10 @@ fn maybe_install_bootstrap(
|
|||
storage_dir: Option<&std::path::Path>,
|
||||
serve_args: &ServeArgs,
|
||||
) -> Result<Option<InstallBootstrap>> {
|
||||
if serve_args.no_web {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if explicit_config.is_some() || has_config_env_override() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
|
@ -180,6 +185,12 @@ fn maybe_install_bootstrap(
|
|||
return Ok(None);
|
||||
}
|
||||
|
||||
if !static_files::assets_available() {
|
||||
bail!(
|
||||
"browser install mode requires web UI assets, but none were found.\n\nRun one of:\n cargo dev build # build a binary with the web UI\n fabro install # terminal wizard\n fabro server start --no-web"
|
||||
);
|
||||
}
|
||||
|
||||
let bind_request = match serve_args.bind.as_deref() {
|
||||
Some(bind) => bind::parse_bind(bind)?,
|
||||
None => default_install_bind_request(),
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ impl ServerRunSummaryInfo {
|
|||
}
|
||||
|
||||
pub(crate) fn goal(&self) -> String {
|
||||
self.summary.goal.clone().unwrap_or_default()
|
||||
self.summary.goal.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,13 +85,12 @@ pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Resul
|
|||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let probe_model = Catalog::builtin().probe_for_provider(provider).map_or_else(
|
||||
|| format!("unknown-{}", provider.as_str()),
|
||||
|model| model.id.clone(),
|
||||
);
|
||||
let probe_model = Catalog::builtin()
|
||||
.probe_for_provider(provider)
|
||||
.map_or_else(|| format!("unknown-{provider}"), |model| model.id.clone());
|
||||
|
||||
let params = GenerateParams::new(probe_model, Arc::new(client))
|
||||
.provider(provider.as_str())
|
||||
.provider(<&'static str>::from(provider))
|
||||
.prompt("Say OK")
|
||||
.max_tokens(16);
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ fn secret_list_uses_json_output_format_from_home_config() {
|
|||
assert!(output.status.success());
|
||||
let value: Value =
|
||||
serde_json::from_slice(&output.stdout).expect("secret list config JSON should parse");
|
||||
assert_eq!(value, Value::Array(vec![]));
|
||||
assert!(value.is_array(), "secret list JSON should be an array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -237,7 +237,7 @@ fn graph_json_with_output_reports_file() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn secret_list_json_missing_env_is_empty_array() {
|
||||
fn secret_list_json_missing_env_outputs_json_array() {
|
||||
let context = test_context!();
|
||||
let output = context
|
||||
.command()
|
||||
|
|
@ -247,5 +247,5 @@ fn secret_list_json_missing_env_is_empty_array() {
|
|||
|
||||
assert!(output.status.success());
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("secret list should parse");
|
||||
assert_eq!(value, Value::Array(vec![]));
|
||||
assert!(value.is_array(), "secret list JSON should be an array");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use std::sync::{Arc, Barrier};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_config::{Storage, envfile};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_test::{
|
||||
TestContext, apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files,
|
||||
test_context, wait_for_log_line, wait_for_path,
|
||||
|
|
@ -382,7 +383,7 @@ fn start_already_running_exits_with_error() {
|
|||
clippy::disallowed_methods,
|
||||
reason = "This integration test needs the real foreground process to verify install-mode startup behavior."
|
||||
)]
|
||||
fn start_without_default_settings_enters_install_mode_in_foreground() {
|
||||
fn start_without_default_settings_reports_missing_web_assets_for_browser_install() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
|
|
@ -390,34 +391,25 @@ fn start_without_default_settings_enters_install_mode_in_foreground() {
|
|||
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, home_dir.path());
|
||||
cmd.env("FABRO_STORAGE_DIR", &storage_dir)
|
||||
.env(EnvVars::FABRO_TEST_DISABLE_SPA_ASSETS, "1")
|
||||
.args(["server", "start", "--bind", "127.0.0.1:0"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().expect("server start should spawn");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
let output = cmd.output().expect("server start should run");
|
||||
assert!(
|
||||
child
|
||||
.try_wait()
|
||||
.expect("install-mode server should still be running")
|
||||
.is_none(),
|
||||
"install mode should run in the foreground instead of daemonizing"
|
||||
!output.status.success(),
|
||||
"server start should reject browser install without web assets"
|
||||
);
|
||||
|
||||
child.kill().expect("kill install-mode server");
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("collect install-mode stderr");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains("install mode active"),
|
||||
"expected install mode banner, got: {stderr}"
|
||||
stderr.contains("browser install mode requires web UI assets"),
|
||||
"expected missing-assets guidance, got: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("/install?token="),
|
||||
"expected install-mode URL with token, got: {stderr}"
|
||||
stderr.contains("fabro install"),
|
||||
"expected terminal install guidance, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -716,44 +708,53 @@ fn concurrent_foreground_start_does_not_retruncate_storage_server_log() {
|
|||
clippy::disallowed_methods,
|
||||
reason = "This sync integration test needs the real foreground process to verify install-mode startup warnings."
|
||||
)]
|
||||
fn start_without_settings_ignores_no_web_during_install() {
|
||||
fn start_with_no_web_and_missing_assets_starts_api_only() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
write_dev_token_server_settings(&config_path, "");
|
||||
provision_dev_token_auth(home_dir.path(), &storage_dir);
|
||||
|
||||
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, home_dir.path());
|
||||
cmd.env("FABRO_STORAGE_DIR", &storage_dir)
|
||||
cmd.env(EnvVars::FABRO_TEST_DISABLE_SPA_ASSETS, "1")
|
||||
.args(["server", "start", "--no-web", "--bind", "127.0.0.1:0"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().expect("server start should spawn");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
let output = cmd.output().expect("server start should run");
|
||||
assert!(
|
||||
child
|
||||
.try_wait()
|
||||
.expect("install-mode server should still be running")
|
||||
.is_none(),
|
||||
"install mode should keep running in the foreground"
|
||||
output.status.success(),
|
||||
"server start --no-web should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
child.kill().expect("kill install-mode server");
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("collect install-mode stderr");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains(
|
||||
"Warning: --no-web is ignored during install; will be respected on next start."
|
||||
),
|
||||
"expected --no-web warning, got: {stderr}"
|
||||
!stderr.contains("install mode active"),
|
||||
"server start --no-web should not launch browser install mode: {stderr}"
|
||||
);
|
||||
|
||||
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut stop, home_dir.path());
|
||||
let stop_output = stop
|
||||
.args(["server", "stop"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.output()
|
||||
.expect("server stop should run");
|
||||
assert!(
|
||||
stderr.contains("install mode active"),
|
||||
"expected install mode banner, got: {stderr}"
|
||||
stop_output.status.success(),
|
||||
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&stop_output.stdout),
|
||||
String::from_utf8_lossy(&stop_output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ impl RealAuthHarness {
|
|||
github_base,
|
||||
))),
|
||||
github_webhook_ip_allowlist: None,
|
||||
static_asset_root: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
23
lib/crates/fabro-dev/src/commands/build.rs
Normal file
23
lib/crates/fabro-dev/src/commands/build.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
|
||||
use super::{PlannedCommand, spa_refresh};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct BuildArgs {
|
||||
/// Arguments forwarded to `cargo build`.
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
cargo_args: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn build(args: BuildArgs) -> Result<()> {
|
||||
let root = super::workspace_root();
|
||||
spa_refresh::spa_refresh_root(&root)?;
|
||||
|
||||
let mut command = PlannedCommand::new("cargo").arg("build");
|
||||
for arg in args.cargo_args {
|
||||
command = command.arg(arg);
|
||||
}
|
||||
|
||||
super::run_command(&root, &command)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use std::path::PathBuf;
|
|||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Args, ValueEnum};
|
||||
|
||||
use super::{PlannedCommand, run_command, shell_arg, workspace_root};
|
||||
use super::{PlannedCommand, run_command, shell_arg, spa_refresh, workspace_root};
|
||||
|
||||
const ZIG_VERSION: &str = "0.13.0";
|
||||
|
||||
|
|
@ -98,6 +98,9 @@ impl DockerBuildPlan {
|
|||
reason = "dev docker-build command reports progress directly"
|
||||
)]
|
||||
fn run(&self) -> Result<()> {
|
||||
println!("Refreshing embedded SPA assets...");
|
||||
spa_refresh::spa_refresh_root(&self.workspace_root)?;
|
||||
|
||||
println!(
|
||||
"Building fabro-cli for {} inside rust:1-bookworm via cargo-zigbuild...",
|
||||
self.arch.target()
|
||||
|
|
@ -130,6 +133,7 @@ impl DockerBuildPlan {
|
|||
|
||||
fn dry_run_lines(&self) -> Vec<String> {
|
||||
let mut lines = vec![
|
||||
Self::spa_refresh_command().to_shell_line(),
|
||||
self.build_command().to_shell_line(),
|
||||
format!("mkdir -p {}", shell_arg(self.relative_context_dir())),
|
||||
self.extract_command().to_shell_line(),
|
||||
|
|
@ -142,6 +146,13 @@ impl DockerBuildPlan {
|
|||
lines
|
||||
}
|
||||
|
||||
fn spa_refresh_command() -> PlannedCommand {
|
||||
PlannedCommand::new("cargo")
|
||||
.arg("dev")
|
||||
.arg("spa")
|
||||
.arg("refresh")
|
||||
}
|
||||
|
||||
fn build_command(&self) -> PlannedCommand {
|
||||
let arch = self.arch.to_string();
|
||||
let target = self.arch.target();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod build;
|
||||
mod docker_build;
|
||||
mod docs;
|
||||
mod docs_cli_reference;
|
||||
|
|
@ -11,6 +12,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::process::{Command, Output};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
pub(crate) use build::{BuildArgs, build};
|
||||
pub(crate) use docker_build::{DockerBuildArgs, docker_build};
|
||||
pub(crate) use docs::{DocsArgs, docs};
|
||||
pub(crate) use release::{ReleaseArgs, release};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail};
|
|||
use chrono::{Local, NaiveDate};
|
||||
use clap::Args;
|
||||
|
||||
use super::{PlannedCommand, capture_command, run_command, workspace_root};
|
||||
use super::{PlannedCommand, capture_command, run_command, spa_refresh, workspace_root};
|
||||
|
||||
const RELEASE_EPOCH: &str = "2026-01-01";
|
||||
const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke";
|
||||
|
|
@ -66,7 +66,7 @@ pub(crate) fn release(args: ReleaseArgs) -> Result<()> {
|
|||
}
|
||||
|
||||
plan.ensure_clean_worktree()?;
|
||||
plan.verify_spa_assets()?;
|
||||
spa_refresh::spa_refresh_root(&plan.root)?;
|
||||
plan.verify_release_tests()?;
|
||||
update_version(&cargo_toml, ¤t_version, &new_version)?;
|
||||
println!("Updated {}", cargo_toml.display());
|
||||
|
|
@ -177,10 +177,6 @@ impl ReleasePlan {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_spa_assets(&self) -> Result<()> {
|
||||
run_command(&self.root, &Self::spa_check_command())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stdout,
|
||||
reason = "dev release command reports release test progress directly"
|
||||
|
|
@ -200,8 +196,8 @@ impl ReleasePlan {
|
|||
reason = "dev release command reports dry-run commands directly"
|
||||
)]
|
||||
fn print_dry_run(&self, current_version: &str, new_version: &str, tag: &str) {
|
||||
println!("DRY RUN: would verify SPA assets:");
|
||||
println!("{}", Self::spa_check_command().to_shell_line());
|
||||
println!("DRY RUN: would refresh SPA assets:");
|
||||
println!("{}", Self::spa_refresh_command().to_shell_line());
|
||||
|
||||
if self.skip_tests {
|
||||
println!("--skip-tests set, would skip release-mode test smoke");
|
||||
|
|
@ -239,11 +235,11 @@ impl ReleasePlan {
|
|||
}
|
||||
}
|
||||
|
||||
fn spa_check_command() -> PlannedCommand {
|
||||
fn spa_refresh_command() -> PlannedCommand {
|
||||
PlannedCommand::new("cargo")
|
||||
.arg("dev")
|
||||
.arg("spa")
|
||||
.arg("check")
|
||||
.arg("refresh")
|
||||
}
|
||||
|
||||
fn release_tests_command() -> PlannedCommand {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub(super) fn check_spa_asset_budgets(
|
|||
|
||||
if report.asset_bytes > asset_budget_bytes {
|
||||
bail!(
|
||||
"fabro-spa assets exceed budget: {} > {}",
|
||||
"fabro-spa embedded assets exceed budget: {} > {}",
|
||||
report.asset_bytes,
|
||||
asset_budget_bytes
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ pub(crate) struct SpaRefreshArgs {
|
|||
/// Repository root containing apps/fabro-web and lib/crates/fabro-spa.
|
||||
#[arg(long, hide = true)]
|
||||
root: Option<PathBuf>,
|
||||
/// Skip bun run build and only mirror an existing dist directory.
|
||||
#[arg(long, hide = true)]
|
||||
pub(super) skip_build: bool,
|
||||
/// Override the raw asset budget.
|
||||
#[arg(long, hide = true, default_value_t = DEFAULT_ASSET_BUDGET_BYTES)]
|
||||
pub(super) asset_budget_bytes: u64,
|
||||
|
|
@ -29,11 +26,14 @@ pub(crate) struct SpaRefreshArgs {
|
|||
|
||||
pub(crate) fn spa_refresh(args: SpaRefreshArgs) -> Result<()> {
|
||||
let root = args.root.unwrap_or_else(workspace_root);
|
||||
spa_refresh_root(
|
||||
&root,
|
||||
args.skip_build,
|
||||
args.asset_budget_bytes,
|
||||
args.payload_budget_bytes,
|
||||
spa_refresh_root_with_budgets(&root, args.asset_budget_bytes, args.payload_budget_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn spa_refresh_root(root: &Path) -> Result<()> {
|
||||
spa_refresh_root_with_budgets(
|
||||
root,
|
||||
DEFAULT_ASSET_BUDGET_BYTES,
|
||||
DEFAULT_PAYLOAD_BUDGET_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -41,9 +41,8 @@ pub(crate) fn spa_refresh(args: SpaRefreshArgs) -> Result<()> {
|
|||
clippy::print_stdout,
|
||||
reason = "dev spa refresh command reports progress directly"
|
||||
)]
|
||||
pub(super) fn spa_refresh_root(
|
||||
fn spa_refresh_root_with_budgets(
|
||||
root: &Path,
|
||||
skip_build: bool,
|
||||
asset_budget_bytes: u64,
|
||||
payload_budget_bytes: u64,
|
||||
) -> Result<()> {
|
||||
|
|
@ -51,20 +50,36 @@ pub(super) fn spa_refresh_root(
|
|||
let dist_dir = web_dir.join("dist");
|
||||
let asset_dir = root.join("lib/crates/fabro-spa/assets");
|
||||
|
||||
if !skip_build {
|
||||
println!("Running bun run build in apps/fabro-web...");
|
||||
run_bun_build(&web_dir)?;
|
||||
}
|
||||
println!("Running bun run build in apps/fabro-web...");
|
||||
run_bun_build(&web_dir)?;
|
||||
|
||||
let staging = TempDir::new(root, "refresh")?;
|
||||
mirror_dist(&dist_dir, staging.path())?;
|
||||
check_spa_asset_budgets(staging.path(), asset_budget_bytes, payload_budget_bytes)?;
|
||||
mirror_dist(staging.path(), &asset_dir)?;
|
||||
refresh_from_dist(
|
||||
root,
|
||||
&dist_dir,
|
||||
&asset_dir,
|
||||
asset_budget_bytes,
|
||||
payload_budget_bytes,
|
||||
)?;
|
||||
println!("Refreshed lib/crates/fabro-spa/assets");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_from_dist(
|
||||
root: &Path,
|
||||
dist_dir: &Path,
|
||||
asset_dir: &Path,
|
||||
asset_budget_bytes: u64,
|
||||
payload_budget_bytes: u64,
|
||||
) -> Result<()> {
|
||||
let staging = TempDir::new(root, "refresh")?;
|
||||
mirror_dist(dist_dir, staging.path())?;
|
||||
check_spa_asset_budgets(staging.path(), asset_budget_bytes, payload_budget_bytes)?;
|
||||
mirror_dist(staging.path(), asset_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "dev spa refresh intentionally runs a synchronous Bun subprocess"
|
||||
|
|
@ -128,6 +143,9 @@ pub(super) fn mirror_dist(dist_dir: &Path, asset_dir: &Path) -> Result<()> {
|
|||
})?;
|
||||
}
|
||||
|
||||
std::fs::write(asset_dir.join(".gitkeep"), b"")
|
||||
.with_context(|| format!("writing {}", asset_dir.join(".gitkeep").display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -170,3 +188,89 @@ impl Drop for TempDir {
|
|||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "tests stage temporary SPA fixture files with sync std::fs operations"
|
||||
)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::{mirror_dist, refresh_from_dist};
|
||||
|
||||
fn write_file(root: &Path, path: &str, contents: impl AsRef<[u8]>) {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().expect("fixture path should have parent"))
|
||||
.expect("creating fixture parent directory");
|
||||
std::fs::write(path, contents).expect("writing fixture file");
|
||||
}
|
||||
|
||||
fn read_bytes(root: &Path, path: &str) -> Vec<u8> {
|
||||
std::fs::read(root.join(path)).expect("reading fixture file")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_dist_removes_stale_files_source_maps_and_keeps_directory_tracked() {
|
||||
let fixture = tempfile::tempdir().expect("creating fixture");
|
||||
write_file(fixture.path(), "dist/index.html", b"index");
|
||||
write_file(fixture.path(), "dist/assets/app.js", b"app");
|
||||
write_file(fixture.path(), "dist/assets/app.js.map", b"map");
|
||||
write_file(fixture.path(), "assets/stale.txt", b"stale");
|
||||
|
||||
mirror_dist(&fixture.path().join("dist"), &fixture.path().join("assets"))
|
||||
.expect("mirroring dist");
|
||||
|
||||
assert!(fixture.path().join("assets/index.html").is_file());
|
||||
assert!(fixture.path().join("assets/assets/app.js").is_file());
|
||||
assert!(fixture.path().join("assets/.gitkeep").is_file());
|
||||
assert!(!fixture.path().join("assets/assets/app.js.map").exists());
|
||||
assert!(!fixture.path().join("assets/stale.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_dist_missing_source_errors_cleanly() {
|
||||
let fixture = tempfile::tempdir().expect("creating fixture");
|
||||
|
||||
let error = mirror_dist(&fixture.path().join("dist"), &fixture.path().join("assets"))
|
||||
.expect_err("missing dist should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("apps/fabro-web/dist is missing; run `bun run build`"),
|
||||
"missing dist should explain how to recover: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_budget_failure_leaves_assets_untouched() {
|
||||
let fixture = tempfile::tempdir().expect("creating fixture");
|
||||
write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello");
|
||||
write_file(
|
||||
fixture.path(),
|
||||
"lib/crates/fabro-spa/assets/index.html",
|
||||
b"embedded",
|
||||
);
|
||||
|
||||
let error = refresh_from_dist(
|
||||
fixture.path(),
|
||||
&fixture.path().join("apps/fabro-web/dist"),
|
||||
&fixture.path().join("lib/crates/fabro-spa/assets"),
|
||||
4,
|
||||
100,
|
||||
)
|
||||
.expect_err("budget failure should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("fabro-spa embedded assets exceed budget: 5 > 4"),
|
||||
"budget failure should report raw byte overage: {error:#}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"),
|
||||
b"embedded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ struct Cli {
|
|||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Refresh embedded SPA assets and run cargo build.
|
||||
Build(commands::BuildArgs),
|
||||
/// Build Fabro Docker images with the release pipeline layout.
|
||||
DockerBuild(commands::DockerBuildArgs),
|
||||
/// Manage generated reference documentation.
|
||||
|
|
@ -31,6 +33,7 @@ enum Command {
|
|||
impl Command {
|
||||
fn run(self) -> Result<()> {
|
||||
match self {
|
||||
Self::Build(args) => commands::build(args),
|
||||
Self::DockerBuild(args) => commands::docker_build(args),
|
||||
Self::Docs(args) => commands::docs(args),
|
||||
Self::Release(args) => commands::release(args),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ fn dry_run_prints_equivalent_build_commands() {
|
|||
.clone();
|
||||
let stdout = output_text(&output.stdout);
|
||||
|
||||
assert!(
|
||||
stdout.contains("cargo dev spa refresh"),
|
||||
"dry-run should print SPA refresh command:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("docker run --rm --platform linux/amd64"),
|
||||
"dry-run should print builder docker run:\n{stdout}"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ fn help_lists_scaffolded_commands() {
|
|||
.clone();
|
||||
let stdout = output_text(&output.stdout);
|
||||
|
||||
for command in ["docker-build", "docs", "release", "spa"] {
|
||||
for command in ["build", "docker-build", "docs", "release", "spa"] {
|
||||
assert!(
|
||||
stdout.contains(command),
|
||||
"top-level help should list {command}:\n{stdout}"
|
||||
|
|
@ -114,6 +114,22 @@ fn group_only_docs_prints_subcommand_help_successfully() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_help_lists_forwarded_cargo_args() {
|
||||
let output = fabro_dev()
|
||||
.args(["build", "--help"])
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.clone();
|
||||
let stdout = output_text(&output.stdout);
|
||||
|
||||
assert!(
|
||||
stdout.contains("Arguments forwarded to `cargo build`"),
|
||||
"build help should explain forwarded cargo args:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_dev_alias_points_at_fabro_dev() {
|
||||
let config = read_file(&workspace_root(), ".cargo/config.toml");
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@ fn dry_run_computes_stable_version_from_date() {
|
|||
"dry-run should compute base version from date:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("cargo dev spa check"),
|
||||
"dry-run should print one SPA verification command:\n{stdout}"
|
||||
stdout.contains("cargo dev spa refresh"),
|
||||
"dry-run should print one SPA refresh command:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("git diff --exit-code -- lib/crates/fabro-spa/assets"),
|
||||
|
|
|
|||
|
|
@ -1,135 +1,19 @@
|
|||
use super::{fabro_dev, output_text, read_bytes, write_file};
|
||||
|
||||
#[test]
|
||||
fn refresh_mirrors_dist_and_removes_source_maps() {
|
||||
let fixture = tempfile::tempdir().expect("creating fixture");
|
||||
write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"index");
|
||||
write_file(fixture.path(), "apps/fabro-web/dist/assets/app.js", b"app");
|
||||
write_file(
|
||||
fixture.path(),
|
||||
"apps/fabro-web/dist/assets/app.js.map",
|
||||
b"map",
|
||||
);
|
||||
write_file(
|
||||
fixture.path(),
|
||||
"lib/crates/fabro-spa/assets/stale.txt",
|
||||
b"stale",
|
||||
);
|
||||
|
||||
fn refresh_rejects_removed_skip_build_flag() {
|
||||
let output = fabro_dev()
|
||||
.args([
|
||||
"spa",
|
||||
"refresh",
|
||||
"--root",
|
||||
fixture
|
||||
.path()
|
||||
.to_str()
|
||||
.expect("fixture path should be utf-8"),
|
||||
"--skip-build",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.clone();
|
||||
let stdout = output_text(&output.stdout);
|
||||
|
||||
assert!(
|
||||
stdout.contains("Refreshed lib/crates/fabro-spa/assets"),
|
||||
"spa refresh should report refreshed assets:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.path()
|
||||
.join("lib/crates/fabro-spa/assets/index.html")
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.path()
|
||||
.join("lib/crates/fabro-spa/assets/assets/app.js")
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.path()
|
||||
.join("lib/crates/fabro-spa/assets/assets/app.js.map")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.path()
|
||||
.join("lib/crates/fabro-spa/assets/stale.txt")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_missing_dist_errors_cleanly() {
|
||||
let fixture = tempfile::tempdir().expect("creating fixture");
|
||||
|
||||
let output = fabro_dev()
|
||||
.args([
|
||||
"spa",
|
||||
"refresh",
|
||||
"--root",
|
||||
fixture
|
||||
.path()
|
||||
.to_str()
|
||||
.expect("fixture path should be utf-8"),
|
||||
"--skip-build",
|
||||
])
|
||||
.args(["spa", "refresh", "--skip-build"])
|
||||
.assert()
|
||||
.failure()
|
||||
.code(1)
|
||||
.code(2)
|
||||
.get_output()
|
||||
.clone();
|
||||
let stderr = output_text(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains("apps/fabro-web/dist is missing; run `bun run build`"),
|
||||
"missing dist should explain how to recover:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_budget_failure_leaves_assets_untouched() {
|
||||
let fixture = tempfile::tempdir().expect("creating fixture");
|
||||
write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello");
|
||||
write_file(
|
||||
fixture.path(),
|
||||
"lib/crates/fabro-spa/assets/index.html",
|
||||
b"committed",
|
||||
);
|
||||
|
||||
let output = fabro_dev()
|
||||
.args([
|
||||
"spa",
|
||||
"refresh",
|
||||
"--root",
|
||||
fixture
|
||||
.path()
|
||||
.to_str()
|
||||
.expect("fixture path should be utf-8"),
|
||||
"--skip-build",
|
||||
"--asset-budget-bytes",
|
||||
"4",
|
||||
"--payload-budget-bytes",
|
||||
"100",
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.code(1)
|
||||
.get_output()
|
||||
.clone();
|
||||
let stderr = output_text(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains("fabro-spa assets exceed budget: 5 > 4"),
|
||||
"budget failure should report raw byte overage:\n{stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"),
|
||||
b"committed"
|
||||
stderr.contains("unexpected argument '--skip-build'"),
|
||||
"spa refresh should reject removed --skip-build flag:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +26,7 @@ fn check_passes_when_dist_matches_assets_and_budgets_pass() {
|
|||
"lib/crates/fabro-spa/assets/index.html",
|
||||
b"hello",
|
||||
);
|
||||
write_file(fixture.path(), "lib/crates/fabro-spa/assets/.gitkeep", b"");
|
||||
|
||||
let output = fabro_dev()
|
||||
.args([
|
||||
|
|
@ -206,7 +91,7 @@ fn check_fails_when_assets_exceed_budget() {
|
|||
let stderr = output_text(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains("fabro-spa assets exceed budget: 5 > 4"),
|
||||
stderr.contains("fabro-spa embedded assets exceed budget: 5 > 4"),
|
||||
"budget failure should report raw byte overage:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
|
@ -218,7 +103,7 @@ fn check_fails_when_assets_do_not_match_dist() {
|
|||
write_file(
|
||||
fixture.path(),
|
||||
"lib/crates/fabro-spa/assets/index.html",
|
||||
b"committed",
|
||||
b"embedded",
|
||||
);
|
||||
|
||||
let output = fabro_dev()
|
||||
|
|
@ -245,7 +130,7 @@ fn check_fails_when_assets_do_not_match_dist() {
|
|||
);
|
||||
assert_eq!(
|
||||
read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"),
|
||||
b"committed"
|
||||
b"embedded"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ pub enum ModelTestMode {
|
|||
}
|
||||
|
||||
impl ModelTestMode {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn timeout_secs(self) -> u64 {
|
||||
match self {
|
||||
|
|
@ -40,13 +35,6 @@ pub enum ModelTestStatus {
|
|||
Error,
|
||||
}
|
||||
|
||||
impl ModelTestStatus {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelTestOutcome {
|
||||
pub status: ModelTestStatus,
|
||||
|
|
@ -84,7 +72,7 @@ pub async fn run_model_test(
|
|||
|
||||
async fn run_basic_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
|
||||
let params = GenerateParams::new(&info.id, client)
|
||||
.provider(info.provider.as_str())
|
||||
.provider(<&'static str>::from(info.provider))
|
||||
.prompt("Say OK")
|
||||
.max_tokens(16);
|
||||
|
||||
|
|
@ -152,7 +140,7 @@ fn build_deep_test_params(info: &Model, client: Arc<Client>) -> Option<GenerateP
|
|||
);
|
||||
|
||||
let mut params = GenerateParams::new(&info.id, client)
|
||||
.provider(info.provider.as_str())
|
||||
.provider(<&'static str>::from(info.provider))
|
||||
.prompt(
|
||||
"Use the add tool twice: first add 15 and 27, then add that result to 42. \
|
||||
Finally, tell me whether the grand total is even or odd and why.",
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ use crate::providers::common::{
|
|||
parse_retry_after, send_and_read_response,
|
||||
};
|
||||
use crate::types::{
|
||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
||||
ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall, ToolChoice,
|
||||
ToolDefinition,
|
||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, ReasoningEffort, Request,
|
||||
Response, ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall,
|
||||
ToolChoice, ToolDefinition,
|
||||
};
|
||||
|
||||
/// Provider adapter for the Anthropic Messages API.
|
||||
|
|
@ -547,13 +547,13 @@ fn extract_thinking_config(
|
|||
/// Map a reasoning effort level to a thinking `budget_tokens` value for models
|
||||
/// that don't support the `output_config.effort` parameter (e.g.
|
||||
/// claude-sonnet-4-5).
|
||||
fn effort_to_budget_tokens(effort: &str, max_tokens: i64) -> i64 {
|
||||
fn effort_to_budget_tokens(effort: ReasoningEffort, max_tokens: i64) -> i64 {
|
||||
let budget = match effort {
|
||||
"low" => max_tokens / 4,
|
||||
"high" => max_tokens * 3 / 4,
|
||||
"xhigh" => max_tokens * 7 / 8,
|
||||
"max" => max_tokens,
|
||||
_ => max_tokens / 2, // "medium" or unknown
|
||||
ReasoningEffort::Low => max_tokens / 4,
|
||||
ReasoningEffort::Medium => max_tokens / 2,
|
||||
ReasoningEffort::High => max_tokens * 3 / 4,
|
||||
ReasoningEffort::XHigh => max_tokens * 7 / 8,
|
||||
ReasoningEffort::Max => max_tokens,
|
||||
};
|
||||
// Anthropic requires budget_tokens >= 1024
|
||||
budget.max(1024)
|
||||
|
|
@ -1152,12 +1152,12 @@ async fn build_api_request(
|
|||
if supports_effort {
|
||||
(
|
||||
explicit_thinking,
|
||||
Some(serde_json::json!({"effort": effort.as_str()})),
|
||||
Some(serde_json::json!({"effort": <&'static str>::from(*effort)})),
|
||||
)
|
||||
} else if explicit_thinking.is_none() {
|
||||
// Convert effort level to a thinking budget for models that don't
|
||||
// support the effort parameter (e.g. claude-sonnet-4-5).
|
||||
let budget = effort_to_budget_tokens(effort.as_str(), resolved_max_tokens);
|
||||
let budget = effort_to_budget_tokens(*effort, resolved_max_tokens);
|
||||
if resolved_max_tokens <= budget {
|
||||
resolved_max_tokens = budget + 1024;
|
||||
}
|
||||
|
|
@ -2251,12 +2251,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn effort_to_budget_tokens_xhigh_maps_to_seven_eighths() {
|
||||
assert_eq!(effort_to_budget_tokens("xhigh", 16_000), 14_000);
|
||||
assert_eq!(
|
||||
effort_to_budget_tokens(ReasoningEffort::XHigh, 16_000),
|
||||
14_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_to_budget_tokens_max_maps_to_full_budget() {
|
||||
assert_eq!(effort_to_budget_tokens("max", 16_000), 16_000);
|
||||
assert_eq!(
|
||||
effort_to_budget_tokens(ReasoningEffort::Max, 16_000),
|
||||
16_000
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) ->
|
|||
let reasoning = request
|
||||
.reasoning_effort
|
||||
.as_ref()
|
||||
.map(|effort| serde_json::json!({"effort": effort.as_str()}));
|
||||
.map(|effort| serde_json::json!({"effort": <&'static str>::from(*effort)}));
|
||||
let text = request
|
||||
.response_format
|
||||
.as_ref()
|
||||
|
|
|
|||
|
|
@ -434,12 +434,6 @@ pub enum ReasoningEffort {
|
|||
Max,
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
(*self).into()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3.6 Request ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -1298,8 +1292,10 @@ mod tests {
|
|||
Ok(ReasoningEffort::XHigh)
|
||||
);
|
||||
assert_eq!(ReasoningEffort::from_str("max"), Ok(ReasoningEffort::Max));
|
||||
assert_eq!(ReasoningEffort::XHigh.as_str(), "xhigh");
|
||||
assert_eq!(ReasoningEffort::Max.as_str(), "max");
|
||||
assert_eq!(ReasoningEffort::XHigh.to_string(), "xhigh");
|
||||
assert_eq!(<&'static str>::from(ReasoningEffort::XHigh), "xhigh");
|
||||
assert_eq!(ReasoningEffort::Max.to_string(), "max");
|
||||
assert_eq!(<&'static str>::from(ReasoningEffort::Max), "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -115,13 +115,6 @@ pub enum Speed {
|
|||
Fast,
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ModelRef {
|
||||
pub provider: Provider,
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ impl Catalog {
|
|||
return Vec::new();
|
||||
};
|
||||
|
||||
let Some(fallback_providers) = fallbacks.get(primary.as_str()) else {
|
||||
let Some(fallback_providers) = fallbacks.get(<&'static str>::from(primary)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
|
|
@ -417,13 +417,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_providers_roundtrip_through_as_str() {
|
||||
fn catalog_providers_roundtrip_through_static_str() {
|
||||
for model in Catalog::builtin().list(None) {
|
||||
let roundtripped = Provider::from_str(model.provider.as_str());
|
||||
let roundtripped = Provider::from_str(<&'static str>::from(model.provider));
|
||||
assert_eq!(
|
||||
roundtripped,
|
||||
Ok(model.provider),
|
||||
"catalog model '{}' provider {:?} does not roundtrip through as_str",
|
||||
"catalog model '{}' provider {:?} does not roundtrip through IntoStaticStr",
|
||||
model.id,
|
||||
model.provider
|
||||
);
|
||||
|
|
@ -431,13 +431,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn provider_as_str_roundtrips_through_from_str() {
|
||||
fn provider_static_str_roundtrips_through_from_str() {
|
||||
for &provider in Provider::ALL {
|
||||
let roundtripped = Provider::from_str(provider.as_str());
|
||||
let roundtripped = Provider::from_str(<&'static str>::from(provider));
|
||||
assert_eq!(
|
||||
roundtripped,
|
||||
Ok(provider),
|
||||
"Provider::{provider:?}.as_str() does not round-trip through from_str"
|
||||
"Provider::{provider:?} IntoStaticStr does not round-trip through from_str"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,13 +120,6 @@ impl Provider {
|
|||
Self::OpenAiCompatible => "OpenAI Compatible",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable lowercase string representation used in `Request.provider`,
|
||||
/// adapter names, and other serialization boundaries.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -150,17 +143,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn kimi_as_str() {
|
||||
assert_eq!(Provider::Kimi.as_str(), "kimi");
|
||||
assert_eq!(Provider::Kimi.to_string(), "kimi");
|
||||
assert_eq!(<&'static str>::from(Provider::Kimi), "kimi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zai_as_str() {
|
||||
assert_eq!(Provider::Zai.as_str(), "zai");
|
||||
assert_eq!(Provider::Zai.to_string(), "zai");
|
||||
assert_eq!(<&'static str>::from(Provider::Zai), "zai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_as_str() {
|
||||
assert_eq!(Provider::Minimax.as_str(), "minimax");
|
||||
assert_eq!(Provider::Minimax.to_string(), "minimax");
|
||||
assert_eq!(<&'static str>::from(Provider::Minimax), "minimax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -177,7 +173,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn inception_as_str() {
|
||||
assert_eq!(Provider::Inception.as_str(), "inception");
|
||||
assert_eq!(Provider::Inception.to_string(), "inception");
|
||||
assert_eq!(<&'static str>::from(Provider::Inception), "inception");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ pub enum DisplaySafeUrlError {
|
|||
pub struct DisplaySafeUrl(Url);
|
||||
|
||||
impl DisplaySafeUrl {
|
||||
/// Parse user-provided URL text; [`FromStr`] delegates here.
|
||||
#[inline]
|
||||
pub fn parse(input: &str) -> Result<Self, DisplaySafeUrlError> {
|
||||
let url = Url::parse(input)?;
|
||||
|
|
@ -53,15 +54,6 @@ impl DisplaySafeUrl {
|
|||
Ok(Self(url))
|
||||
}
|
||||
|
||||
/// Create a `DisplaySafeUrl` from an already parsed [`Url`].
|
||||
///
|
||||
/// This does not perform ambiguity checks because parsed URLs from trusted
|
||||
/// HTTP libraries are not human-entered strings.
|
||||
#[inline]
|
||||
pub fn from_url(url: Url) -> Self {
|
||||
Self(url)
|
||||
}
|
||||
|
||||
/// Cast a `&Url` to a `&DisplaySafeUrl` without allocation.
|
||||
#[inline]
|
||||
pub fn ref_cast(url: &Url) -> &Self {
|
||||
|
|
|
|||
|
|
@ -625,6 +625,7 @@ methods = ["dev-token"]
|
|||
web_enabled: false,
|
||||
github_endpoints: None,
|
||||
github_webhook_ip_allowlist: None,
|
||||
static_asset_root: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ pub(crate) async fn resolve_run(
|
|||
match resolve_run_by_selector(
|
||||
&runs,
|
||||
¶ms.selector,
|
||||
|run| run.run_id.clone(),
|
||||
|run| run.run_id.to_string(),
|
||||
|run| run.workflow_slug.clone(),
|
||||
|run| run.workflow_name.clone(),
|
||||
|run| run.created_at,
|
||||
|
|
@ -312,7 +312,10 @@ pub(crate) async fn get_run_status(
|
|||
State(_state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
match runs::summaries().into_iter().find(|run| run.run_id == id) {
|
||||
match runs::summaries()
|
||||
.into_iter()
|
||||
.find(|run| run.run_id.to_string() == id)
|
||||
{
|
||||
Some(run) => (StatusCode::OK, Json(run)).into_response(),
|
||||
None => ApiError::not_found("Run not found.").into_response(),
|
||||
}
|
||||
|
|
@ -755,18 +758,18 @@ fn ts(s: &str) -> DateTime<Utc> {
|
|||
|
||||
mod runs {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_api::types::*;
|
||||
use fabro_types::WorkflowSettings;
|
||||
use fabro_types::settings::run::{
|
||||
DaytonaSettings, DaytonaSnapshotSettings, LocalSandboxSettings, RunGoal, RunModelSettings,
|
||||
RunNamespace, RunPrepareSettings, RunSandboxSettings,
|
||||
};
|
||||
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
|
||||
use fabro_types::{RunId, WorkflowSettings};
|
||||
|
||||
use super::ts;
|
||||
use crate::server::truncate_goal;
|
||||
|
||||
fn labels(entries: &[(&str, &str)]) -> HashMap<String, String> {
|
||||
entries
|
||||
|
|
@ -775,8 +778,26 @@ mod runs {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn demo_run_ids() -> &'static [RunId; 6] {
|
||||
static IDS: OnceLock<[RunId; 6]> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
[
|
||||
RunId::with_timestamp(ts("2026-03-06T14:30:00Z"), 1),
|
||||
RunId::with_timestamp(ts("2026-03-06T12:00:00Z"), 2),
|
||||
RunId::with_timestamp(ts("2026-03-04T15:00:00Z"), 3),
|
||||
RunId::with_timestamp(ts("2026-03-04T10:00:00Z"), 4),
|
||||
RunId::with_timestamp(ts("2026-03-03T16:45:00Z"), 5),
|
||||
RunId::with_timestamp(ts("2026-02-28T14:00:00Z"), 6),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn demo_run_id(index: usize) -> RunId {
|
||||
demo_run_ids()[index - 1]
|
||||
}
|
||||
|
||||
fn summary(
|
||||
run_id: &str,
|
||||
sequence: u128,
|
||||
repo_name: &str,
|
||||
workflow_slug: &str,
|
||||
workflow_name: &str,
|
||||
|
|
@ -788,28 +809,24 @@ mod runs {
|
|||
pending_control: Option<RunControlAction>,
|
||||
total_usd_micros: Option<i64>,
|
||||
entries: &[(&str, &str)],
|
||||
) -> StoreRunSummary {
|
||||
StoreRunSummary {
|
||||
created_at: ts(created_at),
|
||||
duration_ms: elapsed_secs.and_then(duration_ms_from_secs),
|
||||
elapsed_secs,
|
||||
goal: goal.into(),
|
||||
host_repo_path: Some(format!("/demo/{repo_name}")),
|
||||
labels: labels(entries),
|
||||
pending_control,
|
||||
repository: RepositoryReference {
|
||||
name: repo_name.into(),
|
||||
},
|
||||
run_id: run_id.into(),
|
||||
start_time: Some(ts(created_at)),
|
||||
status: parse_run_status(status, status_reason)
|
||||
) -> RunSummary {
|
||||
let created_at = ts(created_at);
|
||||
let run_id = RunId::with_timestamp(created_at, sequence);
|
||||
RunSummary::new(
|
||||
run_id,
|
||||
Some(workflow_name.into()),
|
||||
Some(workflow_slug.into()),
|
||||
goal.into(),
|
||||
labels(entries),
|
||||
Some(format!("/demo/{repo_name}")),
|
||||
Some(created_at),
|
||||
parse_run_status(status, status_reason)
|
||||
.unwrap_or_else(|| panic!("invalid demo run status: {status}")),
|
||||
superseded_by: None,
|
||||
title: truncate_goal(goal),
|
||||
pending_control,
|
||||
elapsed_secs.and_then(duration_ms_from_secs),
|
||||
total_usd_micros,
|
||||
workflow_name: Some(workflow_name.into()),
|
||||
workflow_slug: Some(workflow_slug.into()),
|
||||
}
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_run_status(status: &str, status_reason: Option<&str>) -> Option<RunStatus> {
|
||||
|
|
@ -860,22 +877,19 @@ mod runs {
|
|||
}
|
||||
}
|
||||
|
||||
fn duration_ms_from_secs(secs: f64) -> Option<i64> {
|
||||
fn duration_ms_from_secs(secs: f64) -> Option<u64> {
|
||||
let duration = Duration::try_from_secs_f64(secs).ok()?;
|
||||
duration.as_millis().try_into().ok()
|
||||
}
|
||||
|
||||
fn take_summary(
|
||||
summaries: &mut HashMap<String, StoreRunSummary>,
|
||||
run_id: &str,
|
||||
) -> StoreRunSummary {
|
||||
fn take_summary(summaries: &mut HashMap<RunId, RunSummary>, run_id: RunId) -> RunSummary {
|
||||
summaries
|
||||
.remove(run_id)
|
||||
.remove(&run_id)
|
||||
.unwrap_or_else(|| panic!("missing demo summary: {run_id}"))
|
||||
}
|
||||
|
||||
fn board_item(
|
||||
summary: StoreRunSummary,
|
||||
summary: RunSummary,
|
||||
column: BoardColumn,
|
||||
pull_request: Option<RunPullRequest>,
|
||||
sandbox: Option<RunSandbox>,
|
||||
|
|
@ -884,7 +898,7 @@ mod runs {
|
|||
RunListItem {
|
||||
column,
|
||||
created_at: summary.created_at,
|
||||
duration_ms: summary.duration_ms,
|
||||
duration_ms: summary.duration_ms.and_then(|ms| i64::try_from(ms).ok()),
|
||||
elapsed_secs: summary.elapsed_secs,
|
||||
goal: summary.goal,
|
||||
host_repo_path: summary.host_repo_path,
|
||||
|
|
@ -893,7 +907,7 @@ mod runs {
|
|||
pull_request,
|
||||
question,
|
||||
repository: summary.repository,
|
||||
run_id: summary.run_id,
|
||||
run_id: summary.run_id.to_string(),
|
||||
sandbox,
|
||||
start_time: summary.start_time,
|
||||
status: summary.status,
|
||||
|
|
@ -960,10 +974,10 @@ mod runs {
|
|||
]
|
||||
}
|
||||
|
||||
pub(super) fn summaries() -> Vec<StoreRunSummary> {
|
||||
pub(super) fn summaries() -> Vec<RunSummary> {
|
||||
vec![
|
||||
summary(
|
||||
"run-1",
|
||||
1,
|
||||
"api-server",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -977,7 +991,7 @@ mod runs {
|
|||
&[("branch", "rate-limit"), ("team", "platform")],
|
||||
),
|
||||
summary(
|
||||
"run-2",
|
||||
2,
|
||||
"web-dashboard",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -991,7 +1005,7 @@ mod runs {
|
|||
&[("owner", "frontend")],
|
||||
),
|
||||
summary(
|
||||
"run-3",
|
||||
3,
|
||||
"shared-types",
|
||||
"expand",
|
||||
"Expand",
|
||||
|
|
@ -1005,7 +1019,7 @@ mod runs {
|
|||
&[("priority", "high")],
|
||||
),
|
||||
summary(
|
||||
"run-4",
|
||||
4,
|
||||
"shared-types",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -1019,7 +1033,7 @@ mod runs {
|
|||
&[("owner", "runtime")],
|
||||
),
|
||||
summary(
|
||||
"run-5",
|
||||
5,
|
||||
"web-dashboard",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -1033,7 +1047,7 @@ mod runs {
|
|||
&[("environment", "staging")],
|
||||
),
|
||||
summary(
|
||||
"run-6",
|
||||
6,
|
||||
"api-server",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -1052,26 +1066,26 @@ mod runs {
|
|||
pub(super) fn board_items() -> Vec<RunListItem> {
|
||||
let mut summaries = summaries()
|
||||
.into_iter()
|
||||
.map(|summary| (summary.run_id.clone(), summary))
|
||||
.map(|summary| (summary.run_id, summary))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
vec![
|
||||
board_item(
|
||||
take_summary(&mut summaries, "run-1"),
|
||||
take_summary(&mut summaries, demo_run_id(1)),
|
||||
BoardColumn::Running,
|
||||
None,
|
||||
Some(sandbox("sb-a1b2c3d4", 4, 8)),
|
||||
None,
|
||||
),
|
||||
board_item(
|
||||
take_summary(&mut summaries, "run-2"),
|
||||
take_summary(&mut summaries, demo_run_id(2)),
|
||||
BoardColumn::Running,
|
||||
None,
|
||||
Some(sandbox("sb-e5f6g7h8", 8, 16)),
|
||||
None,
|
||||
),
|
||||
board_item(
|
||||
take_summary(&mut summaries, "run-3"),
|
||||
take_summary(&mut summaries, demo_run_id(3)),
|
||||
BoardColumn::Initializing,
|
||||
Some(pull_request(0, 567, 234, 0, vec![])),
|
||||
Some(sandbox("sb-q7r8s9t0", 4, 8)),
|
||||
|
|
@ -1080,7 +1094,7 @@ mod runs {
|
|||
}),
|
||||
),
|
||||
board_item(
|
||||
take_summary(&mut summaries, "run-4"),
|
||||
take_summary(&mut summaries, demo_run_id(4)),
|
||||
BoardColumn::Blocked,
|
||||
Some(pull_request(0, 145, 23, 0, vec![])),
|
||||
Some(sandbox("sb-u1v2w3x4", 4, 8)),
|
||||
|
|
@ -1089,7 +1103,7 @@ mod runs {
|
|||
}),
|
||||
),
|
||||
board_item(
|
||||
take_summary(&mut summaries, "run-5"),
|
||||
take_summary(&mut summaries, demo_run_id(5)),
|
||||
BoardColumn::Failed,
|
||||
Some(pull_request(889, 234, 67, 4, vec![
|
||||
check("lint", CheckRunStatus::Success, Some(23.0)),
|
||||
|
|
@ -1102,7 +1116,7 @@ mod runs {
|
|||
None,
|
||||
),
|
||||
board_item(
|
||||
take_summary(&mut summaries, "run-6"),
|
||||
take_summary(&mut summaries, demo_run_id(6)),
|
||||
BoardColumn::Succeeded,
|
||||
Some(pull_request(1249, 189, 45, 7, vec![
|
||||
check("lint", CheckRunStatus::Success, Some(21.0)),
|
||||
|
|
@ -1410,7 +1424,7 @@ mod runs {
|
|||
#[test]
|
||||
fn summary_parses_known_status_reason_values() {
|
||||
let summary = summary(
|
||||
"run-test",
|
||||
99,
|
||||
"demo-repo",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -1432,7 +1446,7 @@ mod runs {
|
|||
#[test]
|
||||
fn summary_ignores_unknown_status_reason() {
|
||||
let summary = summary(
|
||||
"run-test",
|
||||
99,
|
||||
"demo-repo",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
@ -1455,7 +1469,7 @@ mod runs {
|
|||
fn summary_derives_title_like_server() {
|
||||
let goal = format!("## Plan: {}", "a".repeat(120));
|
||||
let summary = summary(
|
||||
"run-test",
|
||||
99,
|
||||
"demo-repo",
|
||||
"implement",
|
||||
"Implement",
|
||||
|
|
|
|||
|
|
@ -146,17 +146,16 @@ async fn check_llm_providers(state: &AppState) -> CheckResult {
|
|||
}
|
||||
|
||||
fn probe_model(provider: Provider) -> String {
|
||||
Catalog::builtin().probe_for_provider(provider).map_or_else(
|
||||
|| format!("unknown-{}", provider.as_str()),
|
||||
|m| m.id.clone(),
|
||||
)
|
||||
Catalog::builtin()
|
||||
.probe_for_provider(provider)
|
||||
.map_or_else(|| format!("unknown-{provider}"), |m| m.id.clone())
|
||||
}
|
||||
|
||||
async fn probe_llm_provider(client: &LlmClient, provider: Provider) -> Result<(), String> {
|
||||
let request = Request {
|
||||
model: probe_model(provider),
|
||||
messages: vec![Message::user("hi")],
|
||||
provider: Some(provider.as_str().to_string()),
|
||||
provider: Some(provider.to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
response_format: None,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -58,6 +58,7 @@ pub struct InstallAppState {
|
|||
first_operator: Arc<Mutex<Option<InstallOperatorFingerprint>>>,
|
||||
finish_in_progress: Arc<AtomicBool>,
|
||||
upstreams: InstallUpstreamConfig,
|
||||
static_asset_root: Option<Arc<Path>>,
|
||||
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
|
||||
finish_hook: Option<InstallFinishHook>,
|
||||
}
|
||||
|
|
@ -105,6 +106,7 @@ impl InstallAppState {
|
|||
first_operator: Arc::new(Mutex::new(None)),
|
||||
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
upstreams: InstallUpstreamConfig::default(),
|
||||
static_asset_root: None,
|
||||
on_finish: None,
|
||||
finish_hook: None,
|
||||
}
|
||||
|
|
@ -146,6 +148,7 @@ impl InstallAppState {
|
|||
first_operator: Arc::new(Mutex::new(None)),
|
||||
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
upstreams: InstallUpstreamConfig::default(),
|
||||
static_asset_root: None,
|
||||
on_finish: None,
|
||||
finish_hook: None,
|
||||
}
|
||||
|
|
@ -173,6 +176,12 @@ impl InstallAppState {
|
|||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_static_asset_root(mut self, root: impl Into<PathBuf>) -> Self {
|
||||
self.static_asset_root = Some(Arc::from(root.into()));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_provider_base_url(
|
||||
mut self,
|
||||
|
|
@ -513,8 +522,8 @@ impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn build_install_router(state: InstallAppState) -> Router {
|
||||
static_files::assert_install_mode_shell_ready().await;
|
||||
pub fn build_install_router(state: InstallAppState) -> Router {
|
||||
let static_asset_root = state.static_asset_root.clone();
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
|
|
@ -551,15 +560,25 @@ pub async fn build_install_router(state: InstallAppState) -> Router {
|
|||
)
|
||||
.route("/install/finish", post(post_install_finish))
|
||||
.with_state(state)
|
||||
.fallback_service(service_fn(move |req: Request| async move {
|
||||
let path = req.uri().path().to_string();
|
||||
if path.starts_with("/api/") {
|
||||
Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response())
|
||||
} else if matches!(req.method(), &Method::GET | &Method::HEAD) {
|
||||
let headers = req.headers().clone();
|
||||
Ok::<_, Infallible>(static_files::serve_install(&path, &headers).await)
|
||||
} else {
|
||||
Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response())
|
||||
.fallback_service(service_fn(move |req: Request| {
|
||||
let static_asset_root = static_asset_root.clone();
|
||||
async move {
|
||||
let path = req.uri().path().to_string();
|
||||
if path.starts_with("/api/") {
|
||||
Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response())
|
||||
} else if matches!(req.method(), &Method::GET | &Method::HEAD) {
|
||||
let headers = req.headers().clone();
|
||||
Ok::<_, Infallible>(
|
||||
static_files::serve_install_with_asset_root(
|
||||
&path,
|
||||
&headers,
|
||||
static_asset_root.as_deref(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
}
|
||||
}))
|
||||
.layer(middleware::from_fn(security_headers::layer))
|
||||
|
|
@ -608,7 +627,7 @@ where
|
|||
let bound_listener = bind_install_listener(&bind_request).await?;
|
||||
state.set_install_bind(&bound_listener.bind);
|
||||
let state = state.with_finish_callback(finish_callback);
|
||||
let router = build_install_router(state).await;
|
||||
let router = build_install_router(state);
|
||||
let bind = bound_listener.bind.clone();
|
||||
on_ready(&bind)?;
|
||||
|
||||
|
|
@ -700,7 +719,7 @@ async fn post_install_llm_test(
|
|||
match validate_llm_provider(&state, &input).await {
|
||||
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Err(err) => {
|
||||
warn!(provider = %input.provider.as_str(), error = %err, "install LLM validation failed");
|
||||
warn!(provider = %input.provider, error = %err, "install LLM validation failed");
|
||||
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -732,7 +751,7 @@ async fn put_install_llm(
|
|||
if provider.api_key.trim().is_empty() {
|
||||
return install_error_response(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
format!("api_key is required for {}", provider.provider.as_str()),
|
||||
format!("api_key is required for {}", provider.provider),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1526,8 +1545,17 @@ async fn post_install_finish(
|
|||
(StatusCode::ACCEPTED, Json(body)).into_response()
|
||||
}
|
||||
|
||||
async fn render_install_shell(headers: HeaderMap, uri: OriginalUri) -> Response {
|
||||
static_files::serve_install(uri.path(), &headers).await
|
||||
async fn render_install_shell(
|
||||
State(state): State<InstallAppState>,
|
||||
headers: HeaderMap,
|
||||
uri: OriginalUri,
|
||||
) -> Response {
|
||||
static_files::serve_install_with_asset_root(
|
||||
uri.path(),
|
||||
&headers,
|
||||
state.static_asset_root.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn token_is_valid(state: &InstallAppState, headers: &HeaderMap, query_token: Option<&str>) -> bool {
|
||||
|
|
@ -1675,7 +1703,7 @@ fn redacted_llm(pending_install: &PendingInstall) -> serde_json::Value {
|
|||
|llm| {
|
||||
serde_json::json!({
|
||||
"providers": llm.providers.iter().map(|provider| serde_json::json!({
|
||||
"provider": provider.provider.as_str(),
|
||||
"provider": <&'static str>::from(provider.provider),
|
||||
"configured": true,
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
|
|
@ -1834,7 +1862,7 @@ async fn validate_llm_provider(
|
|||
| Provider::OpenAiCompatible => {
|
||||
return Err(format!(
|
||||
"{} is not supported by install validation",
|
||||
input.provider.as_str()
|
||||
input.provider
|
||||
));
|
||||
}
|
||||
};
|
||||
|
|
@ -1860,7 +1888,7 @@ async fn validate_llm_provider(
|
|||
} else {
|
||||
Err(format!(
|
||||
"{} model lookup failed ({})",
|
||||
input.provider.as_str(),
|
||||
input.provider,
|
||||
response.status()
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::{Context, bail};
|
||||
use clap::Args;
|
||||
use fabro_config::bind::{self, Bind, BindRequest};
|
||||
use fabro_config::{
|
||||
|
|
@ -40,6 +40,7 @@ use crate::server::{
|
|||
};
|
||||
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
|
||||
use crate::startup::resolve_startup;
|
||||
use crate::static_files;
|
||||
|
||||
pub const DEFAULT_TCP_PORT: u16 = 32276;
|
||||
type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
|
@ -640,7 +641,18 @@ where
|
|||
std::fs::create_dir_all(&data_dir)
|
||||
.with_context(|| format!("creating data directory {}", data_dir.display()))?;
|
||||
let max_concurrent_runs = resolved_server_settings.scheduler.max_concurrent_runs;
|
||||
let web_enabled = resolved_server_settings.web.enabled;
|
||||
let web_enabled = if resolved_server_settings.web.enabled {
|
||||
if static_files::assets_available() {
|
||||
true
|
||||
} else if args.web {
|
||||
bail!("--web requires web UI assets, but none were found");
|
||||
} else {
|
||||
warn!("Web UI assets unavailable, serving API-only mode");
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?;
|
||||
|
||||
let (object_store, slatedb_prefix, flush_interval, disk_cache) =
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ use fabro_types::{
|
|||
RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance,
|
||||
RunServerProvenance, RunSubjectProvenance, ServerSettings,
|
||||
};
|
||||
use fabro_util::text::strip_goal_decoration;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use fabro_vault::{Error as VaultError, SecretType, Vault};
|
||||
use fabro_workflow::artifact_upload::ArtifactSink;
|
||||
|
|
@ -443,7 +442,7 @@ impl SlackService {
|
|||
id: props.question_id.clone(),
|
||||
text: props.question.clone(),
|
||||
stage: props.stage.clone(),
|
||||
question_type: InterviewQuestionType::from_wire_name(&props.question_type),
|
||||
question_type: props.question_type.parse().unwrap_or_default(),
|
||||
options: props.options.clone(),
|
||||
allow_freeform: props.allow_freeform,
|
||||
timeout_seconds: props.timeout_seconds,
|
||||
|
|
@ -950,6 +949,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct RouterOptions {
|
||||
pub web_enabled: bool,
|
||||
pub static_asset_root: Option<PathBuf>,
|
||||
pub github_endpoints: Option<Arc<GithubEndpoints>>,
|
||||
pub github_webhook_ip_allowlist: Option<Arc<IpAllowlistConfig>>,
|
||||
}
|
||||
|
|
@ -958,6 +958,7 @@ impl Default for RouterOptions {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
web_enabled: true,
|
||||
static_asset_root: None,
|
||||
github_endpoints: None,
|
||||
github_webhook_ip_allowlist: None,
|
||||
}
|
||||
|
|
@ -977,6 +978,7 @@ pub fn build_router_with_options(
|
|||
) -> Router {
|
||||
start_optional_slack_service(&state);
|
||||
let web_enabled = options.web_enabled;
|
||||
let static_asset_root = options.static_asset_root.clone();
|
||||
let webhook_ip_allowlist = options.github_webhook_ip_allowlist;
|
||||
let translation_state = Arc::clone(&state);
|
||||
let state_for_canonical_host = Arc::clone(&state);
|
||||
|
|
@ -1044,6 +1046,7 @@ pub fn build_router_with_options(
|
|||
.route("/health", get(health))
|
||||
.fallback_service(service_fn(move |req: axum_extract::Request| {
|
||||
let dispatch = dispatch.clone();
|
||||
let static_asset_root = static_asset_root.clone();
|
||||
async move {
|
||||
let path = req.uri().path().to_string();
|
||||
let dispatch_path = path.starts_with("/api/")
|
||||
|
|
@ -1055,7 +1058,14 @@ pub fn build_router_with_options(
|
|||
Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response())
|
||||
} else if web_enabled && matches!(req.method(), &Method::GET | &Method::HEAD) {
|
||||
let headers = req.headers().clone();
|
||||
Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers).await)
|
||||
Ok::<_, std::convert::Infallible>(
|
||||
static_files::serve_with_asset_root(
|
||||
&path,
|
||||
&headers,
|
||||
static_asset_root.as_deref(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
|
|
@ -2868,56 +2878,6 @@ pub(crate) fn board_columns() -> serde_json::Value {
|
|||
])
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_goal(goal: &str) -> String {
|
||||
const MAX_LEN: usize = 100;
|
||||
|
||||
let stripped = strip_goal_decoration(goal);
|
||||
let char_count = stripped.chars().count();
|
||||
if char_count <= MAX_LEN {
|
||||
return stripped.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = stripped.chars().take(MAX_LEN - 3).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn repository_name(host_repo_path: Option<&str>) -> String {
|
||||
host_repo_path
|
||||
.and_then(|path| path.rsplit(['/', '\\']).find(|segment| !segment.is_empty()))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
|
||||
duration_ms.map(|ms| ms as f64 / 1000.0)
|
||||
}
|
||||
|
||||
fn summary_to_api_run_summary(summary: fabro_types::RunSummary) -> serde_json::Value {
|
||||
let goal = summary.goal.unwrap_or_default();
|
||||
let title = truncate_goal(&goal);
|
||||
let repository = repository_name(summary.host_repo_path.as_deref());
|
||||
let created_at = summary.run_id.created_at().to_rfc3339();
|
||||
|
||||
serde_json::json!({
|
||||
"run_id": summary.run_id.to_string(),
|
||||
"workflow_name": summary.workflow_name,
|
||||
"workflow_slug": summary.workflow_slug,
|
||||
"goal": goal,
|
||||
"title": title,
|
||||
"labels": summary.labels,
|
||||
"host_repo_path": summary.host_repo_path,
|
||||
"repository": { "name": repository },
|
||||
"start_time": summary.start_time.map(|time| time.to_rfc3339()),
|
||||
"status": summary.status,
|
||||
"pending_control": summary.pending_control,
|
||||
"duration_ms": summary.duration_ms,
|
||||
"elapsed_secs": elapsed_secs(summary.duration_ms),
|
||||
"total_usd_micros": summary.total_usd_micros,
|
||||
"superseded_by": summary.superseded_by.map(|run_id| run_id.to_string()),
|
||||
"created_at": created_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn board_run_metadata(
|
||||
state: &AppState,
|
||||
run_id: RunId,
|
||||
|
|
@ -3010,7 +2970,8 @@ async fn list_board_runs(
|
|||
let mut data = Vec::with_capacity(page_summaries.len());
|
||||
for (summary, column) in page_summaries {
|
||||
let run_id = summary.run_id;
|
||||
let mut item = summary_to_api_run_summary(summary);
|
||||
let mut item =
|
||||
serde_json::to_value(&summary).expect("RunSummary serialization is infallible");
|
||||
item["column"] = serde_json::json!(column);
|
||||
if let Some(object) = item.as_object_mut() {
|
||||
object.extend(board_run_metadata(state.as_ref(), run_id).await);
|
||||
|
|
@ -3045,7 +3006,6 @@ async fn list_runs(
|
|||
.filter(|summary| {
|
||||
include_archived || !matches!(summary.status, RunStatus::Archived { .. })
|
||||
})
|
||||
.map(summary_to_api_run_summary)
|
||||
.collect::<Vec<_>>();
|
||||
let (data, has_more) = paginate_items(items, ¶ms.pagination());
|
||||
(
|
||||
|
|
@ -3099,11 +3059,7 @@ async fn resolve_run(
|
|||
|run| run.workflow_name.clone(),
|
||||
|run| run.run_id.created_at(),
|
||||
) {
|
||||
Ok(run) => (
|
||||
StatusCode::OK,
|
||||
Json(summary_to_api_run_summary(run.clone())),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(run) => (StatusCode::OK, Json(run.clone())).into_response(),
|
||||
Err(err @ (ResolveRunError::InvalidSelector | ResolveRunError::AmbiguousPrefix { .. })) => {
|
||||
ApiError::bad_request(err.to_string()).into_response()
|
||||
}
|
||||
|
|
@ -5174,7 +5130,7 @@ async fn get_run_status(
|
|||
.await
|
||||
{
|
||||
Ok(runs) => match runs.into_iter().find(|run| run.run_id == id) {
|
||||
Some(run) => (StatusCode::OK, Json(summary_to_api_run_summary(run))).into_response(),
|
||||
Some(run) => (StatusCode::OK, Json(run)).into_response(),
|
||||
None => ApiError::not_found("Run not found.").into_response(),
|
||||
},
|
||||
Err(err) => {
|
||||
|
|
@ -7532,11 +7488,8 @@ async fn test_model(
|
|||
{
|
||||
return ApiError::bad_request(auth_issue_message(info.provider, issue)).into_response();
|
||||
}
|
||||
if !llm_result
|
||||
.client
|
||||
.provider_names()
|
||||
.contains(&info.provider.as_str())
|
||||
{
|
||||
let provider_name = <&'static str>::from(info.provider);
|
||||
if !llm_result.client.provider_names().contains(&provider_name) {
|
||||
return Json(serde_json::json!({
|
||||
"model_id": info.id,
|
||||
"status": "skip",
|
||||
|
|
@ -7548,7 +7501,7 @@ async fn test_model(
|
|||
let outcome = run_model_test(info, mode, client).await;
|
||||
Json(serde_json::json!({
|
||||
"model_id": info.id,
|
||||
"status": outcome.status.as_str(),
|
||||
"status": <&'static str>::from(outcome.status),
|
||||
"error_message": outcome.error_message,
|
||||
}))
|
||||
.into_response()
|
||||
|
|
@ -8029,7 +7982,6 @@ mod tests {
|
|||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[cfg(unix)]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
|
|
@ -8095,7 +8047,19 @@ mod tests {
|
|||
|
||||
fn test_app_with() -> Router {
|
||||
let state = create_app_state();
|
||||
build_router(state, AuthMode::Disabled)
|
||||
build_router_with_options(
|
||||
state,
|
||||
&AuthMode::Disabled,
|
||||
Arc::new(IpAllowlistConfig::default()),
|
||||
RouterOptions {
|
||||
static_asset_root: Some(spa_fixture_root()),
|
||||
..RouterOptions::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn spa_fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
|
||||
}
|
||||
|
||||
fn test_app_with_scheduler(state: Arc<AppState>) -> Router {
|
||||
|
|
@ -13504,18 +13468,24 @@ provider = "local"
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_get_run_returns_store_run_summary_shape() {
|
||||
async fn demo_get_run_returns_run_summary_shape() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
let run_id = RunId::with_timestamp(
|
||||
"2026-03-06T14:30:00Z"
|
||||
.parse()
|
||||
.expect("demo timestamp should parse"),
|
||||
1,
|
||||
);
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs/run-1"))
|
||||
.uri(api(&format!("/runs/{run_id}")))
|
||||
.header("X-Fabro-Demo", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
// Should have StoreRunSummary fields, not RunStatusResponse fields
|
||||
// Should have RunSummary fields, not RunStatusResponse fields
|
||||
assert!(body["run_id"].is_string(), "should have run_id field");
|
||||
assert!(body["goal"].is_string(), "should have goal field");
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -4,47 +4,71 @@ use std::sync::OnceLock;
|
|||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use fabro_static::EnvVars;
|
||||
use tokio::fs;
|
||||
|
||||
const INSTALL_MODE_MARKER: &str = "__FABRO_MODE__ = \"install\"";
|
||||
|
||||
pub async fn serve(path: &str, headers: &HeaderMap) -> Response {
|
||||
serve_with_mode(path, headers, SpaMode::Normal).await
|
||||
serve_with_asset_root(path, headers, None).await
|
||||
}
|
||||
|
||||
pub async fn serve_install(path: &str, headers: &HeaderMap) -> Response {
|
||||
serve_with_mode(path, headers, SpaMode::Install).await
|
||||
serve_install_with_asset_root(path, headers, None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn assert_install_mode_shell_ready() {
|
||||
let shell = match cached_install_mode_shell().await {
|
||||
Some(shell) => shell,
|
||||
None => load_injected_install_shell()
|
||||
.await
|
||||
.expect("install-mode SPA shell asset missing"),
|
||||
};
|
||||
let html = String::from_utf8(shell).expect("install-mode SPA shell must be valid UTF-8");
|
||||
assert!(
|
||||
html.contains(INSTALL_MODE_MARKER),
|
||||
"install-mode SPA shell marker missing after injection"
|
||||
);
|
||||
pub async fn serve_with_asset_root(
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
asset_root: Option<&Path>,
|
||||
) -> Response {
|
||||
serve_with_mode(path, headers, SpaMode::Normal, asset_root).await
|
||||
}
|
||||
|
||||
async fn cached_install_mode_shell() -> Option<Vec<u8>> {
|
||||
pub async fn serve_install_with_asset_root(
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
asset_root: Option<&Path>,
|
||||
) -> Response {
|
||||
serve_with_mode(path, headers, SpaMode::Install, asset_root).await
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn assets_available() -> bool {
|
||||
assets_available_with_root(None)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn assets_available_with_root(asset_root: Option<&Path>) -> bool {
|
||||
if spa_assets_disabled_for_test() {
|
||||
return false;
|
||||
}
|
||||
if asset_root.is_some_and(|root| root.join("index.html").is_file()) {
|
||||
return true;
|
||||
}
|
||||
if cfg!(debug_assertions) && disk_asset_root().join("index.html").is_file() {
|
||||
return true;
|
||||
}
|
||||
fabro_spa::get("index.html").is_some()
|
||||
}
|
||||
|
||||
async fn cached_install_mode_shell(asset_root: Option<&Path>) -> Option<Vec<u8>> {
|
||||
static SHELL: OnceLock<Option<Vec<u8>>> = OnceLock::new();
|
||||
if cfg!(debug_assertions) {
|
||||
if asset_root.is_some() || cfg!(debug_assertions) {
|
||||
// In debug builds the SPA is reloaded from disk on every request.
|
||||
return load_injected_install_shell().await;
|
||||
return load_injected_install_shell(asset_root).await;
|
||||
}
|
||||
if let Some(cached) = SHELL.get() {
|
||||
return cached.clone();
|
||||
}
|
||||
let loaded = load_injected_install_shell().await;
|
||||
let loaded = load_injected_install_shell(None).await;
|
||||
SHELL.get_or_init(|| loaded).clone()
|
||||
}
|
||||
|
||||
async fn load_injected_install_shell() -> Option<Vec<u8>> {
|
||||
Some(inject_install_mode(load_asset("index.html").await?))
|
||||
async fn load_injected_install_shell(asset_root: Option<&Path>) -> Option<Vec<u8>> {
|
||||
Some(inject_install_mode(
|
||||
load_asset("index.html", asset_root).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
|
@ -53,14 +77,19 @@ enum SpaMode {
|
|||
Install,
|
||||
}
|
||||
|
||||
async fn serve_with_mode(path: &str, headers: &HeaderMap, mode: SpaMode) -> Response {
|
||||
async fn serve_with_mode(
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
mode: SpaMode,
|
||||
asset_root: Option<&Path>,
|
||||
) -> Response {
|
||||
let normalized = normalize(path);
|
||||
|
||||
if is_source_map(&normalized) {
|
||||
return (StatusCode::NOT_FOUND, "Static asset not found").into_response();
|
||||
}
|
||||
|
||||
if let Some(asset) = load_asset_for_mode(&normalized, mode).await {
|
||||
if let Some(asset) = load_asset_for_mode(&normalized, mode, asset_root).await {
|
||||
return asset_response(&normalized, asset);
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +98,7 @@ async fn serve_with_mode(path: &str, headers: &HeaderMap, mode: SpaMode) -> Resp
|
|||
// `Accept: */*`, and similar non-HTML clients get a 404 so typos
|
||||
// don't silently return 25KB of UI shell.
|
||||
if accepts_html(headers) {
|
||||
if let Some(index) = load_asset_for_mode("index.html", mode).await {
|
||||
if let Some(index) = load_asset_for_mode("index.html", mode, asset_root).await {
|
||||
return asset_response("index.html", index);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +129,15 @@ fn normalize(path: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
async fn load_asset(path: &str) -> Option<Vec<u8>> {
|
||||
async fn load_asset(path: &str, asset_root: Option<&Path>) -> Option<Vec<u8>> {
|
||||
if spa_assets_disabled_for_test() {
|
||||
return None;
|
||||
}
|
||||
if let Some(root) = asset_root {
|
||||
if let Some(bytes) = read_disk_asset_from_root(root, path).await {
|
||||
return Some(bytes);
|
||||
}
|
||||
}
|
||||
if cfg!(debug_assertions) {
|
||||
if let Some(bytes) = read_disk_asset(path).await {
|
||||
return Some(bytes);
|
||||
|
|
@ -110,11 +147,25 @@ async fn load_asset(path: &str) -> Option<Vec<u8>> {
|
|||
fabro_spa::get(path).map(fabro_spa::AssetBytes::into_vec)
|
||||
}
|
||||
|
||||
async fn load_asset_for_mode(path: &str, mode: SpaMode) -> Option<Vec<u8>> {
|
||||
async fn load_asset_for_mode(
|
||||
path: &str,
|
||||
mode: SpaMode,
|
||||
asset_root: Option<&Path>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if mode == SpaMode::Install && path == "index.html" {
|
||||
return cached_install_mode_shell().await;
|
||||
return cached_install_mode_shell(asset_root).await;
|
||||
}
|
||||
load_asset(path).await
|
||||
load_asset(path, asset_root).await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "test-only process-env switch disables SPA discovery for asset-independent tests"
|
||||
)]
|
||||
fn spa_assets_disabled_for_test() -> bool {
|
||||
std::env::var(EnvVars::FABRO_TEST_DISABLE_SPA_ASSETS)
|
||||
.ok()
|
||||
.is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no"))
|
||||
}
|
||||
|
||||
fn inject_install_mode(bytes: Vec<u8>) -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -1247,6 +1247,7 @@ client_id = "github-client-id"
|
|||
.expect("api base should parse"),
|
||||
))),
|
||||
github_webhook_ip_allowlist: None,
|
||||
static_asset_root: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
3
lib/crates/fabro-server/tests/fixtures/spa/favicon.svg
vendored
Normal file
3
lib/crates/fabro-server/tests/fixtures/spa/favicon.svg
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<rect width="16" height="16" fill="#111827"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 116 B |
10
lib/crates/fabro-server/tests/fixtures/spa/index.html
vendored
Normal file
10
lib/crates/fabro-server/tests/fixtures/spa/index.html
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Fabro Test SPA</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
|
@ -28,6 +29,10 @@ use tracing_subscriber::{Layer, Registry};
|
|||
|
||||
use crate::helpers::{checked_response, response_json, response_status, response_text};
|
||||
|
||||
fn spa_fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EventCapture {
|
||||
fields: Vec<(String, String)>,
|
||||
|
|
@ -172,7 +177,9 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_router_isolated_from_normal_api_surface() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(
|
||||
InstallAppState::for_test("test-install-token").with_static_asset_root(spa_fixture_root()),
|
||||
);
|
||||
|
||||
let health_response = app
|
||||
.clone()
|
||||
|
|
@ -222,7 +229,7 @@ async fn install_router_isolated_from_normal_api_surface() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_session_requires_valid_install_token() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let unauthorized = app
|
||||
.clone()
|
||||
|
|
@ -264,7 +271,7 @@ async fn install_session_requires_valid_install_token() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_session_sanitizes_wildcard_host_prefill() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
|
@ -285,7 +292,7 @@ async fn install_session_sanitizes_wildcard_host_prefill() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_endpoints_reject_missing_and_wrong_tokens() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
let cases = [
|
||||
("GET", "/install/session", None),
|
||||
(
|
||||
|
|
@ -380,7 +387,7 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
|
@ -399,7 +406,7 @@ async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong
|
|||
|
||||
#[tokio::test]
|
||||
async fn object_store_local_validation_and_save_update_install_session() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let validation_response = app
|
||||
.clone()
|
||||
|
|
@ -449,7 +456,7 @@ async fn object_store_local_validation_and_save_update_install_session() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn object_store_validation_rejects_runtime_mode_access_keys_without_echoing_secrets() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
let access_key_id = "AKIA_RUNTIME_SHOULD_NOT_LEAK";
|
||||
let secret_access_key = "runtime-secret-should-not-leak";
|
||||
|
||||
|
|
@ -485,7 +492,7 @@ async fn object_store_validation_rejects_runtime_mode_access_keys_without_echoin
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_finish_requires_object_store_step() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
|
|
@ -522,8 +529,7 @@ async fn manual_object_store_session_is_redacted_and_blank_resubmit_preserves_cr
|
|||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
));
|
||||
|
||||
put_install_object_store(
|
||||
&app,
|
||||
|
|
@ -604,8 +610,7 @@ async fn switching_object_store_from_manual_to_runtime_clears_saved_manual_crede
|
|||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
));
|
||||
|
||||
put_install_object_store(
|
||||
&app,
|
||||
|
|
@ -690,8 +695,7 @@ async fn runtime_object_store_finish_removes_managed_aws_keys_but_keeps_unmarked
|
|||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
));
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_object_store(
|
||||
|
|
@ -735,8 +739,7 @@ async fn token_install_finish_persists_settings_env_and_vault() {
|
|||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
));
|
||||
configure_token_install(&app, "test-install-token").await;
|
||||
|
||||
let finish_response = app
|
||||
|
|
@ -812,8 +815,7 @@ async fn token_install_finish_invokes_finish_hook_before_response_returns() {
|
|||
let app = build_install_router(
|
||||
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
||||
.with_finish_hook(hook),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
configure_token_install(&app, "test-install-token").await;
|
||||
|
||||
let finish_response = app
|
||||
|
|
@ -874,8 +876,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
|
|||
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
||||
.with_home(home.clone())
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
let llm_response = app
|
||||
.clone()
|
||||
|
|
@ -1024,8 +1025,7 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
|
|||
.with_finish_callback(Arc::new(move || {
|
||||
callback_flag.store(true, Ordering::Release);
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
configure_token_install(&app, "test-install-token").await;
|
||||
|
||||
|
|
@ -1087,8 +1087,7 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() {
|
|||
InstallAppState::for_test("test-install-token")
|
||||
.with_provider_base_url(Provider::Anthropic, format!("{}/v1", llm_mock.url("")))
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
let llm_response = app
|
||||
.clone()
|
||||
|
|
@ -1153,8 +1152,7 @@ async fn github_app_manifest_round_trip_updates_install_session() {
|
|||
let app = build_install_router(
|
||||
InstallAppState::for_test("test-install-token")
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
|
|
@ -1271,7 +1269,7 @@ async fn github_app_manifest_round_trip_updates_install_session() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn github_app_manifest_retry_replaces_pending_and_preserves_prior_token_strategy() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
|
|
@ -1443,8 +1441,7 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
|
|||
let app = build_install_router(
|
||||
InstallAppState::for_test("test-install-token")
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
|
|
@ -1604,8 +1601,7 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
|
|||
let app = build_install_router(
|
||||
InstallAppState::for_test("test-install-token")
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
|
|
@ -1711,7 +1707,7 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_server_rejects_trailing_slash_canonical_urls() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
|
@ -1742,7 +1738,7 @@ async fn install_server_rejects_trailing_slash_canonical_urls() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_server_rejects_wildcard_canonical_urls() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
for canonical_url in [
|
||||
"http://0.0.0.0:32276",
|
||||
|
|
@ -1798,8 +1794,7 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys(
|
|||
.with_finish_callback(Arc::new(move || {
|
||||
callback_flag.store(true, Ordering::Release);
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
configure_token_install(&app, "test-install-token").await;
|
||||
|
||||
|
|
@ -1891,8 +1886,7 @@ async fn install_finish_failure_with_manual_credentials_does_not_leak_values() {
|
|||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
));
|
||||
|
||||
let access_key_id = "AKIA_FINISH_SHOULD_NOT_LEAK";
|
||||
let secret_access_key = "finish-secret-should-not-leak";
|
||||
|
|
@ -1971,8 +1965,7 @@ async fn install_finish_failure_reports_only_env_keys_actually_removed() {
|
|||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
));
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_object_store(
|
||||
|
|
@ -2032,8 +2025,7 @@ async fn install_finish_failure_does_not_create_home_dev_token() {
|
|||
let app = build_install_router(
|
||||
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
||||
.with_home(home.clone()),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
configure_token_install(&app, "test-install-token").await;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::helpers::response_json;
|
|||
|
||||
#[tokio::test]
|
||||
async fn install_llm_endpoints_reject_openai_compatible_in_v1() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let test_response = app
|
||||
.clone()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
|
|
@ -40,6 +41,10 @@ methods = ["dev-token"]
|
|||
.expect("auth mode should resolve")
|
||||
}
|
||||
|
||||
fn spa_fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_unversioned_routes_return_404() {
|
||||
let app = build_router(create_app_state(), AuthMode::Disabled);
|
||||
|
|
@ -59,7 +64,15 @@ async fn old_unversioned_routes_return_404() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn root_and_health_stay_at_root() {
|
||||
let app = build_router(create_app_state(), AuthMode::Disabled);
|
||||
let app = build_router_with_options(
|
||||
create_app_state(),
|
||||
&AuthMode::Disabled,
|
||||
Arc::new(IpAllowlistConfig::default()),
|
||||
RouterOptions {
|
||||
static_asset_root: Some(spa_fixture_root()),
|
||||
..RouterOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
let root_req = Request::builder()
|
||||
.method("GET")
|
||||
|
|
@ -138,7 +151,15 @@ async fn source_maps_are_not_served() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn web_enabled_serves_web_only_routes() {
|
||||
let app = build_router(create_app_state(), AuthMode::Disabled);
|
||||
let app = build_router_with_options(
|
||||
create_app_state(),
|
||||
&AuthMode::Disabled,
|
||||
Arc::new(IpAllowlistConfig::default()),
|
||||
RouterOptions {
|
||||
static_asset_root: Some(spa_fixture_root()),
|
||||
..RouterOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth_me_request = Request::builder()
|
||||
.method("GET")
|
||||
|
|
@ -294,7 +315,15 @@ async fn toggle_demo_allows_authenticated_requests() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn security_headers_are_applied_to_all_responses() {
|
||||
let app = build_router(create_app_state(), AuthMode::Disabled);
|
||||
let app = build_router_with_options(
|
||||
create_app_state(),
|
||||
&AuthMode::Disabled,
|
||||
Arc::new(IpAllowlistConfig::default()),
|
||||
RouterOptions {
|
||||
static_asset_root: Some(spa_fixture_root()),
|
||||
..RouterOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
// Plain HTTP: HSTS must NOT be present.
|
||||
let api_response = checked_response(
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ fn request_for(method: &Method, uri: &str) -> Request<Body> {
|
|||
async fn all_spec_routes_are_routable() {
|
||||
let spec = load_spec();
|
||||
let normal_app = build_router(test_app_state(), AuthMode::Disabled);
|
||||
let install_app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let install_app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let paths = spec
|
||||
.get("paths")
|
||||
|
|
@ -175,7 +175,7 @@ async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present()
|
|||
async fn install_and_normal_routes_stay_isolated() {
|
||||
let spec = load_spec();
|
||||
let normal_app = build_router(test_app_state(), AuthMode::Disabled);
|
||||
let install_app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let install_app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let paths = spec
|
||||
.get("paths")
|
||||
|
|
|
|||
0
lib/crates/fabro-spa/assets/.gitkeep
generated
Normal file
0
lib/crates/fabro-spa/assets/.gitkeep
generated
Normal file
BIN
lib/crates/fabro-spa/assets/apple-touch-icon.png
generated
BIN
lib/crates/fabro-spa/assets/apple-touch-icon.png
generated
Binary file not shown.
|
Before Width: | Height: | Size: 7.7 KiB |
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
import{S as a}from"./chunk-c8zhk10v.js";import"./chunk-xg9nsz1a.js";import"./chunk-gf0502ds.js";export{a as default};
|
||||
|
|
@ -1 +0,0 @@
|
|||
import{d as a}from"./chunk-5q0vf5kd.js";import"./chunk-z1p7fbkb.js";import"./chunk-amk943wr.js";import"./chunk-972wx742.js";import"./chunk-ept66kdn.js";import"./chunk-1ehq66yp.js";import"./chunk-xg9nsz1a.js";import"./chunk-z868q2s0.js";import"./chunk-gf0502ds.js";export{a as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
import{e}from"./chunk-7zy2rxws.js";import"./chunk-gf0502ds.js";var n=Object.freeze(JSON.parse('{"displayName":"Nextflow","name":"nextflow","patterns":[{"include":"#nextflow"}],"repository":{"enum-def":{"begin":"^\\\\s*(enum)\\\\s+(\\\\w+)\\\\s*\\\\{","beginCaptures":{"1":{"name":"keyword.nextflow"},"2":{"name":"storage.type.groovy"}},"end":"}","patterns":[{"include":"source.nextflow-groovy#groovy"},{"include":"#enum-values"}]},"enum-values":{"patterns":[{"begin":"(?<=;|^)\\\\s*\\\\b([0-9A-Z_]+)(?=\\\\s*(?:[(,}]|$))","beginCaptures":{"1":{"name":"constant.enum.name.groovy"}},"end":",|(?=})|^(?!\\\\s*\\\\w+\\\\s*(?:,|$))","patterns":[{"begin":"\\\\(","end":"\\\\)","name":"meta.enum.value.groovy","patterns":[{"match":",","name":"punctuation.definition.seperator.parameter.groovy"},{"include":"#groovy-code"}]}]}]},"function-body":{"patterns":[{"match":"\\\\s"},{"begin":"(?=[<\\\\w][^(]*\\\\s+[$<\\\\w]+\\\\s*\\\\()","end":"(?=[$\\\\w]+\\\\s*\\\\()","name":"meta.method.return-type.java","patterns":[{"include":"source.nextflow-groovy#types"}]},{"begin":"([$\\\\w]+)\\\\s*\\\\(","beginCaptures":{"1":{"name":"entity.name.function.nextflow"}},"end":"\\\\)","name":"meta.definition.method.signature.java","patterns":[{"begin":"(?=[^)])","end":"(?=\\\\))","name":"meta.method.parameters.groovy","patterns":[{"begin":"(?=[^),])","end":"(?=[),])","name":"meta.method.parameter.groovy","patterns":[{"match":",","name":"punctuation.definition.separator.groovy"},{"begin":"=","beginCaptures":{"0":{"name":"keyword.operator.assignment.groovy"}},"end":"(?=[),])","name":"meta.parameter.default.groovy","patterns":[{"include":"source.nextflow-groovy#groovy-code"}]},{"include":"source.nextflow-groovy#parameters"}]}]}]},{"begin":"(?=<)","end":"(?=\\\\s)","name":"meta.method.paramerised-type.groovy","patterns":[{"begin":"<","end":">","name":"storage.type.parameters.groovy","patterns":[{"include":"source.nextflow-groovy#types"},{"match":",","name":"punctuation.definition.seperator.groovy"}]}]},{"begin":"\\\\{","end":"(?=})","name":"meta.method.body.java","patterns":[{"include":"source.nextflow-groovy#groovy-code"}]}]},"function-def":{"applyEndPatternLast":1,"begin":"(?<=;|^|\\\\{)(?=\\\\s*(?:def|(?:(?:boolean|byte|char|short|int|float|long|double)|@?(?:[A-Za-z]\\\\w*\\\\.)*[A-Z]+\\\\w*)[]\\\\[]*(?:<.*>)?n)\\\\s+([^=]+\\\\s+)?\\\\w+\\\\s*\\\\()","end":"}|(?=[^{])","name":"meta.definition.method.groovy","patterns":[{"include":"#function-body"}]},"include-decl":{"patterns":[{"match":"^\\\\b(include)\\\\b","name":"keyword.nextflow"},{"match":"\\\\b(from)\\\\b","name":"keyword.nextflow"}]},"nextflow":{"patterns":[{"include":"#record-def"},{"include":"#enum-def"},{"include":"#function-def"},{"include":"#process-def"},{"include":"#workflow-def"},{"include":"#params-def"},{"include":"#output-def"},{"include":"#include-decl"},{"include":"source.nextflow-groovy"}]},"output-def":{"begin":"^\\\\s*(output)\\\\s*\\\\{","beginCaptures":{"1":{"name":"keyword.nextflow"}},"end":"}","name":"output.nextflow","patterns":[{"include":"source.nextflow-groovy#groovy"}]},"params-def":{"begin":"^\\\\s*(params)\\\\s*\\\\{","beginCaptures":{"1":{"name":"keyword.nextflow"}},"end":"}","name":"params.nextflow","patterns":[{"include":"source.nextflow-groovy#groovy"}]},"process-body":{"patterns":[{"match":"(?:input|output|when|script|shell|exec):","name":"constant.block.nextflow"},{"match":"\\\\b(val|env|file|path|stdin|stdout|tuple)([(\\\\s])","name":"entity.name.function.nextflow"},{"include":"source.nextflow-groovy#groovy"}]},"process-def":{"begin":"^\\\\s*(process)\\\\s+(\\\\w+)\\\\s*\\\\{","beginCaptures":{"1":{"name":"keyword.nextflow"},"2":{"name":"entity.name.function.nextflow"}},"end":"}","name":"process.nextflow","patterns":[{"include":"#process-body"}]},"record-def":{"begin":"^\\\\s*(record)\\\\s+(\\\\w+)\\\\s*\\\\{","beginCaptures":{"1":{"name":"keyword.nextflow"},"2":{"name":"storage.type.groovy"}},"end":"}","name":"record.nextflow","patterns":[{"include":"source.nextflow-groovy#groovy"}]},"workflow-body":{"patterns":[{"match":"(?:take|main|emit|publish):","name":"constant.block.nextflow"},{"include":"source.nextflow-groovy#groovy"}]},"workflow-def":{"begin":"^\\\\s*(workflow)(?:\\\\s+(\\\\w+))?\\\\s*\\\\{","beginCaptures":{"1":{"name":"keyword.nextflow"},"2":{"name":"entity.name.function.nextflow"}},"end":"}","name":"workflow.nextflow","patterns":[{"include":"#workflow-body"}]}},"scopeName":"source.nextflow","embeddedLangs":["nextflow-groovy"],"aliases":["nf"]}')),t=[...e,n];export{t as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
import{v as a}from"./chunk-ktx0nkhz.js";import"./chunk-gf0502ds.js";export{a as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
import{O as a}from"./chunk-ept66kdn.js";import"./chunk-gf0502ds.js";export{a as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
import"./chunk-gf0502ds.js";var e=Object.freeze(JSON.parse('{"displayName":"CODEOWNERS","name":"codeowners","patterns":[{"include":"#comment"},{"include":"#pattern"},{"include":"#owner"}],"repository":{"comment":{"patterns":[{"begin":"^\\\\s*#","captures":{"0":{"name":"punctuation.definition.comment.codeowners"}},"end":"$","name":"comment.line.codeowners"}]},"owner":{"match":"\\\\S*@\\\\S+","name":"storage.type.function.codeowners"},"pattern":{"match":"^\\\\s*(\\\\S+)","name":"variable.other.codeowners"}},"scopeName":"text.codeowners"}')),n=[e];export{n as default};
|
||||
|
|
@ -1 +0,0 @@
|
|||
import"./chunk-gf0502ds.js";var e=Object.freeze(JSON.parse('{"displayName":"Gettext PO","fileTypes":["po","pot","potx"],"name":"po","patterns":[{"begin":"^(?:(?=(msg(?:id(_plural)?|ctxt))\\\\s*\\"[^\\"])|\\\\s*$)","end":"\\\\z","patterns":[{"include":"#body"}]},{"include":"#comments"},{"match":"^msg(id|str)\\\\s+\\"\\"\\\\s*$\\\\n?","name":"comment.line.number-sign.po"},{"captures":{"1":{"name":"constant.language.po"},"2":{"name":"punctuation.separator.key-value.po"},"3":{"name":"string.other.po"}},"match":"^\\"(?:([^:\\\\s]+)(:)\\\\s+)?([^\\"]*)\\"\\\\s*$\\\\n?","name":"meta.header.po"}],"repository":{"body":{"patterns":[{"begin":"^(msgid(_plural)?)\\\\s+","beginCaptures":{"1":{"name":"keyword.control.msgid.po"}},"end":"^(?!\\")","name":"meta.scope.msgid.po","patterns":[{"begin":"(\\\\G|^)\\"","end":"\\"","name":"string.quoted.double.po","patterns":[{"match":"\\\\\\\\[\\"\\\\\\\\]","name":"constant.character.escape.po"}]}]},{"begin":"^(msgstr)(?:(\\\\[)(\\\\d+)(]))?\\\\s+","beginCaptures":{"1":{"name":"keyword.control.msgstr.po"},"2":{"name":"keyword.control.msgstr.po"},"3":{"name":"constant.numeric.po"},"4":{"name":"keyword.control.msgstr.po"}},"end":"^(?!\\")","name":"meta.scope.msgstr.po","patterns":[{"begin":"(\\\\G|^)\\"","end":"\\"","name":"string.quoted.double.po","patterns":[{"match":"\\\\\\\\[\\"\\\\\\\\]","name":"constant.character.escape.po"}]}]},{"begin":"^(msgctxt)(?:(\\\\[)(\\\\d+)(]))?\\\\s+","beginCaptures":{"1":{"name":"keyword.control.msgctxt.po"},"2":{"name":"keyword.control.msgctxt.po"},"3":{"name":"constant.numeric.po"},"4":{"name":"keyword.control.msgctxt.po"}},"end":"^(?!\\")","name":"meta.scope.msgctxt.po","patterns":[{"begin":"(\\\\G|^)\\"","end":"\\"","name":"string.quoted.double.po","patterns":[{"match":"\\\\\\\\[\\"\\\\\\\\]","name":"constant.character.escape.po"}]}]},{"captures":{"1":{"name":"punctuation.definition.comment.po"}},"match":"^(#~).*$\\\\n?","name":"comment.line.number-sign.obsolete.po"},{"include":"#comments"},{"match":"^(?!\\\\s*$)[^\\"#].*$\\\\n?","name":"invalid.illegal.po"}]},"comments":{"patterns":[{"begin":"^(?=#)","end":"(?!\\\\G)","patterns":[{"begin":"(#,)\\\\s+","beginCaptures":{"1":{"name":"punctuation.definition.comment.po"}},"end":"\\\\n","name":"comment.line.number-sign.flag.po","patterns":[{"captures":{"1":{"name":"entity.name.type.flag.po"}},"match":"(?:\\\\G|,\\\\s*)(fuzzy|(?:no-)?(?:c|objc|sh|lisp|elisp|librep|scheme|smalltalk|java|csharp|awk|object-pascal|ycp|tcl|perl|perl-brace|php|gcc-internal|qt|boost)-format)"}]},{"begin":"#\\\\.","beginCaptures":{"0":{"name":"punctuation.definition.comment.po"}},"end":"\\\\n","name":"comment.line.number-sign.extracted.po"},{"begin":"(#:)[\\\\t ]*","beginCaptures":{"1":{"name":"punctuation.definition.comment.po"}},"end":"\\\\n","name":"comment.line.number-sign.reference.po","patterns":[{"match":"(\\\\S+:)([;\\\\d]*)","name":"storage.type.class.po"}]},{"begin":"#\\\\|","beginCaptures":{"0":{"name":"punctuation.definition.comment.po"}},"end":"\\\\n","name":"comment.line.number-sign.previous.po"},{"begin":"#","beginCaptures":{"0":{"name":"punctuation.definition.comment.po"}},"end":"\\\\n","name":"comment.line.number-sign.po"}]}]}},"scopeName":"source.po","aliases":["pot","potx"]}')),n=[e];export{n as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
import"./chunk-gf0502ds.js";var e=Object.freeze(JSON.parse('{"displayName":"Tcl","fileTypes":["tcl"],"foldingStartMarker":"\\\\{\\\\s*$","foldingStopMarker":"^\\\\s*}","name":"tcl","patterns":[{"begin":"(?<=^|;)\\\\s*((#))","beginCaptures":{"1":{"name":"comment.line.number-sign.tcl"},"2":{"name":"punctuation.definition.comment.tcl"}},"contentName":"comment.line.number-sign.tcl","end":"\\\\n","patterns":[{"match":"(\\\\\\\\[\\\\n\\\\\\\\])"}]},{"captures":{"1":{"name":"keyword.control.tcl"}},"match":"(?<=^|[;\\\\[{])\\\\s*(if|while|for|catch|default|return|break|continue|switch|exit|foreach|try|throw)\\\\b"},{"captures":{"1":{"name":"keyword.control.tcl"}},"match":"(?<=^|})\\\\s*(then|elseif|else)\\\\b"},{"captures":{"1":{"name":"keyword.other.tcl"},"2":{"name":"entity.name.function.tcl"}},"match":"(?<=^|\\\\{)\\\\s*(proc)\\\\s+(\\\\S+)"},{"captures":{"1":{"name":"keyword.other.tcl"}},"match":"(?<=^|[;\\\\[{])\\\\s*(after|append|array|auto_execok|auto_import|auto_load|auto_mkindex|auto_mkindex_old|auto_qualify|auto_reset|bgerror|binary|cd|clock|close|concat|dde|encoding|eof|error|eval|exec|expr|fblocked|fconfigure|fcopy|file|fileevent|filename|flush|format|gets|glob|global|history|http|incr|info|interp|join|lappend|library|lindex|linsert|list|llength|load|lrange|lreplace|lsearch|lset|lsort|memory|msgcat|namespace|open|package|parray|pid|pkg::create|pkg_mkIndex|proc|puts|pwd|re_syntax|read|registry|rename|resource|scan|seek|set|socket|SafeBase|source|split|string|subst|Tcl|tcl_endOfWord|tcl_findLibrary|tcl_startOfNextWord|tcl_startOfPreviousWord|tcl_wordBreakAfter|tcl_wordBreakBefore|tcltest|tclvars|tell|time|trace|unknown|unset|update|uplevel|upvar|variable|vwait)\\\\b"},{"begin":"(?<=^|[;\\\\[{])\\\\s*(reg(?:exp|sub))\\\\b\\\\s*","beginCaptures":{"1":{"name":"keyword.other.tcl"}},"end":"[]\\\\n;]","patterns":[{"match":"\\\\\\\\(?:.|\\\\n)","name":"constant.character.escape.tcl"},{"match":"-\\\\w+\\\\s*"},{"applyEndPatternLast":1,"begin":"--\\\\s*","end":"","patterns":[{"include":"#regexp"}]},{"include":"#regexp"}]},{"include":"#escape"},{"include":"#variable"},{"include":"#operator"},{"include":"#numeric"},{"begin":"\\"","beginCaptures":{"0":{"name":"punctuation.definition.string.begin.tcl"}},"end":"\\"","endCaptures":{"0":{"name":"punctuation.definition.string.end.tcl"}},"name":"string.quoted.double.tcl","patterns":[{"include":"#escape"},{"include":"#variable"},{"include":"#embedded"}]}],"repository":{"bare-string":{"begin":"(?:^|(?<=\\\\s))\\"","end":"\\"([^]\\\\s]*)","endCaptures":{"1":{"name":"invalid.illegal.tcl"}},"patterns":[{"include":"#escape"},{"include":"#variable"}]},"braces":{"begin":"(?:^|(?<=\\\\s))\\\\{","end":"}([^]\\\\s]*)","endCaptures":{"1":{"name":"invalid.illegal.tcl"}},"patterns":[{"match":"\\\\\\\\[\\\\n{}]","name":"constant.character.escape.tcl"},{"include":"#inner-braces"}]},"embedded":{"begin":"\\\\[","beginCaptures":{"0":{"name":"punctuation.section.embedded.begin.tcl"}},"end":"]","endCaptures":{"0":{"name":"punctuation.section.embedded.end.tcl"}},"name":"source.tcl.embedded","patterns":[{"include":"source.tcl"}]},"escape":{"match":"\\\\\\\\(\\\\d{1,3}|x\\\\h+|u\\\\h{1,4}|.|\\\\n)","name":"constant.character.escape.tcl"},"inner-braces":{"begin":"\\\\{","end":"}","patterns":[{"match":"\\\\\\\\[\\\\n{}]","name":"constant.character.escape.tcl"},{"include":"#inner-braces"}]},"numeric":{"match":"(?<![A-Za-z])([-+]?([0-9]*\\\\.)?[0-9]+f?)(?![.A-Za-z])","name":"constant.numeric.tcl"},"operator":{"match":"(?<=[ \\\\d])([-+~]|&{1,2}|\\\\|{1,2}|<{1,2}|>{1,2}|\\\\*{1,2}|[!%/]|<=|>=|={1,2}|!=|\\\\^)(?=[ \\\\d])","name":"keyword.operator.tcl"},"regexp":{"begin":"(?=\\\\S)(?![]\\\\n;])","end":"(?=[]\\\\n;])","patterns":[{"begin":"(?=[^\\\\t\\\\n ;])","end":"(?=[\\\\t\\\\n ;])","name":"string.regexp.tcl","patterns":[{"include":"#braces"},{"include":"#bare-string"},{"include":"#escape"},{"include":"#variable"}]},{"begin":"[\\\\t ]","end":"(?=[]\\\\n;])","patterns":[{"include":"#variable"},{"include":"#embedded"},{"include":"#escape"},{"include":"#braces"},{"include":"#string"}]}]},"string":{"applyEndPatternLast":1,"begin":"(?:^|(?<=\\\\s))(?=\\")","end":"","name":"string.quoted.double.tcl","patterns":[{"include":"#bare-string"}]},"variable":{"captures":{"1":{"name":"punctuation.definition.variable.tcl"}},"match":"(\\\\$)((?:[0-9A-Z_a-z]|::)+(\\\\([^)]+\\\\))?|\\\\{[^}]*})","name":"support.function.tcl"}},"scopeName":"source.tcl"}')),n=[e];export{n as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue