From c9f8a1a26dcc07a4ba2fee5d2e12c74e4ea99a73 Mon Sep 17 00:00:00 2001 From: Evanlin <2967329593@qq.com> Date: Mon, 13 Jul 2026 11:41:58 +1000 Subject: [PATCH 1/2] feat: add portable governed workflows --- .github/ISSUE_TEMPLATE/bug_report.yml | 92 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 63 ++ .github/ISSUE_TEMPLATE/support_question.yml | 39 ++ .github/PULL_REQUEST_TEMPLATE.md | 35 ++ CHANGELOG.md | 51 ++ CODE_OF_CONDUCT.md | 48 ++ CONTRIBUTING.md | 135 +++-- README.md | 68 ++- SECURITY.md | 98 ++++ docs/ARCHITECTURE.md | 4 + src/server/actions/agent-runner.ts | 22 +- src/server/actions/execution-actions.ts | 245 +++++++- .../actions/execution-control-actions.ts | 36 ++ src/server/actions/mission-actions.ts | 22 +- src/server/actions/scheduler.ts | 24 +- src/server/actions/turns/dispatch.ts | 201 +++++-- src/server/actions/turns/fix-loop.ts | 9 +- src/server/actions/usage-evidence.ts | 107 ++++ .../actions/workflow-portability-actions.ts | 552 ++++++++++++++++++ src/server/root.ts | 115 ++++ src/server/store.ts | 21 +- src/server/types.ts | 36 +- src/server/workflow-compatibility.ts | 15 + src/ui/components/app-root.jsx | 19 + src/ui/components/workflow-history.jsx | 236 ++++++++ src/ui/components/workflow-transfer.jsx | 158 +++++ src/ui/components/workflow.jsx | 107 +++- src/ui/lib/workflow-history-formatters.js | 83 +++ src/ui/lib/workflow-save-controller.js | 83 ++- src/ui/lib/workflow-transfer-controller.js | 180 ++++++ tests/dispatch-repair.test.ts | 38 +- tests/execution-control.test.ts | 251 ++++++++ tests/execution-runs.test.ts | 45 ++ tests/usage-evidence.test.ts | 76 +++ tests/workflow-history-formatters.test.ts | 57 ++ tests/workflow-portability.test.ts | 282 +++++++++ tests/workflow-revisions.test.ts | 11 + tests/workflow-save-controller.test.ts | 40 +- tests/workflow-transfer-controller.test.ts | 133 +++++ 40 files changed, 3711 insertions(+), 131 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/support_question.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md create mode 100644 src/server/actions/execution-control-actions.ts create mode 100644 src/server/actions/usage-evidence.ts create mode 100644 src/server/actions/workflow-portability-actions.ts create mode 100644 src/server/workflow-compatibility.ts create mode 100644 src/ui/components/workflow-history.jsx create mode 100644 src/ui/components/workflow-transfer.jsx create mode 100644 src/ui/lib/workflow-history-formatters.js create mode 100644 src/ui/lib/workflow-transfer-controller.js create mode 100644 tests/execution-control.test.ts create mode 100644 tests/usage-evidence.test.ts create mode 100644 tests/workflow-history-formatters.test.ts create mode 100644 tests/workflow-portability.test.ts create mode 100644 tests/workflow-transfer-controller.test.ts 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..8925cc0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# 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. + +### 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..44b8276 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; @@ -297,7 +304,7 @@ function toServerTemplate(base, name, stages) { }; } -function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate, onDeleteTemplate, onRefreshTemplates }) { +function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate, onDeleteTemplate, onRefreshTemplates, workflowTransfer, workflowHistory }) { // Server mode: the editor reads/writes the SAME templates the orchestrator // plans from — edits change what actually runs. localStorage mode remains // for the logged-out demo scene only. @@ -313,6 +320,9 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate const [saving, setSaving] = useStateW(false); const [saveError, setSaveError] = useStateW(null); const [pendingServerWorkflowId, setPendingServerWorkflowId] = useStateW(null); + const [editSession, setEditSession] = useStateW(() => workflowEditSessionSnapshot(base)); + const editSessionController = React.useRef(null); + if (!editSessionController.current) editSessionController.current = createWorkflowEditSession(base, setEditSession); const saveController = React.useRef(null); if (!saveController.current) saveController.current = createWorkflowSaveController(); const persist = () => { try { localStorage.setItem('rt.workflows', JSON.stringify(RT.workflows)); } catch { /* ignore */ } }; @@ -320,6 +330,7 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate const w = allWf().find((x) => x.id === id) || base; setWfId(id); if (!serverMode) RT.WORKBENCH.workflowId = id; setWfName(w.name); setStages(clone(w.stages)); setDrawer(null); setPicker(false); + editSessionController.current.load(w); }; const newWorkflow = async () => { const wf = serverMode ? createServerWorkflowDraft() : { id: 'wf-user-' + Date.now(), name: 'Untitled workflow', tag: 'Yours', builtin: false, origin: { kind: 'new' }, @@ -337,9 +348,14 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate await saveController.current.run({ payload: { template: wf, expectedRevision: 0 }, save: onSaveTemplate, - onSaved: () => { + onSaved: (result) => { + const revision = result?.revision; + const loaded = revision?.id + ? { ...clone(revision.template), expectedRevision: revision.revision, workflowRevisionId: revision.id } + : wf; setPendingServerWorkflowId(id); - setWfId(id); setWfName(wf.name); setStages(clone(wf.stages)); setDrawer(null); setPicker(false); + setWfId(id); setWfName(loaded.name); setStages(clone(loaded.stages)); setDrawer(null); setPicker(false); + editSessionController.current.load(loaded); setSaved(true); setTimeout(() => setSaved(false), 2600); }, onConflict: onRefreshTemplates, @@ -360,27 +376,50 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate // client-only workflow, snap the editor onto the first real template. useEffectW(() => { if (!serverMode) return; - if (serverTemplates.some((w) => w.id === wfId)) { + const remote = serverTemplates.find((w) => w.id === wfId); + if (remote) { + editSessionController.current.observeRemote(remote); if (pendingServerWorkflowId === wfId) setPendingServerWorkflowId(null); return; } if (pendingServerWorkflowId === wfId) return; const first = serverTemplates[0]; setWfId(first.id); setWfName(first.name); setStages(clone(first.stages)); setDrawer(null); + editSessionController.current.load(first); }, [serverMode, serverTemplates, wfId, pendingServerWorkflowId]); - const patchStage = (i, patch, replace) => setStages((ss) => ss.map((s, j) => (j === i ? (replace ? patch : { ...s, ...patch }) : s))); + const patchStage = (i, patch, replace) => { + editSessionController.current.markDirty(); + setStages((ss) => ss.map((s, j) => (j === i ? (replace ? patch : { ...s, ...patch }) : s))); + }; const editStage = (i, field, val) => patchStage(i, { [field]: val }); - const moveStage = (i, dir) => setStages((ss) => { - const j = i + dir; if (j < 0 || j >= ss.length) return ss; - const n = [...ss]; const [m] = n.splice(i, 1); n.splice(j, 0, m); return n; - }); - const removeStage = (i) => setStages((ss) => ss.filter((_, j) => j !== i)); - const addStage = (i) => setStages((ss) => { - const n = [...ss]; - n.splice(i, 0, { id: 'custom-' + Date.now(), name: 'New stage', icon: 'dot', kind: 'work', desc: 'Describe what happens here.', seats: [], gate: { kind: 'none' } }); - return n; - }); + const moveStage = (i, dir) => { + editSessionController.current.markDirty(); + setStages((ss) => { + const j = i + dir; if (j < 0 || j >= ss.length) return ss; + const n = [...ss]; const [m] = n.splice(i, 1); n.splice(j, 0, m); return n; + }); + }; + const removeStage = (i) => { + editSessionController.current.markDirty(); + setStages((ss) => ss.filter((_, j) => j !== i)); + }; + const addStage = (i) => { + editSessionController.current.markDirty(); + setStages((ss) => { + const n = [...ss]; + n.splice(i, 0, { id: 'custom-' + Date.now(), name: 'New stage', icon: 'dot', kind: 'work', desc: 'Describe what happens here.', seats: [], gate: { kind: 'none' } }); + return n; + }); + }; + + const loadRemoteRevision = () => { + const remote = editSession.remoteWorkflow; + if (!remote) return; + if (editSession.dirty && !window.confirm('Load the latest revision? Your unsaved workflow edits will be replaced.')) return; + setWfName(remote.name); setStages(clone(remote.stages)); setDrawer(null); setSaveError(null); + editSessionController.current.load(remote); + }; const saveWorkflow = async () => { if (serverMode) { @@ -390,13 +429,17 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate if (!onSaveTemplate || saveController.current.inFlight) return; setSaving(true); setSaveError(null); try { + const pinned = editSessionController.current.state; await saveController.current.run({ payload: { - template: toServerTemplate(base, wfName, stages), - expectedRevision: base.expectedRevision ?? 0, + template: toServerTemplate(pinned.loadedWorkflow, wfName, stages), + expectedRevision: pinned.expectedRevision, }, save: onSaveTemplate, - onSaved: () => { setSaved(true); setTimeout(() => setSaved(false), 2600); }, + onSaved: (result) => { + editSessionController.current.commitSaved(result); + setSaved(true); setTimeout(() => setSaved(false), 2600); + }, onConflict: onRefreshTemplates, onError: setSaveError, }); @@ -412,6 +455,7 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate updatedAt: new Date().toISOString(), stages: clone(stages) }; RT.workflows = [...(RT.workflows || []).filter((w) => w.id !== id), wf]; RT.WORKBENCH.workflowId = id; setWfId(id); persist(); + editSessionController.current.load(wf); setSaved(true); setTimeout(() => setSaved(false), 2600); }; @@ -427,6 +471,11 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate

