diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index 182faad70..2ef56b6c5 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -215,39 +215,178 @@ pub async fn get_run_graph( pub async fn get_run_retro( _auth: AuthenticatedService, State(_state): State>, - Path(_id): Path, + Path(id): Path, ) -> Response { - // Return a demo retro as JSON - (StatusCode::OK, Json(serde_json::json!({ - "run_id": "run-1", - "pipeline_name": "implement", - "goal": "Add rate limiting to auth endpoints", - "smoothness": "smooth", - "intent": "Implement token-bucket rate limiting on /auth/login and /auth/register to prevent brute-force attacks.", - "outcome": "Rate limiter deployed with configurable per-IP limits. Integration tests added. Redis-backed counter with sliding window.", - "stages": [ - {"stage_id": "detect-drift", "stage_label": "Detect Drift", "status": "completed", "duration_ms": 72000, "retries": 0, "cost": 0.48, "files_touched": ["src/middleware/rate-limit.ts"]}, - {"stage_id": "propose-changes", "stage_label": "Propose Changes", "status": "completed", "duration_ms": 154000, "retries": 0, "cost": 1.12, "files_touched": ["src/middleware/rate-limit.ts", "src/routes/auth.ts", "src/config.ts"]}, - {"stage_id": "review-changes", "stage_label": "Review Changes", "status": "completed", "duration_ms": 45000, "retries": 0, "cost": 0.31, "files_touched": []}, - {"stage_id": "apply-changes", "stage_label": "Apply Changes", "status": "completed", "duration_ms": 118000, "retries": 0, "cost": 0.87, "files_touched": ["src/middleware/rate-limit.ts", "src/routes/auth.ts", "src/config.ts", "tests/rate-limit.test.ts"]} - ], - "stats": { - "total_duration_ms": 389000, - "total_cost": 2.78, - "total_retries": 0, - "files_touched": ["src/middleware/rate-limit.ts", "src/routes/auth.ts", "src/config.ts", "tests/rate-limit.test.ts"], - "stages_completed": 4, - "stages_failed": 0 - }, - "learnings": [ - {"category": "repo", "text": "Redis client is initialized lazily in src/infra/redis.ts -- reuse existing connection pool."}, - {"category": "code", "text": "Auth middleware chain order matters: rate-limit must run before JWT validation."} - ], - "friction_points": [], - "open_items": [ - {"kind": "follow_up", "description": "Add rate-limit headers (X-RateLimit-Remaining) to response."} - ] - }))).into_response() + let retro = match id.as_str() { + "run-1" => serde_json::json!({ + "run_id": "run-1", + "workflow_name": "implement", + "goal": "Add rate limiting to auth endpoints", + "timestamp": "2026-02-28T14:32:00Z", + "smoothness": "smooth", + "intent": "Implement token-bucket rate limiting on /auth/login and /auth/register to prevent brute-force attacks.", + "outcome": "Rate limiter deployed with configurable per-IP limits. Integration tests added. Redis-backed counter with sliding window.", + "stages": [ + {"stage_id": "detect-drift", "stage_label": "Detect Drift", "status": "completed", "duration_ms": 72000, "retries": 0, "cost": 0.48, "files_touched": ["src/middleware/rate-limit.ts"]}, + {"stage_id": "propose-changes", "stage_label": "Propose Changes", "status": "completed", "duration_ms": 154000, "retries": 0, "cost": 1.12, "files_touched": ["src/middleware/rate-limit.ts", "src/routes/auth.ts", "src/config.ts"]}, + {"stage_id": "review-changes", "stage_label": "Review Changes", "status": "completed", "duration_ms": 45000, "retries": 0, "cost": 0.31, "files_touched": []}, + {"stage_id": "apply-changes", "stage_label": "Apply Changes", "status": "completed", "duration_ms": 118000, "retries": 0, "cost": 0.87, "files_touched": ["src/middleware/rate-limit.ts", "src/routes/auth.ts", "src/config.ts", "tests/rate-limit.test.ts"]} + ], + "stats": { + "total_duration_ms": 389000, + "total_cost": 2.78, + "total_retries": 0, + "files_touched": ["src/middleware/rate-limit.ts", "src/routes/auth.ts", "src/config.ts", "tests/rate-limit.test.ts"], + "stages_completed": 4, + "stages_failed": 0 + }, + "learnings": [ + {"category": "repo", "text": "Redis client is initialized lazily in src/infra/redis.ts -- reuse existing connection pool."}, + {"category": "code", "text": "Auth middleware chain order matters: rate-limit must run before JWT validation."} + ], + "friction_points": [], + "open_items": [ + {"kind": "follow_up", "description": "Add rate-limit headers (X-RateLimit-Remaining) to response."} + ] + }), + "run-2" => serde_json::json!({ + "run_id": "run-2", + "workflow_name": "implement", + "goal": "Migrate to React Router v7", + "timestamp": "2026-02-28T10:15:00Z", + "smoothness": "bumpy", + "intent": "Upgrade react-router from v6 to v7, updating all route definitions and loader/action patterns to the new API.", + "outcome": "Migration completed but required 3 retries in the apply stage due to breaking changes in nested route handling. All routes now use the v7 data API.", + "stages": [ + {"stage_id": "detect-drift", "stage_label": "Detect Drift", "status": "completed", "duration_ms": 95000, "retries": 0, "cost": 0.62, "files_touched": ["package.json"]}, + {"stage_id": "propose-changes", "stage_label": "Propose Changes", "status": "completed", "duration_ms": 312000, "retries": 1, "cost": 2.45, "notes": "First proposal missed nested outlet patterns. Retry produced correct migration.", "files_touched": ["src/routes.ts", "src/app.tsx", "src/routes/dashboard.tsx", "src/routes/settings.tsx"]}, + {"stage_id": "review-changes", "stage_label": "Review Changes", "status": "completed", "duration_ms": 88000, "retries": 0, "cost": 0.54, "files_touched": []}, + {"stage_id": "apply-changes", "stage_label": "Apply Changes", "status": "completed", "duration_ms": 480000, "retries": 3, "cost": 3.21, "notes": "Type errors in nested layouts required multiple correction passes.", "files_touched": ["src/routes.ts", "src/app.tsx", "src/routes/dashboard.tsx", "src/routes/settings.tsx", "src/routes/profile.tsx", "tests/routes.test.tsx"]} + ], + "stats": { + "total_duration_ms": 975000, + "total_cost": 6.82, + "total_retries": 4, + "files_touched": ["package.json", "src/routes.ts", "src/app.tsx", "src/routes/dashboard.tsx", "src/routes/settings.tsx", "src/routes/profile.tsx", "tests/routes.test.tsx"], + "stages_completed": 4, + "stages_failed": 0 + }, + "learnings": [ + {"category": "workflow", "text": "Framework migration tasks benefit from running type-check after each stage, not just at the end."}, + {"category": "code", "text": "React Router v7 outlets require explicit type annotations for loader data in nested routes."}, + {"category": "tool", "text": "The codemod tool missed JSX spread patterns -- manual fixup was needed."} + ], + "friction_points": [ + {"kind": "retry", "description": "Nested route outlet types were incorrect on first 3 attempts.", "stage_id": "apply-changes"}, + {"kind": "wrong_approach", "description": "Initially tried to keep v6 compat layer, which created more issues than a clean migration.", "stage_id": "propose-changes"} + ], + "open_items": [ + {"kind": "tech_debt", "description": "Leftover v6 compat shims in src/utils/router-compat.ts should be deleted."}, + {"kind": "test_gap", "description": "No E2E coverage for the new nested layout error boundaries."} + ] + }), + "run-6" => serde_json::json!({ + "run_id": "run-6", + "workflow_name": "implement", + "goal": "Add dark mode toggle", + "timestamp": "2026-02-27T16:45:00Z", + "smoothness": "effortless", + "intent": "Add a theme toggle component to the dashboard header with system/light/dark options, persisting preference to localStorage.", + "outcome": "Dark mode toggle shipped with smooth CSS transitions. All existing components already used CSS variables, so no style refactoring was needed.", + "stages": [ + {"stage_id": "detect-drift", "stage_label": "Detect Drift", "status": "completed", "duration_ms": 42000, "retries": 0, "cost": 0.28, "files_touched": []}, + {"stage_id": "propose-changes", "stage_label": "Propose Changes", "status": "completed", "duration_ms": 98000, "retries": 0, "cost": 0.71, "files_touched": ["src/components/ThemeToggle.tsx", "src/hooks/useTheme.ts"]}, + {"stage_id": "apply-changes", "stage_label": "Apply Changes", "status": "completed", "duration_ms": 76000, "retries": 0, "cost": 0.52, "files_touched": ["src/components/ThemeToggle.tsx", "src/hooks/useTheme.ts", "src/layouts/Header.tsx"]} + ], + "stats": { + "total_duration_ms": 216000, + "total_cost": 1.51, + "total_retries": 0, + "files_touched": ["src/components/ThemeToggle.tsx", "src/hooks/useTheme.ts", "src/layouts/Header.tsx"], + "stages_completed": 3, + "stages_failed": 0 + }, + "learnings": [ + {"category": "repo", "text": "CSS variables are defined in src/styles/tokens.css and already support dark values."} + ], + "friction_points": [], + "open_items": [] + }), + "run-3" => serde_json::json!({ + "run_id": "run-3", + "workflow_name": "fix_build", + "goal": "Fix config parsing for nested values", + "timestamp": "2026-02-27T09:20:00Z", + "smoothness": "struggled", + "intent": "Fix TOML config parser to handle deeply nested table arrays, which was causing silent data loss on certain pipeline configs.", + "outcome": "Root cause identified as incorrect recursion depth limit in the TOML walker. Fix applied but exposed a second bug in default value merging that required additional changes.", + "stages": [ + {"stage_id": "investigate", "stage_label": "Investigate", "status": "completed", "duration_ms": 340000, "retries": 2, "cost": 1.85, "notes": "First investigation looked at wrong parser path. Second attempt found the actual recursion limit.", "files_touched": ["src/config/parser.ts", "src/config/defaults.ts"]}, + {"stage_id": "propose-fix", "stage_label": "Propose Fix", "status": "completed", "duration_ms": 210000, "retries": 1, "cost": 1.42, "files_touched": ["src/config/parser.ts", "src/config/defaults.ts", "src/config/merge.ts"]}, + {"stage_id": "apply-fix", "stage_label": "Apply Fix", "status": "completed", "duration_ms": 185000, "retries": 1, "cost": 1.15, "failure_reason": "Initial fix broke the default value merging path. Required a second pass.", "files_touched": ["src/config/parser.ts", "src/config/defaults.ts", "src/config/merge.ts", "tests/config-parser.test.ts"]}, + {"stage_id": "verify", "stage_label": "Verify", "status": "completed", "duration_ms": 95000, "retries": 0, "cost": 0.55, "files_touched": []} + ], + "stats": { + "total_duration_ms": 830000, + "total_cost": 4.97, + "total_retries": 4, + "files_touched": ["src/config/parser.ts", "src/config/defaults.ts", "src/config/merge.ts", "tests/config-parser.test.ts"], + "stages_completed": 4, + "stages_failed": 0 + }, + "learnings": [ + {"category": "code", "text": "TOML walker in parser.ts has a hardcoded depth limit of 8 -- needs to be configurable."}, + {"category": "code", "text": "Default merging in merge.ts uses shallow spread, which silently drops nested keys."}, + {"category": "workflow", "text": "Bug fix pipelines should include a regression test stage before verification."} + ], + "friction_points": [ + {"kind": "wrong_approach", "description": "Initial investigation focused on the YAML compatibility layer instead of the TOML parser.", "stage_id": "investigate"}, + {"kind": "retry", "description": "Fix introduced a regression in default value merging that required rework.", "stage_id": "apply-fix"}, + {"kind": "ambiguity", "description": "Config schema docs were outdated, making it unclear which nesting depth was intended."} + ], + "open_items": [ + {"kind": "tech_debt", "description": "Remove the hardcoded depth limit in src/config/parser.ts and make it configurable."}, + {"kind": "investigation", "description": "Audit other parsers for similar shallow-spread bugs in merging logic."}, + {"kind": "test_gap", "description": "No tests for configs nested deeper than 4 levels."} + ] + }), + "run-8" => serde_json::json!({ + "run_id": "run-8", + "workflow_name": "implement", + "goal": "Implement webhook retry logic", + "timestamp": "2026-02-26T11:00:00Z", + "smoothness": "smooth", + "intent": "Add exponential backoff retry logic for failed webhook deliveries with configurable max attempts and dead-letter queue.", + "outcome": "Webhook retry system implemented with exponential backoff (base 2s, max 5 retries). Failed deliveries route to SQS dead-letter queue. Dashboard shows retry status.", + "stages": [ + {"stage_id": "detect-drift", "stage_label": "Detect Drift", "status": "completed", "duration_ms": 55000, "retries": 0, "cost": 0.35, "files_touched": []}, + {"stage_id": "propose-changes", "stage_label": "Propose Changes", "status": "completed", "duration_ms": 178000, "retries": 0, "cost": 1.28, "files_touched": ["src/webhooks/retry.ts", "src/webhooks/dlq.ts", "src/webhooks/dispatcher.ts"]}, + {"stage_id": "review-changes", "stage_label": "Review Changes", "status": "completed", "duration_ms": 62000, "retries": 0, "cost": 0.41, "files_touched": []}, + {"stage_id": "apply-changes", "stage_label": "Apply Changes", "status": "completed", "duration_ms": 145000, "retries": 1, "cost": 1.05, "notes": "Minor type fix needed on retry delay calculation.", "files_touched": ["src/webhooks/retry.ts", "src/webhooks/dlq.ts", "src/webhooks/dispatcher.ts", "tests/webhook-retry.test.ts"]} + ], + "stats": { + "total_duration_ms": 440000, + "total_cost": 3.09, + "total_retries": 1, + "files_touched": ["src/webhooks/retry.ts", "src/webhooks/dlq.ts", "src/webhooks/dispatcher.ts", "tests/webhook-retry.test.ts"], + "stages_completed": 4, + "stages_failed": 0 + }, + "learnings": [ + {"category": "repo", "text": "SQS client wrapper is in src/infra/sqs.ts with pre-configured DLQ ARNs per environment."}, + {"category": "code", "text": "Webhook dispatcher already had a hook point for retry logic via the onFailure callback."} + ], + "friction_points": [ + {"kind": "retry", "description": "Retry delay formula had an off-by-one in the exponent calculation.", "stage_id": "apply-changes"} + ], + "open_items": [ + {"kind": "follow_up", "description": "Add webhook retry metrics to the Grafana dashboard."}, + {"kind": "follow_up", "description": "Document the DLQ reprocessing procedure in the runbook."} + ] + }), + _ => serde_json::json!(null), + }; + (StatusCode::OK, Json(retro)).into_response() } // ── Workflows ────────────────────────────────────────────────────────── @@ -562,7 +701,14 @@ mod runs { CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(68.0) }, CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(192.0) }, CheckRun { name: "integration-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(334.0) }, + CheckRun { name: "e2e / chrome".into(), status: CheckRunStatus::Success, duration_secs: Some(262.0) }, + CheckRun { name: "e2e / firefox".into(), status: CheckRunStatus::Success, duration_secs: Some(285.0) }, CheckRun { name: "build".into(), status: CheckRunStatus::Success, duration_secs: Some(121.0) }, + CheckRun { name: "deploy-preview".into(), status: CheckRunStatus::Success, duration_secs: Some(93.0) }, + CheckRun { name: "security-scan".into(), status: CheckRunStatus::Skipped, duration_secs: None }, + CheckRun { name: "performance".into(), status: CheckRunStatus::Success, duration_secs: Some(138.0) }, + CheckRun { name: "bundle-size".into(), status: CheckRunStatus::Success, duration_secs: Some(34.0) }, + CheckRun { name: "accessibility".into(), status: CheckRunStatus::Success, duration_secs: Some(72.0) }, ], elapsed_secs: Some(259200.0), elapsed_warning: Some(true), resources: None, comments: Some(7), @@ -578,6 +724,8 @@ mod runs { CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(48.0) }, CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(116.0) }, CheckRun { name: "build".into(), status: CheckRunStatus::Success, duration_secs: Some(82.0) }, + CheckRun { name: "coverage".into(), status: CheckRunStatus::Success, duration_secs: Some(124.0) }, + CheckRun { name: "bundle-size".into(), status: CheckRunStatus::Skipped, duration_secs: None }, ], elapsed_secs: Some(3900.0), elapsed_warning: Some(false), resources: None, comments: Some(2), @@ -1050,7 +1198,7 @@ mod retros { goal: "Add rate limiting to auth endpoints".into(), timestamp: "2026-02-28T14:32:00Z".into(), smoothness: Some(SmoothnessRating::Smooth), - stats: RetroStats { total_duration_ms: 389000, total_cost: Some(2.78), total_retries: 0, files_touched: vec!["src/middleware/rate-limit.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { total_duration_ms: 389000, total_cost: Some(2.78), total_retries: 0, files_touched: vec!["src/middleware/rate-limit.ts".into(), "src/routes/auth.ts".into(), "src/config.ts".into(), "tests/rate-limit.test.ts".into()], stages_completed: 4, stages_failed: 0 }, friction_point_count: 0, }, RetroListItem { @@ -1058,7 +1206,7 @@ mod retros { goal: "Migrate to React Router v7".into(), timestamp: "2026-02-28T10:15:00Z".into(), smoothness: Some(SmoothnessRating::Bumpy), - stats: RetroStats { total_duration_ms: 975000, total_cost: Some(6.82), total_retries: 4, files_touched: vec!["src/routes.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { total_duration_ms: 975000, total_cost: Some(6.82), total_retries: 4, files_touched: vec!["package.json".into(), "src/routes.ts".into(), "src/app.tsx".into(), "src/routes/dashboard.tsx".into(), "src/routes/settings.tsx".into(), "src/routes/profile.tsx".into(), "tests/routes.test.tsx".into()], stages_completed: 4, stages_failed: 0 }, friction_point_count: 2, }, RetroListItem { @@ -1066,7 +1214,7 @@ mod retros { goal: "Add dark mode toggle".into(), timestamp: "2026-02-27T16:45:00Z".into(), smoothness: Some(SmoothnessRating::Effortless), - stats: RetroStats { total_duration_ms: 216000, total_cost: Some(1.51), total_retries: 0, files_touched: vec!["src/components/ThemeToggle.tsx".into()], stages_completed: 3, stages_failed: 0 }, + stats: RetroStats { total_duration_ms: 216000, total_cost: Some(1.51), total_retries: 0, files_touched: vec!["src/components/ThemeToggle.tsx".into(), "src/hooks/useTheme.ts".into(), "src/layouts/Header.tsx".into()], stages_completed: 3, stages_failed: 0 }, friction_point_count: 0, }, RetroListItem { @@ -1074,7 +1222,7 @@ mod retros { goal: "Fix config parsing for nested values".into(), timestamp: "2026-02-27T09:20:00Z".into(), smoothness: Some(SmoothnessRating::Struggled), - stats: RetroStats { total_duration_ms: 830000, total_cost: Some(4.97), total_retries: 4, files_touched: vec!["src/config/parser.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { total_duration_ms: 830000, total_cost: Some(4.97), total_retries: 4, files_touched: vec!["src/config/parser.ts".into(), "src/config/defaults.ts".into(), "src/config/merge.ts".into(), "tests/config-parser.test.ts".into()], stages_completed: 4, stages_failed: 0 }, friction_point_count: 3, }, RetroListItem { @@ -1082,7 +1230,7 @@ mod retros { goal: "Implement webhook retry logic".into(), timestamp: "2026-02-26T11:00:00Z".into(), smoothness: Some(SmoothnessRating::Smooth), - stats: RetroStats { total_duration_ms: 440000, total_cost: Some(3.09), total_retries: 1, files_touched: vec!["src/webhooks/retry.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { total_duration_ms: 440000, total_cost: Some(3.09), total_retries: 1, files_touched: vec!["src/webhooks/retry.ts".into(), "src/webhooks/dlq.ts".into(), "src/webhooks/dispatcher.ts".into(), "tests/webhook-retry.test.ts".into()], stages_completed: 4, stages_failed: 0 }, friction_point_count: 1, }, ] @@ -1106,13 +1254,15 @@ mod sessions { sessions: vec![ SessionListItem { id: "s3".into(), title: "Migrate to React Router v7".into(), repo: "web-dashboard".into(), time: "1d ago".into() }, SessionListItem { id: "s4".into(), title: "Add dark mode toggle".into(), repo: "web-dashboard".into(), time: "1d ago".into() }, + SessionListItem { id: "s5".into(), title: "Update OpenAPI spec for v3".into(), repo: "api-server".into(), time: "1d ago".into() }, ], }, SessionGroup { label: "Previous 7 days".into(), sessions: vec![ SessionListItem { id: "s6".into(), title: "Terraform module for Redis cluster".into(), repo: "infrastructure".into(), time: "3d ago".into() }, - SessionListItem { id: "s7".into(), title: "Add workflow run event types".into(), repo: "shared-types".into(), time: "5d ago".into() }, + SessionListItem { id: "s7".into(), title: "Add pipeline event types".into(), repo: "shared-types".into(), time: "5d ago".into() }, + SessionListItem { id: "s8".into(), title: "Implement webhook retry logic".into(), repo: "api-server".into(), time: "6d ago".into() }, ], }, ] @@ -1123,12 +1273,48 @@ mod sessions { "s1" => Some(SessionDetail { id: "s1".into(), title: "Add rate limiting to auth endpoints".into(), repo: "api-server".into(), model: "Opus 4.6".into(), turns: vec![ - SessionTurn { kind: SessionTurnKind::User, content: Some("Add rate limiting to the auth endpoints. Use a sliding window approach with Redis, 10 requests per minute per IP.".into()), date: Some("Feb 28".into()), tools: vec![] }, + SessionTurn { kind: SessionTurnKind::User, content: Some("Add rate limiting to the auth endpoints. We're getting hit with brute force attempts on /api/auth/login and /api/auth/register. Use a sliding window approach with Redis, 10 requests per minute per IP.".into()), date: Some("Feb 28".into()), tools: vec![] }, SessionTurn { kind: SessionTurnKind::Assistant, content: Some("I'll implement sliding window rate limiting using Redis. Let me first look at the existing auth routes and middleware setup.".into()), date: None, tools: vec![] }, SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ - ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/routes/auth.ts" }"#.into(), result: "import { Router } from \"express\";\n// ...".into() }, + ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/routes/auth.ts" }"#.into(), result: "import { Router } from \"express\";\nimport { loginHandler } from \"../handlers/auth\";\nimport { registerHandler } from \"../handlers/auth\";\n\nconst router = Router();\nrouter.post(\"/login\", loginHandler);\nrouter.post(\"/register\", registerHandler);\n\nexport default router;".into() }, + ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/middleware/index.ts" }"#.into(), result: "export { authMiddleware } from \"./auth\";\nexport { corsMiddleware } from \"./cors\";\nexport { loggingMiddleware } from \"./logging\";".into() }, + ToolUse { tool_name: "search_files".into(), args: r#"{ "pattern": "redis", "path": "src/" }"#.into(), result: "src/lib/redis.ts:1: import Redis from \"ioredis\";\nsrc/lib/redis.ts:3: export const redis = new Redis(process.env.REDIS_URL);".into() }, ] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Good — there's already a Redis client at `src/lib/redis.ts`. I'll create a rate limiting middleware and apply it to the auth routes.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Good \u{2014} there's already a Redis client at `src/lib/redis.ts`. I'll create a rate limiting middleware using the sliding window algorithm and apply it to the auth routes.\n\nHere's the plan:\n1. Create `src/middleware/rate-limit.ts` with sliding window logic\n2. Apply it to `/login` and `/register` routes\n3. Return `429 Too Many Requests` when the limit is exceeded".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + ToolUse { tool_name: "write_file".into(), args: r#"{ "path": "src/middleware/rate-limit.ts" }"#.into(), result: "File written: src/middleware/rate-limit.ts (47 lines)".into() }, + ToolUse { tool_name: "edit_file".into(), args: r#"{ "path": "src/routes/auth.ts", "operations": [{ "type": "insert", "line": 3 }] }"#.into(), result: "Applied 1 edit to src/routes/auth.ts".into() }, + ToolUse { tool_name: "edit_file".into(), args: r#"{ "path": "src/middleware/index.ts", "operations": [{ "type": "append" }] }"#.into(), result: "Applied 1 edit to src/middleware/index.ts".into() }, + ] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Done. I've created the rate limiter and wired it up:\n\n- **`src/middleware/rate-limit.ts`** \u{2014} Sliding window rate limiter using Redis sorted sets. Each request adds a timestamped entry, expired entries are pruned, and the count is checked against the limit.\n- **`src/routes/auth.ts`** \u{2014} Applied `rateLimit({ windowMs: 60_000, max: 10 })` to both `/login` and `/register`.\n- **`src/middleware/index.ts`** \u{2014} Re-exported the new middleware.\n\nWhen the limit is exceeded, clients get a `429` response with a `Retry-After` header indicating seconds until the window resets.".into()), date: None, tools: vec![] }, + ], + }), + "s2" => Some(SessionDetail { + id: "s2".into(), title: "Fix config parsing for nested values".into(), repo: "cli-tools".into(), model: "Sonnet 4.6".into(), + turns: vec![ + SessionTurn { kind: SessionTurnKind::User, content: Some("The CLI crashes when parsing nested TOML config values like [database.connection]. Can you debug and fix this?".into()), date: Some("Feb 28".into()), tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Let me look at the config parser to understand how nested keys are handled.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/config/parser.ts" }"#.into(), result: "export function parseConfig(raw: string): Record {\n const result: Record = {};\n for (const line of raw.split(\"\\n\")) {\n const match = line.match(/^(\\w+)\\s*=\\s*(.+)$/);\n if (match) result[match[1]] = match[2].trim();\n }\n return result;\n}".into() }, + ] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Found the issue. The parser uses a flat `Record` and the regex `^(\\w+)` only matches simple keys \u{2014} it can't handle TOML section headers like `[database.connection]` or dotted keys like `database.host`.\n\nI'll rewrite the parser to support nested sections and dotted keys, producing a properly nested object.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + ToolUse { tool_name: "write_file".into(), args: r#"{ "path": "src/config/parser.ts" }"#.into(), result: "File written: src/config/parser.ts (62 lines)".into() }, + ToolUse { tool_name: "run_command".into(), args: r#"{ "command": "npm test -- --grep 'config parser'" }"#.into(), result: "PASS src/config/__tests__/parser.test.ts\n config parser\n \u{2713} parses flat keys (2ms)\n \u{2713} parses section headers (1ms)\n \u{2713} parses dotted keys (1ms)\n \u{2713} handles nested sections (1ms)\n\nTest Suites: 1 passed\nTests: 4 passed".into() }, + ] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Fixed. The parser now:\n\n1. Tracks the current section header (e.g. `[database.connection]`)\n2. Constructs nested objects from dotted paths\n3. Handles both `key = value` within sections and bare `dotted.key = value` syntax\n\nAll 4 tests pass, including the new nested section case.".into()), date: None, tools: vec![] }, + ], + }), + "s3" => Some(SessionDetail { + id: "s3".into(), title: "Migrate to React Router v7".into(), repo: "web-dashboard".into(), model: "Opus 4.6".into(), + turns: vec![ + SessionTurn { kind: SessionTurnKind::User, content: Some("Help me migrate our app from React Router v6 to v7. We're using createBrowserRouter with data loaders.".into()), date: Some("Feb 26".into()), tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("I'll audit your current router setup and identify what needs to change for v7. Let me scan the codebase.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + ToolUse { tool_name: "search_files".into(), args: r#"{ "pattern": "createBrowserRouter" }"#.into(), result: "src/main.tsx:5: import { createBrowserRouter } from \"react-router-dom\";\nsrc/main.tsx:8: const router = createBrowserRouter([".into() }, + ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "package.json" }"#.into(), result: "\"react-router-dom\": \"^6.22.0\"".into() }, + ] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes.".into()), date: None, tools: vec![] }, ], }), _ => None, @@ -1166,7 +1352,7 @@ mod settings { fields: vec![ SettingField { key: "org_name".into(), label: "Organization name".into(), value: "Acme Corp".into(), type_: SettingFieldType::Text, options: vec![], description: None }, SettingField { key: "default_branch".into(), label: "Default branch".into(), value: "main".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "timezone".into(), label: "Timezone".into(), value: "America/New_York".into(), type_: SettingFieldType::Select, options: vec!["America/New_York".into(), "UTC".into(), "Europe/London".into()], description: None }, + SettingField { key: "timezone".into(), label: "Timezone".into(), value: "America/New_York".into(), type_: SettingFieldType::Select, options: vec!["America/New_York".into(), "America/Chicago".into(), "America/Denver".into(), "America/Los_Angeles".into(), "UTC".into(), "Europe/London".into(), "Europe/Berlin".into(), "Asia/Tokyo".into()], description: None }, SettingField { key: "auto_cancel".into(), label: "Auto-cancel superseded runs".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, ], }, @@ -1176,28 +1362,38 @@ mod settings { SettingField { key: "github_org".into(), label: "GitHub organization".into(), value: "acme-corp".into(), type_: SettingFieldType::Text, options: vec![], description: None }, SettingField { key: "clone_protocol".into(), label: "Clone protocol".into(), value: "SSH".into(), type_: SettingFieldType::Select, options: vec!["SSH".into(), "HTTPS".into()], description: None }, SettingField { key: "auto_merge".into(), label: "Auto-merge when checks pass".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { key: "delete_branch".into(), label: "Delete branch after merge".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { key: "commit_signing".into(), label: "Require commit signing".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, ], }, SettingGroup { id: "compute".into(), name: "Compute".into(), description: "Resource allocation and execution environment.".into(), fields: vec![ SettingField { key: "default_cpu".into(), label: "Default CPU".into(), value: "4".into(), type_: SettingFieldType::Select, options: vec!["2".into(), "4".into(), "8".into(), "16".into()], description: None }, - SettingField { key: "default_memory".into(), label: "Default memory".into(), value: "8 GB".into(), type_: SettingFieldType::Select, options: vec!["4 GB".into(), "8 GB".into(), "16 GB".into()], description: None }, + SettingField { key: "default_memory".into(), label: "Default memory".into(), value: "8 GB".into(), type_: SettingFieldType::Select, options: vec!["4 GB".into(), "8 GB".into(), "16 GB".into(), "32 GB".into()], description: None }, SettingField { key: "max_parallel".into(), label: "Max parallel runs".into(), value: "10".into(), type_: SettingFieldType::Text, options: vec![], description: None }, + SettingField { key: "timeout_minutes".into(), label: "Run timeout (minutes)".into(), value: "120".into(), type_: SettingFieldType::Text, options: vec![], description: None }, + SettingField { key: "gpu_enabled".into(), label: "GPU acceleration".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, ], }, SettingGroup { id: "notifications".into(), name: "Notifications".into(), description: "Alerts and notification delivery preferences.".into(), fields: vec![ + SettingField { key: "slack_webhook".into(), label: "Slack webhook URL".into(), value: "https://hooks.slack.com/services/T00/B00/xxxx".into(), type_: SettingFieldType::Text, options: vec![], description: None }, SettingField { key: "notify_on_failure".into(), label: "Notify on failure".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, SettingField { key: "notify_on_success".into(), label: "Notify on success".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { key: "notify_on_approval".into(), label: "Notify on approval needed".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { key: "email_digest".into(), label: "Daily email digest".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, ], }, SettingGroup { id: "security".into(), name: "Security".into(), description: "Access control and security policies.".into(), fields: vec![ - SettingField { key: "sso_provider".into(), label: "SSO provider".into(), value: "Okta".into(), type_: SettingFieldType::Select, options: vec!["None".into(), "Okta".into(), "Azure AD".into()], description: None }, + SettingField { key: "sso_provider".into(), label: "SSO provider".into(), value: "Okta".into(), type_: SettingFieldType::Select, options: vec!["None".into(), "Okta".into(), "Azure AD".into(), "Google Workspace".into(), "OneLogin".into()], description: None }, SettingField { key: "mfa_required".into(), label: "Require MFA".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { key: "session_timeout".into(), label: "Session timeout".into(), value: "8 hours".into(), type_: SettingFieldType::Select, options: vec!["1 hour".into(), "4 hours".into(), "8 hours".into(), "24 hours".into(), "7 days".into()], description: None }, + SettingField { key: "audit_log".into(), label: "Audit logging".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { key: "ip_allowlist".into(), label: "IP allowlist".into(), value: "".into(), type_: SettingFieldType::Text, options: vec![], description: Some("Comma-separated CIDRs. Leave empty to allow all.".into()) }, ], }, ] diff --git a/docs/draft-api-design.md b/docs/draft-api-design.md deleted file mode 100644 index a861da083..000000000 --- a/docs/draft-api-design.md +++ /dev/null @@ -1,473 +0,0 @@ -# Arc API Design — Draft - -Comprehensive API to support all data needs of `arc-web`. - -## Current state - -**Existing Rust API** (`crates/arc-api/src/server.rs`) exposes 10 endpoints under `/pipelines`. -The OpenAPI spec (`openapi/arc-api.yaml`) uses `/runs`. The router needs to be renamed to match. - -**arc-web** uses zero real API calls today. Every page renders hardcoded mock data from: -- `app/data/runs.ts` — 10 runs in 4 kanban columns -- `app/data/retros.ts` — 5 retros with full stage/learning/friction data -- `app/data/verifications.ts` — 8 categories, 30 controls, performance metrics, recent results -- `routes/workflow-detail.tsx` — 4 workflow definitions (config TOML + graph DOT) -- `routes/run-stages.tsx` — hardcoded conversation turns (system/assistant/tool) -- `routes/run-files-changed.tsx` — hardcoded file diffs + checkpoints -- `routes/run-usage.tsx` — hardcoded token/cost usage per stage -- `routes/start.tsx` — hardcoded projects, branches, session history -- `routes/session-detail.tsx` — 3 sessions with chat turns -- `routes/insights.tsx` — saved SQL queries + history -- `routes/settings.tsx` — 5 setting groups with fields - ---- - -## Endpoint inventory - -### 1. Runs - -Already partially exists. Needs enrichment to carry the data the UI actually renders. - -| Method | Path | Description | Status | -|--------|------|-------------|--------| -| `GET` | `/runs` | List runs (board view data) | **extend** | -| `POST` | `/runs` | Start a new run | exists | -| `GET` | `/runs/{id}` | Full run detail | **extend** | -| `POST` | `/runs/{id}/cancel` | Cancel a running run | exists | -| `GET` | `/runs/{id}/events` | SSE event stream | exists | -| `GET` | `/runs/{id}/questions` | Pending questions | exists | -| `POST` | `/runs/{id}/questions/{qid}/answer` | Submit answer | exists | -| `GET` | `/runs/{id}/checkpoint` | Checkpoint data | exists | -| `GET` | `/runs/{id}/context` | Context key-value map | exists | -| `GET` | `/runs/{id}/graph` | Workflow graph SVG | exists | -| `GET` | `/runs/{id}/retro` | Retrospective | exists | -| `GET` | `/runs/{id}/stages` | List stages with status/duration | **new** | -| `GET` | `/runs/{id}/stages/{stageId}/turns` | Conversation transcript for a stage | **new** | -| `GET` | `/runs/{id}/files` | File diffs grouped by checkpoint | **new** | -| `GET` | `/runs/{id}/usage` | Token/cost breakdown by stage + model | **new** | -| `GET` | `/runs/{id}/verifications` | Verification results for this run | **new** | -| `GET` | `/runs/{id}/configuration` | Run configuration (TOML) | **new** | -| `POST` | `/runs/{id}/steer` | Submit steering guidance on a file line | **new** | - -#### `GET /runs` response shape - -```jsonc -[ - { - "id": "run-1", - "repo": "api-server", - "title": "Add rate limiting to auth endpoints", - "workflow": "implement", - "status": "working", // working | pending | review | merge - "number": null, // PR number, if opened - "additions": null, - "deletions": null, - "checks": [ // CI check runs - { "name": "lint", "status": "success", "duration_secs": 23 } - ], - "elapsed_secs": 420, - "elapsed_warning": false, - "resources": "4 CPU / 8 GB", - "comments": 0, - "question": null, // pending human-in-the-loop question - "sandbox_id": "sb-a1b2c3d4" - } -] -``` - -#### `GET /runs/{id}/stages` response shape - -```jsonc -[ - { - "id": "detect-drift", - "name": "Detect Drift", - "status": "completed", // completed | running | pending | failed - "duration_secs": 72, - "dot_id": "detect" // node ID in workflow graph (for annotations) - } -] -``` - -#### `GET /runs/{id}/stages/{stageId}/turns` response shape - -```jsonc -[ - { "kind": "system", "content": "You are a drift detection agent..." }, - { "kind": "assistant", "content": "I'll start by loading..." }, - { - "kind": "tool", - "tools": [ - { "tool_name": "read_file", "args": "{ \"path\": \"...\" }", "result": "..." } - ] - } -] -``` - -#### `GET /runs/{id}/files?checkpoint=all` response shape - -```jsonc -{ - "checkpoints": [ - { "id": "all", "label": "All changes" }, - { "id": "cp-4", "label": "Checkpoint 4 — Apply Changes" } - ], - "files": [ - { - "old_file": { "name": "src/commands/run.ts", "contents": "..." }, - "new_file": { "name": "src/commands/run.ts", "contents": "..." } - } - ], - "stats": { "additions": 567, "deletions": 234 } -} -``` - -#### `GET /runs/{id}/usage` response shape - -```jsonc -{ - "stages": [ - { - "stage": "Detect Drift", - "model": "Opus 4.6", - "input_tokens": 12480, - "output_tokens": 3210, - "runtime_secs": 72, - "cost": 0.48 - } - ], - "totals": { - "runtime_secs": 389, - "input_tokens": 71540, - "output_tokens": 21080, - "cost": 2.26 - }, - "by_model": [ - { "model": "Opus 4.6", "stages": 2, "input_tokens": 33780, "output_tokens": 9690, "cost": 1.35 } - ] -} -``` - -#### `GET /runs/{id}/verifications` response shape - -```jsonc -[ - { - "name": "Traceability", - "question": "Do we understand what this change is and why we're making it?", - "status": "pass", - "controls": [ - { - "name": "Motivation", - "description": "Origin of proposal identified", - "type": "ai", // ai | automated | analysis | ai-analysis | null - "status": "pass" // pass | fail | na - } - ] - } -] -``` - ---- - -### 2. Workflows - -Entirely new. Supports the `/workflows` list page, detail page with definition/diagram/runs tabs. - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/workflows` | List all workflows | -| `GET` | `/workflows/{name}` | Workflow detail (config + graph DOT) | -| `POST` | `/workflows/{name}/runs` | Trigger a run for this workflow | -| `GET` | `/workflows/{name}/runs` | List runs filtered to this workflow | - -#### `GET /workflows` response shape - -```jsonc -[ - { - "name": "Fix Build", - "slug": "fix_build", - "filename": "fix_build.dot", - "last_run": "2 hours ago", - "schedule": null, // e.g. "Daily at 09:00" - "next_run": null // e.g. "Starts in 3 hours" - } -] -``` - -#### `GET /workflows/{name}` response shape - -```jsonc -{ - "title": "Fix Build", - "slug": "fix_build", - "filename": "fix_build.dot", - "description": "Automatically diagnoses and fixes CI build failures...", - "config": "version = 1\ntask = ...", // raw TOML - "graph": "digraph fix_build { ... }" // raw DOT source -} -``` - ---- - -### 3. Verifications - -Entirely new. Supports the `/verifications` list and `/verifications/:slug` detail pages. - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/verifications` | List all verification categories + controls | -| `GET` | `/verifications/{slug}` | Control detail (performance, evaluations, control detail, recent results) | - -#### `GET /verifications` response shape - -```jsonc -[ - { - "name": "Traceability", - "question": "Do we understand what this change is and why we're making it?", - "controls": [ - { - "name": "Motivation", - "slug": "motivation", - "description": "Origin of proposal identified", - "type": "ai", - "mode": "active", // active | evaluate | disabled - "f1": 0.87, - "pass_at_1": 0.82, - "evaluations": ["pass", "pass", "fail", "pass", ...] - } - ] - } -] -``` - -#### `GET /verifications/{slug}` response shape - -```jsonc -{ - "control": { - "name": "Motivation", - "slug": "motivation", - "description": "Origin of proposal identified", - "type": "ai", - "category": "Traceability" - }, - "performance": { - "mode": "active", - "f1": 0.87, - "pass_at_1": 0.82, - "evaluations": ["pass", "pass", "fail", ...] - }, - "control_detail": { - "description": "Verifies that every change traces back to a clear origin...", - "checks": ["PR body or linked issue explains why...", ...], - "pass_example": "PR links to JIRA-1234...", - "fail_example": "PR description is empty..." - }, - "recent_results": [ - { - "run_id": "run-047", - "run_title": "PR #312 — Add OAuth2 PKCE flow", - "workflow": "code_review", - "result": "pass", - "timestamp": "2h ago" - } - ], - "siblings": [ - { "name": "Specifications", "slug": "specifications", "type": "ai", "mode": "active" } - ] -} -``` - ---- - -### 4. Retros - -Already partial (per-run). Needs a top-level list endpoint. - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/retros` | List all retros across runs | -| `GET` | `/runs/{id}/retro` | Retro for a specific run (exists) | - -#### `GET /retros` response shape - -```jsonc -[ - { - "run_id": "run-1", - "pipeline_name": "implement", - "goal": "Add rate limiting to auth endpoints", - "timestamp": "2026-02-28T14:32:00Z", - "smoothness": "smooth", - "stats": { - "total_duration_ms": 389000, - "total_cost": 2.78, - "total_retries": 0, - "files_touched": [...], - "stages_completed": 4, - "stages_failed": 0 - }, - "friction_point_count": 0 - } -] -``` - ---- - -### 5. Sessions - -Entirely new. Supports the `/start` and `/sessions/:id` pages (chat-like interaction). - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/sessions` | List sessions grouped by recency | -| `POST` | `/sessions` | Create a new session | -| `GET` | `/sessions/{id}` | Session detail with full turn history | -| `POST` | `/sessions/{id}/messages` | Send a user message | -| `GET` | `/sessions/{id}/events` | SSE stream for live assistant responses | - -#### `GET /sessions` response shape - -```jsonc -[ - { - "label": "Today", - "sessions": [ - { "id": "s1", "title": "Add rate limiting to auth endpoints", "repo": "api-server", "time": "2h ago" } - ] - } -] -``` - -#### `GET /sessions/{id}` response shape - -```jsonc -{ - "id": "s1", - "title": "Add rate limiting to auth endpoints", - "repo": "api-server", - "model": "Opus 4.6", - "turns": [ - { "kind": "user", "content": "Add rate limiting...", "date": "Feb 28" }, - { "kind": "assistant", "content": "I'll implement..." }, - { "kind": "tool", "tools": [{ "tool_name": "read_file", "args": "...", "result": "..." }] } - ] -} -``` - ---- - -### 6. Insights - -Entirely new. Supports the `/insights` SQL query editor. - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/insights/queries` | List saved queries | -| `POST` | `/insights/queries` | Save a query | -| `PUT` | `/insights/queries/{id}` | Update a saved query | -| `DELETE` | `/insights/queries/{id}` | Delete a saved query | -| `POST` | `/insights/execute` | Execute a SQL query, return results | -| `GET` | `/insights/history` | Query execution history | - -#### `POST /insights/execute` request/response - -```jsonc -// Request -{ "sql": "SELECT workflow_name, COUNT(*) FROM runs GROUP BY 1" } - -// Response -{ - "columns": ["workflow_name", "count"], - "rows": [["implement", 42], ["fix_build", 18]], - "elapsed": 0.342, - "row_count": 6 -} -``` - ---- - -### 7. Settings - -Entirely new. Supports the `/settings` page. - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/settings` | Get all setting groups with current values | - -#### `GET /settings` response shape - -```jsonc -[ - { - "id": "general", - "name": "General", - "description": "Core platform settings and defaults.", - "fields": [ - { - "key": "org_name", - "label": "Organization name", - "value": "Acme Corp", - "type": "text" - }, - { - "key": "timezone", - "label": "Timezone", - "value": "America/New_York", - "type": "select", - "options": ["America/New_York", "UTC", ...] - } - ] - } -] -``` - ---- - -### 8. Projects (for Start page) - -Supports the project/branch picker on `/start`. - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/projects` | List available projects | -| `GET` | `/projects/{id}/branches` | List branches for a project | - ---- - -## Summary: total endpoints - -| Domain | Existing | New | Total | -|--------|----------|-----|-------| -| Runs | 10 | 7 | 17 | -| Workflows | 0 | 4 | 4 | -| Verifications | 0 | 2 | 2 | -| Retros | 0 | 1 | 1 | -| Sessions | 0 | 5 | 5 | -| Insights | 0 | 6 | 6 | -| Settings | 0 | 1 | 1 | -| Projects | 0 | 2 | 2 | -| **Total** | **10** | **28** | **38** | - -## Priority order - -1. **Runs** — extend existing endpoints to carry full board data (status columns, checks, diffs, usage, stages) -2. **Workflows** — needed for the core workflow management UI -3. **Sessions** — the primary interaction model (chat UX) -4. **Verifications** — central to the quality assurance story -5. **Retros** — lightweight list endpoint on top of existing per-run retro -6. **Insights** — SQL query interface (requires query engine backend) -7. **Settings** — configuration CRUD -8. **Projects** — start page pickers - -## Open questions - -- Should `GET /runs` support filtering by status column, repo, workflow? The UI has search + repo filter + view toggle. -- Should file diffs be fetched per-checkpoint or all at once with checkpoint metadata? -- Is the insights SQL query engine in-process (SQLite) or a separate service? -- Should sessions use SSE for streaming assistant responses, or WebSocket? -- How should verification criteria definitions be managed — API-editable or config-file driven? -- Should the stage turn transcript be paginated for very long conversations? diff --git a/docs/logging-strategy.md b/docs/logging-strategy.md deleted file mode 100644 index 29ca60a26..000000000 --- a/docs/logging-strategy.md +++ /dev/null @@ -1,180 +0,0 @@ -# Arc Logging Strategy - -Arc uses the `tracing` crate for structured, file-based logging. Logs write to `~/.arc/logs/YYYY-MM-DD.log`, controlled by the `ARC_LOG` env var (default: `info`). Logs are for **developers debugging issues after the fact** — they are not user-facing output. - -Production runs at INFO level. INFO should be low-volume and high-signal — the summary of what happened. When something goes wrong, developers enable `ARC_LOG=debug` to get the full picture. DEBUG can be as verbose as needed since it's only turned on temporarily. - -## When to Log - -**Log at INFO (always on in production):** - -- Lifecycle boundaries of top-level operations — session started/completed, pipeline started/completed, server ready -- Failures and warnings — every error/warn path, with enough context to diagnose the cause -- Keep it sparse: a typical agent session should produce ~5-10 INFO lines - -**Log at DEBUG (enabled on-demand for investigation):** - -- Individual steps within an operation — each LLM request, each tool call, each pipeline node -- External interactions with detail — request parameters, response metadata, token counts -- Decision points — why a code path was taken (retry triggered, fallback used, config value resolved) -- State changes and intermediate results — config resolution, parsing outcomes - -**Do not log:** - -- Hot loops or per-token streaming events (use DEBUG only if truly needed for diagnosis) -- Data that belongs in user-facing output (`eprintln!` for CLI feedback, not tracing) -- Redundant information already captured by a parent event (if you logged "starting X", you don't need to log every sub-step at the same level) -- Events that are already traced via `EventEnum::trace()` — the event enums (`AgentEvent`, `PipelineEvent`, `ExecutionEnvEvent`) each have a `trace()` method called automatically at their emit site; do not add manual `info!`/`debug!` calls that duplicate what `trace()` already emits -- Wrapper/forwarding variants that re-emit an inner event — `PipelineEvent::Agent`, `PipelineEvent::ExecutionEnv`, and `AgentEvent::SubAgentEvent` are no-ops in `trace()` because the inner event is already traced at its origin -- Secrets, API keys, or auth tokens — even at DEBUG level - -## Log Levels - -### ERROR — Something failed and the operation cannot continue - -The current operation is aborting. A human reviewing logs should investigate every ERROR. - -```rust -error!(server = %name, error = %err, "MCP server failed to start"); -error!(provider = %provider, status = %status, "LLM request failed after all retries"); -``` - -### WARN — Something unexpected happened but execution continues - -Degraded behavior, fallback paths, or conditions that might indicate a problem. - -```rust -warn!(server = %name, "MCP server disconnected, removing tools"); -warn!(attempt = attempt, max = max_retries, error = %err, "LLM request failed, retrying"); -``` - -### INFO — The production log level - -INFO is always on. It should tell you **what** happened at a high level: which operations started, which completed, and key outcomes. Think of INFO as the audit trail — enough to answer "what did the system do?" but not so much that it creates noise. A typical agent session should produce a handful of INFO lines, not hundreds. - -```rust -info!(model = %model, "Starting agent session"); -info!(server = %name, tools = tool_count, "MCP server ready"); -info!(pipeline = %name, "Pipeline complete"); -info!(turns = turn_count, tool_calls = tool_call_count, "Agent session complete"); -``` - -### DEBUG — Turn this on when something goes wrong - -DEBUG is off in production by default. Enable it with `ARC_LOG=debug` to investigate a specific issue. DEBUG events provide the **how** and **why**: request/response details, intermediate state, config resolution, individual steps within a larger operation. DEBUG can be verbose — that's fine, since it's only enabled temporarily. - -```rust -debug!(model = %model, messages = msg_count, tools = tool_count, "Sending LLM request"); -debug!(provider = %provider, input_tokens = input, output_tokens = output, "LLM response received"); -debug!(tool = %name, duration_ms = elapsed, "Tool call complete"); -debug!(path = %path.display(), "Loading workflow file"); -debug!(env_var = "ANTHROPIC_API_KEY", "API key resolved from environment"); -``` - -## How to Write a Log Event - -### Message: describe what happened - -The message string is a short, human-readable description. Use sentence fragments starting with a verb or noun. No variable interpolation in the message — put variable data in structured fields. - -```rust -// Good — message is a fixed string, data is in fields -info!(server = %name, tools = tool_count, "MCP server ready"); - -// Bad — variable data interpolated into message string -info!("MCP server '{}' ready with {} tools", name, tool_count); -``` - -Fixed message strings make logs grepable and let tooling aggregate events by message. - -### Fields: attach structured context - -Fields are key-value pairs that make events queryable. Include enough context that the event is useful on its own without reading surrounding log lines. - -**Field naming:** -- Use `snake_case` for field names -- Use consistent names across the codebase (see table below) -- Keep names short but unambiguous - -**Common field names:** - -| Field | Used for | -|-------|----------| -| `model` | LLM model identifier | -| `provider` | LLM provider name (anthropic, openai, gemini) | -| `server` | MCP server name | -| `tool` | Tool name being called | -| `turn` | Agent turn number | -| `attempt` | Retry attempt number | -| `error` | Error value on failure | -| `path` | File system path | -| `duration_ms` | Elapsed time in milliseconds | -| `input_tokens` | Token count for LLM input | -| `output_tokens` | Token count for LLM output | - -**Field format specifiers:** -- `%` (Display) for user-readable values: `server = %name`, `error = %err`, `path = %path.display()` -- `?` (Debug) for internal/enum values: `level = ?params.level`, `status = ?response.status` -- No specifier for primitives: `tools = tool_count`, `attempt = 3` - -### Examples by crate - -**arc-agent:** -```rust -info!(model = %model, "Starting agent session"); -info!(turns = turn_count, tool_calls = total_calls, "Agent session complete"); -debug!(turn = turn_number, "Starting agent turn"); -debug!(tool = %name, "Executing tool call"); -debug!(tool = %name, duration_ms = elapsed, "Tool call complete"); -warn!(tool = %name, error = %err, "Tool execution failed"); -``` - -**arc-llm:** -```rust -debug!(provider = %provider, model = %model, messages = count, "Sending LLM request"); -debug!(provider = %provider, model = %model, input_tokens = input, output_tokens = output, "LLM response received"); -warn!(provider = %provider, attempt = n, error = %err, "Request failed, retrying"); -error!(provider = %provider, error = %err, "Request failed after all retries"); -``` - -**arc-workflows:** -```rust -info!(pipeline = %name, "Starting pipeline execution"); -info!(pipeline = %name, nodes = count, "Pipeline complete"); -debug!(node = %id, handler = %handler_type, "Executing pipeline node"); -debug!(node = %id, duration_ms = elapsed, "Pipeline node complete"); -``` - -**arc-mcp:** -```rust -info!(server = %name, tools = tool_count, "MCP server ready"); -debug!(server = %name, transport = %transport_type, "Connecting to MCP server"); -error!(server = %name, error = %err, "MCP server failed to start"); -``` - -## Cross-Package Guidelines - -Every crate that does meaningful work should emit tracing events. The `tracing` dependency is workspace-level — add it to any crate's `Cargo.toml` with: - -```toml -tracing.workspace = true -``` - -The subscriber is initialized once in `arc-cli`. Library crates (`arc-agent`, `arc-llm`, etc.) only emit events — they never configure the subscriber. This means: - -- Library crates import `tracing::{info, debug, warn, error}` and call the macros -- The events go nowhere in unit tests (this is fine — tests verify behavior, not log output) -- The events are captured by whatever subscriber the binary sets up - -When adding tracing to a new crate, start with the boundaries: INFO for the start/end of top-level operations, DEBUG for the individual steps within them. When in doubt about the level, use DEBUG — it's easy to promote something to INFO later, but hard to demote a noisy INFO event without breaking someone's log monitoring. - -## Event Enum Tracing - -The domain event enums (`AgentEvent`, `PipelineEvent`, `ExecutionEnvEvent`) each implement a `pub fn trace(&self)` method (or `trace(&self, session_id: &str)` for `AgentEvent`) that emits a structured tracing log line per variant. This method is called automatically from each enum's emit site, so every emitted event produces a log line without any additional code at the call site. - -**Rules for event tracing:** - -- **Add tracing for new variants** by adding a match arm in the enum's `trace()` method. Choose the level based on the guidelines above (INFO for lifecycle boundaries, DEBUG for individual steps, WARN/ERROR for failures). -- **Do not add manual log calls at emit sites.** The `trace()` call in the emitter handles it. Adding `info!` or `debug!` next to an `emit()` call will double-log. -- **Wrapper variants are no-ops.** When one event enum wraps another (`PipelineEvent::Agent` wraps `AgentEvent`, `AgentEvent::SubAgentEvent` wraps a child `AgentEvent`), the wrapper's `trace()` arm is `{}` because the inner event was already traced at its origin. This prevents double-logging. -- **Streaming noise variants are no-ops.** `TextDelta` and `ToolCallOutputDelta` produce no log output — per-token events would flood the logs even at DEBUG level. diff --git a/docs/spec-structure.md b/docs/spec-structure.md deleted file mode 100644 index 9da3e24f2..000000000 --- a/docs/spec-structure.md +++ /dev/null @@ -1,121 +0,0 @@ -# Spec Format Reference - -Common format shared by `specs/unified-llm-spec.md`, `specs/coding-agent-loop-spec.md` (agent crate), and `specs/arc-spec.md`. - ---- - -## 1. Title and Opening Paragraph - -Each spec starts with a level-1 heading (`# Specification`) followed immediately by a one-sentence summary paragraph that describes what the spec is, states it is **language-agnostic**, and says it is **designed to be implementable from scratch by any developer or coding agent in any programming language**. This framing signals the intended audience: an AI coding agent or a human developer doing a greenfield implementation. - -## 2. Horizontal Rule + Table of Contents - -A `---` separator follows the summary, then a `## Table of Contents` section with a numbered list of all top-level sections, each as a markdown anchor link (e.g., `[Overview and Goals](#1-overview-and-goals)`). - -## 3. Numbered Top-Level Sections - -All sections use the pattern `## N. Section Name` where N is a sequential integer. Subsections use `### N.M` (e.g., `### 2.3 Session Lifecycle`). This gives every concept a unique coordinate (e.g., "Section 5.4") for cross-referencing. - -## 4. Section 1: Overview and Goals - -Always the first section. Contains these standard subsections: - -### 4.1 Problem Statement (1.1) - -A prose description of the problem being solved. Written in concrete, opinionated terms. Explains *why* this thing needs to exist by describing the pain of not having it. - -### 4.2 Design Principles (1.2) - -A bulleted list of named principles, each formatted as **`Bold keyword.`** followed by an explanation. Examples: "Provider-agnostic.", "Streaming-first.", "Declarative pipelines.", "Hackable." These are prescriptive statements about how the system should behave, not aspirational goals. - -### 4.3 Reference Open-Source Projects (1.3 or 1.4) - -A list of existing projects that solve related problems. Each entry includes the project name, URL, language, and a description of what patterns to study from it. Explicitly stated as "not dependencies" -- inspiration sources only. - -### 4.4 Architecture Diagram - -An ASCII art box diagram showing the layers/components and how they connect. All three specs include at least one. - -### 4.5 Relationship to Companion Specs - -When the spec depends on another (agent depends on unified-llm; arc depends on agent), it states this explicitly with the types it imports and how the layering works. - -## 5. Core Technical Sections - -The middle sections define the system's data model, algorithms, and contracts using a consistent notation. - -### 5.1 Pseudocode and Type Definitions - -All code is written in a language-neutral pseudocode style: - -- **Records** use `RECORD Name:` with indented fields as `field_name : Type -- comment` -- **Enums** use `ENUM Name:` with indented values -- **Interfaces** use `INTERFACE Name:` with method signatures -- **Functions** use `FUNCTION name(params) -> ReturnType:` with indented body -- **Control flow** uses `IF`, `ELSE`, `FOR EACH`, `WHILE`, `LOOP`, `BREAK`, `CONTINUE`, `RETURN`, `TRY/CATCH` -- Keywords are UPPERCASE: `APPEND`, `AWAIT_ALL`, `YIELD`, `NONE` - -### 5.2 Type Convention - -A standard set of type primitives is used across all three specs: - -- `String`, `Integer`, `Float`, `Boolean`, `Bytes`, `Dict` -- `List` for ordered collections -- `T | None` for optional values -- `T | U` for union types -- `Map` for key-value stores - -### 5.3 Tables - -Heavy use of markdown tables for: - -- **Attribute reference tables** with columns: Key, Type, Default, Description -- **Provider mapping tables** showing how one concept translates per-provider (OpenAI / Anthropic / Gemini) -- **Enum value tables** with Value and Meaning columns - -### 5.4 Design Decision Rationale - -When a non-obvious choice is made, it's explained inline with **bold "Why..." questions**. For example: "**Why two methods, not one.**" or "**Why provider-aligned toolsets instead of a universal tool set?**" These appear immediately after the design they justify, or collected in an appendix. - -## 6. Out of Scope / Nice-to-Haves (optional) - -A section explicitly listing features that are *intentionally excluded*, with an explanation of why each is out of scope and where in the architecture it could be added later. Prevents scope creep and signals extensibility points. (Present in the agent spec; not all specs include this.) - -## 7. Definition of Done - -Always the **last numbered section**. Opens with the standard sentence: "This section defines how to validate that an implementation of this spec is complete and correct. An implementation is done when every item is checked off." - -### 7.1 Subsections by Feature Area - -Each subsection (e.g., "Core Infrastructure", "DOT Parsing", "Provider Adapters") contains a markdown checklist (`- [ ]`) of specific, verifiable assertions. - -### 7.2 Cross-Provider/Feature Parity Matrix - -A markdown table where rows are test cases and columns are providers (or a single "Pass" column). Every cell is `[ ]`. Serves as a validation matrix ensuring nothing is missed. - -### 7.3 Integration Smoke Test - -The final subsection. Contains a pseudocode end-to-end test that exercises the major codepaths with real APIs/backends. Written as executable assertions (`ASSERT`), not prose. This is the "if this passes, you're done" test. - -## 8. Appendices - -After the Definition of Done, labeled as `## Appendix A/B/C/D: Title`. Used for: - -- Reference material too detailed for the main spec (e.g., the `apply_patch` v4a format grammar) -- Complete attribute reference tables -- Error category taxonomies -- Design decision rationale (when there's a lot of it) - -## 9. Cross-cutting Patterns - -Several patterns repeat across all three specs: - -**Escape hatches over false abstractions.** Each spec defines a clean unified model, then provides explicit escape hatches (`provider_options`, `type` attribute overrides, `CodergenBackend` interface) for cases the unified model doesn't cover. The escape hatches are documented, not hidden. - -**Concrete defaults with override points.** Every configurable value has a stated default (e.g., `max_tool_rounds_per_input = 200`, `default_command_timeout_ms = 10000`). Nothing is left as "implementation-defined." - -**Provider-specific mapping tables.** When behavior differs per LLM provider, a table shows the exact field/header/API mapping for each. - -**Event-driven observability.** All three systems emit typed events for external consumption. Event kinds are defined as enums with clear semantics. - -**Separation of concerns via interfaces.** Key extension points are defined as interfaces (`ProviderAdapter`, `ExecutionEnvironment`, `Handler`, `CodergenBackend`, `Interviewer`) that decouple the core from implementations. diff --git a/docs/specs/README.md b/docs/specs/README.md deleted file mode 100644 index 7fb3b088d..000000000 --- a/docs/specs/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Arc - -This repository contains [NLSpecs](#terminology) to build your own version of Arc to create your own software factory. - -Although bringing your own agentic loop and unified LLM SDK is not required to build your own Arc, we highly recommend controlling the stack so you have a strong foundation. - -## Specs - -- [Arc Specification](./arc-spec.md) -- [Agent Specification](./coding-agent-loop-spec.md) -- [Unified LLM Client Specification](./unified-llm-spec.md) - -## Building Arc - -Supply the following prompt to a modern coding agent (Claude Code, Codex, OpenCode, Amp, Cursor, etc): - -``` -codeagent> Implement Arc as described by https://factory.strongdm.ai/ -``` - -## Terminology - -- **NLSpec** (Natural Language Spec): a human-readable spec intended to be directly usable by coding agents to implement/validate behavior. diff --git a/docs/specs/agent-cli-spec.md b/docs/specs/agent-cli-spec.md deleted file mode 100644 index bc2ac5c9d..000000000 --- a/docs/specs/agent-cli-spec.md +++ /dev/null @@ -1,324 +0,0 @@ -# Agent CLI Specification - -This document specifies a command-line interface for the coding agent library defined in the [Coding Agent Loop Specification](./coding-agent-loop-spec.md). It is a thin wrapper that configures and runs a `Session`, designed to be implementable from scratch by any developer or coding agent in any programming language. - ---- - -## Table of Contents - -1. [Overview and Goals](#1-overview-and-goals) -2. [Invocation](#2-invocation) -3. [Permission Model](#3-permission-model) -4. [Output](#4-output) -5. [Environment and Configuration](#5-environment-and-configuration) -6. [Exit Behavior](#6-exit-behavior) -7. [Definition of Done](#7-definition-of-done) - ---- - -## 1. Overview and Goals - -### 1.1 Problem Statement - -The coding agent library (`Session`) is programmable-first: it gives host applications full control over the agentic loop. But a library alone forces every user to write a host application before they can point an agent at a task. Developers need a zero-ceremony way to run a coding agent from the terminal, and CI systems need a headless way to invoke one from a script. - -The CLI is the simplest possible host application for `Session`. It translates command-line arguments into a `SessionConfig`, subscribes to the event stream for rendering, and exits when the session completes. It does not add concepts of its own -- no workspace management, no persistent identity, no project-level config files. Every feature lives in the library; the CLI is the wiring. - -### 1.2 Design Principles - -**Thin wrapper.** The CLI configures a `Session` and renders its events. All intelligence -- tool execution, loop detection, truncation, subagent spawning -- lives in the library. The CLI never duplicates library logic. - -**Dual-mode.** Interactive by default (streaming output, tool approval prompts). Fully scriptable with flags (`--auto-approve`). Same binary, same flags, different defaults based on whether a TTY is attached. - -**Single command.** No subcommands. `agent ` is the only invocation. This keeps the mental model flat and the `--help` output short. - -**Opinionated defaults.** The CLI picks reasonable defaults (provider, model, permissions) so that the common case requires zero flags. Power users override with explicit flags. - -### 1.3 Relationship to Companion Specs - -The CLI depends on the [Coding Agent Loop Specification](./coding-agent-loop-spec.md) for all agent behavior. It uses `Session`, `SessionConfig`, `EventEmitter`, `EventKind`, and `ExecutionEnvironment` directly. LLM communication flows through the agent library's use of the [Unified LLM Client Specification](./unified-llm-spec.md); the CLI does not interact with the LLM client directly. - -``` -┌─────────────────────────────┐ -│ agent CLI │ -│ (this spec) │ -│ - arg parsing │ -│ - event rendering │ -│ - permission prompts │ -└──────────┬──────────────────┘ - │ configures + runs - ▼ -┌─────────────────────────────┐ -│ Session (agent library) │ -│ - agentic loop │ -│ - tools, subagents │ -│ - loop detection │ -└──────────┬──────────────────┘ - │ uses - ▼ -┌─────────────────────────────┐ -│ LLM Client (llm library) │ -│ - provider adapters │ -│ - streaming, retry │ -└─────────────────────────────┘ -``` - ---- - -## 2. Invocation - -### 2.1 Usage - -``` -agent [OPTIONS] -``` - -`PROMPT` is a required positional argument: the task for the agent to perform. No subcommands exist. No stdin reading, no REPL. If the user has a long prompt, they can quote it or use shell heredoc syntax. - -### 2.2 Flags - -| Flag | Type | Default | Description | -|---|---|---|---| -| `--provider` | `String` | `"anthropic"` | LLM provider: `anthropic`, `openai`, or `gemini`. | -| `--model` | `String` | Provider default | Model identifier. When omitted, uses the provider profile's default model. | -| `--permissions` | `Enum` | `read-write` | Permission level: `read-only`, `read-write`, or `full`. See [Section 3](#3-permission-model). | -| `--auto-approve` | `Boolean` | `false` | Skip all interactive approval prompts. Denied tools are hard-blocked instead. | -| `--debug` | `Boolean` | `false` | Dump raw LLM request/response payloads to stderr. | - -**No other flags.** The CLI does not expose: LLM parameters (temperature, max_tokens), subagent configuration, turn limits, session resumption, context injection, dry-run mode, structured output, working directory override, or verbose levels. These are deliberate omissions to keep the surface area minimal. - -### 2.3 Provider and Model Resolution - -The `--provider` flag selects the provider profile from the agent library: - -| `--provider` value | Profile | Default model | -|---|---|---| -| `anthropic` | `AnthropicProfile` | Profile's default | -| `openai` | `OpenAiProfile` | Profile's default | -| `gemini` | `GeminiProfile` | Profile's default | - -When `--model` is specified, it overrides the profile's default model but does not change the profile selection. The provider profile determines the system prompt, tool definitions, and tool-calling conventions. - -**Why explicit --provider instead of auto-detection from model string.** Model naming conventions are not stable across providers and can collide. Explicit provider selection is unambiguous and avoids a mapping table that rots. - ---- - -## 3. Permission Model - -### 3.1 Permission Levels - -The `--permissions` flag controls which tools the agent can use without approval: - -| Level | Tools available without approval | Tools requiring approval | -|---|---|---| -| `read-only` | `read`, `grep`, `glob` | `write`, `edit`, `shell` | -| `read-write` | `read`, `grep`, `glob`, `write`, `edit` | `shell` | -| `full` | `read`, `grep`, `glob`, `write`, `edit`, `shell` | *(none)* | - -The default is `read-write`: the agent can read and modify files freely but must ask before running shell commands. - -### 3.2 Interactive Approval (TTY attached, no --auto-approve) - -When the agent calls a tool that requires approval, the CLI prompts the user on stderr: - -``` -Agent wants to run shell: npm test -Allow? [y]es / [n]o / [a]lways -``` - -- **y**: Allow this single invocation. The agent proceeds. Future calls to the same tool still prompt. -- **n**: Deny this invocation. The agent receives a tool error: `"shell tool denied by user at current permission level"`. The agent must adapt. -- **a**: Escalate permissions for the remainder of the session. The tool (and all tools at or below its permission level) no longer prompt. Equivalent to upgrading `--permissions` mid-session. - -### 3.3 Non-Interactive Mode (no TTY or --auto-approve) - -When the CLI cannot prompt (piped stdin, `--auto-approve` set, or no TTY), tools that require approval are hard-blocked. The agent receives a tool error message and must find another way to accomplish the task. - -`--auto-approve` does **not** implicitly upgrade to `--permissions full`. It means "don't prompt me, just enforce the stated permission level." A CI pipeline that wants full tool access must explicitly pass `--permissions full --auto-approve`. - -**Why hard-block instead of auto-approve-all in CI.** Silent full access in CI is dangerous. Forcing `--permissions full` to be explicit makes the trust decision visible in the pipeline definition. - ---- - -## 4. Output - -### 4.1 Event Rendering - -The CLI subscribes to the `Session`'s `EventEmitter` and renders events as follows: - -| EventKind | Rendering | -|---|---| -| `AssistantMessage` | Stream text content to stdout as it arrives. | -| `ToolCall` | Print a one-line summary to stderr: tool name and key argument. | -| `ToolResult` | Suppressed (not shown to user). | -| `TurnComplete` | No output. | -| `Error` | Print error message to stderr. | - -**Assistant text** streams to stdout character-by-character (or chunk-by-chunk as delivered by the LLM streaming response). This is the primary output. - -**Tool call summaries** go to stderr so they don't interfere with piping stdout. Format: - -``` -[tool] read src/main.rs -[tool] edit src/lib.rs -[tool] shell npm test -``` - -### 4.2 Debug Mode - -When `--debug` is set, the CLI additionally logs full LLM request and response payloads to stderr. This includes: - -- The complete message array sent to the LLM -- System prompt -- Tool definitions -- Raw response body (streamed chunks or complete response) - -Debug output is prefixed with `[debug]` to distinguish it from tool summaries. - -### 4.3 Completion Summary - -When the session ends, the CLI prints a one-line summary to stderr: - -``` -Done (4 turns, 7 tool calls, 3.2k tokens) -``` - -This always appears, regardless of whether the agent succeeded or failed. Token count is the total across all turns (input + output). The summary goes to stderr so stdout contains only the agent's text output. - ---- - -## 5. Environment and Configuration - -### 5.1 API Keys - -API keys are read from standard environment variables. No config file, no `.env` loading, no key management. - -| Provider | Environment variable | -|---|---| -| Anthropic | `ANTHROPIC_API_KEY` | -| OpenAI | `OPENAI_API_KEY` | -| Gemini | `GEMINI_API_KEY` | - -If the required key is missing, the CLI exits immediately with a clear error message naming the expected variable. - -### 5.2 Working Directory - -The agent always operates in the process's current working directory. There is no `--dir` flag. Users who need a different directory use `cd` before invoking `agent`, following Unix convention. - -### 5.3 Session Lifecycle - -Every invocation is a fresh session. There is no session persistence, no resume flag, no checkpoint support at the CLI level. The agent starts, runs to completion, and exits. State between runs is carried only by the filesystem (files the agent created or modified). - ---- - -## 6. Exit Behavior - -### 6.1 Exit Codes - -| Code | Meaning | -|---|---| -| `0` | Agent completed successfully. | -| `1` | Agent failed (LLM error, tool error, config error, agent gave up, or any other failure). | - -Two codes only. The CLI does not differentiate between failure causes via exit code. Diagnostic information is in stderr output. - -### 6.2 Interruption - -Ctrl-C (SIGINT) triggers a graceful shutdown: the current LLM request is cancelled, any running tool is terminated, and the CLI exits with code 1. No cleanup prompt, no "are you sure" -- immediate stop. - ---- - -## 7. Definition of Done - -This section defines how to validate that an implementation of this spec is complete and correct. An implementation is done when every item is checked off. - -### 7.1 Invocation - -- [ ] `agent 'hello world'` sends "hello world" to the LLM and prints the response to stdout -- [ ] `agent` with no arguments prints usage and exits with code 1 -- [ ] `--provider anthropic` uses the Anthropic profile -- [ ] `--provider openai` uses the OpenAI profile -- [ ] `--provider gemini` uses the Gemini profile -- [ ] `--model` overrides the default model within the selected profile -- [ ] Invalid `--provider` value exits with code 1 and a clear error - -### 7.2 Permissions - -- [ ] Default permission level is `read-write` -- [ ] `--permissions read-only` blocks write, edit, and shell -- [ ] `--permissions full` allows all tools without prompts -- [ ] In interactive mode, denied tools trigger an approval prompt on stderr -- [ ] Answering "y" allows a single invocation -- [ ] Answering "n" returns a tool error to the agent -- [ ] Answering "a" escalates permissions for the session -- [ ] In non-interactive mode, denied tools are hard-blocked (tool error returned to agent) -- [ ] `--auto-approve` does not implicitly upgrade permission level - -### 7.3 Output - -- [ ] Assistant text streams to stdout -- [ ] Tool call summaries print to stderr in `[tool] name args` format -- [ ] Tool results are not shown to the user -- [ ] Completion summary prints to stderr: `Done (N turns, N tool calls, Nk tokens)` -- [ ] `--debug` dumps full LLM request/response payloads to stderr - -### 7.4 Configuration - -- [ ] API keys read from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` -- [ ] Missing API key exits with code 1 and names the expected variable -- [ ] Agent operates in the current working directory - -### 7.5 Exit Behavior - -- [ ] Successful completion exits with code 0 -- [ ] Any failure exits with code 1 -- [ ] Ctrl-C cancels the current operation and exits with code 1 - -### 7.6 Integration Smoke Test - -``` -FUNCTION smoke_test(): - -- Setup - SET dir = create_temp_directory() - write_file(dir + "/hello.txt", "world") - SET env = {"ANTHROPIC_API_KEY": valid_key} - - -- Test 1: Basic invocation - SET result = run_cli( - args: ["agent", "Read hello.txt and tell me what it says"], - cwd: dir, - env: env - ) - ASSERT result.exit_code == 0 - ASSERT result.stdout CONTAINS "world" - ASSERT result.stderr CONTAINS "Done (" - ASSERT result.stderr CONTAINS "[tool] read" - - -- Test 2: Permission enforcement - SET result = run_cli( - args: ["agent", "--permissions", "read-only", "--auto-approve", - "Write 'test' to output.txt"], - cwd: dir, - env: env - ) - -- Agent should complete (exit 0) but output.txt should not exist - -- because write was blocked and agent adapted - ASSERT NOT file_exists(dir + "/output.txt") - - -- Test 3: Missing API key - SET result = run_cli( - args: ["agent", "hello"], - cwd: dir, - env: {} - ) - ASSERT result.exit_code == 1 - ASSERT result.stderr CONTAINS "ANTHROPIC_API_KEY" - - -- Test 4: No arguments - SET result = run_cli( - args: ["agent"], - cwd: dir, - env: env - ) - ASSERT result.exit_code == 1 -``` diff --git a/docs/specs/arc-spec.md b/docs/specs/arc-spec.md deleted file mode 100644 index 5a2689b4e..000000000 --- a/docs/specs/arc-spec.md +++ /dev/null @@ -1,2128 +0,0 @@ -# Arc Specification - -A DOT-based pipeline runner that uses directed graphs (defined in Graphviz DOT syntax) to orchestrate multi-stage AI workflows. Each node in the graph is an AI task (LLM call, human review, conditional branch, parallel fan-out, etc.) and edges define the flow between them. - ---- - -## Table of Contents - -1. [Overview and Goals](#1-overview-and-goals) -2. [DOT DSL Schema](#2-dot-dsl-schema) -3. [Pipeline Execution Engine](#3-pipeline-execution-engine) -4. [Node Handlers](#4-node-handlers) -5. [State and Context](#5-state-and-context) -6. [Human-in-the-Loop (Interviewer Pattern)](#6-human-in-the-loop-interviewer-pattern) -7. [Validation and Linting](#7-validation-and-linting) -8. [Model Stylesheet](#8-model-stylesheet) -9. [Transforms and Extensibility](#9-transforms-and-extensibility) -10. [Condition Expression Language](#10-condition-expression-language) -11. [Definition of Done](#11-definition-of-done) - ---- - -## 1. Overview and Goals - -### 1.1 Problem Statement - -AI-powered software workflows -- code generation, code review, testing, deployment planning -- often require multiple LLM calls chained together with conditional logic, human approvals, and parallel execution. Without a structured orchestration layer, developers either write fragile imperative scripts or build ad-hoc state machines that are difficult to visualize, version, or debug. - -Arc solves this by letting pipeline authors define multi-stage AI workflows as directed graphs using Graphviz DOT syntax. The graph is the workflow: nodes are tasks, edges are transitions, and attributes configure behavior. The result is a declarative, visual, version-controllable pipeline definition that an execution engine can traverse deterministically. - -### 1.2 Why DOT Syntax - -DOT is chosen as the pipeline definition format for several reasons: - -- **DOT is inherently a graph description language.** Workflow pipelines are directed graphs. Using DOT means the structure (nodes and edges) maps directly to the language's primary construct, rather than being encoded in a data format like YAML or JSON that has no native concept of graphs. -- **Existing tooling.** DOT files can be rendered to SVG/PNG with standard Graphviz tooling, giving pipeline authors immediate visual feedback. Editors, linters, and parsers already exist. -- **Declarative and human-readable.** A `.dot` file is a complete, self-contained workflow definition that can be version-controlled, diffed, and reviewed in pull requests. -- **Constrained extensibility.** By restricting to a well-defined DOT subset (directed graphs only, typed attributes, no HTML labels), the DSL remains predictable while being extensible through custom attributes. - -For reference on DOT syntax, see the Graphviz DOT language specification: https://graphviz.org/doc/info/lang.html - -### 1.3 Design Principles - -**Declarative pipelines.** The `.dot` file declares what the workflow looks like and what each stage should do. The execution engine decides how and when to run each stage. Pipeline authors do not write control flow; they declare graph structure. - -**Pluggable handlers.** Each node type (LLM call, human gate, parallel fan-out) is backed by a handler that implements a common interface. New node types are added by registering new handlers. The execution engine does not know about handler internals. - -**Checkpoint and resume.** After each node completes, the execution engine saves a serializable checkpoint. If the process crashes, execution resumes from the last checkpoint. - -**Human-in-the-loop.** The pipeline can pause at designated nodes, present choices to a human operator, and route based on the human's decision. This supports approval gates, code review, and manual override -- critical for AI workflows where automated judgment may not be sufficient. - -**Edge-based routing.** Transitions between nodes are controlled by conditions, labels, and weights on edges, with runtime condition evaluation. - -### 1.4 Layering and LLM Backends - -Arc defines the orchestration layer: graph definition, traversal, state management, and extensibility. It does NOT require any specific LLM integration. The codergen handler (Section 4.5) needs a way to call an LLM and get a response -- how you provide that is up to you. - -The codergen handler takes a backend that conforms to the `CodergenBackend` interface (Section 4.5). What that backend does internally is entirely up to the implementor -- use the companion [Agent](./coding-agent-loop-spec.md) and [Unified LLM Client](./unified-llm-spec.md) specs, spawn CLI agents (Claude Code, Codex, Gemini CLI) in subprocesses, run agents in tmux panes with a manager attaching to them, call an LLM API directly, or anything else. The pipeline definition (the DOT file) does not change regardless of backend choice. - -Arc pipelines are driven by an event stream (Section 9.6). TUI, web, and IDE frontends consume events and submit human-in-the-loop answers. The pipeline engine is headless; the presentation layer is separate. - ---- - -## 2. DOT DSL Schema - -### 2.1 Supported Subset - -Arc accepts a strict subset of the Graphviz DOT language. The restrictions exist for predictability: one graph per file, directed edges only, no HTML labels, and typed attributes with defaults. - -### 2.2 BNF-Style Grammar - -``` -Graph ::= 'digraph' Identifier '{' Statement* '}' - -Statement ::= GraphAttrStmt - | NodeDefaults - | EdgeDefaults - | SubgraphStmt - | NodeStmt - | EdgeStmt - | GraphAttrDecl - -GraphAttrStmt ::= 'graph' AttrBlock ';'? -NodeDefaults ::= 'node' AttrBlock ';'? -EdgeDefaults ::= 'edge' AttrBlock ';'? -GraphAttrDecl ::= Identifier '=' Value ';'? - -SubgraphStmt ::= 'subgraph' Identifier? '{' Statement* '}' - -NodeStmt ::= Identifier AttrBlock? ';'? -EdgeStmt ::= Identifier ( '->' Identifier )+ AttrBlock? ';'? - -AttrBlock ::= '[' Attr ( ',' Attr )* ']' -Attr ::= Key '=' Value - -Key ::= Identifier | QualifiedId -QualifiedId ::= Identifier ( '.' Identifier )+ - -Value ::= String | Integer | Float | Boolean | Duration -Identifier ::= [A-Za-z_][A-Za-z0-9_]* -String ::= '"' ( '\\"' | '\\n' | '\\t' | '\\\\' | [^"\\] )* '"' -Integer ::= '-'? [0-9]+ -Float ::= '-'? [0-9]* '.' [0-9]+ -Boolean ::= 'true' | 'false' -Duration ::= Integer ( 'ms' | 's' | 'm' | 'h' | 'd' ) - -Direction ::= 'TB' | 'LR' | 'BT' | 'RL' -``` - -### 2.3 Key Constraints - -- **One digraph per file.** Multiple graphs, undirected graphs, and `strict` modifiers are rejected. -- **Bare identifiers for node IDs.** Node IDs must match `[A-Za-z_][A-Za-z0-9_]*`. Human-readable names go in the `label` attribute. -- **Commas required between attributes.** Inside attribute blocks, commas separate key-value pairs for unambiguous parsing. -- **Directed edges only.** `->` is the only edge operator. `--` (undirected) is rejected. -- **Comments supported.** Both `// line` and `/* block */` comments are stripped before parsing. -- **Semicolons optional.** Statement-terminating semicolons are accepted but not required. - -### 2.4 Value Types - -| Type | Syntax | Examples | -|----------|---------------------------------|--------------------------------------| -| String | Double-quoted with escapes | `"Hello world"`, `"line1\nline2"` | -| Integer | Optional sign, digits | `42`, `-1`, `0` | -| Float | Decimal number | `0.5`, `-3.14` | -| Boolean | Literal keywords | `true`, `false` | -| Duration | Integer + unit suffix | `900s`, `15m`, `2h`, `250ms`, `1d` | - -### 2.5 Graph-Level Attributes - -Graph attributes are declared in a `graph [ ... ]` block or as top-level `key = value` declarations. They configure the entire workflow. - -| Key | Type | Default | Description | -|---------------------------|----------|-----------|-------------| -| `goal` | String | `""` | Human-readable goal for the pipeline. Exposed as `$goal` in prompt templates and mirrored into the run context as `graph.goal`. | -| `label` | String | `""` | Display name for the graph (used in visualization). | -| `model_stylesheet` | String | `""` | CSS-like stylesheet for per-node LLM model/provider defaults. See Section 8. | -| `default_max_retry` | Integer | `3` | Global retry ceiling for nodes that omit `max_retries`. | -| `retry_target` | String | `""` | Node ID to jump to if exit is reached with unsatisfied goal gates. | -| `fallback_retry_target` | String | `""` | Secondary jump target if `retry_target` is missing or invalid. | -| `default_fidelity` | String | `""` | Default context fidelity mode (see Section 5.4). | - -### 2.6 Node Attributes - -| Key | Type | Default | Description | -|---------------------|----------|-----------------|-------------| -| `label` | String | node ID | Display name shown in UI, prompts, and telemetry. | -| `shape` | String | `"box"` | Graphviz shape. Determines the default handler type (see mapping table below). | -| `type` | String | `""` | Explicit handler type override. Takes precedence over shape-based resolution. | -| `prompt` | String | `""` | Primary instruction for the stage. Supports `$goal` variable expansion. Falls back to `label` if empty for LLM stages. | -| `max_retries` | Integer | `0` | Number of additional attempts beyond the initial execution. `max_retries=3` means up to 4 total executions. | -| `goal_gate` | Boolean | `false` | If `true`, this node must reach SUCCESS before the pipeline can exit. | -| `retry_target` | String | `""` | Node ID to jump to if this node fails and retries are exhausted. | -| `fallback_retry_target` | String | `""` | Secondary retry target. | -| `fidelity` | String | inherited | Context fidelity mode for this node's LLM session. See Section 5.4. | -| `thread_id` | String | derived | Explicit thread identifier for LLM session reuse under `full` fidelity. | -| `class` | String | `""` | Comma-separated class names for model stylesheet targeting. | -| `timeout` | Duration | unset | Maximum execution time for this node. | -| `llm_model` | String | inherited | LLM model identifier. Overridable by stylesheet. | -| `llm_provider` | String | auto-detected | LLM provider key. Auto-detected from model if unset. | -| `reasoning_effort` | String | `"high"` | LLM reasoning effort: `low`, `medium`, `high`. | -| `auto_status` | Boolean | `false` | If `true` and the handler writes no status, the engine auto-generates a SUCCESS outcome. | -| `allow_partial` | Boolean | `false` | Accept PARTIAL_SUCCESS when retries are exhausted instead of failing. | - -### 2.7 Edge Attributes - -| Key | Type | Default | Description | -|--------------|----------|---------|-------------| -| `label` | String | `""` | Human-facing caption and routing key. Used for preferred-label matching in edge selection. | -| `condition` | String | `""` | Boolean guard expression evaluated against the current context and outcome. See Section 10. | -| `weight` | Integer | `0` | Numeric priority for edge selection. Higher weight wins among equally eligible edges. | -| `fidelity` | String | unset | Override fidelity mode for the target node. Highest precedence in fidelity resolution. | -| `thread_id` | String | unset | Override thread ID for session reuse at the target node. | -| `loop_restart` | Boolean | `false` | When `true`, terminates the current run and re-launches with a fresh log directory. | -| `freeform` | Boolean | `false` | Marks this edge as the free-text input route for `wait.human` nodes. At most one per node. | - -### 2.8 Shape-to-Handler-Type Mapping - -The `shape` attribute on a node determines which handler executes it, unless overridden by an explicit `type` attribute. This table defines the canonical mapping: - -| Shape | Handler Type | Description | -|-------------------|-----------------------|-------------| -| `Mdiamond` | `start` | Pipeline entry point. No-op handler. Every graph must have exactly one. | -| `Msquare` | `exit` | Pipeline exit point. No-op handler. Every graph must have exactly one. | -| `box` | `codergen` | LLM task (code generation, analysis, planning). The default for all nodes without an explicit shape. | -| `hexagon` | `wait.human` | Human-in-the-loop gate. Blocks until a human selects an option. | -| `diamond` | `conditional` | Conditional routing point. Routes based on edge conditions against current context. | -| `component` | `parallel` | Parallel fan-out. Executes multiple branches concurrently. | -| `tripleoctagon` | `parallel.fan_in` | Parallel fan-in. Waits for all branches and consolidates results. | -| `parallelogram` | `script` | External script execution (shell command, Python script, API call). | -| `house` | `stack.manager_loop` | Supervisor loop. Orchestrates observe/steer/wait cycles over a child pipeline. | - -### 2.9 Chained Edges - -Chained edge declarations are syntactic sugar. The statement: - -``` -A -> B -> C [label="next"] -``` - -expands to two edges: - -``` -A -> B [label="next"] -B -> C [label="next"] -``` - -Edge attributes in a chained declaration apply to all edges in the chain. - -### 2.10 Subgraphs - -Subgraphs serve two purposes: **scoping defaults** and **deriving classes** for the model stylesheet. - -**Scoping defaults:** Attributes declared in a subgraph's `node [ ... ]` block apply to nodes within that subgraph unless the node explicitly overrides them. - -``` -subgraph cluster_loop { - label = "Loop A" - node [thread_id="loop-a", timeout="900s"] - - Plan [label="Plan next step"] - Implement [label="Implement", timeout="1800s"] -} -``` - -Here `Plan` inherits `thread_id="loop-a"` and `timeout="900s"`, while `Implement` inherits `thread_id` but overrides `timeout`. - -**Class derivation:** Subgraph labels can produce CSS-like classes for model stylesheet matching. Nodes inside a subgraph receive the derived class. The class name is derived by lowercasing the label, replacing spaces with hyphens, and stripping non-alphanumeric characters (except hyphens). For example, `label="Loop A"` yields class `loop-a`. - -### 2.11 Node and Edge Default Blocks - -Default blocks set baseline attributes for all subsequent nodes or edges within their scope: - -``` -node [shape=box, timeout="900s"] -edge [weight=0] -``` - -Explicit attributes on individual nodes or edges override these defaults. - -### 2.12 Class Attribute - -The `class` attribute assigns one or more CSS-like class names to a node for model stylesheet targeting: - -``` -review_code [shape=box, class="code,critical", prompt="Review the code"] -``` - -Classes are comma-separated. They can be referenced in the model stylesheet with dot-prefix selectors (`.code`, `.critical`). - -### 2.13 Minimal Examples - -**Simple linear workflow:** - -``` -digraph Simple { - graph [goal="Run tests and report"] - rankdir=LR - - start [shape=Mdiamond, label="Start"] - exit [shape=Msquare, label="Exit"] - - run_tests [label="Run Tests", prompt="Run the test suite and report results"] - report [label="Report", prompt="Summarize the test results"] - - start -> run_tests -> report -> exit -} -``` - -**Branching workflow with conditions:** - -``` -digraph Branch { - graph [goal="Implement and validate a feature"] - rankdir=LR - node [shape=box, timeout="900s"] - - start [shape=Mdiamond, label="Start"] - exit [shape=Msquare, label="Exit"] - plan [label="Plan", prompt="Plan the implementation"] - implement [label="Implement", prompt="Implement the plan"] - validate [label="Validate", prompt="Run tests"] - gate [shape=diamond, label="Tests passing?"] - - start -> plan -> implement -> validate -> gate - gate -> exit [label="Yes", condition="outcome=success"] - gate -> implement [label="No", condition="outcome!=success"] -} -``` - -**Human gate:** - -``` -digraph Review { - rankdir=LR - - start [shape=Mdiamond, label="Start"] - exit [shape=Msquare, label="Exit"] - - review_gate [ - shape=hexagon, - label="Review Changes", - type="wait.human" - ] - - start -> review_gate - review_gate -> ship_it [label="[A] Approve"] - review_gate -> fixes [label="[F] Fix"] - ship_it -> exit - fixes -> review_gate -} -``` - ---- - -## 3. Pipeline Execution Engine - -### 3.1 Run Lifecycle - -The execution lifecycle proceeds through five phases: - -``` -PARSE -> VALIDATE -> INITIALIZE -> EXECUTE -> FINALIZE -``` - -1. **Parse:** Read the `.dot` source and produce an in-memory Graph model (nodes, edges, attributes). -2. **Validate:** Run lint rules (Section 7). Reject invalid graphs. Warn on suspicious patterns. -3. **Initialize:** Create the run directory, initial context, and checkpoint. Mirror graph attributes into the context. Apply transforms (stylesheet, variable expansion). -4. **Execute:** Traverse the graph from the start node, executing handlers and selecting edges. -5. **Finalize:** Write the final checkpoint, emit completion events, and clean up resources (close sessions, release files). - -### 3.2 Core Execution Loop - -The following pseudocode defines the execution engine's traversal algorithm. This is the heart of the system. - -``` -FUNCTION run(graph, config): - context = new Context() - mirror_graph_attributes(graph, context) - checkpoint = new Checkpoint() - completed_nodes = [] - node_outcomes = {} - - current_node = find_start_node(graph) - -- Resolves by: (1) shape=Mdiamond, (2) id="start" or "Start" - -- Raises error if not found - - WHILE true: - node = graph.nodes[current_node.id] - - -- Step 1: Check for terminal node - IF is_terminal(node): - gate_ok, failed_gate = check_goal_gates(graph, node_outcomes) - IF NOT gate_ok AND failed_gate exists: - retry_target = get_retry_target(failed_gate, graph) - IF retry_target exists: - current_node = graph.nodes[retry_target] - CONTINUE - ELSE: - RAISE "Goal gate unsatisfied and no retry target" - BREAK -- Exit the loop; pipeline complete - - -- Step 2: Execute node handler with retry policy - retry_policy = build_retry_policy(node, graph) - outcome = execute_with_retry(node, context, graph, retry_policy) - - -- Step 3: Record completion - completed_nodes.append(node.id) - node_outcomes[node.id] = outcome - - -- Step 4: Apply context updates from outcome - FOR EACH (key, value) IN outcome.context_updates: - context.set(key, value) - context.set("outcome", outcome.status) - IF outcome.preferred_label is not empty: - context.set("preferred_label", outcome.preferred_label) - - -- Step 5: Save checkpoint - checkpoint = create_checkpoint(context, current_node.id, completed_nodes) - save_checkpoint(checkpoint, logs_root) - - -- Step 6: Select next edge - next_edge = select_edge(node, outcome, context, graph) - IF next_edge is NONE: - IF outcome.status == FAIL: - RAISE "Stage failed with no outgoing fail edge" - BREAK - - -- Step 7: Handle loop_restart - IF next_edge has loop_restart=true: - restart_run(graph, config, start_at=next_edge.target) - RETURN - - -- Step 8: Advance to next node - current_node = graph.nodes[next_edge.to_node] - - RETURN last_outcome -``` - -### 3.3 Edge Selection Algorithm - -After a node completes, the engine selects the next edge from the node's outgoing edges. The selection is deterministic and follows a five-step priority order: - -**Step 1: Condition-matching edges.** Evaluate each edge's `condition` expression (see Section 10) against the current context and outcome. Edges whose condition evaluates to `true` are eligible. Edges with no condition are not considered in this step; they proceed to later steps. - -**Step 2: Preferred label match.** If the node's outcome includes a `preferred_label`, find the first eligible edge (condition-passing or unconditional) whose `label` matches after normalization. Label normalization: lowercase, trim whitespace, strip accelerator prefixes (patterns like `[Y] `, `Y) `, `Y - `). - -**Step 3: Suggested next IDs.** If no label match and the outcome includes `suggested_next_ids`, find the first eligible edge whose target node ID appears in the list. - -**Step 4: Highest weight.** Among remaining eligible unconditional edges, choose the one with the highest `weight` attribute (default 0). - -**Step 5: Lexical tiebreak.** If weights are equal, choose the edge whose target node ID comes first lexicographically. - -``` -FUNCTION select_edge(node, outcome, context, graph): - edges = graph.outgoing_edges(node.id) - IF edges is empty: - RETURN NONE - - -- Step 1: Condition matching - condition_matched = [] - FOR EACH edge IN edges: - IF edge.condition is not empty: - IF evaluate_condition(edge.condition, outcome, context) == true: - condition_matched.append(edge) - IF condition_matched is not empty: - RETURN best_by_weight_then_lexical(condition_matched) - - -- Step 2: Preferred label - IF outcome.preferred_label is not empty: - FOR EACH edge IN edges: - IF normalize_label(edge.label) == normalize_label(outcome.preferred_label): - RETURN edge - - -- Step 3: Suggested next IDs - IF outcome.suggested_next_ids is not empty: - FOR EACH suggested_id IN outcome.suggested_next_ids: - FOR EACH edge IN edges: - IF edge.to_node == suggested_id: - RETURN edge - - -- Step 4 & 5: Weight with lexical tiebreak (unconditional edges only) - unconditional = [e FOR e IN edges WHERE e.condition is empty] - IF unconditional is not empty: - RETURN best_by_weight_then_lexical(unconditional) - - -- Fallback: any edge - RETURN best_by_weight_then_lexical(edges) - - -FUNCTION best_by_weight_then_lexical(edges): - SORT edges BY (weight DESCENDING, to_node ASCENDING) - RETURN edges[0] -``` - -### 3.4 Goal Gate Enforcement - -Nodes with `goal_gate=true` represent critical stages that must succeed before the pipeline can exit. When the traversal reaches a terminal node (shape=Msquare): - -1. Check all visited nodes that have `goal_gate=true`. -2. If any goal gate node has a non-success outcome (not SUCCESS or PARTIAL_SUCCESS), the pipeline cannot exit. -3. Instead, jump to the `retry_target` of the unsatisfied goal gate node. If that is not set, try `fallback_retry_target`. If that is also not set, try the graph-level `retry_target` and `fallback_retry_target`. -4. If no retry target exists at any level, the pipeline fails with an error. - -``` -FUNCTION check_goal_gates(graph, node_outcomes): - FOR EACH (node_id, outcome) IN node_outcomes: - node = graph.nodes[node_id] - IF node.goal_gate == true: - IF outcome.status NOT IN {SUCCESS, PARTIAL_SUCCESS}: - RETURN (false, node) - RETURN (true, NONE) -``` - -### 3.5 Retry Logic - -Each node has a retry policy determined by: - -1. Node attribute `max_retries` (if set) -- number of additional attempts beyond the initial execution -2. Graph attribute `default_max_retry` (fallback) -3. Built-in default: 50 - -The `max_retries` attribute specifies additional attempts. So `max_retries=3` means a total of 4 executions (1 initial + 3 retries). Internally this maps to `max_attempts = max_retries + 1`. - -``` -FUNCTION execute_with_retry(node, context, graph, retry_policy): - FOR attempt FROM 1 TO retry_policy.max_attempts: - TRY: - outcome = handler.execute(node, context, graph, logs_root) - CATCH exception: - IF retry_policy.should_retry(exception) AND attempt < retry_policy.max_attempts: - delay = retry_policy.backoff.delay_for_attempt(attempt) - sleep(delay) - CONTINUE - ELSE: - RETURN Outcome(status=FAIL, failure_reason=str(exception)) - - IF outcome.status IN {SUCCESS, PARTIAL_SUCCESS}: - reset_retry_counter(node.id) - RETURN outcome - - IF outcome.status == RETRY: - IF attempt < retry_policy.max_attempts: - increment_retry_counter(node.id) - delay = retry_policy.backoff.delay_for_attempt(attempt) - sleep(delay) - CONTINUE - ELSE: - IF node.allow_partial == true: - RETURN Outcome(status=PARTIAL_SUCCESS, notes="retries exhausted, partial accepted") - RETURN Outcome(status=FAIL, failure_reason="max retries exceeded") - - IF outcome.status == FAIL: - RETURN outcome - - RETURN Outcome(status=FAIL, failure_reason="max retries exceeded") -``` - -### 3.6 Retry Policy - -``` -RetryPolicy: - max_attempts : Integer -- minimum 1 (1 means no retries) - backoff : BackoffConfig -- delay calculation between retries - should_retry : Function(Error) -> Boolean -- predicate for retryable errors - -BackoffConfig: - initial_delay_ms : Integer -- first retry delay in milliseconds (default: 200) - backoff_factor : Float -- multiplier for subsequent delays (default: 2.0) - max_delay_ms : Integer -- cap on delay in milliseconds (default: 60000) - jitter : Boolean -- add random jitter to prevent thundering herd (default: true) -``` - -**Delay calculation:** - -``` -FUNCTION delay_for_attempt(attempt, config): - -- attempt is 1-indexed (first retry is attempt=1) - delay = config.initial_delay_ms * (config.backoff_factor ^ (attempt - 1)) - delay = MIN(delay, config.max_delay_ms) - IF config.jitter: - delay = delay * random_uniform(0.5, 1.5) - RETURN delay -``` - -**Preset policies:** - -| Name | Max Attempts | Initial Delay | Factor | Description | -|--------------|-------------|---------------|--------|-------------| -| `none` | 1 | -- | -- | No retries. Fail immediately on error. | -| `standard` | 5 | 200ms | 2.0 | General-purpose. Delays: 200, 400, 800, 1600, 3200ms. | -| `aggressive` | 5 | 500ms | 2.0 | For unreliable operations. Delays: 500, 1000, 2000, 4000, 8000ms. | -| `linear` | 3 | 500ms | 1.0 | Fixed delay between attempts. Delays: 500, 500, 500ms. | -| `patient` | 3 | 2000ms | 3.0 | Long-running operations. Delays: 2000, 6000, 18000ms. | - -**Default should_retry predicate:** Returns `true` for network errors, rate limit errors (HTTP 429), server errors (HTTP 5xx), and provider-reported transient failures. Returns `false` for authentication errors (HTTP 401, 403), bad request errors (HTTP 400), validation errors, and configuration errors. - -### 3.7 Failure Routing - -When a stage returns FAIL (or retries are exhausted), the engine attempts failure routing in this order: - -1. **Fail edge:** An outgoing edge with `condition="outcome=fail"`. If found, follow it. -2. **Retry target:** Node attribute `retry_target`. Jump to that node. -3. **Fallback retry target:** Node attribute `fallback_retry_target`. Jump to that node. -4. **Pipeline termination:** No failure route found. The pipeline fails with the stage's failure reason. - -### 3.8 Concurrency Model - -The graph traversal is single-threaded. Only one node executes at a time in the top-level graph. This simplifies reasoning about context state and avoids race conditions. - -Parallelism exists within specific node handlers (`parallel`, `parallel.fan_in`) that manage concurrent execution internally. Each parallel branch receives an isolated clone of the context. Branch results are collected but individual branch context changes are not merged back into the parent -- only the handler's outcome and its `context_updates` are applied. - ---- - -## 4. Node Handlers - -### 4.1 Handler Interface - -Every node handler implements a common interface. The execution engine dispatches to the appropriate handler based on the node's `type` attribute (or shape-based resolution if `type` is empty). - -``` -INTERFACE Handler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome - - -- Parameters: - -- node : The parsed Node with all its attributes - -- context : The shared key-value Context for the pipeline run (read/write) - -- graph : The full parsed Graph (for reading outgoing edges, etc.) - -- logs_root : Filesystem path for this run's log/artifact directory - - -- Returns: - -- Outcome : The result of execution (see Section 5.2) -``` - -### 4.2 Handler Registry - -The handler registry maps type strings to handler instances. Resolution follows this order: - -1. **Explicit `type` attribute** on the node (e.g., `type="wait.human"`) -2. **Shape-based resolution** using the shape-to-handler-type mapping table (Section 2.8) -3. **Default handler** (the codergen/LLM handler) - -``` -HandlerRegistry: - handlers : Map -- type string -> handler instance - default_handler : Handler -- fallback handler (typically codergen) - - FUNCTION register(type_string, handler): - handlers[type_string] = handler - -- Registering for an already-registered type replaces the previous handler - - FUNCTION resolve(node) -> Handler: - -- 1. Explicit type attribute - IF node.type is not empty AND node.type IN handlers: - RETURN handlers[node.type] - - -- 2. Shape-based resolution - handler_type = SHAPE_TO_TYPE[node.shape] - IF handler_type IN handlers: - RETURN handlers[handler_type] - - -- 3. Default - RETURN default_handler -``` - -### 4.3 Start Handler - -A no-op handler for the pipeline entry point. Returns SUCCESS immediately without performing any work. - -``` -StartHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - RETURN Outcome(status=SUCCESS) -``` - -Every graph must have exactly one start node (shape=Mdiamond). The lint rules enforce this. - -### 4.4 Exit Handler - -A no-op handler for the pipeline exit point. Returns SUCCESS immediately. Goal gate enforcement is handled by the execution engine (Section 3.4), not by this handler. - -``` -ExitHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - RETURN Outcome(status=SUCCESS) -``` - -Every graph must have exactly one exit node (shape=Msquare). - -### 4.5 Codergen Handler (LLM Task) - -The codergen handler is the default for all nodes that invoke an LLM. It reads the node's prompt, expands template variables, calls the LLM backend (see Section 1.4 for backend options), writes the prompt and response to the logs directory, and returns the outcome. - -``` -CodergenHandler: - backend : CodergenBackend | None - -- The LLM execution backend. Any implementation of the - -- CodergenBackend interface (Section 4.5). None = simulation mode. - - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - -- 1. Build prompt - prompt = node.prompt - IF prompt is empty: - prompt = node.label - prompt = expand_variables(prompt, graph, context) - - -- 2. Write prompt to logs - stage_dir = logs_root + "/" + node.id + "/" - create_directory(stage_dir) - write_file(stage_dir + "prompt.md", prompt) - - -- 3. Call LLM backend - IF backend is not NONE: - TRY: - result = backend.run(node, prompt, context) - IF result is an Outcome: - write_status(stage_dir, result) - RETURN result - response_text = string(result) - CATCH exception: - RETURN Outcome(status=FAIL, failure_reason=str(exception)) - ELSE: - response_text = "[Simulated] Response for stage: " + node.id - - -- 4. Write response to logs - write_file(stage_dir + "response.md", response_text) - - -- 5. Write status and return outcome - outcome = Outcome( - status=SUCCESS, - notes="Stage completed: " + node.id, - context_updates={ - "last_stage": node.id, - "last_response": truncate(response_text, 200) - } - ) - write_status(stage_dir, outcome) - RETURN outcome -``` - -**Variable expansion:** The only built-in template variable is `$goal`, which resolves to the graph-level `goal` attribute. Variable expansion is simple string replacement, not a templating engine. - -**Status file:** The handler writes `status.json` in the stage directory with the Outcome fields serialized as JSON. This file serves as an audit trail and enables the status-file contract: external tools or agents can write `status.json` to communicate outcomes back to the engine. - -#### CodergenBackend Interface - -``` -INTERFACE CodergenBackend: - FUNCTION run(node: Node, prompt: String, context: Context) -> String | Outcome -``` - -How you implement this interface is up to you. The pipeline engine only cares that it gets a String or Outcome back. - -### 4.6 Wait For Human Handler - -Blocks pipeline execution until a human selects an option derived from the node's outgoing edges. This implements the human-in-the-loop pattern (see Section 6 for the full Interviewer protocol). - -``` -WaitForHumanHandler: - interviewer : Interviewer -- the human interaction frontend - - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - -- 1. Derive choices from outgoing edges - edges = graph.outgoing_edges(node.id) - freeform_edge = NONE - choices = [] - FOR EACH edge IN edges: - IF edge.attrs["freeform"] == "true": - freeform_edge = edge - CONTINUE - label = edge.label OR edge.to_node - key = parse_accelerator_key(label) - choices.append(Choice(key=key, label=label, to=edge.to_node)) - - IF choices is empty AND freeform_edge is NONE: - RETURN Outcome(status=FAIL, failure_reason="No outgoing edges for human gate") - - -- 2. Build question from choices - options = [Option(key=c.key, label=c.label) FOR c IN choices] - question = Question( - text=node.label OR "Select an option:", - type=MULTIPLE_CHOICE, - options=options, - allow_freeform=(freeform_edge is not NONE), - stage=node.id - ) - - -- 3. Present to interviewer and wait for answer - answer = interviewer.ask(question) - - -- 4. Handle timeout/skip - IF answer is TIMEOUT: - default_choice = node.attrs["human.default_choice"] - IF default_choice exists: - -- Use default - ELSE: - RETURN Outcome(status=RETRY, failure_reason="human gate timeout, no default") - - IF answer is SKIPPED: - RETURN Outcome(status=FAIL, failure_reason="human skipped interaction") - - -- 5. Try fixed-choice match first - selected = find_choice_matching(answer, choices) - - IF selected is not NONE: - -- 6a. Fixed choice selected - RETURN Outcome( - status=SUCCESS, - suggested_next_ids=[selected.to], - context_updates={ - "human.gate.selected": selected.key, - "human.gate.label": selected.label - } - ) - - -- 6b. No fixed choice matched — route through freeform edge - IF freeform_edge is not NONE: - RETURN Outcome( - status=SUCCESS, - suggested_next_ids=[freeform_edge.to_node], - context_updates={ - "human.gate.selected": "freeform", - "human.gate.label": answer.text OR answer.value, - "human.gate.text": answer.text OR answer.value - } - ) - - -- 6c. Fallback to first choice - selected = choices[0] - RETURN Outcome( - status=SUCCESS, - suggested_next_ids=[selected.to], - context_updates={ - "human.gate.selected": selected.key, - "human.gate.label": selected.label - } - ) -``` - -**Accelerator key parsing** extracts shortcut keys from edge labels using these patterns: - -| Pattern | Example | Extracted Key | -|-------------------|-------------------|---------------| -| `[K] Label` | `[Y] Yes, deploy` | `Y` | -| `K) Label` | `Y) Yes, deploy` | `Y` | -| `K - Label` | `Y - Yes, deploy` | `Y` | -| First character | `Yes, deploy` | `Y` | - -### 4.7 Conditional Handler - -For diamond-shaped nodes that act as conditional routing points. The handler itself is a no-op that returns SUCCESS; the actual routing is handled by the execution engine's edge selection algorithm (Section 3.3), which evaluates conditions on outgoing edges. - -``` -ConditionalHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - RETURN Outcome( - status=SUCCESS, - notes="Conditional node evaluated: " + node.id - ) -``` - -This design keeps routing logic in the engine (where it can be deterministic and inspectable) rather than in the handler. - -### 4.8 Parallel Handler - -Fans out execution to multiple branches concurrently. Each parallel branch receives an isolated clone of the parent context and runs independently. The handler waits for all branches to complete (or applies a configurable join policy) before returning. - -``` -ParallelHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - -- 1. Identify fan-out edges (all outgoing edges from this node) - branches = graph.outgoing_edges(node.id) - - -- 2. Determine join policy from node attributes - join_policy = node.attrs.get("join_policy", "wait_all") - error_policy = node.attrs.get("error_policy", "continue") - max_parallel = integer(node.attrs.get("max_parallel", "4")) - - -- 3. Execute branches concurrently with bounded parallelism - results = [] - FOR EACH branch IN branches (up to max_parallel at a time): - branch_context = context.clone() - branch_outcome = execute_subgraph(branch.to_node, branch_context, graph, logs_root) - results.append(branch_outcome) - - -- 4. Evaluate join policy - success_count = count(r FOR r IN results WHERE r.status == SUCCESS) - fail_count = count(r FOR r IN results WHERE r.status == FAIL) - - IF join_policy == "wait_all": - IF fail_count == 0: - RETURN Outcome(status=SUCCESS) - ELSE: - RETURN Outcome(status=PARTIAL_SUCCESS) - - IF join_policy == "first_success": - IF success_count > 0: - RETURN Outcome(status=SUCCESS) - ELSE: - RETURN Outcome(status=FAIL) - - -- 5. Store results in context for downstream fan-in - context.set("parallel.results", serialize_results(results)) - RETURN Outcome(status=SUCCESS) -``` - -**Join policies:** - -| Policy | Behavior | -|------------------|----------| -| `wait_all` | All branches must complete. Join satisfied when all are done. | -| `k_of_n` | At least K branches must succeed. | -| `first_success` | Join satisfied as soon as one branch succeeds. Others may be cancelled. | -| `quorum` | At least a configurable fraction of branches must succeed. | - -**Error policies:** - -| Policy | Behavior | -|---------------------|----------| -| `fail_fast` | Cancel all remaining branches on first failure. | -| `continue` | Continue remaining branches. Collect all results. | -| `ignore` | Ignore failures entirely. Return only successful results. | - -### 4.9 Fan-In Handler - -Consolidates results from a preceding parallel node and selects the best candidate. - -``` -FanInHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - -- 1. Read parallel results - results = context.get("parallel.results") - IF results is empty: - RETURN Outcome(status=FAIL, failure_reason="No parallel results to evaluate") - - -- 2. Evaluate candidates - IF node.prompt is not empty: - -- LLM-based evaluation: call LLM to rank candidates - best = llm_evaluate(node.prompt, results) - ELSE: - -- Heuristic: rank by outcome status, then by score - best = heuristic_select(results) - - -- 3. Record winner in context - context_updates = { - "parallel.fan_in.best_id": best.id, - "parallel.fan_in.best_outcome": best.outcome - } - - RETURN Outcome( - status=SUCCESS, - context_updates=context_updates, - notes="Selected best candidate: " + best.id - ) - - -FUNCTION heuristic_select(candidates): - outcome_rank = {SUCCESS: 0, PARTIAL_SUCCESS: 1, RETRY: 2, FAIL: 3} - SORT candidates BY (outcome_rank[c.outcome], -c.score, c.id) - RETURN candidates[0] -``` - -Fan-in runs even when some candidates failed, as long as at least one candidate is available. Only when all candidates fail does fan-in return FAIL. - -### 4.10 Script Handler - -Executes an external script (shell command, Python script, or other non-LLM operation) configured via node attributes. - -``` -ScriptHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - script = node.attrs.get("script", "") - IF script is empty: - RETURN Outcome(status=FAIL, failure_reason="No script specified") - - language = node.attrs.get("language", "shell") -- "shell" or "python" - - -- Execute the script - TRY: - IF language == "python": - result = run_command("python3", "-c", script, timeout=node.timeout) - ELSE: - result = run_command("sh", "-c", script, timeout=node.timeout) - RETURN Outcome( - status=SUCCESS, - context_updates={"script.output": result.stdout}, - notes="Script completed: " + script - ) - CATCH exception: - RETURN Outcome(status=FAIL, failure_reason=str(exception)) -``` - -### 4.11 Manager Loop Handler - -Orchestrates sprint-based iteration by supervising a child pipeline. The manager observes the child's telemetry, evaluates progress via a guard function, and optionally steers the child through intervention. - -``` -SubWorkflowHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - child_dotfile = graph.attrs.get("stack.child_dotfile") - poll_interval = parse_duration(node.attrs.get("manager.poll_interval", "45s")) - max_cycles = integer(node.attrs.get("manager.max_cycles", "1000")) - stop_condition = node.attrs.get("manager.stop_condition", "") - actions = split(node.attrs.get("manager.actions", "observe,wait"), ",") - - -- 1. Auto-start child if configured - IF node.attrs.get("stack.child_autostart", "true") == "true": - start_child_pipeline(child_dotfile) - - -- 2. Observation loop - FOR cycle FROM 1 TO max_cycles: - IF "observe" IN actions: - ingest_child_telemetry(context) - - IF "steer" IN actions AND steer_cooldown_elapsed(): - steer_child(context, node) - - -- Evaluate stop conditions - child_status = context.get_string("context.stack.child.status") - IF child_status IN {"completed", "failed"}: - child_outcome = context.get_string("context.stack.child.outcome") - IF child_outcome == "success": - RETURN Outcome(status=SUCCESS, notes="Child completed") - IF child_status == "failed": - RETURN Outcome(status=FAIL, failure_reason="Child failed") - - IF stop_condition is not empty: - IF evaluate_condition(stop_condition, ..., context): - RETURN Outcome(status=SUCCESS, notes="Stop condition satisfied") - - IF "wait" IN actions: - sleep(poll_interval) - - RETURN Outcome(status=FAIL, failure_reason="Max cycles exceeded") -``` - -The manager pattern implements a **supervisor architecture** where: -- **Observe** ingests worker telemetry (active stage, outcomes, retry counts, artifacts) -- **Guard** scores worker progress and routes to continue, intervene, or escalate -- **Steer** writes intervention instructions to the child's active stage directory - -### 4.12 Custom Handlers - -New handler types are added by implementing the Handler interface and registering with the registry: - -``` --- Define a custom handler -MyCustomHandler: - FUNCTION execute(node, context, graph, logs_root) -> Outcome: - -- Custom logic here - RETURN Outcome(status=SUCCESS) - --- Register it -registry.register("my_custom_type", MyCustomHandler()) - --- Reference in DOT file -my_node [type="my_custom_type", shape=box, custom_attr="value"] -``` - -**Handler contract:** -- Handlers MUST be stateless or protect shared mutable state with synchronization. -- Handler panics/exceptions MUST be caught by the engine and converted to FAIL outcomes. -- Handlers SHOULD NOT embed provider-specific logic; LLM orchestration is delegated to the integrated SDK. - ---- - -## 5. State and Context - -### 5.1 Context - -The context is a thread-safe key-value store shared across all stages during a pipeline run. It is the primary mechanism for passing data between nodes. - -``` -Context: - values : Map -- key-value store - lock : ReadWriteLock -- thread safety for parallel access - logs : List -- append-only run log - - FUNCTION set(key, value): - ACQUIRE write lock - values[key] = value - RELEASE write lock - - FUNCTION get(key, default=NONE) -> Any: - ACQUIRE read lock - result = values.get(key, default) - RELEASE read lock - RETURN result - - FUNCTION get_string(key, default="") -> String: - value = get(key) - IF value is NONE: RETURN default - RETURN string(value) - - FUNCTION append_log(entry): - ACQUIRE write lock - logs.append(entry) - RELEASE write lock - - FUNCTION snapshot() -> Map: - -- Returns a serializable copy of all values - ACQUIRE read lock - result = shallow_copy(values) - RELEASE read lock - RETURN result - - FUNCTION clone() -> Context: - -- Deep copy for parallel branch isolation - ACQUIRE read lock - new_context = new Context() - new_context.values = shallow_copy(values) - new_context.logs = copy(logs) - RELEASE read lock - RETURN new_context - - FUNCTION apply_updates(updates): - -- Merge a dictionary of updates into the context - ACQUIRE write lock - FOR EACH (key, value) IN updates: - values[key] = value - RELEASE write lock -``` - -**Built-in context keys set by the engine:** - -| Key | Type | Set By | Description | -|---------------------------------------|---------|----------|-------------| -| `outcome` | String | Engine | Last handler outcome status (`success`, `fail`, etc.) | -| `preferred_label` | String | Engine | Last handler's preferred edge label | -| `graph.goal` | String | Engine | Mirrored from graph `goal` attribute | -| `current_node` | String | Engine | ID of the currently executing node | -| `last_stage` | String | Handler | ID of the last completed stage | -| `last_response` | String | Handler | Truncated text of the last LLM response | -| `internal.retry_count.` | Integer | Engine | Retry counter for a specific node | - -**Context key namespace conventions:** - -| Prefix | Purpose | -|---------------|------------------------------------------------| -| `context.*` | Semantic state shared between nodes | -| `graph.*` | Graph attributes mirrored at initialization | -| `internal.*` | Engine bookkeeping (retry counters, timing) | -| `parallel.*` | Parallel handler state (results, counts) | -| `stack.*` | Supervisor/worker state | -| `human.gate.*`| Human interaction state | -| `work.*` | Per-item context for parallel work items | - -### 5.2 Outcome - -The outcome is the result of executing a node handler. It drives routing decisions and state updates. - -``` -Outcome: - status : StageStatus -- SUCCESS, FAIL, PARTIAL_SUCCESS, RETRY, SKIPPED - preferred_label : String -- which edge label to follow (optional) - suggested_next_ids : List -- explicit next node IDs (optional) - context_updates : Map -- key-value pairs to merge into context - notes : String -- human-readable execution summary - failure_reason : String -- reason for failure (when status is FAIL or RETRY) -``` - -**StageStatus values:** - -| Status | Meaning | -|--------------------|---------| -| `SUCCESS` | Stage completed its work. Proceed to next edge. Reset retry counter. | -| `PARTIAL_SUCCESS` | Stage completed with caveats. Treated as success for routing but notes describe what was incomplete. | -| `RETRY` | Stage requests re-execution. Engine increments retry counter and re-executes if within limits. | -| `FAIL` | Stage failed permanently. Engine looks for a fail edge or terminates the pipeline. | -| `SKIPPED` | Stage was skipped (e.g., condition not met). Proceed without recording an outcome. | - -### 5.3 Checkpoint - -A serializable snapshot of execution state, saved after each node completes. Enables crash recovery and resume. - -``` -Checkpoint: - timestamp : Timestamp -- when this checkpoint was created - current_node : String -- ID of the last completed node - completed_nodes : List -- IDs of all completed nodes in order - node_retries : Map -- retry counters per node - context_values : Map -- serialized snapshot of the context - logs : List -- run log entries - - FUNCTION save(path): - -- Serialize to JSON and write to filesystem - data = { - "timestamp": timestamp, - "current_node": current_node, - "completed_nodes": completed_nodes, - "node_retries": node_retries, - "context": serialize_to_json(context_values), - "logs": logs - } - write_json_file(path, data) - - FUNCTION load(path) -> Checkpoint: - -- Deserialize from JSON file - data = read_json_file(path) - RETURN new Checkpoint from data -``` - -**Resume behavior:** - -1. Load the checkpoint from `{logs_root}/checkpoint.json`. -2. Restore context state from `context_values`. -3. Restore `completed_nodes` to skip already-finished work. -4. Restore retry counters from `node_retries`. -5. Determine the next node to execute (the one after `current_node` in the traversal). -6. If the previous node used `full` fidelity, degrade to `summary:high` for the first resumed node, because in-memory LLM sessions cannot be serialized. After this one degraded hop, subsequent nodes may use `full` fidelity again. - -### 5.4 Context Fidelity - -Context fidelity controls how much prior conversation and state is carried into the next node's LLM session. This is a core mechanism for managing context window usage across multi-stage pipelines. - -``` -FidelityMode ::= 'full' - | 'truncate' - | 'compact' - | 'summary:low' - | 'summary:medium' - | 'summary:high' -``` - -| Mode | Session | Context Carried | Approximate Token Budget | -|------------------|---------|---------------------------------------------------------|--------------------------| -| `full` | Reused (same thread) | Full conversation history preserved | Unbounded (uses compaction) | -| `truncate` | Fresh | Minimal: only graph goal and run ID | Minimal | -| `compact` | Fresh | Structured bullet-point summary: completed stages, outcomes, key context values | Moderate | -| `summary:low` | Fresh | Brief textual summary with minimal event counts | ~600 tokens | -| `summary:medium` | Fresh | Moderate detail: recent stage outcomes, active context values, notable events | ~1500 tokens | -| `summary:high` | Fresh | Detailed: many recent events, tool call summaries, comprehensive context | ~3000 tokens | - -**Fidelity resolution precedence (highest to lowest):** - -1. Edge `fidelity` attribute (on the incoming edge) -2. Target node `fidelity` attribute -3. Graph `default_fidelity` attribute -4. Default: `compact` - -**Thread resolution (for `full` fidelity):** - -When fidelity resolves to `full`, the engine determines a thread key for session reuse: - -1. Target node `thread_id` attribute -2. Edge `thread_id` attribute -3. Graph-level default thread -4. Derived class from enclosing subgraph -5. Fallback: previous node ID - -Nodes that share the same thread key reuse the same LLM session. Nodes with different thread keys start fresh sessions. - -### 5.5 Artifact Store - -The artifact store provides named, typed storage for large stage outputs that do not belong in the context (which should contain only small scalar values for routing and checkpoint serialization). - -``` -ArtifactStore: - artifacts : Map - lock : ReadWriteLock - base_dir : String or NONE -- filesystem directory for file-backed artifacts - - FUNCTION store(artifact_id, name, data) -> ArtifactInfo: - size = byte_size(data) - is_file_backed = (size > FILE_BACKING_THRESHOLD) AND (base_dir is not NONE) - IF is_file_backed: - write data to "{base_dir}/artifacts/{artifact_id}.json" - stored_data = file_path - ELSE: - stored_data = data - info = ArtifactInfo(id=artifact_id, name=name, size=size, is_file_backed=is_file_backed) - artifacts[artifact_id] = (info, stored_data) - RETURN info - - FUNCTION retrieve(artifact_id) -> Any: - IF artifact_id NOT IN artifacts: - RAISE "Artifact not found" - (info, data) = artifacts[artifact_id] - IF info.is_file_backed: - RETURN read_json_file(data) - RETURN data - - FUNCTION has(artifact_id) -> Boolean - FUNCTION list() -> List - FUNCTION remove(artifact_id) - FUNCTION clear() - -ArtifactInfo: - id : String - name : String - size_bytes : Integer - stored_at : Timestamp - is_file_backed : Boolean -``` - -The default file-backing threshold is 100KB. Artifacts below this threshold are stored in memory; above it, they are written to disk. - -### 5.6 Run Directory Structure - -Each pipeline execution produces a directory tree for logging, checkpoints, and artifacts: - -``` -{logs_root}/ - checkpoint.json -- Serialized checkpoint after each node - manifest.json -- Pipeline metadata (name, goal, start time) - {node_id}/ - status.json -- Node execution outcome - prompt.md -- Rendered prompt sent to LLM - response.md -- LLM response text - artifacts/ - {artifact_id}.json -- File-backed artifacts -``` - ---- - -## 6. Human-in-the-Loop (Interviewer Pattern) - -### 6.1 Interviewer Interface - -All human interaction in Arc goes through an Interviewer interface. This abstraction allows the pipeline to present questions to a human and receive answers through any frontend: CLI, web UI, Slack bot, or a programmatic queue for testing. - -``` -INTERFACE Interviewer: - FUNCTION ask(question: Question) -> Answer - FUNCTION ask_multiple(questions: List) -> List - FUNCTION inform(message: String, stage: String) -> Void -``` - -### 6.2 Question Model - -``` -Question: - text : String -- the question to present to the human - type : QuestionType -- determines the UI and valid answers - options : List