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.
[](https://github.com/EdwinjJ1/roundtable/actions/workflows/ci.yml)
[](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 (
+
+
+ {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)' }}>
+
+
+ {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.
+
}
+
+
+
+
+ )}
+ >
+ );
+}
+
+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 ;
+}
+
+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