+ Agent CLIs
{saveError &&
{saveError}
} + {editSession.remoteChanged && ( +
+ + + Revision {editSession.remoteExpectedRevision} is available. This editor remains pinned to Revision {editSession.expectedRevision} + {editSession.dirty ? ' with unsaved edits.' : '.'} + + +
+ )}
- setWfName(e.target.value)} title="Rename this workflow" spellCheck={false} + { editSessionController.current.markDirty(); setWfName(e.target.value); }} title="Rename this workflow" spellCheck={false} onFocus={(e) => (e.currentTarget.style.borderColor = 'var(--border)')} onBlur={(e) => (e.currentTarget.style.borderColor = 'transparent')} style={{ font: 'inherit', fontSize: 13.5, fontWeight: 600, color: 'var(--text)', background: 'transparent', border: '1px solid transparent', borderRadius: 6, outline: 'none', padding: '2px 6px', margin: '-2px 0', minWidth: 90, maxWidth: 260 }} /> @@ -500,6 +560,13 @@ function WorkflowView({ agents, onOpenTemplates, serverTemplates, onSaveTemplate ))}
+ +
{serverMode diff --git a/src/ui/lib/workflow-history-formatters.js b/src/ui/lib/workflow-history-formatters.js new file mode 100644 index 0000000..d5e17df --- /dev/null +++ b/src/ui/lib/workflow-history-formatters.js @@ -0,0 +1,83 @@ +export function formatHistoryDate(value) { + const date = new Date(value || ''); + if (!Number.isFinite(date.getTime())) return 'Time unavailable'; + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }).format(date); +} + +export function formatAttemptDuration(durationMs) { + if (!Number.isFinite(durationMs) || durationMs < 0) return 'Duration unavailable'; + if (durationMs < 1_000) return `${Math.round(durationMs)} ms`; + const seconds = durationMs / 1_000; + if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)} s`; + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + return `${minutes}m ${remainder}s`; +} + +export function formatAttemptTokens(tokens) { + if (!tokens || tokens.status !== 'available' || !Number.isFinite(tokens.total)) return 'Tokens unavailable'; + return `${new Intl.NumberFormat().format(tokens.total)} tokens · ${formatEvidenceProvenance(tokens)}`; +} + +export function formatAttemptCost(cost) { + if (!cost || cost.status !== 'available' || !Number.isFinite(cost.amount) || !cost.currency) return 'Cost unavailable'; + try { + const amount = new Intl.NumberFormat(undefined, { + style: 'currency', currency: cost.currency, maximumFractionDigits: 6, + }).format(cost.amount); + return `${amount} · ${formatEvidenceProvenance(cost)}`; + } catch { + return `${cost.amount} ${cost.currency} · ${formatEvidenceProvenance(cost)}`; + } +} + +function formatEvidenceProvenance(evidence) { + return evidence.completeness === 'complete' ? 'provider reported' : 'partial provider report'; +} + +export function formatAttemptRuntime(value) { + return typeof value === 'string' && value.trim() ? value.trim() : 'Runtime unavailable'; +} + +export function formatAttemptModel(value) { + return typeof value === 'string' && value.trim() ? value.trim() : 'Model unavailable'; +} + +export function shortContentHash(value) { + const hash = typeof value === 'string' ? value.trim() : ''; + return hash ? hash.slice(0, 12) : 'Hash unavailable'; +} + +export function groupAttemptsByTask(taskSnapshots, attempts) { + const taskById = new Map((taskSnapshots || []).map((task) => [task.id, task])); + const groups = new Map(); + for (const attempt of attempts || []) { + if (!groups.has(attempt.taskId)) { + groups.set(attempt.taskId, { + task: taskById.get(attempt.taskId) + ?? { id: attempt.taskId, title: `Task ${attempt.taskId}`, stageId: null }, + attempts: [], + }); + } + groups.get(attempt.taskId).attempts.push(attempt); + } + return [...groups.values()].map((group) => ({ + ...group, + attempts: group.attempts.sort((left, right) => left.attempt - right.attempt), + })); +} + +export function canPauseExecution(status) { + return status === 'created' || status === 'running' || status === 'resuming'; +} + +export function canResumeExecution(status) { + return status === 'paused'; +} + +export function canRetryAttempt(runStatus, attemptStatus) { + return (runStatus === 'paused' || runStatus === 'completed') + && (attemptStatus === 'completed' || attemptStatus === 'failed'); +} diff --git a/src/ui/lib/workflow-save-controller.js b/src/ui/lib/workflow-save-controller.js index eba717c..f259727 100644 --- a/src/ui/lib/workflow-save-controller.js +++ b/src/ui/lib/workflow-save-controller.js @@ -1,4 +1,85 @@ -export const WORKFLOW_CONFLICT_MESSAGE = 'This workflow changed elsewhere. Latest version loaded; your edits are still here.'; +export const WORKFLOW_CONFLICT_MESSAGE = 'A newer workflow revision exists. Your edits are still here; load the latest revision before saving again.'; + +/** + * Pin an editor to the exact immutable revision it loaded. Query refetches may + * reveal a newer remote revision, but they never advance this session's CAS or + * export target until the user explicitly loads it. + * @param {*} workflow + * @param {((state: ReturnType) => void)=} onChange + */ +export function createWorkflowEditSession(workflow, onChange = () => {}) { + let state = workflowEditSessionSnapshot(workflow); + const publish = (next) => { + state = next; + onChange(state); + return state; + }; + const load = (nextWorkflow) => publish(workflowEditSessionSnapshot(nextWorkflow)); + const markDirty = () => state.dirty ? state : publish({ ...state, dirty: true }); + const observeRemote = (remoteWorkflow) => { + if (!remoteWorkflow || remoteWorkflow.id !== state.workflowId) return state; + const remote = revisionIdentity(remoteWorkflow); + const changed = remote.expectedRevision > state.expectedRevision + || (remote.expectedRevision === state.expectedRevision && remote.loadedRevisionId !== state.loadedRevisionId); + if ( + changed === state.remoteChanged + && remote.expectedRevision === state.remoteExpectedRevision + && remote.loadedRevisionId === state.remoteRevisionId + ) return state; + return publish({ + ...state, + remoteChanged: changed, + remoteExpectedRevision: changed ? remote.expectedRevision : null, + remoteRevisionId: changed ? remote.loadedRevisionId : null, + remoteWorkflow: changed ? cloneWorkflow(remoteWorkflow) : null, + }); + }; + const commitSaved = (result) => { + const revision = result?.revision; + if (!revision?.id || !Number.isInteger(revision.revision)) return state; + const loadedWorkflow = { + ...cloneWorkflow(revision.template ?? state.loadedWorkflow), + expectedRevision: revision.revision, + workflowRevisionId: revision.id, + }; + return load(loadedWorkflow); + }; + return { + get state() { return state; }, + load, + markDirty, + observeRemote, + commitSaved, + }; +} + +/** @param {*} workflow */ +export function workflowEditSessionSnapshot(workflow) { + const identity = revisionIdentity(workflow); + return { + workflowId: workflow?.id ?? null, + loadedWorkflow: cloneWorkflow(workflow), + loadedRevisionId: identity.loadedRevisionId, + expectedRevision: identity.expectedRevision, + dirty: false, + remoteChanged: false, + remoteExpectedRevision: null, + remoteRevisionId: null, + remoteWorkflow: null, + }; +} + +function revisionIdentity(workflow) { + return { + loadedRevisionId: typeof workflow?.workflowRevisionId === 'string' ? workflow.workflowRevisionId : null, + expectedRevision: Number.isInteger(workflow?.expectedRevision) ? workflow.expectedRevision : 0, + }; +} + +function cloneWorkflow(workflow) { + if (workflow == null) return workflow; + return JSON.parse(JSON.stringify(workflow)); +} /** * @typedef {Object} WorkflowSaveArgs diff --git a/src/ui/lib/workflow-transfer-controller.js b/src/ui/lib/workflow-transfer-controller.js new file mode 100644 index 0000000..e595269 --- /dev/null +++ b/src/ui/lib/workflow-transfer-controller.js @@ -0,0 +1,180 @@ +const INITIAL_STATE = Object.freeze({ + phase: 'idle', + fileName: null, + document: null, + preview: null, + error: null, + imported: null, +}); + +export const MAX_WORKFLOW_FILE_BYTES = 1_000_000; + +/** + * @typedef {{ name: string, version: string, hash: string, contentHash: string | null, canImport: boolean, canRun: boolean, blocking: string[], warnings: string[] }} WorkflowTransferPreview + * @typedef {{ phase: string, fileName: string | null, document: unknown, preview: WorkflowTransferPreview | null, error: string | null, imported: unknown }} WorkflowTransferState + * @typedef {{ name: string, size?: number, text: () => Promise }} WorkflowFile + */ + +/** @param {(state: WorkflowTransferState) => void} [onStateChange] */ +export function createWorkflowTransferController(onStateChange = () => {}) { + /** @type {WorkflowTransferState} */ + let state = { ...INITIAL_STATE }; + let requestGeneration = 0; + + /** @param {Partial} patch */ + const publish = (patch) => { + state = { ...state, ...patch }; + onStateChange(state); + return state; + }; + + const reset = () => { + requestGeneration += 1; + publish({ ...INITIAL_STATE }); + }; + + /** @param {{ file: WorkflowFile, previewImport: (input: { document: unknown }) => Promise }} input */ + const previewFile = async ({ file, previewImport }) => { + const generation = ++requestGeneration; + if (!isWorkflowFileName(file?.name)) { + publish({ ...INITIAL_STATE, error: 'Choose a .roundtable.json or .json workflow file.' }); + return { ok: false, code: 'INVALID_FILE_TYPE' }; + } + if (Number.isFinite(file.size) && file.size > MAX_WORKFLOW_FILE_BYTES) { + publish({ ...INITIAL_STATE, error: 'This workflow file is larger than the 1 MB import limit.' }); + return { ok: false, code: 'FILE_TOO_LARGE' }; + } + + publish({ + phase: 'previewing', + fileName: file.name, + document: null, + preview: null, + error: null, + imported: null, + }); + + try { + const text = await file.text(); + const document = JSON.parse(text); + const value = await previewImport({ document }); + if (generation !== requestGeneration) return { ok: false, code: 'STALE' }; + const preview = normalizeWorkflowPreview(value); + publish({ phase: 'ready', document, preview, error: null }); + return { ok: true, value: preview }; + } catch (error) { + if (generation !== requestGeneration) return { ok: false, code: 'STALE' }; + const message = error instanceof SyntaxError + ? 'This file is not valid JSON.' + : errorMessage(error, 'Could not inspect this workflow file.'); + publish({ phase: 'error', document: null, preview: null, error: message }); + return { ok: false, code: 'ERROR' }; + } + }; + + /** @param {{ importDocument: (input: { document: unknown, confirmedContentHash: string }) => Promise, onImported?: (value: unknown) => void | Promise }} input */ + const confirmImport = async ({ importDocument, onImported }) => { + if (state.phase === 'importing') return { ok: false, code: 'IN_FLIGHT' }; + if (!state.document || !state.preview) return { ok: false, code: 'NO_PREVIEW' }; + if (!state.preview.canImport || state.preview.blocking.length > 0 || !state.preview.contentHash) { + return { ok: false, code: 'BLOCKED' }; + } + + const generation = requestGeneration; + publish({ phase: 'importing', error: null }); + try { + const value = await importDocument({ + document: state.document, + confirmedContentHash: state.preview.contentHash, + }); + if (generation !== requestGeneration) return { ok: false, code: 'STALE' }; + publish({ phase: 'imported', imported: value, error: null }); + await onImported?.(value); + return { ok: true, value }; + } catch (error) { + if (generation !== requestGeneration) return { ok: false, code: 'STALE' }; + publish({ phase: 'ready', error: errorMessage(error, 'Could not import this workflow.') }); + return { ok: false, code: 'ERROR' }; + } + }; + + /** @param {{ revisionId: string | null | undefined, exportRevision: (input: { revisionId: string }) => Promise<*> }} input */ + const exportRevision = async ({ revisionId, exportRevision: runExport }) => { + if (!revisionId) return { ok: false, code: 'NO_REVISION' }; + if (state.phase === 'exporting') return { ok: false, code: 'IN_FLIGHT' }; + publish({ phase: 'exporting', error: null, imported: null }); + try { + const response = await runExport({ revisionId }); + const document = response?.document ?? response?.file; + if (document == null) throw new Error('workflow_export_missing_document'); + const value = { + document, + fileName: safeWorkflowFileName(response?.fileName), + }; + publish({ phase: state.preview ? 'ready' : 'idle', error: null }); + return { ok: true, value }; + } catch (error) { + publish({ phase: state.preview ? 'ready' : 'idle', error: errorMessage(error, 'Could not export this workflow.') }); + return { ok: false, code: 'ERROR' }; + } + }; + + return { + get state() { return state; }, + reset, + previewFile, + confirmImport, + exportRevision, + }; +} + +/** @param {*} value @returns {WorkflowTransferPreview} */ +export function normalizeWorkflowPreview(value) { + const compatibility = value?.compatibility ?? value ?? {}; + const provenance = value?.provenance ?? value?.document?.provenance ?? {}; + const workflow = value?.workflow ?? value?.document?.workflow ?? {}; + const checks = Array.isArray(value?.checks) ? value.checks : []; + const blockingChecks = checks.filter((check) => check?.status === 'blocking'); + const warningChecks = checks.filter((check) => check?.status === 'warning' || check?.status === 'unavailable'); + const contentHash = value?.contentHash ?? value?.hash ?? provenance.contentHash ?? null; + return { + name: String(value?.name ?? workflow.name ?? 'Untitled workflow'), + version: String(value?.version ?? provenance.revision ?? workflow.version ?? '—'), + hash: String(contentHash ?? '—'), + contentHash: contentHash == null ? null : String(contentHash), + canImport: value?.canImport ?? blockingChecks.length === 0, + canRun: value?.canRun ?? !checks.some((check) => check?.status === 'blocking' || check?.status === 'unavailable'), + blocking: normalizeIssues(blockingChecks.length ? blockingChecks : (compatibility.blocking ?? value?.blocking)), + warnings: normalizeIssues(warningChecks.length ? warningChecks : (compatibility.warnings ?? value?.warnings)), + }; +} + +/** @param {unknown} input */ +export function safeWorkflowFileName(input) { + const basename = String(input || 'workflow.roundtable.json').split(/[\\/]/).pop(); + const cleaned = basename + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^[.-]+/, '') + .slice(0, 160) || 'workflow'; + if (cleaned.toLowerCase().endsWith('.roundtable.json')) return cleaned; + return `${cleaned.replace(/\.json$/i, '')}.roundtable.json`; +} + +/** @param {unknown} name */ +export function isWorkflowFileName(name) { + return /(?:\.roundtable)?\.json$/i.test(String(name || '')); +} + +/** @param {unknown} value */ +function normalizeIssues(value) { + if (!Array.isArray(value)) return []; + return value.map((issue) => { + if (typeof issue === 'string') return issue; + return String(issue?.message ?? issue?.label ?? issue?.code ?? 'Compatibility issue'); + }); +} + +/** @param {unknown} error @param {string} fallback */ +function errorMessage(error, fallback) { + return error instanceof Error && error.message ? error.message : fallback; +} diff --git a/tests/dispatch-repair.test.ts b/tests/dispatch-repair.test.ts index b93459b..15e2c79 100644 --- a/tests/dispatch-repair.test.ts +++ b/tests/dispatch-repair.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runAgentTask } from '../src/server/actions/agent-runner.js'; import { resetData } from '../src/server/store.js'; +import { normalizeUsageEvidence } from '../src/server/actions/usage-evidence.js'; +import { unresolvedFailureRecords } from '../src/server/actions/turns/fix-loop.js'; import { isReviewGateTask, makeFixerTask, @@ -12,7 +14,7 @@ import { shouldAttemptFix, } from '../src/server/actions/turn-actions.js'; import type { ScheduledTask } from '../src/server/actions/scheduler.js'; -import type { Artifact, PlanTask } from '../src/server/types.js'; +import type { Artifact, DispatchRecord, PlanTask } from '../src/server/types.js'; function task(overrides: Partial & { id: string }): PlanTask { return { @@ -37,6 +39,25 @@ function scheduled(overrides: Partial & { id: string }): Schedule }; } +describe('unresolvedFailureRecords — retry history', () => { + it('does not report an earlier failed attempt after the same task later completes', () => { + const base = { + taskId: 'task_build', + agentId: 'atlas', + events: [], + startedAt: '2026-01-01T00:00:00.000Z', + finishedAt: '2026-01-01T00:00:01.000Z', + artifactIds: [], + } satisfies Omit; + const records: DispatchRecord[] = [ + { ...base, status: 'failed', error: 'first_attempt_failed' }, + { ...base, status: 'completed', error: null }, + ]; + + expect(unresolvedFailureRecords(records)).toEqual([]); + }); +}); + describe('isReviewGateTask — which tasks gate delivery through the fix loop', () => { it('gates the quality reviewer and the architect post-build check, not the design pass', () => { expect(isReviewGateTask({ role: 'reviewer', stageId: 'review' })).toBe(true); @@ -258,7 +279,7 @@ describe('runAgentTask — chat model deliverable extraction', () => { stubModelResponses([{ content, finishReason: 'stop' }]); } - function stubModelResponses(turns: Array<{ content: string; finishReason: string }>): ReturnType { + function stubModelResponses(turns: Array<{ content: string; finishReason: string; usage?: Record }>): ReturnType { let call = 0; const mock = vi.fn(async () => { const turn = turns[Math.min(call, turns.length - 1)]!; @@ -266,7 +287,7 @@ describe('runAgentTask — chat model deliverable extraction', () => { return new Response( JSON.stringify({ choices: [{ message: { content: turn.content }, finish_reason: turn.finishReason }], - usage: {}, + usage: turn.usage ?? {}, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); @@ -345,7 +366,11 @@ describe('runAgentTask — chat model deliverable extraction', () => { // Every response is length-cut inside : no ever appears. The // task must fail (feeding the fix loop), never complete with a blank page. stubModelResponses([ - { content: '\n