diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..5f86e14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,92 @@ +name: Bug report +description: Report a reproducible problem in Roundtable +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for helping improve Roundtable. Do not report vulnerabilities, secrets, private source, or personal data here. Use the private process in SECURITY.md. + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the observable problem and why it matters. + placeholder: Roundtable showed… + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: I expected… + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Include the smallest safe sequence that reproduces the problem. Redact prompts, paths, credentials, source, and runtime output. + placeholder: | + 1. Start with local-dispatch… + 2. Create a mission… + 3. Approve the plan… + validations: + required: true + - type: input + id: version + attributes: + label: Roundtable version or commit + placeholder: 0.1.0-beta.1 or commit SHA + validations: + required: true + - type: dropdown + id: adapter + attributes: + label: Adapter/runtime + options: + - local-dispatch + - Claude Code + - Claude Code Router + - Codex + - OpenCode + - E2B + - MiniMax + - OpenAI-compatible + - Not runtime-related + validations: + required: true + - type: dropdown + id: store + attributes: + label: Store driver + options: + - Local JSON + - Normalized Postgres + - Legacy Postgres JSONB + - Unknown / not applicable + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: OS, Node version, browser, and whether this is local or hosted. Do not include personal paths. + placeholder: Ubuntu 24.04, Node 24, Chromium, local + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Safe supporting evidence + description: Optional screenshots or sanitized error codes only. Do not paste raw logs until you have checked them for secrets and private data. + - type: checkboxes + id: checks + attributes: + label: Checklist + options: + - label: I reproduced this on the latest supported beta or current main. + required: true + - label: This report contains no secrets, private source, personal paths, or vulnerability details. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1520383 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/EdwinjJ1/roundtable/security/advisories/new + about: Report vulnerabilities privately; never include security details in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..479d148 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,63 @@ +name: Feature request +description: Describe a workflow or governance problem Roundtable should solve +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Start with the user's problem and evidence. A proposed implementation is useful after the need is clear. + - type: textarea + id: problem + attributes: + label: Problem + description: Who encounters this problem, in what workflow, and what do they do today? + placeholder: When I manage several local coding agents… + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe observable success without prescribing internal architecture. + validations: + required: true + - type: textarea + id: example + attributes: + label: Concrete example + description: Give a redacted input/output or workflow example. Do not include proprietary prompts or source. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Current workaround or alternatives + description: What have you tried, and why is it insufficient? + - type: dropdown + id: area + attributes: + label: Product area + options: + - Workflows and revisions + - Planning and approval + - Scheduling and execution + - Runtime integration + - Artifacts and review + - Permissions and safety + - Storage and migration + - UI and accessibility + - Contributor experience + - Other + validations: + required: true + - type: checkboxes + id: scope + attributes: + label: Scope check + options: + - label: I checked ROADMAP.md and existing issues for related work. + required: true + - label: This request concerns file-, script-, or command-centered workflows rather than general SaaS automation. + required: false diff --git a/.github/ISSUE_TEMPLATE/support_question.yml b/.github/ISSUE_TEMPLATE/support_question.yml new file mode 100644 index 0000000..31a6362 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support_question.yml @@ -0,0 +1,39 @@ +name: Setup or usage question +description: Ask for help using or contributing to Roundtable +title: "[Question]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Questions are public. Do not include credentials, private source, prompts, local paths, raw logs, or vulnerability details. + - type: textarea + id: goal + attributes: + label: What are you trying to do? + description: Describe the intended workflow or contribution. + validations: + required: true + - type: textarea + id: tried + attributes: + label: What have you tried? + description: Include relevant documentation and sanitized commands or error codes. + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: Roundtable version/commit, OS, Node version, store driver, and runtime kind when relevant. + placeholder: main@abc1234, Ubuntu 24.04, Node 24, local JSON, local-dispatch + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I removed secrets, private source, prompts, personal paths, raw logs, and security-sensitive details. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e7c148c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +## Problem + +What user or contributor problem does this change solve? Link the issue or ADR +when one exists. + +## Change + +Describe the smallest relevant behavior change and any deliberate non-goals. + +## Verification + +List commands and manual checks actually run. Do not mark a check complete if it +was skipped. + +- [ ] `corepack pnpm typecheck` +- [ ] `corepack pnpm lint` +- [ ] `corepack pnpm test` +- [ ] `corepack pnpm build` +- [ ] `corepack pnpm test:e2e` (cross-layer/UI changes on POSIX) + +## Evidence and risk + +- Add screenshots or a short clip for visible UI changes. +- Explain migration, owner-isolation, runtime, permission, privacy, or recovery + risk when relevant. +- State any unsupported platform/runtime or follow-up work. + +## Contributor checklist + +- [ ] Tests verify public behavior or a distinct risk, not private call order or + coverage for its own sake. +- [ ] No secret, private source, personal path, raw runtime output, or generated + `.roundtable` data is included. +- [ ] User-facing behavior and `CHANGELOG.md` are updated when appropriate. +- [ ] The PR is focused and does not overwrite unrelated work. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83fb64f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +Notable user-visible changes are documented here. This project follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses +[Semantic Versioning](https://semver.org/) for tagged releases. + +## [Unreleased] + +### Added + +- Initial open-source beta documentation, including architecture decisions, + privacy boundaries, security reporting, roadmap, and contribution templates. +- Immutable, owner-scoped workflow revision foundation with historical mission + snapshots. +- Execution run and task-attempt foundation for durable run history. +- Deterministic Chromium Playwright golden paths for plan approval, delivery, + and history recovery. +- Versioned `.roundtable.json` export, compatibility preflight, and explicit + owner-scoped import with server-recomputed content confirmation. +- Workflow revision and execution history in the editor, including honest + runtime, model, duration, token, and cost evidence per task attempt. +- Safe-checkpoint pause/resume and single-task retry with transitive downstream + stale-state detection. +- A first-time-friendly workflow builder with a visible creation action, + explicit step instructions, expected results, ownership, and approval rules. + +### Changed + +- Package metadata now identifies the private application as + `@roundtable/app` and prepares version `0.1.0-beta.1`. +- Product positioning now describes Roundtable as a visual workflow and + governance layer over local AI coding agents. +- Runtime usage evidence distinguishes complete, partial, and unavailable + provider reports instead of presenting missing data as zero. + +### Security + +- Documented the difference between artifact safety scanning and runtime + permission enforcement, including the current external-runtime risks. +- Defined an opt-in telemetry allowlist and manual diagnostic-export boundary; + these are policies for future implementation, not enabled collection. +- Workflow imports reject incompatible environments, invalid domain graphs, + invalid SemVer requirements, and provenance hash mismatches before + persistence. +- Clarified that the current beta supports one trusted operator per host; + runtime commands and provider configuration are not tenant-isolated. + +## Before this changelog + +Roundtable was developed without a maintained release changelog. Git history is +the source of truth for earlier work. The first published beta should move the +relevant entries above into a dated version section without rewriting earlier +release history. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3784b10 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,48 @@ +# Code of Conduct + +Roundtable contributors, maintainers, and community members are expected to +make participation safe, focused, and welcoming. + +## Expected behavior + +- Be respectful of different backgrounds, experience levels, and ways of + communicating. +- Critique ideas and observable behavior, not people. +- Give technical feedback with enough context to act on it. +- Respect privacy. Do not publish another person's identity, prompts, source, + logs, paths, or private correspondence without consent. +- Accept a clear “no”, maintain project scope, and stop conduct when asked by a + maintainer. + +## Unacceptable behavior + +- harassment, threats, sexualized attention, discrimination, or personal + attacks; +- trolling, sustained disruption, or knowingly misleading security claims; +- doxxing or sharing private information, credentials, source, or vulnerability + details; +- retaliation against someone who raises a good-faith concern. + +## Scope + +This policy applies in repository issues, pull requests, discussions, review +comments, project events, and private project communication. It also applies +when someone publicly represents the Roundtable community. + +## Reporting and enforcement + +For an immediate public-thread problem, stop engaging and use GitHub's report or +block controls. To request confidential maintainer review, contact the +repository owner through their [GitHub profile](https://github.com/EdwinjJ1) +with only a request for a private channel; do not publish sensitive details. +Security vulnerabilities use the separate process in +[SECURITY.md](SECURITY.md). + +Maintainers will consider context, impact, history, and the safety of affected +people. Responses may include a private warning, content removal, temporary +participation limits, or a permanent ban. Maintainers must recuse themselves +when they have a conflict of interest and must protect reporter privacy as far +as reasonably possible. + +This policy is informed by the +[Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32fe27d..ff76694 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,70 +1,139 @@ # Contributing to Roundtable -Thanks for your interest in contributing! This guide keeps things short and -practical. +Thank you for improving Roundtable. This guide is designed to get a first +contribution from clone to pull request without requiring a real AI runtime or +API key. -## Getting started +Security reports do not belong in public issues. Follow +[SECURITY.md](SECURITY.md) instead. + +## Prerequisites + +- Node.js 20 or newer (CI currently uses Node.js 24); +- Corepack and pnpm 9; +- Git; +- Linux or macOS for the current Playwright runner; +- Docker only if you choose to test normalized Postgres. + +The app itself may run in other Node environments, but Windows E2E process-tree +cleanup is not currently supported. Do not claim Windows compatibility based on +unit tests alone. + +## First local run ```bash git clone https://github.com/EdwinjJ1/roundtable.git cd roundtable -corepack pnpm install +corepack pnpm install --frozen-lockfile +cp .env.example .env.local corepack pnpm dev ``` -The default `local-dispatch` agent adapter is deterministic and needs no API -keys, so you can develop and test every feature locally without secrets. +Open . If Google OAuth is not configured, enable the +documented non-production developer login in `.env.local`: + +```text +ROUNDTABLE_ENABLE_DEV_AUTH=1 +``` + +Keep `ROUNDTABLE_AGENT_ADAPTER=local-dispatch`. It is deterministic, requires no +secret, and writes fixture-like outputs so the workflow can be exercised. It +does not call Claude Code, Codex, or another model and is not a real-runtime +acceptance test. -## Before you open a PR +## Make a focused change -Run the full check suite — CI runs the same commands: +Before editing, read [the architecture overview](docs/ARCHITECTURE.md) and any +relevant decision under `docs/adr/`. Keep interface concerns in the UI/API, +domain transitions in actions, graph ordering in the scheduler, runtime protocol +translation in adapters, and persistence/migration in the store. + +Do not bundle a feature, a broad refactor, generated formatting, and dependency +updates in one pull request. Preserve unrelated changes in a dirty worktree. + +## Test the behavior you changed + +Vitest covers domain and module behavior: ```bash -corepack pnpm typecheck -corepack pnpm lint +corepack pnpm test -- tests/scheduler.test.ts corepack pnpm test ``` -For store or scheduler changes, also run the smoke workflow: +Use the lowest layer that proves the behavior. Add a cross-layer test only when +the integration is the risk. Tests should not assert private helper calls, +incidental copy, or coverage for its own sake. + +Playwright covers the deterministic workbench golden path: + +```bash +corepack pnpm exec playwright install chromium +corepack pnpm test:e2e +``` + +The E2E configuration starts an isolated dev server, enables developer auth, +uses `local-dispatch`, and writes JSON/workspaces under the system temporary +directory. It does not require runtime credentials. The runner currently relies +on POSIX process groups for reliable descendant cleanup and signal forwarding; +run it on Linux or macOS. Windows is not yet a supported E2E environment. + +For scheduler or store behavior, the deterministic CLI smoke is also useful: ```bash corepack pnpm cli workflow smoke --message "Build a waitlist page" ``` -## Commit messages +Normalized Postgres changes should additionally use the local database commands +documented in [README.md](README.md). Never point tests at production data. -Use [Conventional Commits](https://www.conventionalcommits.org/): +## Full verification -``` -: +Before opening a pull request, run: + +```bash +corepack pnpm typecheck +corepack pnpm lint +corepack pnpm test +corepack pnpm build +corepack pnpm test:e2e ``` -Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`. +CI runs typecheck, lint, Vitest, dependency audit, and build on Ubuntu. If you +cannot run an applicable command, explain why and what narrower verification you +performed. Do not repeatedly retry a flaky failure until it happens to pass. -## Pull requests +Real-runtime changes also need a sanitized manual result from the runtime they +affect. Never post raw transcripts, prompts, local paths, API responses, or +credentials. A maintainer may perform credentialed smoke testing separately. -- Keep PRs focused — one change per PR is easier to review and ship. -- Include tests for new behavior (unit tests live in `tests/`, run by Vitest). -- Describe *why* the change is needed, not just what it does. -- Screenshots or short clips are appreciated for UI changes. +## Pull requests -## Reporting bugs & proposing features +- Open an issue first for a large feature, schema change, security-boundary + change, or new runtime. +- Use Conventional Commits: `: `, where type is `feat`, + `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, or `ci`. +- Explain the problem and observable outcome, not only the files changed. +- Include migration and rollback implications for stored-data changes. +- Add screenshots or a short clip for visible UI changes. +- Update `CHANGELOG.md` for notable user-visible, compatibility, migration, or + security changes. +- Keep generated files, local data, test results, and secrets out of the diff. -Open a [GitHub issue](https://github.com/EdwinjJ1/roundtable/issues) with: +## Issues -- What you expected and what actually happened. -- Steps to reproduce (adapter, store driver, and OS help a lot). -- For features: the problem you're trying to solve, before any specific design. +Use the GitHub templates for reproducible bugs and evidence-led feature +requests. Search existing issues and [ROADMAP.md](ROADMAP.md) first. Sanitize +screenshots and logs: issue content is public and permanent even after editing. -## Code style +## Style -- TypeScript strict; avoid `any` where practical. -- Prefer immutable patterns — return new objects instead of mutating. -- Many small focused files over few large ones. -- No hardcoded secrets — configuration goes through environment variables - (see `.env.example`). +- TypeScript is strict; avoid `any` where practical. +- Prefer cohesive modules, explicit boundaries, and immutable inputs. +- Validate user and persisted data at trust boundaries. +- Do not hardcode credentials, endpoints, or machine-specific paths. +- Comments should explain decisions or constraints, not restate syntax. ## License -By contributing, you agree that your contributions will be licensed under the +By contributing, you agree that your contribution is licensed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index 48fc5cc..64a4518 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ **Turn AI coding sessions into reusable, reviewable workflows.** -Roundtable is the visual workflow and governance layer over local AI agents such +Roundtable is a visual workflow and governance layer over local AI agents such as Claude Code and Codex. Save a successful way of working, run it again, and -stay in control at planning, permission, review, and delivery gates. +keep planning, review, artifacts, and delivery in one inspectable history. [![CI](https://github.com/EdwinjJ1/roundtable/actions/workflows/ci.yml/badge.svg)](https://github.com/EdwinjJ1/roundtable/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -57,7 +57,7 @@ the product: - **Pluggable agent runtimes** — deterministic local dispatch for CI, real CLIs (Claude Code, Codex, OpenCode), E2B sandboxes, or MiniMax models. - **Storage that grows with you** — local JSON for prototypes, normalized - Postgres for shared production runs. + Postgres for durable single-operator self-hosting and migration testing. - **One action layer** — the same business workflows power the Next.js app, REST routes, tRPC, and the CLI. @@ -104,13 +104,40 @@ Useful checks: ```bash corepack pnpm typecheck +corepack pnpm lint corepack pnpm test +corepack pnpm build corepack pnpm cli workflow smoke --message "Build a waitlist page" ``` > **Zero-key demo:** the default `local-dispatch` adapter is deterministic and > needs no API keys — perfect for trying the workbench, CI, and the golden-path -> demo before wiring up a real agent runtime. +> demo before wiring up a real agent runtime. Its generated files are +> deterministic templates; they are not model output or proof that a real +> runtime integration works. + +### Supported development environments + +- The current beta supports one trusted operator per deployment. Runtime + commands, environment configuration, and defaults are host-scoped rather + than tenant-scoped. Do not expose this build as an untrusted multi-user + hosted service; login and workflow ownership are not a runtime-isolation + boundary. +- Node.js 20 or newer and pnpm 9 are required; CI verifies Node.js 24 on Ubuntu. +- Local development is expected to work on Linux and macOS. +- The current Playwright runner is supported on Linux and macOS only. It relies + on POSIX process groups to forward signals and clean up the Next.js process + tree. Windows E2E support is not yet implemented. +- Real-runtime support also depends on the selected CLI version, login, and host + platform. Runtime probing reports readiness, but the deterministic CI suite + does not certify an installed Claude Code, Codex, or OpenCode CLI. + +To run the Chromium golden path on a supported POSIX host: + +```bash +corepack pnpm exec playwright install chromium +corepack pnpm test:e2e +``` ## ⚙️ How it works @@ -150,7 +177,7 @@ Swap in a real runtime when you want real work: | `ROUNDTABLE_AGENT_ADAPTER` | Behavior | Requires | | --- | --- | --- | | `local-dispatch` *(default)* | Deterministic template output; used by devrt/CI. | — | -| `agent-cli` / `claude-cli` / `opencode` | Spawns the selected local CLI runtime (`claude-code`, `codex`, `opencode`, router, or custom command) in the workspace. Runtime status reports command path, detected version, and credential source before execution. | `ROUNDTABLE_ENABLE_EXTERNAL_AGENT=1`; CLI login or API key | +| `agent-cli` | Spawns the per-agent local runtime (`claude-code`, `claude-code-router`, `codex`, or `opencode`) in the workspace. Runtime status reports command path, detected version, and credential source before execution. | `ROUNDTABLE_ENABLE_EXTERNAL_AGENT=1`; supported CLI installed and logged in, or its documented API key | | `e2b` | Runs the agent CLI inside an E2B sandbox. Falls back to `local-dispatch` (logged) if the key is missing. | `E2B_API_KEY` | | `minimax` | Runs each agent against the real MiniMax chat model (M3/M2.7). Strips `` reasoning; falls back to `local-dispatch` if the key is missing. | `MINIMAX_API_KEY` | @@ -223,8 +250,30 @@ is set deliberately. The safety scan of agent artifacts (secrets + dangerous code) is on by default; set `ROUNDTABLE_SAFETY_ENABLED=false` only for testing. +External runtimes are powerful local processes. The artifact scan runs after +work has occurred and is not a process sandbox. Roundtable does not yet enforce +one unified read/write/execute/network permission model across every runtime. +Use a trusted runtime, review its configuration, and prefer an isolated or +disposable workspace. See [SECURITY.md](SECURITY.md) for current boundaries and +private vulnerability reporting. + +### Privacy boundary + +Roundtable does not currently implement product analytics or automatic +diagnostic uploads. Local JSON data stays at the configured +`ROUNDTABLE_DATA_PATH` (default `.roundtable/data.json`); Postgres data goes to +the database you configure. +Prompts, repository context, and runtime output may be sent by the runtime or +model provider you deliberately configure, under that provider's terms. + +The framework may have its own telemetry; set `NEXT_TELEMETRY_DISABLED=1` if +required for your environment. The proposed allowlist for any future Roundtable +telemetry and the manual diagnostic-export boundary are documented in +[docs/PRIVACY.md](docs/PRIVACY.md). They are policy and roadmap constraints, not +a claim that those features already exist. + ## 🗂 Project structure ``` @@ -239,6 +288,15 @@ src/ **Tech stack:** Next.js 15 · React 18 · tRPC · NextAuth · Postgres · Vitest · pnpm +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) +- [Roadmap](ROADMAP.md) +- [Privacy and diagnostics](docs/PRIVACY.md) +- [Security policy](SECURITY.md) +- [Code of conduct](CODE_OF_CONDUCT.md) +- [Changelog](CHANGELOG.md) + ## 🤝 Contributing Contributions are welcome! Check out the diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..aa1587d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,98 @@ +# Security Policy + +Roundtable can launch AI coding runtimes that read and modify a workspace and +execute commands. Treat a real-runtime run with the same care as a local shell +session. The artifact safety scan is useful defense in depth; it is not a +sandbox and does not prevent all process side effects. + +## Supported versions + +Roundtable is preparing its first public beta and does not yet provide long-term +support branches. + +| Version | Security fixes | +| --- | --- | +| `main` | Yes | +| Latest `0.1.0-beta.x` release | Best effort | +| Older prereleases and commits | No | + +Upgrade to the newest beta before reporting a problem that may already be +fixed. Security-relevant fixes will be documented in [CHANGELOG.md](CHANGELOG.md). + +## Report a vulnerability privately + +Do not open a public issue for a suspected vulnerability or include secrets, +exploit details, private source, or personal data in a public discussion. + +1. Use GitHub's + [private vulnerability report](https://github.com/EdwinjJ1/roundtable/security/advisories/new). +2. If GitHub does not offer the private report form, contact the maintainer + through the repository owner's + [GitHub profile](https://github.com/EdwinjJ1) with only a request for a + private reporting channel. Do not send vulnerability details publicly. +3. Include the affected commit/version, operating system, store driver and + runtime kind, impact, minimal reproduction, and any mitigation you have + already tested. Redact credentials, tokens, source, and personal paths. + +You should receive an acknowledgement within five business days. The +maintainer will validate the report, agree on disclosure timing, prepare a fix +and regression test, and publish an advisory or release note when users can +upgrade. Please allow a reasonable remediation window before public disclosure. + +There is currently no bug bounty program. Good-faith research that avoids +privacy violations, destructive actions, service disruption, and access to +other users' data is welcome. + +## Useful report categories + +- authentication or owner-isolation bypass; +- workspace traversal, symlink escape, or unauthorized file access; +- command or argument injection; +- runtime permission bypass or misleading enforcement claims; +- credential exposure through logs, artifacts, diagnostics, APIs, or UI; +- unsafe workflow import/deserialization; +- cross-site scripting, request forgery, or server-side request forgery; +- Postgres/JSON migration that exposes another owner's data; +- telemetry or diagnostics collecting fields excluded by + [the privacy policy](docs/PRIVACY.md). + +## Current security boundaries + +- This beta is a single-trusted-operator deployment. Runtime commands, + provider environment, and runtime defaults are shared by the host. Do not + deploy it for mutually untrusted users; authentication and workflow owner + checks do not isolate runtime configuration or credentials between tenants. +- `local-dispatch` is deterministic test/demo code. It does not invoke a model + or prove that a real runtime is safely configured. +- Claude Code, Codex, OpenCode, E2B, and model-provider adapters have different + controls. Roundtable does not yet provide one technically enforced + read/write/process/network permission model across all of them. +- The E2B adapter's default Claude command currently uses + `bypassPermissions` when no custom arguments are configured. Only use + external agents and sandboxes you trust, with a disposable or isolated + workspace. +- Production workspaces are rooted under `ROUNDTABLE_WORKSPACE_ROOT` unless an + operator deliberately enables custom paths. That path restriction does not + turn a runtime into a complete sandbox. +- A hosted web server cannot use a browser visitor's local CLI login without a + separate local bridge. No such general hosted-to-local trust bridge is + claimed by the current repository. + +The intended permission design and its enforcement vocabulary are documented +as a proposal in +[ADR 003](docs/adr/003-permission-enforcement.md). Do not report the absence of +a roadmap feature as a vulnerability; do report a behavior that contradicts a +current security statement or crosses an existing authorization boundary. + +## Protecting local development + +- Keep `.env`, `.env.local`, `.roundtable/`, workspaces, test results, and + runtime credentials out of commits. +- Use `local-dispatch` for ordinary tests and untrusted workflow examples. +- Enable `ROUNDTABLE_ENABLE_EXTERNAL_AGENT=1` only when you intend to launch a + local runtime. +- Review the workspace, runtime command, model provider, and environment before + approval. Prefer a disposable repository with clean Git state. +- Never put secrets in prompts or diagnostic reports. Rotate a credential if it + appears in a log, artifact, diff, or commit. +- Keep the artifact safety scan enabled outside tests. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f0ac6d..db3a699 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -103,6 +103,10 @@ workspace paths are ignored in production unless explicitly enabled. Hosted deployments may provide a demo, collaboration UI, or persisted metadata, but must not imply that a remote server has access to the user's local runtime. +The current beta supports one trusted operator per deployment: runtime command, +environment, and default configuration are host-scoped, not tenant-scoped. +Authentication and owner-scoped workflow data must not be presented as runtime +credential isolation for mutually untrusted hosted users. Any future hosted product that performs local work requires an authenticated local bridge or desktop service with explicit, scoped capability grants. diff --git a/src/server/actions/agent-runner.ts b/src/server/actions/agent-runner.ts index e6475d8..6c80c9a 100644 --- a/src/server/actions/agent-runner.ts +++ b/src/server/actions/agent-runner.ts @@ -30,6 +30,7 @@ import { } from './runtime-actions.js'; import { applyDocPolicy, quarantineDocs } from './turns/doc-policy.js'; import { collectChangedWorkspaceFiles, type ChangedWorkspaceFile } from './turns/workspace-scan.js'; +import { mergeProviderUsage } from './usage-evidence.js'; export type AgentRunResult = { text: string; @@ -38,6 +39,9 @@ export type AgentRunResult = { events: AgentEvent[]; ok: boolean; error: string | null; + runtime?: string | undefined; + model?: string | null | undefined; + usage?: Record | undefined; // Real files the agent produced or edited in the workspace during this run // (CLI-backed agents only). These are the deliverables; `text` is the // agent's narration and must never be presented as the product. @@ -117,6 +121,7 @@ async function runLocalTask(input: { kind: kindForPath(path), ok: true, error: null, + runtime: 'local-dispatch', events: [ { type: 'thinking_delta', delta: `${role} received the handoff and prepared ${path}.` }, { type: 'tool_use', id: toolId, name: 'write_artifact', input: { path, role, agentId: agent.id } }, @@ -244,6 +249,8 @@ async function runAgentCliTask(input: { kind: 'markdown', ok, error: result.error, + runtime, + model: config?.model ?? null, files: policy.kept, events: [ ...started, @@ -302,6 +309,8 @@ async function runAgentCliTask(input: { kind: kindForPath(path), ok: false, error: message, + runtime, + model: config?.model ?? null, events: [ ...started, { type: 'tool_result', id: toolId, output: { error: message }, isError: true }, @@ -343,6 +352,7 @@ async function runE2BTask(input: { kind: kindForPath(path), ok, error: ok ? null : `e2b_exit_${run.exitCode}`, + runtime: 'e2b', events: [ ...started, { type: 'tool_result', id: toolId, output: { exitCode: run.exitCode }, ...(ok ? {} : { isError: true }) }, @@ -377,7 +387,7 @@ async function runChatModelTask( message: string; handoffContext?: string | undefined; }, - provider: { name: string; toolName: string; model: string; run: ChatModelRun; isUnavailable: (error: unknown) => boolean }, + provider: { name: string; runtime: string; toolName: string; model: string; run: ChatModelRun; isUnavailable: (error: unknown) => boolean }, ): Promise { const agent = agentForTask(input.task); const path = pathForTask(input.task); @@ -398,6 +408,7 @@ async function runChatModelTask( { type: 'thinking_delta', delta: `${agent.displayName} querying ${provider.model}.` }, { type: 'tool_use', id: toolId, name: provider.toolName, input: { agentId: agent.id, role: agent.role, path } }, ]; + const usageRounds: Array | undefined> = []; const failure = async (message: string, rawResponse?: string): Promise => { const text = [ @@ -415,6 +426,9 @@ async function runChatModelTask( kind: kindForPath(path), ok: false, error: message, + runtime: provider.runtime, + model: provider.model, + usage: mergeProviderUsage(usageRounds), events: [ ...started, { type: 'tool_result', id: toolId, output: { error: message }, isError: true }, @@ -442,6 +456,7 @@ async function runChatModelTask( timeoutMs: timeoutMs(), maxTokens: modelMaxTokens(), }); + usageRounds.push(run.usage); combined += run.text; if (run.finishReason !== 'length') break; messages = [ @@ -488,6 +503,9 @@ async function runChatModelTask( kind: kindForPath(path), ok: true, error: null, + runtime: provider.runtime, + model: provider.model, + usage: mergeProviderUsage(usageRounds), events: [ ...started, { type: 'tool_result', id: toolId, output: { model: provider.model, reasoningTokens: reasoningTokens ?? 0, chars: text.length } }, @@ -520,6 +538,7 @@ async function runMiniMaxTask(input: { const model = await resolvedMiniMaxModel(); return runChatModelTask(input, { name: 'MiniMax', + runtime: 'minimax', toolName: 'minimax_chat', model, run: runOnMiniMax, @@ -539,6 +558,7 @@ async function runOpenAICompatTask(input: { const model = await resolvedOpenAICompatModel(); return runChatModelTask(input, { name: model, + runtime: 'openai-compat', toolName: 'model_chat', model, run: runOnOpenAICompat, diff --git a/src/server/actions/execution-actions.ts b/src/server/actions/execution-actions.ts index e5f7be4..19d4e7c 100644 --- a/src/server/actions/execution-actions.ts +++ b/src/server/actions/execution-actions.ts @@ -1,4 +1,5 @@ import { id, mutateData, nowIso, readData } from '../store.js'; +import type { RoundtableData } from '../store.js'; import type { Actor, AgentRuntimeKind, @@ -8,6 +9,9 @@ import type { TaskAttemptStatus, } from '../types.js'; import { workflowExecutableContentHash } from './mission-actions.js'; +import { workflowRevisionCompatibilityError } from './workflow-portability-actions.js'; +import { normalizeUsageEvidence } from './usage-evidence.js'; +import { downstreamOf } from './scheduler.js'; export type ExecutionRunProjection = { run: ExecutionRun; @@ -46,6 +50,8 @@ export async function createExecutionRun(actor: Actor, input: { ) { throw new ExecutionActionError('execution_workflow_revision_mismatch', 409); } + const workflowRevisionId = turn.workflowRevisionId ?? turn.mission?.workflowRevisionId ?? null; + assertWorkflowRevisionCompatible(data, actor.id, workflowRevisionId); const now = nowIso(); const run: ExecutionRun = { id: id('execution_run'), @@ -53,11 +59,12 @@ export async function createExecutionRun(actor: Actor, input: { missionId: mission.id, turnId: turn.id, workflowId: workflowSnapshot.id, - workflowRevisionId: turn.workflowRevisionId ?? turn.mission?.workflowRevisionId ?? null, + workflowRevisionId, workflowContentHash, workflowSnapshot: structuredClone(workflowSnapshot), planSnapshot: structuredClone(turn.plan), taskSnapshots: structuredClone(turn.plan.tasks), + staleTaskIds: [], status: 'created', generation: 0, createdAt: now, @@ -83,10 +90,11 @@ export async function startTaskAttempt(actor: Actor, input: { if (!run) throw new ExecutionActionError('execution_run_not_found', 404); if ( (input.expectedGeneration !== undefined && run.generation !== input.expectedGeneration) - || (run.status !== 'created' && run.status !== 'running') + || !['created', 'running', 'resuming'].includes(run.status) ) { throw new ExecutionActionError('execution_run_fenced', 409); } + assertWorkflowRevisionCompatible(data, actor.id, run.workflowRevisionId); let pinnedTask = run.taskSnapshots.find((task) => task.id === input.taskId); if (!pinnedTask && input.taskSnapshot?.id === input.taskId && input.taskSnapshot.producedFor) { const parentIsPinned = run.taskSnapshots.some((task) => task.id === input.taskSnapshot!.producedFor); @@ -110,6 +118,11 @@ export async function startTaskAttempt(actor: Actor, input: { attempt: Math.max(0, ...priorAttempts.map((item) => item.attempt)) + 1, status: 'running', runtime: input.runtime ?? null, + model: null, + ...normalizeUsageEvidence(undefined), + durationMs: null, + outputSummary: null, + artifactRefs: [], createdAt: now, updatedAt: now, startedAt: now, @@ -124,11 +137,85 @@ export async function startTaskAttempt(actor: Actor, input: { }); } +export async function startTaskAttemptWave(actor: Actor, input: { + executionRunId: string; + tasks: PlanTask[]; + expectedGeneration: number; +}): Promise { + return mutateData((data) => { + const run = data.executionRuns.find((item) => item.id === input.executionRunId && item.ownerId === actor.id); + if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (run.generation !== input.expectedGeneration) { + throw new ExecutionActionError('execution_run_fenced', 409); + } + // The pause request and wave registration share this mutation boundary. If + // pause wins, no task in the wave starts; if the wave wins, every attempt is + // registered before any task is launched. + if (run.status === 'pause_requested') return null; + if (!['created', 'running', 'resuming'].includes(run.status)) { + throw new ExecutionActionError('execution_run_fenced', 409); + } + assertWorkflowRevisionCompatible(data, actor.id, run.workflowRevisionId); + + const pinnedTasks: PlanTask[] = []; + for (const task of input.tasks) { + let pinned = run.taskSnapshots.find((candidate) => candidate.id === task.id); + if (!pinned && task.producedFor && run.taskSnapshots.some((candidate) => candidate.id === task.producedFor)) { + pinned = structuredClone(task); + } + if (!pinned) throw new ExecutionActionError('execution_task_not_found', 404); + pinnedTasks.push(pinned); + } + + for (const task of pinnedTasks) { + if (!run.taskSnapshots.some((candidate) => candidate.id === task.id)) { + run.taskSnapshots.push(task); + } + } + const now = nowIso(); + const attempts = pinnedTasks.map((task) => { + const prior = data.taskAttempts.filter((item) => item.executionRunId === run.id && item.taskId === task.id); + const attempt: TaskAttempt = { + id: id('task_attempt'), + ownerId: actor.id, + executionRunId: run.id, + taskId: task.id, + attempt: Math.max(0, ...prior.map((item) => item.attempt)) + 1, + status: 'running', + runtime: null, + model: null, + ...normalizeUsageEvidence(undefined), + durationMs: null, + outputSummary: null, + artifactRefs: [], + createdAt: now, + updatedAt: now, + startedAt: now, + finishedAt: null, + error: null, + }; + return attempt; + }); + data.taskAttempts.push(...attempts); + run.status = 'running'; + run.startedAt ??= now; + run.updatedAt = now; + return attempts; + }); +} + export async function finishTaskAttempt(actor: Actor, input: { attemptId: string; status: Extract; error?: string | null | undefined; expectedGeneration?: number | undefined; + evidence?: { + runtime?: string | undefined; + model?: string | null | undefined; + usage?: Record | undefined; + } | undefined; + outputSummary?: string | null | undefined; + artifactRefs?: string[] | undefined; }): Promise { return mutateData((data) => { const attempt = data.taskAttempts.find((item) => item.id === input.attemptId && item.ownerId === actor.id); @@ -136,7 +223,7 @@ export async function finishTaskAttempt(actor: Actor, input: { const run = data.executionRuns.find((item) => item.id === attempt.executionRunId && item.ownerId === actor.id); if ( input.expectedGeneration !== undefined - && (!run || run.generation !== input.expectedGeneration || run.status !== 'running') + && (!run || run.generation !== input.expectedGeneration || !['running', 'pause_requested'].includes(run.status)) ) { throw new ExecutionActionError('execution_run_fenced', 409); } @@ -148,6 +235,16 @@ export async function finishTaskAttempt(actor: Actor, input: { attempt.updatedAt = now; attempt.finishedAt = now; attempt.error = input.error ?? null; + attempt.outputSummary = input.outputSummary ?? attempt.outputSummary; + attempt.artifactRefs = input.artifactRefs ? [...new Set(input.artifactRefs)] : attempt.artifactRefs; + if (input.evidence) { + attempt.runtime = input.evidence.runtime ?? attempt.runtime; + attempt.model = input.evidence.model ?? null; + Object.assign(attempt, normalizeUsageEvidence(input.evidence.usage)); + } + attempt.durationMs = attempt.startedAt + ? Math.max(0, Date.parse(now) - Date.parse(attempt.startedAt)) + : null; if (run) run.updatedAt = now; return attempt; }); @@ -162,9 +259,16 @@ export async function finishExecutionRun( return mutateData((data) => { const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (expectedGeneration !== undefined && run.generation === expectedGeneration && run.status === 'pause_requested') { + const now = nowIso(); + run.status = 'paused'; + run.updatedAt = now; + run.workerFinishedAt = now; + return null; + } if ( expectedGeneration !== undefined - && (run.generation !== expectedGeneration || (run.status !== 'created' && run.status !== 'running')) + && (run.generation !== expectedGeneration || !['created', 'running', 'resuming'].includes(run.status)) ) { run.workerFinishedAt = nowIso(); run.updatedAt = run.workerFinishedAt; @@ -174,6 +278,97 @@ export async function finishExecutionRun( run.status = status; run.updatedAt = now; run.finishedAt = now; + if (status === 'completed') run.staleTaskIds = []; + return run; + }); +} + +export async function requestExecutionRunPause(actor: Actor, runId: string): Promise { + return mutateData((data) => { + const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); + if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (!['created', 'running'].includes(run.status)) { + throw new ExecutionActionError('execution_run_invalid_state', 409); + } + run.status = 'pause_requested'; + run.updatedAt = nowIso(); + return run; + }); +} + +export async function executionRunPauseRequested( + actor: Actor, + runId: string, + generation: number, +): Promise { + const data = await readData(); + const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); + return Boolean(run && run.generation === generation && run.status === 'pause_requested'); +} + +export async function markExecutionRunPaused( + actor: Actor, + runId: string, + generation: number, +): Promise { + return mutateData((data) => { + const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); + if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (run.generation !== generation || run.status !== 'pause_requested') { + throw new ExecutionActionError('execution_run_fenced', 409); + } + run.status = 'paused'; + run.updatedAt = nowIso(); + return run; + }); +} + +export async function prepareExecutionRunResume(actor: Actor, runId: string): Promise { + return mutateData((data) => { + const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); + if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (run.status !== 'paused') throw new ExecutionActionError('execution_run_invalid_state', 409); + run.generation += 1; + run.status = 'resuming'; + run.updatedAt = nowIso(); + run.finishedAt = null; + run.workerFinishedAt = null; + return run; + }); +} + +export async function rollbackExecutionRunResume( + actor: Actor, + runId: string, + generation: number, +): Promise { + return mutateData((data) => { + const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); + if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (run.generation !== generation || run.status !== 'resuming') return null; + run.status = 'paused'; + run.updatedAt = nowIso(); + return run; + }); +} + +export async function requestExecutionTaskRetry(actor: Actor, runId: string, taskId: string): Promise { + return mutateData((data) => { + const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); + if (!run) throw new ExecutionActionError('execution_run_not_found', 404); + if (!['paused', 'completed'].includes(run.status)) { + throw new ExecutionActionError('execution_run_invalid_state', 409); + } + if (!run.taskSnapshots.some((task) => task.id === taskId)) { + throw new ExecutionActionError('execution_task_not_found', 404); + } + const stale = new Set([taskId, ...downstreamOf(taskId, run.taskSnapshots)]); + run.staleTaskIds = [...new Set([...run.staleTaskIds, ...stale])]; + run.generation += 1; + run.status = 'paused'; + run.updatedAt = nowIso(); + run.finishedAt = null; + run.workerFinishedAt = null; return run; }); } @@ -194,6 +389,9 @@ export async function interruptExecutionRun(actor: Actor, runId: string): Promis attempt.updatedAt = now; attempt.finishedAt = now; attempt.error = 'interrupted_by_user'; + attempt.durationMs = attempt.startedAt + ? Math.max(0, Date.parse(now) - Date.parse(attempt.startedAt)) + : null; } return run; }); @@ -202,7 +400,7 @@ export async function interruptExecutionRun(actor: Actor, runId: string): Promis export async function executionRunIsActive(actor: Actor, runId: string, generation: number): Promise { const data = await readData(); const run = data.executionRuns.find((item) => item.id === runId && item.ownerId === actor.id); - return Boolean(run && run.generation === generation && (run.status === 'created' || run.status === 'running')); + return Boolean(run && run.generation === generation && ['created', 'running', 'resuming', 'pause_requested'].includes(run.status)); } export async function getExecutionRun(actor: Actor, runId: string): Promise { @@ -216,3 +414,40 @@ export async function getExecutionRun(actor: Actor, runId: string): Promise a.createdAt.localeCompare(b.createdAt) || a.attempt - b.attempt), }; } + +export async function listExecutionRuns(actor: Actor, input: { + workflowId?: string | undefined; + missionId?: string | undefined; + turnId?: string | undefined; + limit?: number | undefined; +} = {}): Promise { + const data = await readData(); + const limit = Math.min(Math.max(input.limit ?? 20, 1), 100); + return data.executionRuns + .filter((run) => run.ownerId === actor.id) + .filter((run) => !input.workflowId || run.workflowId === input.workflowId) + .filter((run) => !input.missionId || run.missionId === input.missionId) + .filter((run) => !input.turnId || run.turnId === input.turnId) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id)) + .slice(0, limit) + .map((run) => ({ + run, + attempts: data.taskAttempts + .filter((attempt) => attempt.ownerId === actor.id && attempt.executionRunId === run.id) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.attempt - right.attempt), + })); +} + +function assertWorkflowRevisionCompatible( + data: RoundtableData, + ownerId: string, + workflowRevisionId: string | null, +): void { + if (!workflowRevisionId || workflowRevisionId.startsWith('builtin:')) return; + const revision = data.workflowRevisions.find((item) => + item.id === workflowRevisionId && item.ownerId === ownerId, + ); + if (!revision) throw new ExecutionActionError('execution_workflow_revision_not_found', 409); + const compatibilityError = workflowRevisionCompatibilityError(revision, data); + if (compatibilityError) throw new ExecutionActionError(compatibilityError, 409); +} diff --git a/src/server/actions/execution-control-actions.ts b/src/server/actions/execution-control-actions.ts new file mode 100644 index 0000000..43ecb90 --- /dev/null +++ b/src/server/actions/execution-control-actions.ts @@ -0,0 +1,36 @@ +import type { Actor, ExecutionRun } from '../types.js'; +import { + prepareExecutionRunResume, + rollbackExecutionRunResume, + requestExecutionRunPause, + requestExecutionTaskRetry, +} from './execution-actions.js'; +import { dispatchTurn } from './turns/dispatch.js'; +import type { DispatchResponse } from './turns/responses.js'; + +export async function requestExecutionPause(actor: Actor, runId: string): Promise { + return requestExecutionRunPause(actor, runId); +} + +export async function requestTaskRetry(actor: Actor, runId: string, taskId: string): Promise { + return requestExecutionTaskRetry(actor, runId, taskId); +} + +export async function resumeExecutionRun(input: { + actor: Actor; + runId: string; + agentAdapter?: string | undefined; +}): Promise { + const run = await prepareExecutionRunResume(input.actor, input.runId); + try { + return await dispatchTurn({ + actor: input.actor, + turnId: run.turnId, + executionRunId: run.id, + agentAdapter: input.agentAdapter, + }); + } catch (error) { + await rollbackExecutionRunResume(input.actor, run.id, run.generation).catch(() => null); + throw error; + } +} diff --git a/src/server/actions/mission-actions.ts b/src/server/actions/mission-actions.ts index 386577f..3ed1d79 100644 --- a/src/server/actions/mission-actions.ts +++ b/src/server/actions/mission-actions.ts @@ -25,6 +25,7 @@ import type { OwnedWorkflow, AgentRole, } from '../types.js'; +import type { WorkflowCompatibilityRequirements } from '../workflow-compatibility.js'; import { AGENT_ROSTER, agentCardFor, agentForTask } from './agent-roster.js'; const seat = (role: AgentRole, agentId?: string) => ({ @@ -308,17 +309,21 @@ export async function listWorkflowTemplates(): Promise { return [...overridden, ...extra].map(cloneTemplate); } -export type EditableWorkflowTemplate = WorkflowTemplate & { expectedRevision: number }; +export type EditableWorkflowTemplate = WorkflowTemplate & { + expectedRevision: number; + workflowRevisionId: string | null; +}; export async function listWorkflowTemplatesForActor(actor: Actor): Promise { const owned = await listOwnedWorkflows(actor); const custom = owned.map(({ workflow, latestRevision }) => ({ ...cloneTemplate(latestRevision.template), expectedRevision: workflow.latestRevision, + workflowRevisionId: latestRevision.id, })); const overridden = BUILTIN_WORKFLOW_TEMPLATES.map((builtin) => custom.find((template) => template.id === builtin.id) - ?? { ...cloneTemplate(builtin), expectedRevision: 0 }, + ?? { ...cloneTemplate(builtin), expectedRevision: 0, workflowRevisionId: null }, ); return [ ...overridden, @@ -410,6 +415,8 @@ export class WorkflowTemplateError extends Error { export type SaveWorkflowRevisionInput = { template: WorkflowTemplate; expectedRevision: number; + documentHash?: string | null | undefined; + compatibility?: WorkflowCompatibilityRequirements | null | undefined; }; export type SaveWorkflowRevisionResult = { @@ -445,6 +452,8 @@ export async function saveWorkflowRevision( ownerId: actor.id, revision: revisionNumber, contentHash: workflowExecutableContentHash(template), + documentHash: input.documentHash ?? null, + compatibility: input.compatibility ? structuredClone(input.compatibility) : null, template, createdAt: now, }; @@ -493,6 +502,13 @@ export async function getWorkflowRevision(actor: Actor, revisionId: string): Pro return data.workflowRevisions.find((revision) => revision.ownerId === actor.id && revision.id === revisionId) ?? null; } +export async function listWorkflowRevisions(actor: Actor, workflowId: string): Promise { + const data = await readData(); + return data.workflowRevisions + .filter((revision) => revision.ownerId === actor.id && revision.workflowId === workflowId) + .sort((left, right) => right.revision - left.revision); +} + export function workflowExecutableContentHash(template: WorkflowTemplate): string { const spec = { planning: template.planning, stages: template.stages }; return createHash('sha256').update(stableJson(spec)).digest('hex'); @@ -507,7 +523,7 @@ function stableJson(value: unknown): string { return JSON.stringify(value) ?? 'null'; } -function validateWorkflowTemplate(template: WorkflowTemplate): void { +export function validateWorkflowTemplate(template: WorkflowTemplate): void { const idValue = (template.id ?? '').trim(); if (!idValue) throw new WorkflowTemplateError('missing_template_id'); if (!(template.name ?? '').trim()) throw new WorkflowTemplateError('missing_template_name'); diff --git a/src/server/actions/scheduler.ts b/src/server/actions/scheduler.ts index 0cbe5a3..2d4a205 100644 --- a/src/server/actions/scheduler.ts +++ b/src/server/actions/scheduler.ts @@ -62,6 +62,7 @@ export type SchedulerRecord = { export type SchedulerRun = { tasks: ScheduledTask[]; records: SchedulerRecord[]; + paused: boolean; }; export type RunTask = ( @@ -82,11 +83,17 @@ export type OnTaskState = ( status: 'running' | 'completed' | 'failed' | 'blocked', ) => void | Promise; +export type OnWaveStart = (tasks: PlanTask[]) => boolean | Promise; + export type SchedulerOpts = { tasks: PlanTask[]; runTask: RunTask; onFailure?: OnFailure | undefined; onTaskState?: OnTaskState | undefined; + onWaveStart?: OnWaveStart | undefined; + initialCompletedTaskIds?: string[] | undefined; + initialCompletedTaskOutputs?: Record | undefined; + shouldPause?: (() => boolean | Promise) | undefined; maxFixRounds?: number | undefined; now?: (() => string) | undefined; }; @@ -181,15 +188,17 @@ export async function runScheduler(opts: SchedulerOpts): Promise { // Working copy — we never touch opts.tasks. `state` is the live graph that can // grow as fixer tasks are derived. const state = new Map(); + const initialCompleted = new Set(opts.initialCompletedTaskIds ?? []); for (const task of opts.tasks) { + const completed = initialCompleted.has(task.id); state.set(task.id, { ...task, deps: [...task.deps], - status: 'pending', - output: null, + status: completed ? 'completed' : 'pending', + output: completed ? opts.initialCompletedTaskOutputs?.[task.id] ?? null : null, error: null, startedAt: null, - finishedAt: null, + finishedAt: completed ? now() : null, }); } @@ -234,6 +243,9 @@ export async function runScheduler(opts: SchedulerOpts): Promise { // A hard ceiling on iterations guards against logic bugs (never expected to hit). const maxIterations = opts.tasks.length * (maxFixRounds + 2) + 16; for (let iteration = 0; iteration < maxIterations; iteration += 1) { + if (await opts.shouldPause?.()) { + return { tasks: [...state.values()], records, paused: true }; + } const all = [...state.values()]; // Mark tasks whose deps failed/blocked as blocked (they can never run). @@ -266,6 +278,10 @@ export async function runScheduler(opts: SchedulerOpts): Promise { break; } + if (opts.onWaveStart && !(await opts.onWaveStart(ready))) { + return { tasks: [...state.values()], records, paused: true }; + } + // Run the wave. allSettled so a thrown runTask can't reject the whole wave // and starve sibling tasks. const waveStart = now(); @@ -390,7 +406,7 @@ export async function runScheduler(opts: SchedulerOpts): Promise { } } - return { tasks: [...state.values()], records }; + return { tasks: [...state.values()], records, paused: false }; } function errorMessage(reason: unknown): string { diff --git a/src/server/actions/turns/dispatch.ts b/src/server/actions/turns/dispatch.ts index de9cf8f..3f62816 100644 --- a/src/server/actions/turns/dispatch.ts +++ b/src/server/actions/turns/dispatch.ts @@ -5,6 +5,7 @@ import type { Artifact, DispatchRecord, PlanTask, + TaskAttempt, WorkflowRun, } from '../../types.js'; import { E2BUnavailableError } from '../adapters/e2b-adapter.js'; @@ -13,11 +14,15 @@ import { OpenAICompatUnavailableError } from '../adapters/openai-compat-adapter. import { normalizeAdapter, runAgentTask } from '../agent-runner.js'; import { createExecutionRun, + ExecutionActionError, + executionRunPauseRequested, executionRunIsActive, finishExecutionRun, finishTaskAttempt, + getExecutionRun, interruptExecutionRun, - startTaskAttempt, + markExecutionRunPaused, + startTaskAttemptWave, } from '../execution-actions.js'; import { buildHandoffCardV2, @@ -64,6 +69,7 @@ export type DispatchInput = { turnId: string; agentAdapter?: string | undefined; actor?: Actor | null | undefined; + executionRunId?: string | undefined; }; export async function approveTurn(input: ApprovalInput): Promise { @@ -145,7 +151,7 @@ export async function approveTurn(input: ApprovalInput): Promise { const turn = await getTurn(input.turnId, input); if (!turn) throw new ActionError('turn_not_found', 404); - if (turn.dispatchStatus === 'completed' && turn.dispatch.length > 0) return dispatchResponse(turn); + if (!input.executionRunId && turn.dispatchStatus === 'completed' && turn.dispatch.length > 0) return dispatchResponse(turn); const adapter = normalizeAdapter( publicAiExecutionDisabled() @@ -154,9 +160,36 @@ export async function dispatchTurn(input: DispatchInput): Promise(); + for (const attempt of existingProjection?.attempts ?? []) { + const current = latestAttemptByTask.get(attempt.taskId); + if (!current || attempt.attempt > current.attempt) latestAttemptByTask.set(attempt.taskId, attempt); + } + const initialCompletedTaskIds = executionRun + ? executionRun.taskSnapshots + .filter((task) => latestAttemptByTask.get(task.id)?.status === 'completed') + .filter((task) => !executionRun.staleTaskIds.includes(task.id)) + .map((task) => task.id) + : []; + const initialCompletedTaskOutputs = Object.fromEntries( + initialCompletedTaskIds.flatMap((taskId) => { + const attempt = latestAttemptByTask.get(taskId); + if (!attempt?.outputSummary) return []; + return [[taskId, { + summary: attempt.outputSummary, + ...(attempt.artifactRefs[0] ? { artifactId: attempt.artifactRefs[0] } : {}), + }]]; + }), + ); + const schedulerTasks = executionRun?.taskSnapshots ?? turn.plan.tasks; await updateTurn(turn.id, (current) => ({ ...current, dispatchStatus: 'running', @@ -175,6 +208,12 @@ export async function dispatchTurn(input: DispatchInput): Promise(); const artifactByTask = new Map(); const allArtifactsByTask = new Map(); + const outputByTask = new Map(); + const evidenceByTask = new Map | undefined; + }>(); // Work completed by EARLIER turns in this chat: handed to every agent so a // follow-up request is treated as an increment on the existing work, not a @@ -281,6 +320,11 @@ export async function dispatchTurn(input: DispatchInput): Promise task.id === taskId); - const attempt = await startTaskAttempt(input.actor, { - executionRunId: executionRun.id, - taskId, - ...(taskSnapshot ? { taskSnapshot } : {}), - expectedGeneration: executionRun.generation, - }); - attemptIdByTask.set(taskId, attempt.id); - } else if (executionRun && input.actor && (status === 'completed' || status === 'failed')) { + if (executionRun && input.actor && (status === 'completed' || status === 'failed')) { const attemptId = attemptIdByTask.get(taskId); if (!attemptId) throw new ActionError('task_attempt_not_started', 409); - await finishTaskAttempt(input.actor, { attemptId, status, expectedGeneration: executionRun.generation }); + await finishTaskAttempt(input.actor, { + attemptId, + status, + expectedGeneration: executionRun.generation, + evidence: evidenceByTask.get(taskId), + outputSummary: outputByTask.get(taskId)?.summary ?? null, + artifactRefs: (allArtifactsByTask.get(taskId) ?? []).map((artifact) => artifact.id), + }); } // Persist this task's artifacts as soon as it reaches a terminal state — not // only at the end-of-run fold. An interrupted or crashed run must not lose @@ -426,41 +470,72 @@ export async function dispatchTurn(input: DispatchInput): Promise { - if (!shouldAttemptFix(error)) return null; - const fixer = makeFixerTask(failed, error); - // Point the fixer at the concrete deliverable it repairs (the previewable - // artifact among the failed task and its upstream deps — for a failed - // review that's the implementer's page). The fixer then writes its - // corrected output to the SAME path and the artifact is updated in place, - // so the fix actually lands in what the user previews. - const repairTarget = [failed.id, ...failed.deps] - .map((taskId) => ({ taskId, artifact: artifactByTask.get(taskId) })) - .find((entry) => entry.artifact?.kind === 'preview'); - const enriched: PlanTask = repairTarget?.artifact - ? { - ...fixer, - producedFor: failed.id, - repairTargetPath: repairTarget.artifact.title, - repairTargetTaskId: repairTarget.taskId, + let run: Awaited>; + try { + run = await runScheduler({ + tasks: schedulerTasks, + initialCompletedTaskIds, + initialCompletedTaskOutputs, + onWaveStart: executionRun && input.actor + ? async (tasks) => { + const attempts = await startTaskAttemptWave(input.actor!, { + executionRunId: executionRun.id, + tasks, + expectedGeneration: executionRun.generation, + }); + if (!attempts) return false; + for (const attempt of attempts) attemptIdByTask.set(attempt.taskId, attempt.id); + return true; } - : { ...fixer, producedFor: failed.id }; - // Remember it so onTaskState can add it to the persisted plan when it runs - // (a concurrent write here would race the store's read-modify-write). - derivedById.set(enriched.id, enriched); - return enriched; - }, - onTaskState, - }); + : undefined, + shouldPause: executionRun && input.actor + ? () => executionRunPauseRequested(input.actor!, executionRun.id, executionRun.generation) + : undefined, + runTask, + maxFixRounds: maxFixRounds(), + now: nowIso, + onFailure: (failed, error) => { + if (!shouldAttemptFix(error)) return null; + const fixer = makeFixerTask(failed, error); + // Point the fixer at the concrete deliverable it repairs (the previewable + // artifact among the failed task and its upstream deps — for a failed + // review that's the implementer's page). The fixer then writes its + // corrected output to the SAME path and the artifact is updated in place, + // so the fix actually lands in what the user previews. + const repairTarget = [failed.id, ...failed.deps] + .map((taskId) => ({ taskId, artifact: artifactByTask.get(taskId) })) + .find((entry) => entry.artifact?.kind === 'preview'); + const enriched: PlanTask = repairTarget?.artifact + ? { + ...fixer, + producedFor: failed.id, + repairTargetPath: repairTarget.artifact.title, + repairTargetTaskId: repairTarget.taskId, + } + : { ...fixer, producedFor: failed.id }; + // Remember it so onTaskState can add it to the persisted plan when it runs + // (a concurrent write here would race the store's read-modify-write). + derivedById.set(enriched.id, enriched); + return enriched; + }, + onTaskState, + }); + } catch (error) { + if ( + executionRun + && input.actor + && error instanceof ExecutionActionError + && error.message === 'execution_run_fenced' + ) { + await finishExecutionRun(input.actor, executionRun.id, 'cancelled', executionRun.generation); + return dispatchResponse(requireTurn(await getTurn(turn.id, input))); + } + throw error; + } // Assemble DispatchRecords from scheduler records, enriching with the captured // agent events. Blocked tasks carry no events. - const records: DispatchRecord[] = run.records.map((record) => ({ + const newRecords: DispatchRecord[] = run.records.map((record) => ({ taskId: record.taskId, agentId: record.agentId, status: record.status, @@ -472,6 +547,21 @@ export async function dispatchTurn(input: DispatchInput): Promise artifact.id), })); + const records: DispatchRecord[] = [...turn.dispatch, ...newRecords]; + + if (run.paused && executionRun && input.actor) { + const pausedTurn = requireTurn(await updateTurn(turn.id, (current) => ({ + ...current, + dispatchStatus: 'running', + dispatchStage: 'paused', + dispatchError: null, + dispatch: records, + }), input)); + // Publish `paused` last: observers may treat that status as a quiescence + // barrier and immediately snapshot or remove the workspace. + await markExecutionRunPaused(input.actor, executionRun.id, executionRun.generation); + return dispatchResponse(pausedTurn); + } // Fold every task's artifacts together with replace-by-identity semantics: // two tasks (or a fixer round) touching the same file yield ONE artifact with @@ -523,9 +613,13 @@ export async function dispatchTurn(input: DispatchInput): Promise { upsertArtifacts(data.artifacts, finalTurn.artifacts); - data.handoffs.push(...handoffsForTasks(finalTurn, finalChatId)); + const generated = handoffsForTasks(finalTurn, finalChatId); + const generatedCardIds = new Set(generated.map((handoff) => handoff.card.id)); + data.handoffs = data.handoffs.filter((handoff) => + handoff.chatId !== finalChatId || !generatedCardIds.has(handoff.card.id), + ); + data.handoffs.push(...generated); }); } return dispatchResponse(finalTurn); diff --git a/src/server/actions/turns/fix-loop.ts b/src/server/actions/turns/fix-loop.ts index 41a5019..c953ca4 100644 --- a/src/server/actions/turns/fix-loop.ts +++ b/src/server/actions/turns/fix-loop.ts @@ -134,7 +134,14 @@ export function repairedTargetArtifact(original: Artifact, fixedText: string): A // Failed/blocked records whose failure was not repaired by a completed fixer in // their producedFor lineage (a completed final-delivery repair clears them all). export function unresolvedFailureRecords(records: DispatchRecord[]): DispatchRecord[] { - const failed = records.filter((record) => record.status === 'failed' || record.status === 'blocked'); + const lastCompletedByTask = new Map(); + records.forEach((record, index) => { + if (record.status === 'completed') lastCompletedByTask.set(record.taskId, index); + }); + const failed = records.filter((record, index) => + (record.status === 'failed' || record.status === 'blocked') + && (lastCompletedByTask.get(record.taskId) ?? -1) < index, + ); const completedFinalRepair = records.some((record) => record.status === 'completed' && record.taskId.startsWith('repair_final_'), ); diff --git a/src/server/actions/usage-evidence.ts b/src/server/actions/usage-evidence.ts new file mode 100644 index 0000000..b25454d --- /dev/null +++ b/src/server/actions/usage-evidence.ts @@ -0,0 +1,107 @@ +import type { CostEvidence, TokenEvidence } from '../types.js'; + +export type RuntimeUsageEvidence = { + tokens: TokenEvidence; + cost: CostEvidence; +}; + +export function normalizeUsageEvidence(usage: Record | null | undefined): RuntimeUsageEvidence { + const input = tokenCount(usage?.['prompt_tokens'] ?? usage?.['input_tokens']); + const output = tokenCount(usage?.['completion_tokens'] ?? usage?.['output_tokens']); + const reportedTotal = tokenCount(usage?.['total_tokens']); + const derivedTotal = input !== null || output !== null ? (input ?? 0) + (output ?? 0) : null; + const total = reportedTotal ?? derivedTotal; + const amount = finiteAmount(usage?.['cost']); + const currency = currencyCode(usage?.['currency']); + const rounds = tokenCount(usage?.['usage_rounds']); + const tokenRounds = tokenCount(usage?.['token_reported_rounds']); + const costRounds = tokenCount(usage?.['cost_reported_rounds']); + + return { + tokens: total === null + ? { status: 'unavailable', reason: 'provider_did_not_report_tokens' } + : { + status: 'available', + source: 'provider_reported', + completeness: rounds !== null && tokenRounds !== null && tokenRounds < rounds ? 'partial' : 'complete', + input, + output, + total, + }, + cost: amount !== null && currency !== null + ? { + status: 'available', + source: 'provider_reported', + completeness: rounds !== null && costRounds !== null && costRounds < rounds ? 'partial' : 'complete', + amount, + currency, + } + : { status: 'unavailable', reason: 'provider_did_not_report_cost' }, + }; +} + +export function mergeProviderUsage(usages: Array | null | undefined>): Record { + let input = 0; + let output = 0; + let total = 0; + let hasInput = false; + let hasOutput = false; + let hasTotal = false; + let cost = 0; + let costCurrency: string | null = null; + let costIsConsistent = true; + let hasCost = false; + let tokenReportedRounds = 0; + let costReportedRounds = 0; + + for (const usage of usages) { + const normalized = normalizeUsageEvidence(usage); + const roundCost = normalized.cost; + if (roundCost.status === 'available') { + if (costCurrency !== null && costCurrency !== roundCost.currency) costIsConsistent = false; + costCurrency ??= roundCost.currency; + cost += roundCost.amount; + hasCost = true; + costReportedRounds += 1; + } + const evidence = normalized.tokens; + if (evidence.status !== 'available') continue; + tokenReportedRounds += 1; + if (evidence.input !== null) { + input += evidence.input; + hasInput = true; + } + if (evidence.output !== null) { + output += evidence.output; + hasOutput = true; + } + total += evidence.total; + hasTotal = true; + } + + return { + ...(hasInput ? { prompt_tokens: input } : {}), + ...(hasOutput ? { completion_tokens: output } : {}), + ...(hasTotal ? { total_tokens: total } : {}), + ...(hasCost && costIsConsistent && costCurrency ? { cost, currency: costCurrency } : {}), + usage_rounds: usages.length, + token_reported_rounds: tokenReportedRounds, + cost_reported_rounds: costReportedRounds, + }; +} + +function tokenCount(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : null; +} + +function finiteAmount(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +} + +function currencyCode(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim().toUpperCase(); + return /^[A-Z]{3}$/.test(normalized) ? normalized : null; +} diff --git a/src/server/actions/workflow-portability-actions.ts b/src/server/actions/workflow-portability-actions.ts new file mode 100644 index 0000000..04b4313 --- /dev/null +++ b/src/server/actions/workflow-portability-actions.ts @@ -0,0 +1,552 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import { + getWorkflowRevision, + saveWorkflowRevision, + validateWorkflowTemplate, + WorkflowTemplateError, + type SaveWorkflowRevisionResult, +} from './mission-actions.js'; +import { workflowExecutableContentHash } from './mission-actions.js'; +import { id, readData } from '../store.js'; +import type { RoundtableData } from '../store.js'; +import type { Actor, WorkflowRevision, WorkflowTemplate } from '../types.js'; +import type { WorkflowCompatibilityRequirements, WorkflowPermission } from '../workflow-compatibility.js'; +import { AGENT_ROSTER } from './agent-roster.js'; +import { normalizeRuntimeKind } from './cli-runtimes/registry.js'; + +export type { WorkflowCompatibilityRequirements, WorkflowPermission } from '../workflow-compatibility.js'; + +export const ROUNDTABLE_WORKFLOW_FILE_SCHEMA = 'roundtable.workflow' as const; +export const ROUNDTABLE_WORKFLOW_FILE_VERSION = 1 as const; +export const MAX_WORKFLOW_FILE_BYTES = 1_000_000; + +export type RoundtableWorkflowFile = { + schema: typeof ROUNDTABLE_WORKFLOW_FILE_SCHEMA; + schemaVersion: typeof ROUNDTABLE_WORKFLOW_FILE_VERSION; + minimumAppVersion: string; + exportedAt: string; + provenance: { + workflowId: string; + revision: number; + contentHash: string; + documentHash: string; + }; + compatibility: { + runtimes: string[]; + platforms: string[]; + capabilities: string[]; + permissions: WorkflowPermission[]; + }; + workflow: WorkflowTemplate; +}; + +export class WorkflowPortabilityError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +export type WorkflowCompatibilityStatus = 'blocking' | 'warning' | 'available' | 'unavailable'; +export type WorkflowCompatibilityCheck = { + category: 'schema' | 'app' | 'runtime' | 'platform' | 'capabilities' | 'permissions' | 'integrity'; + status: WorkflowCompatibilityStatus; + message: string; + missing?: string[]; +}; + +export type WorkflowPreflightResult = { + canImport: boolean; + canRun: boolean; + /** Backward-compatible confirmation alias; now the canonical document hash. */ + contentHash: string | null; + documentHash: string | null; + workflowContentHash: string | null; + requirements: WorkflowCompatibilityRequirements | null; + workflow: WorkflowTemplate | null; + checks: WorkflowCompatibilityCheck[]; +}; + +const gateSchema = z.object({ + kind: z.enum([ + 'none', 'requirement_clarification', 'plan_approval', 'handoff_acceptance', + 'test_failure_repair', 'reviewer_signoff', 'final_delivery_acceptance', + ]), + required: z.boolean(), + label: z.string().max(200), + description: z.string().max(2_000), + actions: z.array(z.string().max(100)).max(30), +}); + +const workflowTemplateFileSchema = z.object({ + id: z.string().min(1).max(200), + name: z.string().min(1).max(200), + tag: z.string().max(100).nullable(), + desc: z.string().max(5_000), + builtin: z.boolean(), + version: z.number().int().nonnegative(), + updatedAt: z.string().max(100), + planning: z.object({ + cut: z.enum(['by_role', 'by_capability', 'by_artifact']), + clarifyThreshold: z.number().min(0).max(1), + maxClarifyQuestions: z.number().int().min(0).max(10), + }), + stages: z.array(z.object({ + id: z.string().min(1).max(200), + name: z.string().min(1).max(200), + icon: z.string().max(100), + kind: z.enum(['intake', 'clarify', 'plan', 'work', 'review', 'repair', 'ship']), + desc: z.string().max(5_000), + seats: z.array(z.object({ + ref: z.union([ + z.object({ kind: z.literal('user') }), + z.object({ + kind: z.literal('role'), + role: z.enum(['planner', 'pm', 'architect', 'implementer', 'reviewer', 'fixer']), + agentId: z.string().min(1).max(200).optional(), + }), + ]), + })).max(30), + fixed: z.boolean().optional(), + parallelGroup: z.string().max(200).optional(), + gate: gateSchema, + requiredInputs: z.array(z.string().max(500)).max(100), + expectedOutputs: z.array(z.string().max(500)).max(100), + requiredCapabilities: z.array(z.string().max(200)).max(100), + })).min(1).max(100), +}); + +const workflowFileSchema = z.object({ + schema: z.literal(ROUNDTABLE_WORKFLOW_FILE_SCHEMA), + schemaVersion: z.literal(ROUNDTABLE_WORKFLOW_FILE_VERSION), + minimumAppVersion: z.string().min(1).max(100), + exportedAt: z.string().max(100), + provenance: z.object({ + workflowId: z.string().min(1).max(200), + revision: z.number().int().positive(), + contentHash: z.string().regex(/^[a-f0-9]{64}$/), + documentHash: z.string().regex(/^[a-f0-9]{64}$/), + }), + compatibility: z.object({ + runtimes: z.array(z.string().min(1).max(100)).max(20), + platforms: z.array(z.string().min(1).max(100)).max(20), + capabilities: z.array(z.string().min(1).max(200)).max(300), + permissions: z.array(z.enum(['filesystem.read', 'filesystem.write', 'process.execute', 'network.connect'])).max(10), + }), + workflow: workflowTemplateFileSchema, +}); + +export async function exportWorkflowRevisionFile(actor: Actor, revisionId: string): Promise<{ + fileName: string; + file: RoundtableWorkflowFile; +}> { + const revision = await getWorkflowRevision(actor, revisionId); + if (!revision) throw new WorkflowPortabilityError('workflow_revision_not_found', 404); + const template = structuredClone(revision.template); + const requirements = revision.compatibility ?? defaultRequirementsFor(template); + if (requirements.schemaVersion !== ROUNDTABLE_WORKFLOW_FILE_VERSION) { + throw new WorkflowPortabilityError('workflow_schema_version_not_exportable', 409); + } + const file: RoundtableWorkflowFile = { + schema: ROUNDTABLE_WORKFLOW_FILE_SCHEMA, + schemaVersion: ROUNDTABLE_WORKFLOW_FILE_VERSION, + minimumAppVersion: requirements.minimumAppVersion, + exportedAt: new Date().toISOString(), + provenance: { + workflowId: revision.workflowId, + revision: revision.revision, + contentHash: revision.contentHash, + documentHash: '', + }, + compatibility: { + runtimes: [...requirements.runtimes], + platforms: [...requirements.platforms], + capabilities: [...requirements.capabilities], + permissions: [...requirements.permissions], + }, + workflow: template, + }; + file.provenance.documentHash = workflowDocumentHash(file); + return { + fileName: `${safeFileStem(template.name)}.roundtable.json`, + file, + }; +} + +export async function preflightWorkflowFile(actor: Actor, input: unknown): Promise { + const parsedInput = parseBoundedInput(input); + const parsed = workflowFileSchema.safeParse(parsedInput); + if (!parsed.success) { + return { + canImport: false, + canRun: false, + contentHash: null, + documentHash: null, + workflowContentHash: null, + requirements: null, + workflow: null, + checks: [{ category: 'schema', status: 'blocking', message: 'File structure or schema version is invalid.' }], + }; + } + const file = parsed.data as RoundtableWorkflowFile; + // Preflight is deliberately non-executing: it reads saved configuration but + // never launches a CLI probe or any content from the imported document. + const data = await readData(); + const requirements = requirementsFromFile(file); + const workflowContentHash = workflowExecutableContentHash(file.workflow); + const documentHash = workflowDocumentHash(file); + const domainError = workflowDomainError(file.workflow); + const checks: WorkflowCompatibilityCheck[] = [ + { + category: 'schema', + status: domainError ? 'blocking' : 'available', + message: domainError + ? `Workflow definition is invalid: ${domainError}.` + : 'Workflow file schema v1 and definition are valid.', + }, + ...environmentCompatibilityChecks(requirements, file.workflow, data), + { + category: 'integrity', + status: workflowContentHash === file.provenance.contentHash + && documentHash === file.provenance.documentHash ? 'available' : 'blocking', + message: workflowContentHash === file.provenance.contentHash + && documentHash === file.provenance.documentHash + ? 'Workflow and compatibility envelope match their integrity hashes. Hashes are not signatures or proof of origin.' + : 'The workflow or compatibility envelope differs from its integrity hash.', + }, + ]; + const importBlocked = checks.some((check) => check.status === 'blocking'); + const runBlocked = checks.some((check) => check.status === 'blocking' || check.status === 'unavailable'); + return { + canImport: !importBlocked, + canRun: !runBlocked, + contentHash: documentHash, + documentHash, + workflowContentHash, + requirements, + workflow: structuredClone(file.workflow), + checks, + }; +} + +export async function importWorkflowFile(actor: Actor, input: { + input: unknown; + confirmedContentHash: string; +}): Promise { + const preview = await preflightWorkflowFile(actor, input.input); + if (!preview.canImport || !preview.workflow || !preview.documentHash || !preview.requirements) { + throw new WorkflowPortabilityError('workflow_file_not_importable', 409); + } + if (input.confirmedContentHash !== preview.documentHash) { + throw new WorkflowPortabilityError('workflow_import_confirmation_mismatch', 409); + } + const importedTemplate: WorkflowTemplate = { + ...structuredClone(preview.workflow), + id: id('wf_import'), + builtin: false, + version: 0, + updatedAt: '', + }; + return saveWorkflowRevision(actor, { + template: importedTemplate, + expectedRevision: 0, + documentHash: preview.documentHash, + compatibility: preview.requirements, + }); +} + +function parseBoundedInput(input: unknown): unknown { + let serialized: string; + if (typeof input === 'string') serialized = input; + else { + try { + const encoded = JSON.stringify(input); + if (typeof encoded !== 'string') throw new Error('not_serializable'); + serialized = encoded; + } catch { + throw new WorkflowPortabilityError('workflow_file_not_serializable', 400); + } + } + if (Buffer.byteLength(serialized, 'utf8') > MAX_WORKFLOW_FILE_BYTES) { + throw new WorkflowPortabilityError('workflow_file_too_large', 413); + } + if (typeof input !== 'string') return input; + try { + return JSON.parse(input) as unknown; + } catch { + return null; + } +} + +function requiredCapabilitiesFor(template: WorkflowTemplate): string[] { + return [...new Set(template.stages.flatMap((stage) => stage.requiredCapabilities))].sort(); +} + +function requiredPermissionsFor(template: WorkflowTemplate): WorkflowPermission[] { + const hasRunnableStage = template.stages.some((stage) => ['plan', 'work', 'review'].includes(stage.kind)); + const hasWritableStage = template.stages.some((stage) => ['work', 'repair'].includes(stage.kind)); + return [ + 'filesystem.read', + ...(hasRunnableStage ? ['process.execute' as const] : []), + ...(hasWritableStage ? ['filesystem.write' as const] : []), + ]; +} + +function defaultRequirementsFor(template: WorkflowTemplate): WorkflowCompatibilityRequirements { + return { + schemaVersion: ROUNDTABLE_WORKFLOW_FILE_VERSION, + minimumAppVersion: currentAppVersion(), + runtimes: ['local-dispatch'], + platforms: ['darwin', 'linux', 'win32'], + capabilities: requiredCapabilitiesFor(template), + permissions: requiredPermissionsFor(template), + }; +} + +function requirementsFromFile(file: RoundtableWorkflowFile): WorkflowCompatibilityRequirements { + return { + schemaVersion: file.schemaVersion, + minimumAppVersion: file.minimumAppVersion, + runtimes: [...file.compatibility.runtimes], + platforms: [...file.compatibility.platforms], + capabilities: [...file.compatibility.capabilities], + permissions: [...file.compatibility.permissions], + }; +} + +function environmentCompatibilityChecks( + requirements: WorkflowCompatibilityRequirements, + template: WorkflowTemplate, + data: Pick, +): WorkflowCompatibilityCheck[] { + const minimumVersion = parseSemVer(requirements.minimumAppVersion); + const installedVersion = parseSemVer(currentAppVersion()); + const versionAvailable = Boolean( + minimumVersion + && installedVersion + && compareVersions(installedVersion, minimumVersion) >= 0, + ); + + const configuredRuntimes = new Set(['local-dispatch']); + for (const config of data.agentRuntimeConfigs) configuredRuntimes.add(config.runtime); + for (const config of data.agentRuntimeDefaults) configuredRuntimes.add(config.runtime); + const globalRuntime = normalizeRuntimeKind(process.env.ROUNDTABLE_AGENT_RUNTIME); + if (globalRuntime) configuredRuntimes.add(globalRuntime); + for (const agent of AGENT_ROSTER) { + const byAgent = normalizeRuntimeKind(process.env[`ROUNDTABLE_AGENT_RUNTIME_${envKey(agent.id)}`]); + const byRole = normalizeRuntimeKind(process.env[`ROUNDTABLE_AGENT_RUNTIME_${envKey(agent.role)}`]); + if (byAgent) configuredRuntimes.add(byAgent); + if (byRole) configuredRuntimes.add(byRole); + } + const missingRuntimes = unique(requirements.runtimes).filter((runtime) => !configuredRuntimes.has(runtime)); + + const supportedPlatforms = unique(requirements.platforms); + const platformAvailable = supportedPlatforms.length === 0 || supportedPlatforms.includes(process.platform); + + const availableCapabilities = new Set(AGENT_ROSTER.flatMap((agent) => [ + ...agent.capabilities, + ...agent.skills, + ])); + // Requirements derived from executable workflow content are authoritative. + // A document cannot weaken them by omitting its declared capability list. + const effectiveCapabilities = unique([ + ...requirements.capabilities, + ...requiredCapabilitiesFor(template), + ]); + const missingCapabilities = effectiveCapabilities.filter((capability) => !availableCapabilities.has(capability)); + + // Permission declarations are advisory until each runtime exposes a stable + // permission introspection API. Server-derived permissions remain included, + // so a document cannot erase the access implied by its executable stages. + const effectivePermissions = unique([ + ...requirements.permissions, + ...requiredPermissionsFor(template), + ]); + + return [ + { + category: 'app', + status: versionAvailable ? 'available' : 'blocking', + message: versionAvailable + ? `Roundtable ${currentAppVersion()} satisfies minimum ${requirements.minimumAppVersion}.` + : minimumVersion && installedVersion + ? `Roundtable ${requirements.minimumAppVersion} or newer is required; current version is ${currentAppVersion()}.` + : 'The current or required Roundtable version is not valid SemVer.', + }, + { + category: 'runtime', + status: missingRuntimes.length === 0 ? 'available' : 'unavailable', + message: missingRuntimes.length === 0 + ? 'Declared runtimes are configured. CLI readiness is checked when execution starts.' + : `Required runtimes are not configured: ${missingRuntimes.join(', ')}.`, + ...(missingRuntimes.length > 0 ? { missing: missingRuntimes } : {}), + }, + { + category: 'platform', + status: platformAvailable ? 'available' : 'unavailable', + message: platformAvailable + ? `Current platform ${process.platform} is supported.` + : `Current platform ${process.platform} is not declared as supported.`, + ...(!platformAvailable ? { missing: [process.platform] } : {}), + }, + { + category: 'capabilities', + status: missingCapabilities.length === 0 ? 'available' : 'unavailable', + message: missingCapabilities.length === 0 + ? 'Required agent capabilities are available.' + : `Required agent capabilities are unavailable: ${missingCapabilities.join(', ')}.`, + ...(missingCapabilities.length > 0 ? { missing: missingCapabilities } : {}), + }, + { + category: 'permissions', + status: effectivePermissions.length === 0 ? 'available' : 'warning', + message: effectivePermissions.length === 0 + ? 'No file, process, or network permissions are declared.' + : `Permission checks are advisory and runtime-specific: ${effectivePermissions.join(', ')}.`, + }, + ]; +} + +/** + * Canonical integrity hash for the executable workflow and its compatibility + * envelope. This deliberately excludes mutable discovery/provenance metadata. + * It is an integrity checksum, not a signature or proof of origin. + */ +export function workflowDocumentHash(file: RoundtableWorkflowFile): string { + return workflowDocumentHashForRequirements(file.workflow, requirementsFromFile(file)); +} + +function workflowDocumentHashForRequirements( + template: WorkflowTemplate, + requirements: WorkflowCompatibilityRequirements, +): string { + return createHash('sha256').update(canonicalJson({ + schema: ROUNDTABLE_WORKFLOW_FILE_SCHEMA, + schemaVersion: requirements.schemaVersion, + minimumAppVersion: requirements.minimumAppVersion, + compatibility: { + runtimes: requirements.runtimes, + platforms: requirements.platforms, + capabilities: requirements.capabilities, + permissions: requirements.permissions, + }, + workflow: { + planning: template.planning, + stages: template.stages, + }, + })).digest('hex'); +} + +/** Revalidates an imported revision against its persisted declaration and current host. */ +export function workflowRevisionCompatibilityError( + revision: Pick, + data: Pick, +): string | null { + // Revisions created locally before portability declarations remain runnable, + // but imported revision metadata is an invariant pair: clearing either side + // must never downgrade the revision into the legacy path. + if (!revision.compatibility && !revision.documentHash) return null; + if (!revision.compatibility) return 'workflow_incompatible:missing_compatibility_declaration'; + if (!revision.documentHash) return 'workflow_incompatible:missing_document_hash'; + const expectedDocumentHash = workflowDocumentHashForRequirements(revision.template, revision.compatibility); + if (expectedDocumentHash !== revision.documentHash) { + return 'workflow_incompatible:document_integrity_mismatch'; + } + if (revision.compatibility.schemaVersion !== ROUNDTABLE_WORKFLOW_FILE_VERSION) { + return 'workflow_incompatible:schema'; + } + const domainError = workflowDomainError(revision.template); + if (domainError) return `workflow_incompatible:definition:${domainError}`; + const blockers = environmentCompatibilityChecks(revision.compatibility, revision.template, data) + .filter((check) => check.status === 'blocking' || check.status === 'unavailable') + .map((check) => check.category); + return blockers.length > 0 ? `workflow_incompatible:${unique(blockers).join(',')}` : null; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +function unique(values: T[]): T[] { + return [...new Set(values)].sort(); +} + +function envKey(value: string): string { + return value.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase(); +} + +type SemanticVersion = { + major: string; + minor: string; + patch: string; + prerelease: string[]; +}; + +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +function parseSemVer(value: string): SemanticVersion | null { + const match = SEMVER_PATTERN.exec(value.trim()); + if (!match) return null; + return { + major: match[1]!, + minor: match[2]!, + patch: match[3]!, + prerelease: match[4]?.split('.') ?? [], + }; +} + +function compareVersions(left: SemanticVersion, right: SemanticVersion): number { + for (const key of ['major', 'minor', 'patch'] as const) { + const order = compareNumericIdentifier(left[key], right[key]); + if (order !== 0) return order; + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + if (left.prerelease.length === right.prerelease.length) return 0; + return left.prerelease.length === 0 ? 1 : -1; + } + for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index += 1) { + const a = left.prerelease[index]; + const b = right.prerelease[index]; + if (a === undefined || b === undefined) return a === undefined ? -1 : 1; + if (a === b) continue; + const aNumeric = /^\d+$/.test(a); + const bNumeric = /^\d+$/.test(b); + if (aNumeric && bNumeric) return compareNumericIdentifier(a, b); + if (aNumeric !== bNumeric) return aNumeric ? -1 : 1; + return a > b ? 1 : -1; + } + return 0; +} + +function compareNumericIdentifier(left: string, right: string): number { + if (left.length !== right.length) return left.length > right.length ? 1 : -1; + if (left === right) return 0; + return left > right ? 1 : -1; +} + +function workflowDomainError(template: WorkflowTemplate): string | null { + try { + validateWorkflowTemplate(template); + return null; + } catch (error) { + if (error instanceof WorkflowTemplateError) return error.message; + throw error; + } +} + +function currentAppVersion(): string { + return process.env.ROUNDTABLE_APP_VERSION?.trim() || '0.1.0-beta.1'; +} + +function safeFileStem(value: string): string { + const stem = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return stem || 'workflow'; +} diff --git a/src/server/root.ts b/src/server/root.ts index 28817c7..eeec348 100644 --- a/src/server/root.ts +++ b/src/server/root.ts @@ -29,9 +29,24 @@ import { archiveOwnedWorkflow, getMission, listMissions, + listWorkflowRevisions, listWorkflowTemplatesForActor, saveWorkflowRevision, + WorkflowTemplateError, } from './actions/mission-actions.js'; +import { + exportWorkflowRevisionFile, + importWorkflowFile, + preflightWorkflowFile, + WorkflowPortabilityError, +} from './actions/workflow-portability-actions.js'; +import { ExecutionActionError, listExecutionRuns } from './actions/execution-actions.js'; +import { + requestExecutionPause, + requestTaskRetry, + resumeExecutionRun, +} from './actions/execution-control-actions.js'; +import { ActionError } from './actions/turns/errors.js'; import type { WorkflowTemplate } from './types.js'; import { listArtifactsByChat, listHandoffsByChat } from './actions/read-actions.js'; import { createWorkbench, listWorkbenches } from './actions/workbench-actions.js'; @@ -209,6 +224,64 @@ const missionsRouter = createTRPCRouter({ await archiveOwnedWorkflow(ctx.user, input.id); return { ok: true }; }), + exportRevision: protectedProcedure + .input(z.object({ revisionId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const exported = await callWorkflowPortability(() => exportWorkflowRevisionFile(ctx.user, input.revisionId)); + return { fileName: exported.fileName, document: exported.file }; + }), + previewImport: protectedProcedure + .input(z.object({ document: z.unknown() })) + .mutation(({ ctx, input }) => callWorkflowPortability(() => preflightWorkflowFile(ctx.user, input.document))), + importDocument: protectedProcedure + .input(z.object({ + document: z.unknown(), + confirmedContentHash: z.string().min(1).max(128), + })) + .mutation(({ ctx, input }) => callWorkflowPortability(() => importWorkflowFile(ctx.user, { + input: input.document, + confirmedContentHash: input.confirmedContentHash, + }))), + revisions: protectedProcedure + .input(z.object({ workflowId: z.string().min(1) })) + .query(({ ctx, input }) => listWorkflowRevisions(ctx.user, input.workflowId)), + runs: protectedProcedure + .input(z.object({ + workflowId: z.string().min(1).optional(), + missionId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + limit: z.number().int().min(1).max(100).optional(), + }).optional()) + .query(async ({ ctx, input }) => (await listExecutionRuns(ctx.user, input ?? {})).map(({ run, attempts }) => ({ + run: { + id: run.id, + status: run.status, + workflowRevisionId: run.workflowRevisionId, + staleTaskIds: run.staleTaskIds, + taskSnapshots: run.taskSnapshots.map((task) => ({ + id: task.id, + title: task.title, + stageId: task.stageId ?? null, + })), + createdAt: run.createdAt, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + }, + attempts: attempts.map((attempt) => ({ + id: attempt.id, + taskId: attempt.taskId, + attempt: attempt.attempt, + status: attempt.status, + runtime: attempt.runtime, + model: attempt.model, + tokens: attempt.tokens, + cost: attempt.cost, + durationMs: attempt.durationMs, + startedAt: attempt.startedAt, + finishedAt: attempt.finishedAt, + error: attempt.error, + })), + }))), list: protectedProcedure .input(z.object({ chatId: z.string().min(1).optional() }).optional()) .query(({ ctx, input }) => listMissions(ctx.user, input?.chatId)), @@ -217,6 +290,16 @@ const missionsRouter = createTRPCRouter({ .query(({ ctx, input }) => getMission(ctx.user, input.id)), }); +async function callWorkflowPortability(action: () => Promise): Promise { + try { + return await action(); + } catch (error) { + if (!(error instanceof WorkflowPortabilityError) && !(error instanceof WorkflowTemplateError)) throw error; + const code = error.status === 404 ? 'NOT_FOUND' : error.status === 409 ? 'CONFLICT' : 'BAD_REQUEST'; + throw new TRPCError({ code, message: error.message, cause: error }); + } +} + const aiRouter = createTRPCRouter({ // Public: the local (unauthenticated) build flow uses Polish too, so it must // not require a session. It only rewrites the text the caller sends. @@ -228,12 +311,44 @@ const aiRouter = createTRPCRouter({ .query(({ ctx, input }) => suggestTasks(ctx.user, input?.context)), }); +const executionRouter = createTRPCRouter({ + pause: protectedProcedure + .input(z.object({ runId: z.string().min(1) })) + .mutation(({ ctx, input }) => callExecutionControl(() => requestExecutionPause(ctx.user, input.runId))), + resume: protectedProcedure + .input(z.object({ runId: z.string().min(1), agentAdapter: z.string().min(1).optional() })) + .mutation(({ ctx, input }) => callExecutionControl(() => resumeExecutionRun({ + actor: ctx.user, + runId: input.runId, + agentAdapter: input.agentAdapter, + }))), + retryTask: protectedProcedure + .input(z.object({ runId: z.string().min(1), taskId: z.string().min(1) })) + .mutation(({ ctx, input }) => callExecutionControl(() => requestTaskRetry(ctx.user, input.runId, input.taskId))), +}); + +async function callExecutionControl(action: () => Promise): Promise { + try { + return await action(); + } catch (error) { + if (!(error instanceof ExecutionActionError) && !(error instanceof ActionError)) throw error; + const status = error.status; + const code = status === 404 ? 'NOT_FOUND' : status === 409 ? 'CONFLICT' : 'BAD_REQUEST'; + throw new TRPCError({ + code, + message: error instanceof Error ? error.message : 'execution_control_failed', + cause: error, + }); + } +} + export const appRouter = createTRPCRouter({ agentMemory: agentMemoryRouter, ai: aiRouter, artifacts: artifactsRouter, breakouts: breakoutsRouter, chats: chatsRouter, + execution: executionRouter, handoffs: handoffsRouter, messages: messagesRouter, missions: missionsRouter, diff --git a/src/server/store.ts b/src/server/store.ts index 1b40b3a..522891b 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -1111,7 +1111,7 @@ function normalizeData(raw: Partial): RoundtableData { executionRuns: Array.isArray(raw.executionRuns) ? raw.executionRuns.map((run) => normalizeExecutionRun(run, raw.turns ?? [])) : [], - taskAttempts: Array.isArray(raw.taskAttempts) ? raw.taskAttempts : [], + taskAttempts: Array.isArray(raw.taskAttempts) ? raw.taskAttempts.map(normalizeTaskAttempt) : [], agentRuntimeConfigs: Array.isArray(raw.agentRuntimeConfigs) ? raw.agentRuntimeConfigs.map(normalizeRuntimeConfig) : [], @@ -1154,6 +1154,8 @@ function normalizeWorkflowRevision(revision: WorkflowRevision, workflows: Workfl workflowStorageId: revision.workflowStorageId ?? workflow?.storageId ?? legacyWorkflowStorageId(revision.ownerId, revision.workflowId), + documentHash: revision.documentHash ?? null, + compatibility: revision.compatibility ?? null, }; } @@ -1168,10 +1170,27 @@ function normalizeExecutionRun(run: ExecutionRun, turns: LocalTurn[]): Execution workflowSnapshot: run.workflowSnapshot ?? turn?.workflow as ExecutionRun['workflowSnapshot'], planSnapshot, taskSnapshots: run.taskSnapshots ?? structuredClone(planSnapshot.tasks), + staleTaskIds: run.staleTaskIds ?? [], workerFinishedAt: run.workerFinishedAt ?? null, }; } +function normalizeTaskAttempt(attempt: TaskAttempt): TaskAttempt { + return { + ...attempt, + model: attempt.model ?? null, + tokens: attempt.tokens ?? { status: 'unavailable', reason: 'provider_did_not_report_tokens' }, + cost: attempt.cost ?? { status: 'unavailable', reason: 'provider_did_not_report_cost' }, + durationMs: attempt.durationMs ?? ( + attempt.startedAt && attempt.finishedAt + ? Math.max(0, Date.parse(attempt.finishedAt) - Date.parse(attempt.startedAt)) + : null + ), + outputSummary: attempt.outputSummary ?? null, + artifactRefs: attempt.artifactRefs ?? [], + }; +} + function legacyWorkflowStorageId(ownerId: string, workflowId: string): string { const digest = createHash('sha256').update(JSON.stringify([ownerId, workflowId])).digest('hex').slice(0, 24); return `workflow_legacy_${digest}`; diff --git a/src/server/types.ts b/src/server/types.ts index 6013e80..4e3e6da 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,3 +1,5 @@ +import type { WorkflowCompatibilityRequirements } from './workflow-compatibility.js'; + export type Actor = { id: string; email: string; @@ -150,6 +152,8 @@ export type WorkflowRevision = { ownerId: string; revision: number; contentHash: string; + documentHash: string | null; + compatibility: WorkflowCompatibilityRequirements | null; template: WorkflowTemplate; createdAt: string; }; @@ -181,6 +185,9 @@ export type ExecutionRun = { workflowSnapshot: WorkflowTemplate; planSnapshot: Plan; taskSnapshots: PlanTask[]; + // Tasks whose previously completed output is no longer valid after a retry. + // Includes the retried task and all of its transitive dependents. + staleTaskIds: string[]; status: ExecutionRunStatus; generation: number; createdAt: string; @@ -194,6 +201,27 @@ export type ExecutionRun = { export type TaskAttemptStatus = 'created' | 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'; +export type TokenEvidence = + | { + status: 'available'; + source: 'provider_reported'; + completeness: 'complete' | 'partial'; + input: number | null; + output: number | null; + total: number; + } + | { status: 'unavailable'; reason: 'provider_did_not_report_tokens' }; + +export type CostEvidence = + | { + status: 'available'; + source: 'provider_reported'; + completeness: 'complete' | 'partial'; + amount: number; + currency: string; + } + | { status: 'unavailable'; reason: 'provider_did_not_report_cost' }; + export type TaskAttempt = { id: string; ownerId: string; @@ -201,7 +229,13 @@ export type TaskAttempt = { taskId: string; attempt: number; status: TaskAttemptStatus; - runtime: AgentRuntimeKind | null; + runtime: string | null; + model: string | null; + tokens: TokenEvidence; + cost: CostEvidence; + durationMs: number | null; + outputSummary: string | null; + artifactRefs: string[]; createdAt: string; updatedAt: string; startedAt: string | null; diff --git a/src/server/workflow-compatibility.ts b/src/server/workflow-compatibility.ts new file mode 100644 index 0000000..e01aba0 --- /dev/null +++ b/src/server/workflow-compatibility.ts @@ -0,0 +1,15 @@ +export type WorkflowPermission = + | 'filesystem.read' + | 'filesystem.write' + | 'process.execute' + | 'network.connect'; + +/** Compatibility claims persisted with an imported immutable revision. */ +export type WorkflowCompatibilityRequirements = { + schemaVersion: number; + minimumAppVersion: string; + runtimes: string[]; + platforms: string[]; + capabilities: string[]; + permissions: WorkflowPermission[]; +}; diff --git a/src/ui/components/app-root.jsx b/src/ui/components/app-root.jsx index 552e6ce..e7f1a1d 100644 --- a/src/ui/components/app-root.jsx +++ b/src/ui/components/app-root.jsx @@ -693,6 +693,13 @@ function App() { const deleteWorkflowTemplate = trpc.missions.deleteTemplate.useMutation({ onSuccess: () => trpcUtils.missions.templates.invalidate(), }); + const previewWorkflowImport = trpc.missions.previewImport.useMutation(); + const importWorkflowDocument = trpc.missions.importDocument.useMutation({ + onSuccess: () => trpcUtils.missions.templates.invalidate(), + }); + const pauseExecution = trpc.execution.pause.useMutation(); + const resumeExecution = trpc.execution.resume.useMutation(); + const retryExecutionTask = trpc.execution.retryTask.useMutation(); const deleteChat = trpc.chats.delete.useMutation({ onSuccess: () => { trpcUtils.chats.list.invalidate(); @@ -1450,6 +1457,18 @@ function App() { serverTemplates={authed ? workflowTemplatesQ.data : null} onSaveTemplate={(payload) => saveWorkflowTemplate.mutateAsync(payload)} onRefreshTemplates={() => trpcUtils.missions.templates.invalidate()} + workflowTransfer={authed ? { + exportRevision: (input) => trpcUtils.missions.exportRevision.fetch(input), + previewImport: (input) => previewWorkflowImport.mutateAsync(input), + importDocument: (input) => importWorkflowDocument.mutateAsync(input), + } : null} + workflowHistory={authed ? { + revisions: (input) => trpcUtils.missions.revisions.fetch(input), + runs: (input) => trpcUtils.missions.runs.fetch(input), + pause: (input) => pauseExecution.mutateAsync(input), + resume: (input) => resumeExecution.mutateAsync(input), + retryTask: (input) => retryExecutionTask.mutateAsync(input), + } : null} onDeleteTemplate={(id) => deleteWorkflowTemplate.mutate({ id })} />} diff --git a/src/ui/components/workflow-history.jsx b/src/ui/components/workflow-history.jsx new file mode 100644 index 0000000..cb3fff7 --- /dev/null +++ b/src/ui/components/workflow-history.jsx @@ -0,0 +1,236 @@ +import React from 'react'; +import { Icon, alpha } from './primitives'; +import { + canPauseExecution, + canResumeExecution, + canRetryAttempt, + formatAttemptCost, + formatAttemptDuration, + formatAttemptModel, + formatAttemptRuntime, + formatAttemptTokens, + formatHistoryDate, + groupAttemptsByTask, + shortContentHash, +} from '../lib/workflow-history-formatters'; + +const { useEffect, useRef, useState } = React; + +function WorkflowHistory({ workflowId, revisionId, enabled, handlers }) { + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + const generationRef = useRef(0); + const actionKeyRef = useRef(null); + const [refreshGeneration, setRefreshGeneration] = useState(0); + const [state, setState] = useState({ loading: false, revisions: [], runs: [], error: null }); + const [actionKey, setActionKey] = useState(null); + + useEffect(() => { + if (!enabled || !workflowId || !handlersRef.current?.revisions || !handlersRef.current?.runs) { + setState({ loading: false, revisions: [], runs: [], error: null }); + return undefined; + } + const generation = ++generationRef.current; + let active = true; + setState((current) => ({ ...current, loading: true, error: null })); + Promise.all([ + handlersRef.current.revisions({ workflowId }), + handlersRef.current.runs({ workflowId, limit: 8 }), + ]).then(([revisions, runs]) => { + if (!active || generation !== generationRef.current) return; + setState({ + loading: false, + revisions: Array.isArray(revisions) ? revisions : [], + runs: Array.isArray(runs) ? runs : [], + error: null, + }); + }).catch((error) => { + if (!active || generation !== generationRef.current) return; + setState((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : 'Could not load workflow history.', + })); + }); + return () => { + active = false; + }; + }, [enabled, workflowId, revisionId, refreshGeneration]); + + if (!enabled) return null; + const runControl = async (action, input, key) => { + if (actionKeyRef.current || !handlersRef.current?.[action]) return; + actionKeyRef.current = key; + setActionKey(key); + setState((current) => ({ ...current, error: null })); + try { + await handlersRef.current[action](input); + setRefreshGeneration((value) => value + 1); + } catch (error) { + setState((current) => ({ + ...current, + error: error instanceof Error ? error.message : 'Could not update this execution run.', + })); + } finally { + actionKeyRef.current = null; + setActionKey(null); + } + }; + return ( +
+
+ +
+
Versions & run history
+
Immutable definitions and the runs pinned to them.
+
+ +
+ {state.error &&
{state.error}
} + {state.loading && state.revisions.length === 0 && state.runs.length === 0 + ?
Loading workflow history…
+ :
+ + +
} +
+ ); +} + +function RevisionList({ revisions, currentRevisionId }) { + return
+
Versions · {revisions.length}
+ {revisions.length === 0 + ? + :
+ {revisions.slice(0, 8).map((revision) => { + const current = revision.id === currentRevisionId; + return
+
+ Revision {revision.revision} + {current && CURRENT} +
+
+ {shortContentHash(revision.contentHash)} +
+
{formatHistoryDate(revision.createdAt)}
+
; + })} +
} +
; +} + +function RunList({ runs, handlers, actionKey, onControl }) { + return
+
Recent runs · {runs.length}
+ {runs.length === 0 + ? + :
+ {runs.map(({ run, attempts }) => )} +
} +
; +} + +function RunRow({ run, attempts, handlers, actionKey, onControl }) { + const statusColor = run.status === 'completed' ? 'var(--ok)' + : run.status === 'failed' || run.status === 'cancelled' ? 'var(--bad)' + : run.status === 'paused' || run.status === 'pause_requested' ? 'var(--warn)' : 'var(--run)'; + const busy = Boolean(actionKey); + const taskGroups = groupAttemptsByTask(run.taskSnapshots, attempts); + return
+
+ + {run.status} + {formatHistoryDate(run.startedAt || run.createdAt)} +
+ {handlers?.pause && canPauseExecution(run.status) && ( + onControl('pause', { runId: run.id }, `pause:${run.id}`)} /> + )} + {handlers?.resume && canResumeExecution(run.status) && ( + onControl('resume', { runId: run.id }, `resume:${run.id}`)} /> + )} +
+
+
+ + {attempts.length} attempt{attempts.length === 1 ? '' : 's'} · Show details + +
+ {taskGroups.length === 0 + ? + : taskGroups.map((group) => )} +
+
+
; +} + +function TaskAttemptGroup({ group, run, canRetry, actionKey, onControl }) { + const latestAttempt = group.attempts[group.attempts.length - 1]; + return
+
+
{group.task.title}
+
+ {group.task.stageId || 'stage unavailable'} · {group.task.id} +
+
+
+ {group.attempts.map((attempt) => )} +
+
; +} + +function AttemptRow({ attempt, run, canRetry, actionKey, onControl }) { + const retryKey = `retry:${run.id}:${attempt.taskId}`; + const retryable = canRetry && canRetryAttempt(run.status, attempt.status); + return
+
+
{formatAttemptRuntime(attempt.runtime)}
+
attempt {attempt.attempt} · {attempt.status}
+
+
+ {formatAttemptModel(attempt.model)} +
+
+ + + +
+ {retryable && onControl('retryTask', { runId: run.id, taskId: attempt.taskId }, retryKey)} />} +
; +} + +function ControlButton({ label, onClick, disabled, primary }) { + return ; +} + +function Metric({ value }) { + const unavailable = /unavailable/i.test(value); + return {value}; +} + +function Empty({ text }) { + return
{text}
; +} + +const sectionLabel = { marginBottom: 8, fontSize: 10.5, color: 'var(--text-faint)', fontWeight: 700, letterSpacing: '.04em', textTransform: 'uppercase' }; + +export { WorkflowHistory }; diff --git a/src/ui/components/workflow-transfer.jsx b/src/ui/components/workflow-transfer.jsx new file mode 100644 index 0000000..108c908 --- /dev/null +++ b/src/ui/components/workflow-transfer.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { createWorkflowTransferController } from '../lib/workflow-transfer-controller'; +import { Icon, alpha } from './primitives'; + +const { useRef, useState } = React; + +const buttonStyle = { + display: 'inline-flex', alignItems: 'center', gap: 7, padding: '8px 12px', borderRadius: 'var(--r-sm)', + border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text-muted)', font: 'inherit', + fontSize: 12.5, fontWeight: 600, cursor: 'pointer', +}; + +function WorkflowTransfer({ revisionId, handlers, onImported }) { + const [open, setOpen] = useState(false); + const [state, setState] = useState(() => ({ phase: 'idle', preview: null, error: null })); + const inputRef = useRef(null); + const controllerRef = useRef(null); + if (!controllerRef.current) controllerRef.current = createWorkflowTransferController(setState); + if (!handlers?.previewImport || !handlers?.importDocument) return null; + + const chooseFile = () => inputRef.current?.click(); + const inspectFile = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + setOpen(true); + await controllerRef.current.previewFile({ file, previewImport: handlers.previewImport }); + }; + const confirmImport = () => controllerRef.current.confirmImport({ + importDocument: handlers.importDocument, + onImported, + }); + const close = () => { + controllerRef.current.reset(); + setOpen(false); + }; + const exportCurrent = async () => { + const result = await controllerRef.current.exportRevision({ + revisionId, + exportRevision: handlers.exportRevision, + }); + if (!result.ok) return; + downloadWorkflowDocument(result.value); + }; + + const blocked = Boolean(state.preview && (!state.preview.canImport || state.preview.blocking.length > 0)); + const busy = state.phase === 'previewing' || state.phase === 'importing' || state.phase === 'exporting'; + return ( + <> + + + {handlers.exportRevision && ( + + )} + {!open && state.error && {state.error}} + {open && ( +
+
event.stopPropagation()} + style={{ width: 'min(620px, 100%)', maxHeight: 'min(720px, 90vh)', overflowY: 'auto', borderRadius: 'var(--r-card)', + border: '1px solid var(--border)', background: 'var(--surface)', boxShadow: 'var(--shadow-pop)' }}> +
+ +
+
Import workflow
+
Review compatibility before adding it to your gallery.
+
+ +
+
+ {state.phase === 'previewing' &&
Checking workflow compatibility…
} + {state.error &&
{state.error}
} + {state.preview && } + {state.phase === 'imported' &&
+ Imported to your workflow gallery. Your current unsaved edits were left untouched. +
} +
+
+ +
+ + {state.phase !== 'imported' && ( + + )} +
+
+
+
+ )} + + ); +} + +function WorkflowImportPreview({ preview, fileName }) { + return ( + <> +
+ + + +
+
{fileName}
+ {!preview.canRun && preview.canImport && ( +
+ This workflow can be imported, but it cannot run in the current environment until unavailable requirements are resolved. +
+ )} + + + + ); +} + +function Meta({ label, value, mono }) { + return
+
{label}
+
{value}
+
; +} + +function IssueList({ title, issues, color, empty }) { + return
+
{title} · {issues.length}
+
+ {issues.length === 0 + ? {empty} + : issues.map((issue, index) =>
• {issue}
)} +
+
; +} + +function downloadWorkflowDocument({ document, fileName }) { + const blob = new Blob([JSON.stringify(document, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = window.document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +export { WorkflowTransfer }; diff --git a/src/ui/components/workflow.jsx b/src/ui/components/workflow.jsx index c3217b6..957c42d 100644 --- a/src/ui/components/workflow.jsx +++ b/src/ui/components/workflow.jsx @@ -6,7 +6,14 @@ ============================================================================ */ import React from 'react'; import { RT } from '../lib/rt'; -import { createServerWorkflowDraft, createWorkflowSaveController } from '../lib/workflow-save-controller'; +import { + createServerWorkflowDraft, + createWorkflowEditSession, + createWorkflowSaveController, + workflowEditSessionSnapshot, +} from '../lib/workflow-save-controller'; +import { WorkflowTransfer } from './workflow-transfer'; +import { WorkflowHistory } from './workflow-history'; import { Avatar, Icon, alpha, tint } from './primitives'; const { useState: useStateW, useEffect: useEffectW } = React; @@ -49,7 +56,7 @@ const removeX = { position: 'absolute', top: -5, right: -5, width: 15, height: 1 background: 'var(--bad)', color: '#fff', cursor: 'pointer', display: 'none', placeItems: 'center', padding: 0 }; /* ---- SeatChips : the roster for a stage (roles, optionally bound to agents) - */ -function SeatChips({ seats, agents, editable, onRemove, onAdd }) { +function SeatChips({ seats, agents, editable, onRemove, onAdd, addLabel }) { const [menu, setMenu] = useStateW(false); const members = (RT.WORKBENCH.members || []).map((id) => agents[id]).filter(Boolean); return ( @@ -78,9 +85,13 @@ function SeatChips({ seats, agents, editable, onRemove, onAdd }) { })} {editable && onAdd && ( <> - + {menu && (
-