From ce1f33aa69eda409781dea9f8f2f17e688481528 Mon Sep 17 00:00:00 2001
From: -Puter <22245429+puterhimself@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:26:41 +0530
Subject: [PATCH] feat: consolidate agent execution stack
---
.env.example | 2 +-
.../src/components/workspace/work-card.tsx | 102 +-
.../hooks/workspace/use-project-workspace.ts | 70 +-
apps/web/src/lib/workspace/types.ts | 24 +-
docs/LOCAL_SETUP.md | 39 +-
docs/PRODUCT.md | 38 +-
docs/TECH.md | 12 +-
package.json | 20 +-
packages/agents/package.json | 19 +-
packages/agents/scripts/start-engine.mjs | 13 +
.../src/{ => adapters}/admission-context.ts | 0
packages/agents/src/adapters/convex-client.ts | 18 +
.../{db.ts => adapters/flue-persistence.ts} | 71 +-
packages/agents/src/agents/work-planner.ts | 30 -
packages/agents/src/agents/zopu.ts | 25 +-
packages/agents/src/app.ts | 38 +-
.../{auth.ts => auth/conversation-route.ts} | 4 +-
packages/agents/src/auth/internal-route.ts | 12 +
packages/agents/src/auth/workflow-route.ts | 39 +
.../agents/src/prompts/zopu-instructions.md | 41 +-
packages/agents/src/runner.ts | 2 +-
.../agents/src/runtime/agent-os-registry.ts | 26 +
.../{agent-os.ts => attempt-runner.ts} | 87 +-
.../agents/src/runtime/execution-errors.ts | 29 +
...-repository.ts => repository-workspace.ts} | 46 +-
.../agents/src/tools/create-pr-for-issue.ts | 572 ---------
.../tools/{slice-one.ts => signal-routing.ts} | 10 +-
packages/agents/src/tools/work-commands.ts | 79 ++
packages/agents/src/tools/work-planner.ts | 69 --
packages/agents/src/workflows/plan-work.ts | 121 ++
packages/backend/convex/README.md | 6 +-
packages/backend/convex/_generated/api.d.ts | 12 +-
.../backend/convex/_generated/server.d.ts | 3 +
...{workExecutionAgent.ts => agentBackend.ts} | 4 +-
.../backend/convex/gitConnectionHealth.ts | 49 +-
packages/backend/convex/gitProvisioning.ts | 2 -
packages/backend/convex/gitWebhooks.ts | 2 -
packages/backend/convex/schema.ts | 40 +-
packages/backend/convex/workExecution.test.ts | 462 ++------
packages/backend/convex/workExecution.ts | 1040 +++++++++--------
.../convex/workExecutionWorkflow.test.ts | 396 -------
.../backend/convex/workExecutionWorkflow.ts | 654 -----------
packages/backend/convex/workPlanning.test.ts | 751 ++++++------
packages/backend/convex/workPlanning.ts | 381 +++---
packages/backend/convex/works.ts | 11 +
.../src/agent-os-git-software.test.ts | 2 +-
.../primitives/src/execution-runtime.test.ts | 2 +-
packages/primitives/src/execution-runtime.ts | 4 +-
.../primitives/src/git-local-runtime.test.ts | 2 +-
packages/primitives/src/git-provider.test.ts | 6 +-
packages/primitives/src/git-provisioning.ts | 4 +-
.../primitives/src/git-remote-runtime.test.ts | 4 +-
packages/primitives/src/git-webhook.ts | 2 +-
packages/primitives/src/git.test.ts | 2 +-
packages/primitives/src/project-issue.test.ts | 2 +-
packages/primitives/src/project-work.test.ts | 2 +-
.../primitives/src/project-workspace.test.ts | 2 +-
packages/primitives/src/project.ts | 4 +-
packages/primitives/src/signal.test.ts | 4 +-
packages/primitives/src/smoke.test.ts | 2 +-
packages/primitives/src/work-artifact.test.ts | 2 +-
packages/primitives/src/work-lifecycle.ts | 32 +-
.../primitives/src/work-resolution.test.ts | 10 +-
packages/primitives/src/work.test.ts | 2 +-
packages/primitives/src/work.ts | 14 +-
pnpm-lock.yaml | 390 ++++---
scripts/wait-for-port.mjs | 40 +
t3.json | 18 +
68 files changed, 2196 insertions(+), 3827 deletions(-)
create mode 100644 packages/agents/scripts/start-engine.mjs
rename packages/agents/src/{ => adapters}/admission-context.ts (100%)
create mode 100644 packages/agents/src/adapters/convex-client.ts
rename packages/agents/src/{db.ts => adapters/flue-persistence.ts} (95%)
delete mode 100644 packages/agents/src/agents/work-planner.ts
rename packages/agents/src/{auth.ts => auth/conversation-route.ts} (87%)
create mode 100644 packages/agents/src/auth/internal-route.ts
create mode 100644 packages/agents/src/auth/workflow-route.ts
create mode 100644 packages/agents/src/runtime/agent-os-registry.ts
rename packages/agents/src/runtime/{agent-os.ts => attempt-runner.ts} (66%)
create mode 100644 packages/agents/src/runtime/execution-errors.ts
rename packages/agents/src/runtime/{host-repository.ts => repository-workspace.ts} (87%)
delete mode 100644 packages/agents/src/tools/create-pr-for-issue.ts
rename packages/agents/src/tools/{slice-one.ts => signal-routing.ts} (94%)
create mode 100644 packages/agents/src/tools/work-commands.ts
delete mode 100644 packages/agents/src/tools/work-planner.ts
create mode 100644 packages/agents/src/workflows/plan-work.ts
rename packages/backend/convex/{workExecutionAgent.ts => agentBackend.ts} (97%)
delete mode 100644 packages/backend/convex/workExecutionWorkflow.test.ts
delete mode 100644 packages/backend/convex/workExecutionWorkflow.ts
create mode 100644 scripts/wait-for-port.mjs
create mode 100644 t3.json
diff --git a/.env.example b/.env.example
index 58e4259..aa5c7da 100644
--- a/.env.example
+++ b/.env.example
@@ -25,7 +25,7 @@ AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token
-FLUE_URL=http://localhost:3583
+FLUE_URL=http://127.0.0.1:3585
# Agent model provider
AGENT_MODEL_PROVIDER=xiaomi
diff --git a/apps/web/src/components/workspace/work-card.tsx b/apps/web/src/components/workspace/work-card.tsx
index c4c28f9..10dd956 100644
--- a/apps/web/src/components/workspace/work-card.tsx
+++ b/apps/web/src/components/workspace/work-card.tsx
@@ -1,7 +1,6 @@
import { Button } from "@code/ui/components/button";
import {
AlertTriangle,
- Check,
ChevronRight,
FileCode2,
Hammer,
@@ -32,16 +31,13 @@ const starterDefinition = (work: WorkRecord) => ({
outOfScope: ["Unrelated product changes"],
problem: work.objective,
questions: [],
- requiredArtifacts: ["Simulation activity and terminal outcome"],
+ requiredArtifacts: ["Implementation diff and terminal outcome"],
risk: "medium",
});
const starterDesign = (work: WorkRecord) => ({
- architectureSummary:
- "Validate the approved Definition, then exercise one deterministic fake slice.",
- callFlowDelta: [
- "Work -> Run -> Attempt -> normalized events -> terminal outcome",
- ],
+ architectureSummary: "Implement the requested outcome in focused changes.",
+ callFlowDelta: ["Work -> Run -> Attempt -> AgentOS -> candidate revision"],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
@@ -49,30 +45,30 @@ const starterDesign = (work: WorkRecord) => ({
files: [],
modules: [],
risks: [],
- summary: "Compact vertical-slice simulation",
+ summary: "Focused implementation",
},
invariants: [
- "Simulation never claims implementation",
+ "A successful AgentOS response alone never marks Work complete",
"Every Attempt reaches a terminal classification",
],
- keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
+ keyInterfaces: ["WorkExecution", "AttemptResult"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
- id: "deterministic-simulation",
+ id: "implementation",
objective: work.objective,
- observableBehavior: "A terminal fake Run is visible",
+ observableBehavior: "Implementation changes are visible in the diff",
reviewRequired: false,
- title: "Deterministic simulation",
+ title: "Implementation",
verification: ["Run completes with a terminal classification"],
},
],
- tradeoffs: ["Fake runtime proves contract before sandbox integration"],
+ tradeoffs: [],
});
-// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
+// oxlint-disable-next-line complexity -- this card coordinates planning and run evidence.
export const WorkCard = ({
onSourceSelect,
work,
@@ -95,7 +91,7 @@ export const WorkCard = ({
- Proposed Work
+ {work.status === "proposed" ? "Proposed Work" : "Work"}
{work.title}
@@ -149,7 +145,8 @@ export const WorkCard = ({
Outcome
{work.objective}
-
+
Status: {work.status}
+
Risk: {work.definition?.risk ?? "not defined"}
{openQuestions > 0 ? (
@@ -180,20 +177,6 @@ export const WorkCard = ({
Save definition
) : null}
- {work.status === "awaiting-definition-approval" &&
- work.definitionVersion ? (
-
- ) : null}
@@ -220,22 +203,6 @@ export const WorkCard = ({
Save design
) : null}
- {work.status === "awaiting-design-approval" &&
- work.definitionApprovalVersion &&
- work.designVersion ? (
-
- ) : null}
@@ -261,10 +228,7 @@ export const WorkCard = ({
{latestRun ? (
- {latestRun.executionKind === "real"
- ? "AgentOS"
- : "Simulation"}{" "}
- run {latestRun.status}:{" "}
+ Run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
@@ -343,45 +307,29 @@ export const WorkCard = ({
)}
{work.status === "ready" ? (
- <>
-
-
- >
+
) : null}
{latestRun?.status === "running" ? (
) : null}
- {latestRun?.executionKind !== "real" &&
- latestRun?.status === "terminal" &&
+ {latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
diff --git a/apps/web/src/hooks/workspace/use-project-workspace.ts b/apps/web/src/hooks/workspace/use-project-workspace.ts
index 8eb3f82..4aabde6 100644
--- a/apps/web/src/hooks/workspace/use-project-workspace.ts
+++ b/apps/web/src/hooks/workspace/use-project-workspace.ts
@@ -13,13 +13,6 @@ import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
-type ExecutionScenario =
- | "success"
- | "transient-failure-then-success"
- | "needs-input"
- | "permanent-failure"
- | "cancelled";
-
const workListRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
@@ -35,46 +28,26 @@ const saveDefinitionRef = makeFunctionReference<
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDefinitionProposal");
-const approveDefinitionRef = makeFunctionReference<
- "mutation",
- { workId: Id<"works">; version: number },
- unknown
->("workPlanning:approveDefinition");
const saveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDesignProposal");
-const approveDesignRef = makeFunctionReference<
- "mutation",
- { workId: Id<"works">; definitionVersion: number; designVersion: number },
- unknown
->("workPlanning:approveDesign");
-const startSimulationRef = makeFunctionReference<
- "mutation",
- { workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
- unknown
->("workExecution:startSimulatedExecution");
-const cancelSimulationRef = makeFunctionReference<
- "mutation",
- { runId: Id<"workRuns"> },
- unknown
->("workExecution:cancelSimulatedExecution");
-const retrySimulationRef = makeFunctionReference<
- "mutation",
- { runId: Id<"workRuns"> },
- unknown
->("workExecution:retrySimulatedExecution");
const startExecutionRef = makeFunctionReference<
"mutation",
- { workId: Id<"works">; sliceId?: string },
+ { sliceId?: string; workId: Id<"works"> },
unknown
->("workExecutionWorkflow:startExecution");
+>("workExecution:start");
const cancelExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
->("workExecutionWorkflow:cancelExecution");
+>("workExecution:cancel");
+const retryExecutionRef = makeFunctionReference<
+ "mutation",
+ { runId: Id<"workRuns"> },
+ unknown
+>("workExecution:retry");
const listGitConnectionsRef = makeFunctionReference<
"query",
Record
,
@@ -153,14 +126,10 @@ export const useProjectWorkspace = (): WorkspaceState => {
);
const requestDefinitionMutation = useMutation(requestDefinitionRef);
const saveDefinitionMutation = useMutation(saveDefinitionRef);
- const approveDefinitionMutation = useMutation(approveDefinitionRef);
const saveDesignMutation = useMutation(saveDesignRef);
- const approveDesignMutation = useMutation(approveDesignRef);
- const startSimulationMutation = useMutation(startSimulationRef);
- const cancelSimulationMutation = useMutation(cancelSimulationRef);
- const retrySimulationMutation = useMutation(retrySimulationRef);
const startExecutionMutation = useMutation(startExecutionRef);
const cancelExecutionMutation = useMutation(cancelExecutionRef);
+ const retryExecutionMutation = useMutation(retryExecutionRef);
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
const connectGiteaAction = useAction(connectGiteaRef);
const connectGithubAction = useAction(connectGithubRef);
@@ -224,13 +193,6 @@ export const useProjectWorkspace = (): WorkspaceState => {
return {
agent,
- approveDefinition: (workId: Id<"works">, version: number) =>
- approveDefinitionMutation({ version, workId }),
- approveDesign: (
- workId: Id<"works">,
- definitionVersion: number,
- designVersion: number
- ) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () =>
authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
@@ -238,8 +200,6 @@ export const useProjectWorkspace = (): WorkspaceState => {
}),
cancelExecution: (runId: Id<"workRuns">) =>
runOperation(() => cancelExecutionMutation({ runId })),
- cancelSimulation: (runId: Id<"workRuns">) =>
- runOperation(() => cancelSimulationMutation({ runId })),
clearOperationError: () => setOperationError(undefined),
connectGitea,
connectLinkedGithub,
@@ -253,8 +213,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
repository,
requestDefinition: (workId: Id<"works">) =>
requestDefinitionMutation({ workId }),
- retrySimulation: (runId: Id<"workRuns">) =>
- runOperation(() => retrySimulationMutation({ runId })),
+ retryExecution: (runId: Id<"workRuns">) =>
+ runOperation(() => retryExecutionMutation({ runId })),
saveDefinition: (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
saveDesign: (workId: Id<"works">, payload: unknown) =>
@@ -267,14 +227,6 @@ export const useProjectWorkspace = (): WorkspaceState => {
setRepository,
startExecution: (workId: Id<"works">, sliceId?: string) =>
runOperation(() => startExecutionMutation({ sliceId, workId })),
- startSimulation: (
- workId: Id<"works">,
- scenario: ExecutionScenario,
- sliceId?: string
- ) =>
- runOperation(() =>
- startSimulationMutation({ scenario, sliceId, workId })
- ),
works,
};
};
diff --git a/apps/web/src/lib/workspace/types.ts b/apps/web/src/lib/workspace/types.ts
index 44c6b76..c127b3b 100644
--- a/apps/web/src/lib/workspace/types.ts
+++ b/apps/web/src/lib/workspace/types.ts
@@ -9,7 +9,6 @@ export interface WorkRecord {
readonly objective: string;
readonly status: string;
readonly definitionVersion?: number;
- readonly definitionApprovalVersion?: number;
readonly designVersion?: number;
readonly signals: readonly {
readonly sources: readonly { messageId: string; rawText: string }[];
@@ -34,7 +33,6 @@ export interface WorkRun {
readonly attemptEvents?: readonly WorkAttemptEvent[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
- readonly executionKind?: string;
readonly _id: Id<"workRuns">;
readonly status: string;
readonly terminalClassification?: string;
@@ -81,18 +79,8 @@ export interface WorkspaceState {
| "streaming"
| "submitted";
};
- readonly approveDefinition: (
- workId: Id<"works">,
- version: number
- ) => Promise;
- readonly approveDesign: (
- workId: Id<"works">,
- definitionVersion: number,
- designVersion: number
- ) => Promise;
readonly authorizeGithub: () => Promise;
readonly cancelExecution: (runId: Id<"workRuns">) => Promise;
- readonly cancelSimulation: (runId: Id<"workRuns">) => Promise;
readonly clearOperationError: () => void;
readonly connectGitea: (input: {
readonly token: string;
@@ -108,7 +96,7 @@ export interface WorkspaceState {
readonly projects: readonly ProjectListItem[] | undefined;
readonly repository: string;
readonly requestDefinition: (workId: Id<"works">) => Promise;
- readonly retrySimulation: (runId: Id<"workRuns">) => Promise;
+ readonly retryExecution: (runId: Id<"workRuns">) => Promise;
readonly saveDefinition: (
workId: Id<"works">,
payload: unknown
@@ -124,16 +112,6 @@ export interface WorkspaceState {
workId: Id<"works">,
sliceId?: string
) => Promise;
- readonly startSimulation: (
- workId: Id<"works">,
- scenario:
- | "success"
- | "transient-failure-then-success"
- | "needs-input"
- | "permanent-failure"
- | "cancelled",
- sliceId?: string
- ) => Promise;
readonly works: readonly WorkRecord[] | undefined;
}
diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md
index dc463c1..bfc38dc 100644
--- a/docs/LOCAL_SETUP.md
+++ b/docs/LOCAL_SETUP.md
@@ -148,17 +148,32 @@ bun run dev:server
This watches and publishes functions to the configured Convex cloud development deployment.
-### 2. Start Rivet Engine
+### 2. Start the whole stack
-The installed RivetKit 2.3.9 package supplies the platform-specific `rivet-engine` binary. Start it with:
+The root `dev` script starts Rivet Engine, the AgentOS registry runner, Convex, Flue agents, and the web app in one terminal:
```bash
-./node_modules/.pnpm/@rivetkit+engine-cli-*/node_modules/@rivetkit/engine-cli-*/rivet-engine start
+pnpm dev
+```
+
+For phone or Tailscale-device access, start the stack with the web server bound to all interfaces:
+
+```bash
+pnpm dev:tailscale
+```
+
+### 3. Start Rivet Engine
+
+If you prefer separate terminals, start the engine from the agents package:
+
+```bash
+cd packages/agents
+bun run dev:engine
```
There must be exactly one local engine listening on `127.0.0.1:6420`. Do not start a second engine, and do not use `npx rivetkit dev`; that command is unavailable in the installed version.
-### 3. Start the AgentOS registry runner
+### 4. Start the AgentOS registry runner
```bash
cd packages/agents
@@ -167,21 +182,21 @@ bun run runner
The runner registers the AgentOS workspace actor with Rivet Engine. Keep it running whenever a coding attempt or issue-to-PR request may execute.
-### 4. Start Flue agents
+### 5. Start Flue agents
For laptop-only access:
```bash
-bun run dev:agents -- --port 3585
+pnpm dev:agents
```
For Convex callbacks or access from another device, bind Flue to all interfaces:
```bash
-bun run dev:tailscale:agents -- --port 3585
+pnpm dev:tailscale:agents
```
-Always pass port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
+The dev scripts already use port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
If the shell already exports remote Rivet values, shell values override `.env`. Start Flue with explicit local values:
@@ -191,18 +206,20 @@ RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420 \
bun run dev:tailscale:agents -- --port 3585
```
-### 5. Start the web application
+### 6. Start the web application
+
+If you are not using the root `dev` script, start the web app separately.
For laptop-only access:
```bash
-bun run dev:web
+pnpm dev:web
```
For phone or other Tailscale-device access:
```bash
-bun run dev:tailscale:web
+pnpm dev:tailscale:web
```
Open `http://localhost:5173`, or `http://:5173` when using the Tailscale command.
diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md
index e43e0bc..bbf15f3 100644
--- a/docs/PRODUCT.md
+++ b/docs/PRODUCT.md
@@ -5,7 +5,7 @@
> **Status:** Working source of truth
> **Audience:** product, design, engineering, agents
> **Normative words:** **MUST** = required, **SHOULD** = default, **MAY** = optional
-> **Product boundary:** this file defines *what and why*, not implementation details.
+> **Product boundary:** this file defines _what and why_, not implementation details.
## 1. Thesis
@@ -35,7 +35,7 @@ Technical founders and small engineering teams working in one Git repository.
```text
Project chat
→ actionable Signal
-→ approved Work Definition
+→ ready Work Definition
→ lightweight Design Packet
→ vertical slices
→ isolated coding run
@@ -90,7 +90,7 @@ A Work card is the durable, inspectable representation of one desired outcome. I
All decisions requiring a human are first-class objects:
- clarify intent;
-- approve definition/design;
+- definition/design persist automatically;
- grant permission;
- select an alternative;
- review a slice;
@@ -249,18 +249,18 @@ Canonical knowledge updates require review.
High-quality software is evaluated across:
-| Dimension | Required question |
-|---|---|
-| Correctness | Does behavior satisfy the accepted criteria? |
-| Regression safety | Did existing behavior remain valid? |
-| Maintainability | Does the change preserve understandable boundaries? |
-| Security | Are permissions, secrets, data, and dependencies safe? |
-| Reliability | Are failures and edge cases handled? |
-| Operability | Can it be deployed, observed, debugged, and rolled back? |
-| Performance | Are expected resource limits preserved? |
-| UX | Does the actual user path work? |
-| Traceability | Can claims be tied to evidence and revisions? |
-| Reversibility | Can risk be disabled or rolled back? |
+| Dimension | Required question |
+| ----------------- | -------------------------------------------------------- |
+| Correctness | Does behavior satisfy the accepted criteria? |
+| Regression safety | Did existing behavior remain valid? |
+| Maintainability | Does the change preserve understandable boundaries? |
+| Security | Are permissions, secrets, data, and dependencies safe? |
+| Reliability | Are failures and edge cases handled? |
+| Operability | Can it be deployed, observed, debugged, and rolled back? |
+| Performance | Are expected resource limits preserved? |
+| UX | Does the actual user path work? |
+| Traceability | Can claims be tied to evidence and revisions? |
+| Reversibility | Can risk be disabled or rolled back? |
### Completion rule
@@ -283,7 +283,7 @@ The implementation worker MUST NOT be the sole authority on completion.
Builder → candidate output
Verifier → independent evidence
Resolver → completion decision
-Human → policy-defined approvals
+Human → merge/release authorization
```
### Risk lanes
@@ -309,7 +309,7 @@ definition → combined design → 1–3 slices → verify → review → PR
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
```text
-definition approval → architecture approval → program-design approval
+definition → design → ready → merge/release
→ one slice at a time → staged release → observation/rollback gate
```
@@ -356,8 +356,8 @@ Humans should spend attention on high-leverage decisions, not supervise tool cal
Preferred gates:
-- Work Definition approval;
-- Design Packet approval for standard/critical work;
+- Work Definition (no approval gate);
+- Design Packet (no approval gate);
- slice review when risk requires it;
- merge/release approval;
- resolution of ambiguity or policy exceptions.
diff --git a/docs/TECH.md b/docs/TECH.md
index 4b51b5d..afb546f 100644
--- a/docs/TECH.md
+++ b/docs/TECH.md
@@ -198,9 +198,7 @@ All external/model payloads MUST be decoded with schemas at boundaries.
```text
Proposed
→ Defining
-→ AwaitingDefinitionApproval
→ Designing
-→ AwaitingDesignApproval
→ Ready
→ ExecutingSlice
→ VerifyingSlice
@@ -242,7 +240,7 @@ Initial central aggregate:
- Design Packet and versions;
- slice/step graph;
- Resolver state;
-- approvals/questions;
+- questions;
- artifact references;
- result.
@@ -425,7 +423,7 @@ HarnessRuntime
v0 selects one harness. Prefer mature coding harnesses for repository exploration/edit/test loops; use custom FLUE agents for product-specific reasoning.
-Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completion. Harness owns one bounded implementation loop.
+Zopu owns Work, branches, worktrees, budgets, artifacts, and completion. Harness owns one bounded implementation loop.
## 10. Runtime strategy
@@ -580,7 +578,7 @@ Never create a “verified PR” from a different SHA than the verified candidat
Review package:
- original intent;
-- approved definition/design;
+- current definition/design;
- slice narrative;
- meaningful diffs;
- screenshots/video;
@@ -608,7 +606,7 @@ Production release requires explicit policy, rollout plan, health signals, and r
- no long-lived model/Git secrets in workspace files;
- network egress policy;
- artifact access authorization;
-- immutable audit trail for approvals;
+- immutable audit trail for all Work events;
- tool allowlist per Kit;
- destructive tools denied by default;
- merge/deploy human-gated initially;
@@ -663,7 +661,7 @@ Keep Cube control APIs private; expose only authorized preview paths. Rivet and
The architecture is proven when one real repository supports:
```text
-message → Signal → approved Work → approved Design Packet
+message → Signal → proposed Work → ready Work
→ one slice → isolated harness run → independent verification
→ verified commit → real PR → human response/resume
```
diff --git a/package.json b/package.json
index 96769ef..6aa1ccd 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,8 @@
},
"type": "module",
"scripts": {
- "dev": "vp run -r dev",
+ "dev": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:web\"",
+ "dev:tailscale": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:tailscale:web\"",
"build": "vp run -r build",
"check-types": "vp run -r check-types",
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
@@ -58,12 +59,14 @@
"format": "vp fmt",
"staged": "vp staged",
"hooks:setup": "vp config",
- "dev:web": "vp run --filter web dev",
- "dev:agents": "vp run --filter @code/agents dev",
- "dev:tailscale:web": "vp run --filter web dev:tailscale",
- "dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale",
- "dev:server": "vp run --filter @code/backend dev",
- "dev:setup": "vp run --filter @code/backend dev:setup",
+ "dev:web": "pnpm --filter web run dev",
+ "dev:agents": "pnpm --filter @code/agents run dev",
+ "dev:tailscale:web": "pnpm --filter web run dev:tailscale",
+ "dev:tailscale:agents": "pnpm --filter @code/agents run dev:tailscale",
+ "dev:server": "pnpm --filter @code/backend run dev",
+ "dev:setup": "pnpm --filter @code/backend run dev:setup",
+ "dev:engine": "pnpm --filter @code/agents run dev:engine",
+ "dev:runner": "node scripts/wait-for-port.mjs 127.0.0.1 6420 && pnpm --filter @code/agents run runner",
"build:agents": "vp run --filter @code/agents build",
"docs:update": "node scripts/update-docs.ts",
"subtree": "node scripts/subtree.ts",
@@ -74,9 +77,10 @@
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
- "@flue/sdk": "1.0.0-beta.9",
+ "@flue/sdk": "npm:@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2",
"@types/bun": "catalog:",
"@types/node": "catalog:",
+ "concurrently": "^9.1.2",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
diff --git a/packages/agents/package.json b/packages/agents/package.json
index ccd4a62..20d6496 100644
--- a/packages/agents/package.json
+++ b/packages/agents/package.json
@@ -7,29 +7,26 @@
"build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
"start": "node --env-file=../../.env dist/server.mjs",
"check-types": "tsc --noEmit",
- "dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
- "dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
+ "dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev --port 3585",
+ "dev:engine": "node --env-file=../../.env scripts/start-engine.mjs",
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
"runner": "bun --env-file=../../.env src/runner.ts",
- "run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
- "run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
+ "run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu"
},
"dependencies": {
- "@agentos-software/git": "0.3.3",
- "@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
- "@flue/runtime": "latest",
- "@rivet-dev/agentos": "0.2.14",
- "@rivet-dev/agentos-core": "0.2.14",
+ "@flue/runtime": "npm:@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2",
+ "@rivet-dev/agentos": "catalog:",
+ "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"hono": "catalog:",
- "rivetkit": "2.3.9",
"valibot": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
- "@flue/cli": "latest",
+ "@flue/cli": "npm:@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2",
+ "@rivetkit/engine-cli": "2.3.9",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
diff --git a/packages/agents/scripts/start-engine.mjs b/packages/agents/scripts/start-engine.mjs
new file mode 100644
index 0000000..9317cd6
--- /dev/null
+++ b/packages/agents/scripts/start-engine.mjs
@@ -0,0 +1,13 @@
+import { spawn } from "node:child_process";
+
+import { getEnginePath } from "@rivetkit/engine-cli";
+
+const enginePath = getEnginePath();
+const proc = spawn(enginePath, ["start"], { stdio: "inherit" });
+
+process.on("SIGINT", () => proc.kill("SIGINT"));
+process.on("SIGTERM", () => proc.kill("SIGTERM"));
+
+proc.on("exit", (code) => {
+ process.exit(code ?? 0);
+});
diff --git a/packages/agents/src/admission-context.ts b/packages/agents/src/adapters/admission-context.ts
similarity index 100%
rename from packages/agents/src/admission-context.ts
rename to packages/agents/src/adapters/admission-context.ts
diff --git a/packages/agents/src/adapters/convex-client.ts b/packages/agents/src/adapters/convex-client.ts
new file mode 100644
index 0000000..df9d1cb
--- /dev/null
+++ b/packages/agents/src/adapters/convex-client.ts
@@ -0,0 +1,18 @@
+import { parseAgentEnv } from "@code/env/agent";
+import { ConvexHttpClient } from "convex/browser";
+
+export interface ConvexServiceClient {
+ readonly client: ConvexHttpClient;
+ readonly token: string;
+}
+
+/** Shared factory for service-authenticated ConvexHttpClient used by agent tools. */
+export const createConvexServiceClient = (
+ runtimeEnv: Record
+): ConvexServiceClient => {
+ const env = parseAgentEnv(runtimeEnv);
+ return {
+ client: new ConvexHttpClient(env.CONVEX_URL),
+ token: env.FLUE_DB_TOKEN,
+ };
+};
diff --git a/packages/agents/src/db.ts b/packages/agents/src/adapters/flue-persistence.ts
similarity index 95%
rename from packages/agents/src/db.ts
rename to packages/agents/src/adapters/flue-persistence.ts
index d3f1d0a..8a8cc26 100644
--- a/packages/agents/src/db.ts
+++ b/packages/agents/src/adapters/flue-persistence.ts
@@ -8,11 +8,9 @@
* build typed references against explicit DTO shapes, so the file typechecks
* against the installed runtime package without depending on codegen output.
*
- * DTO conventions shared with the backend:
- * - Submissions persist `inputJson` (the dispatch input or the direct input
- * with image data stripped via `prepareDirectSubmission`) plus `chunksJson`
- * for direct submissions (a `PersistedImageChunk[]`). Direct inputs are
- * rehydrated with `hydratePersistedDirectSubmission`.
+ * - Submissions persist a shared input payload plus `chunksJson` for
+ * attachments stripped by `prepareSubmissionAttachments` and restored by
+ * `hydratePersistedSubmissionAttachments`.
* - Conversation batches persist a numeric `offset` and `recordsJson`.
* - Event entries persist a numeric `offset` and `dataJson`.
* - Attachments carry `attachmentJson` and raw `bytes` as `ArrayBuffer`.
@@ -36,13 +34,14 @@ import {
assertSupportedFlueSchemaVersion,
clampLimit,
copyAttachmentBytes,
+ createDispatchAgentSubmissionInput,
createSessionStorageKey,
decodeRunCursor,
encodeRunCursor,
formatOffset,
- hydratePersistedDirectSubmission,
parseOffset,
- prepareDirectSubmission,
+ hydratePersistedSubmissionAttachments,
+ prepareSubmissionAttachments,
verifyAttachmentBytes,
} from "@flue/runtime/adapter";
import type {
@@ -61,9 +60,8 @@ import type {
ConversationStreamMeta,
ConversationStreamReadResult,
ConversationStreamStore,
+ AgentSubmissionInput,
CreateRunInput,
- DirectAgentSubmissionInput,
- DispatchAgentSubmissionInput,
DispatchInput,
EndRunInput,
EventStreamMeta,
@@ -113,7 +111,7 @@ interface TokenArgs {
const TOKEN = (): string => env.FLUE_DB_TOKEN;
/** Trace carrier shape (W3C tracecontext). Not exported by Flue's adapter. */
-type TraceCarrier = NonNullable;
+type TraceCarrier = NonNullable;
// ---------------------------------------------------------------------------
// Shared DTO shapes (wire types returned by the Convex backend)
@@ -651,9 +649,7 @@ const attachmentRefToWire = (ref: AttachmentRef): AttachmentRefWire => ({
/**
* Rehydrate a {@link SubmissionRow} into the {@link AgentSubmission} contract.
- * `dispatch` inputs round-trip through `inputJson`; `direct` inputs are split
- * into `inputJson` (images stripped) + `chunksJson` and rehydrated with Flue's
- * `hydratePersistedDirectSubmission`.
+ * Submission attachments are stored separately from the shared persisted payload.
*/
const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
const baseSubmission: Omit = {
@@ -682,29 +678,15 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
...(row.error === null ? {} : { error: row.error }),
...(row.ownerId === null ? {} : { ownerId: row.ownerId }),
};
-
- const traceCarrier = safeJsonParse(row.traceCarrierJson);
-
- if (row.kind === "dispatch") {
- const input = safeJsonParse(row.inputJson);
- if (input === undefined) {
- throw new Error(
- `[flue] persisted dispatch submission ${row.submissionId} has empty inputJson`
- );
- }
- const inputWithCarrier =
- traceCarrier === undefined ? input : { ...input, traceCarrier };
- return { ...baseSubmission, input: inputWithCarrier };
- }
-
- const stripped = safeJsonParse(row.inputJson);
- if (stripped === undefined) {
+ const persisted = safeJsonParse(row.inputJson);
+ if (persisted === undefined) {
throw new Error(
- `[flue] persisted direct submission ${row.submissionId} has empty inputJson`
+ `[flue] persisted submission ${row.submissionId} has empty inputJson`
);
}
const chunks = safeJsonParse(row.chunksJson) ?? [];
- const hydrated = hydratePersistedDirectSubmission(stripped, chunks);
+ const hydrated = hydratePersistedSubmissionAttachments(persisted, chunks);
+ const traceCarrier = safeJsonParse(row.traceCarrierJson);
const input =
traceCarrier === undefined ? hydrated : { ...hydrated, traceCarrier };
return { ...baseSubmission, input };
@@ -849,25 +831,14 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
async admitDispatch(input: DispatchInput): Promise {
+ const submissionInput = createDispatchAgentSubmissionInput(input);
const sessionKey = createSessionStorageKey(input.id, "default", "default");
- const submissionInput: Omit<
- DispatchAgentSubmissionInput,
- "kind" | "submissionId"
- > = {
- acceptedAt: input.acceptedAt,
- agent: input.agent,
- dispatchId: input.dispatchId,
- id: input.id,
- input: input.input,
- };
+ const extracted = prepareSubmissionAttachments(submissionInput);
const res = await this.convex.mutation(admitSubmission, {
input: {
acceptedAt: parseAcceptedAt(input.acceptedAt, "admitDispatch"),
- inputJson: jsonStringify({
- kind: "dispatch",
- submissionId: input.dispatchId,
- ...submissionInput,
- }),
+ chunksJson: jsonStringify(extracted.chunks),
+ inputJson: jsonStringify(extracted.value),
kind: "dispatch",
sessionKey,
submissionId: input.dispatchId,
@@ -886,11 +857,9 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
return { kind: "conflict" };
}
- async admitDirect(
- input: DirectAgentSubmissionInput
- ): Promise {
+ async admitDirect(input: AgentSubmissionInput): Promise {
const sessionKey = createSessionStorageKey(input.id, "default", "default");
- const extracted = prepareDirectSubmission(input);
+ const extracted = prepareSubmissionAttachments(input);
const admission = currentTurnAdmission();
const res = await this.convex.mutation(admitSubmission, {
input: {
diff --git a/packages/agents/src/agents/work-planner.ts b/packages/agents/src/agents/work-planner.ts
deleted file mode 100644
index 0ea8183..0000000
--- a/packages/agents/src/agents/work-planner.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { parseAgentEnv } from "@code/env/agent";
-import { defineAgent } from "@flue/runtime";
-
-import { createWorkPlannerTools } from "../tools/work-planner";
-
-const INSTRUCTIONS = `You are Zopu's private Work Planner.
-
-You may only submit typed proposals through tools:
-- a Work Definition proposal;
-- a Design Packet proposal containing 1-4 observable, independently verifiable slices;
-- structured questions.
-
-You must never approve a Definition or Design, change Work status, start execution, claim implementation, create Git changes, or claim that simulation implemented the Work. Convex validates and stores every proposal. Ask a focused structured question when a high-impact decision is unresolved.`;
-
-export {
- convexAgentRoute as attachments,
- convexAgentRoute as route,
-} from "../auth";
-
-export default defineAgent(({ env }) => {
- const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
- return {
- description:
- "Proposes Work Definitions, Design Packets, and structured questions.",
- instructions: INSTRUCTIONS,
- model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
- thinkingLevel: "medium",
- tools: createWorkPlannerTools(env),
- };
-});
diff --git a/packages/agents/src/agents/zopu.ts b/packages/agents/src/agents/zopu.ts
index eb0efa0..795edb6 100644
--- a/packages/agents/src/agents/zopu.ts
+++ b/packages/agents/src/agents/zopu.ts
@@ -1,31 +1,26 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
-import { local } from "@flue/runtime/node";
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
-import { createCreatePrForIssueTool } from "../tools/create-pr-for-issue";
-import { createSliceOneTools } from "../tools/slice-one";
+import { createSignalRoutingTools } from "../tools/signal-routing";
+import { createWorkCommandTools } from "../tools/work-commands";
export {
- convexAgentRoute as attachments,
- convexAgentRoute as route,
-} from "../auth";
-
-// The host checkout the chat agent explores. The `local()` sandbox reuses the
-// machine's existing shell, Git, and Tea install unchanged; its cwd becomes the
-// default working directory for every command the agent runs.
-const ZOPU_CODE_PATH = "/Users/puter/Workspace/zopu/code";
+ conversationRoute as attachments,
+ conversationRoute as route,
+} from "../auth/conversation-route";
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
-
return {
description:
- "Turns actionable conversation into provenanced Signals and proposed Work.",
+ "Turns actionable conversation into provenanced Signals, proposed Work, and execution requests.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
- sandbox: local({ cwd: ZOPU_CODE_PATH }),
thinkingLevel: "high",
- tools: [...createSliceOneTools(id, env), createCreatePrForIssueTool(env)],
+ tools: [
+ ...createSignalRoutingTools(id, env),
+ ...createWorkCommandTools(id, env),
+ ],
};
});
diff --git a/packages/agents/src/app.ts b/packages/agents/src/app.ts
index 491aeeb..631672d 100644
--- a/packages/agents/src/app.ts
+++ b/packages/agents/src/app.ts
@@ -5,11 +5,12 @@ import type { Fetchable } from "@flue/runtime/routing";
import { flue } from "@flue/runtime/routing";
import { Hono } from "hono";
+import { internalRoute } from "./auth/internal-route";
+import { runtimeRegistry } from "./runtime/agent-os-registry";
import {
cancelAgentOsAttempt,
executeAgentOsAttempt,
- runtimeRegistry,
-} from "./runtime/agent-os";
+} from "./runtime/attempt-runner";
const agentEnv = parseAgentEnv(process.env);
@@ -34,12 +35,7 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
const app = new Hono();
-app.post("/internal/work-attempts/execute", async (context) => {
- if (
- context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
- ) {
- return context.json({ error: "Unauthorized" }, 401);
- }
+app.post("/internal/work-attempts/execute", internalRoute, async (context) => {
try {
return context.json(await executeAgentOsAttempt(await context.req.json()));
} catch (error) {
@@ -65,19 +61,21 @@ app.post("/internal/work-attempts/execute", async (context) => {
}
});
-app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
- if (
- context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
- ) {
- return context.json({ error: "Unauthorized" }, 401);
+app.post(
+ "/internal/work-attempts/:workspaceKey/cancel",
+ internalRoute,
+ async (context) => {
+ const body = (await context.req.json()) as { attemptId?: unknown };
+ if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
+ return context.json({ error: "attemptId is required" }, 400);
+ }
+ await cancelAgentOsAttempt(
+ context.req.param("workspaceKey"),
+ body.attemptId
+ );
+ return context.json({ cancelled: true });
}
- const body = (await context.req.json()) as { attemptId?: unknown };
- if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
- return context.json({ error: "attemptId is required" }, 400);
- }
- await cancelAgentOsAttempt(context.req.param("workspaceKey"), body.attemptId);
- return context.json({ cancelled: true });
-});
+);
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
diff --git a/packages/agents/src/auth.ts b/packages/agents/src/auth/conversation-route.ts
similarity index 87%
rename from packages/agents/src/auth.ts
rename to packages/agents/src/auth/conversation-route.ts
index e2af8e3..dfa227b 100644
--- a/packages/agents/src/auth.ts
+++ b/packages/agents/src/auth/conversation-route.ts
@@ -1,10 +1,10 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
-import { withTurnAdmission } from "./admission-context";
+import { withTurnAdmission } from "../adapters/admission-context";
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
-export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
+export const conversationRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
diff --git a/packages/agents/src/auth/internal-route.ts b/packages/agents/src/auth/internal-route.ts
new file mode 100644
index 0000000..fc1c75b
--- /dev/null
+++ b/packages/agents/src/auth/internal-route.ts
@@ -0,0 +1,12 @@
+import { parseAgentEnv } from "@code/env/agent";
+import type { MiddlewareHandler } from "hono";
+
+/** Internal service authorization for execution/cancel routes. Not client-facing. */
+export const internalRoute: MiddlewareHandler = async (context, next) => {
+ const env = parseAgentEnv(process.env);
+ const authorization = context.req.header("authorization");
+ if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
+ return context.json({ error: "Unauthorized" }, 401);
+ }
+ return await next();
+};
diff --git a/packages/agents/src/auth/workflow-route.ts b/packages/agents/src/auth/workflow-route.ts
new file mode 100644
index 0000000..ecdefd1
--- /dev/null
+++ b/packages/agents/src/auth/workflow-route.ts
@@ -0,0 +1,39 @@
+import { parseAgentEnv } from "@code/env/agent";
+import type { WorkflowRouteHandler } from "@flue/runtime";
+import * as v from "valibot";
+
+const WorkflowInputEnvelope = v.object({
+ input: v.object({
+ organizationId: v.string(),
+ requestId: v.string(),
+ }),
+});
+
+/** Service authorization for finite Flue workflows. No turn headers required. */
+export const workflowRoute: WorkflowRouteHandler = async (context, next) => {
+ const env = parseAgentEnv(process.env);
+ const authorization = context.req.header("authorization");
+ if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
+ return context.json({ error: "Unauthorized" }, 401);
+ }
+
+ if (context.req.method !== "POST") {
+ return await next();
+ }
+
+ const body = (await context.req.json().catch(() => null)) as unknown;
+ const parsed = v.safeParse(WorkflowInputEnvelope, body);
+ if (!parsed.success) {
+ return context.json({ error: "Invalid workflow input envelope" }, 400);
+ }
+
+ const organizationId = context.req.header("x-zopu-organization-id");
+ if (
+ !organizationId ||
+ organizationId !== parsed.output.input.organizationId
+ ) {
+ return context.json({ error: "Forbidden" }, 403);
+ }
+
+ return await next();
+};
diff --git a/packages/agents/src/prompts/zopu-instructions.md b/packages/agents/src/prompts/zopu-instructions.md
index 71ed400..e2f5191 100644
--- a/packages/agents/src/prompts/zopu-instructions.md
+++ b/packages/agents/src/prompts/zopu-instructions.md
@@ -1,4 +1,4 @@
-You are Zopu for product Slice 1 and the Work planning handoff.
+You are Zopu, the conversational agent that turns user messages into provenanced Signals, proposed Work, and execution requests.
## Your role
@@ -12,15 +12,18 @@ When a user sends a message, follow this decision flow:
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
-2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
+2. **Identify project context.** Call `list_projects`. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
-3. **Create a Signal (only when actionable).** When the message is actionable: a. Call list_signal_evidence to see the exact admitted user messages. b. Select the message IDs that compose the problem statement. c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. d. Include the projectId when the project is known.
+3. **Create a Signal (only when actionable).** a. Call `list_signal_evidence` to see the exact admitted user messages. b. Select the message IDs that compose the problem statement. c. Call `create_signal` with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. d. Include the `projectId` when the project is known.
-4. **Route the Signal.** After creating the Signal: a. Call list_proposed_work for the project. b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work. c. Otherwise call create_work_from_signal. e. If genuinely uncertain whether to attach or create, ask one focused question.
+4. **Route the Signal.** a. Call `list_proposed_work` for the project. b. If the Signal clearly describes the same desired outcome as existing Work, call `attach_signal_to_work`. c. Otherwise call `create_work_from_signal`. d. If genuinely uncertain whether to attach or create, ask one focused question.
-5. **Explain the outcome.** Tell the user clearly what happened:
+5. **Execution requests.** When a user explicitly asks to implement or start work on a ready Work unit, call `start_work` with the work ID. The system handles planning, execution, verification, and delivery. Do not claim implementation has started until the tool returns a runId.
+
+6. **Explain the outcome.** Tell the user clearly what happened:
- "Captured [Signal title] and linked it to [Work title]."
- "Captured [Signal title] and proposed [Work title]."
+ - "Started execution of [Work title]."
- Keep the response brief; the product renders the durable Work card separately.
## Rules
@@ -31,27 +34,7 @@ When a user sends a message, follow this decision flow:
- Ask at most one focused clarification when genuinely ambiguous.
- Preserve project and organization scope at all times.
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
-- Never claim you created a Signal until the tool call returns successfully.
-- Do not start implementation, planning, sandboxes, verification, or delivery. Those are explicitly outside Slice 1.
- - After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- - Proposed Work is the only Work status you directly create.
-
-## Issue resolution with create_pr_for_issue
-
-When a user wants an open Gitea issue implemented and turned into a pull request, call `create_pr_for_issue` with the issue number or URL.
-
-- The tool accepts the request immediately and runs the AgentOS implementation, branch push, and PR creation in the background.
-- Tell the user the issue was accepted for background implementation; do not claim the PR exists yet and do not wait for completion in the chat turn.
-- Do not retry the tool in a tight loop. Background success and failure are written to the agent server logs with the `[create_pr_for_issue]` prefix.
-- Do not run repository mutations yourself; the tool owns the implementation and PR pipeline.
-
-## Repository access
-
-Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace/zopu/code`, so you can answer questions about this repository and manage its Gitea issues.
-
-- Explore the repository with read-only Git: `git log`, `git status`, `git diff`, `git branch`, `git show`, and reading files. Use this to ground answers about the codebase.
-- List open issues with `tea issues list` (defaults to open issues).
-- Create a Gitea issue in this repo with `tea issues create --title "" [--description ""]`.
-- Never mutate repository state: do not run `git push`, `git merge`, `git rebase`, `git reset`, `git commit`, `git add`, force operations, or any history rewrite.
-- Never close, reopen, or edit existing issues (`tea issues close|reopen|edit`); only list and create.
-- Never expose credentials, tokens, or the contents of Tea/Git config files.
+- Never claim you created a Signal, started Work, or produced a result until the tool call returns successfully.
+- Do not perform implementation, planning, design, verification, or delivery yourself. Those are handled by dedicated workflows and the execution runtime.
+- You have no repository access. You cannot run shell commands, inspect code, manage Git, or create pull requests. Focus on conversation, Signal capture, and Work orchestration.
+- Proposed Work is the only Work status you directly create. The system advances Work through planning (Definition, Design) automatically. Work becomes ready for execution when planning completes.
diff --git a/packages/agents/src/runner.ts b/packages/agents/src/runner.ts
index 3ca7118..1b9155e 100644
--- a/packages/agents/src/runner.ts
+++ b/packages/agents/src/runner.ts
@@ -1,3 +1,3 @@
-import { runtimeRegistry } from "./runtime/agent-os";
+import { runtimeRegistry } from "./runtime/agent-os-registry";
await runtimeRegistry.startAndWait();
diff --git a/packages/agents/src/runtime/agent-os-registry.ts b/packages/agents/src/runtime/agent-os-registry.ts
new file mode 100644
index 0000000..b3ceb20
--- /dev/null
+++ b/packages/agents/src/runtime/agent-os-registry.ts
@@ -0,0 +1,26 @@
+import { makePiAgentOsConfig } from "@code/primitives";
+import { agentOS, setup } from "@rivet-dev/agentos";
+
+const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
+const piConfig = makePiAgentOsConfig();
+
+/** AgentOS workspace actor. Only the registry runner serves this actor type. */
+export const workspace = agentOS({
+ limits: {
+ acp: {
+ maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
+ },
+ },
+ onBeforeConnect: (_context, params) => {
+ if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
+ throw new Error("Unauthorized workspace connection");
+ }
+ },
+ options: {
+ actionTimeout: 10 * 60 * 1000,
+ },
+ permissions: piConfig.permissions,
+ software: piConfig.software,
+});
+
+export const runtimeRegistry = setup({ use: { workspace } });
diff --git a/packages/agents/src/runtime/agent-os.ts b/packages/agents/src/runtime/attempt-runner.ts
similarity index 66%
rename from packages/agents/src/runtime/agent-os.ts
rename to packages/agents/src/runtime/attempt-runner.ts
index 680e635..06dd751 100644
--- a/packages/agents/src/runtime/agent-os.ts
+++ b/packages/agents/src/runtime/attempt-runner.ts
@@ -1,43 +1,20 @@
import { parseAgentEnv } from "@code/env/agent";
import {
- makePiAgentOsConfig,
- makePiHomeFiles,
decodeWorkAttemptExecutionInput,
- WorkAttemptExecutionError,
+ makePiHomeFiles,
piSessionEnv,
} from "@code/primitives";
import type {
ExecutionEvent,
WorkAttemptExecutionResult,
} from "@code/primitives";
-import { agentOS, setup } from "@rivet-dev/agentos";
import { createHostDirBackend } from "@rivet-dev/agentos-core";
import { createClient } from "@rivet-dev/agentos/client";
import { Effect } from "effect";
-import { HostRepositoryWorkspace } from "./host-repository";
-
-const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
-const piConfig = makePiAgentOsConfig();
-const workspace = agentOS({
- limits: {
- acp: {
- maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
- },
- },
- onBeforeConnect: (_context, params) => {
- if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
- throw new Error("Unauthorized workspace connection");
- }
- },
- options: {
- actionTimeout: 10 * 60 * 1000,
- },
- permissions: piConfig.permissions,
- software: piConfig.software,
-});
-
-export const runtimeRegistry = setup({ use: { workspace } });
+import type { runtimeRegistry } from "./agent-os-registry";
+import { classifyRuntimeFailure, executionError } from "./execution-errors";
+import { RepositoryWorkspace } from "./repository-workspace";
const event = (
sequence: number,
@@ -52,32 +29,6 @@ const event = (
sequence,
});
-const executionError = (
- message: string,
- reason: WorkAttemptExecutionError["reason"],
- retryable: boolean
-) => new WorkAttemptExecutionError({ message, reason, retryable });
-
-const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
- if (cause instanceof WorkAttemptExecutionError) {
- return cause;
- }
- const message = cause instanceof Error ? cause.message : "Execution failed";
- if (/auth|credential|401|403/iu.test(message)) {
- return executionError(message, "Authentication", false);
- }
- if (/clone|checkout|repository|git /iu.test(message)) {
- return executionError(message, "RepositoryFailed", false);
- }
- if (/timeout|timed out/iu.test(message)) {
- return executionError(message, "Timeout", true);
- }
- if (/connect|network|unavailable|502|503|504/iu.test(message)) {
- return executionError(message, "ProviderUnavailable", true);
- }
- return executionError(message, "HarnessFailed", false);
-};
-
export const executeAgentOsAttempt = async (
rawInput: unknown
): Promise => {
@@ -100,9 +51,10 @@ export const executeAgentOsAttempt = async (
workspaceKey: input.workspaceKey,
}),
];
- const hostRepository = new HostRepositoryWorkspace();
- const prepared = await hostRepository.prepare({
+ const repository = new RepositoryWorkspace();
+ const prepared = await repository.prepare({
attemptId: input.attemptId,
+ baseBranch: input.baseBranch,
cloneUrl: input.repositoryUrl,
credential: input.auth.credential,
piHomeFiles: makePiHomeFiles({
@@ -122,8 +74,8 @@ export const executeAgentOsAttempt = async (
1,
"runtime.preparing",
prepared.created
- ? "Repository cloned on the execution host"
- : "Repository re-cloned"
+ ? "Isolated repository clone created on the execution host"
+ : "Isolated repository clone recreated"
)
);
const mounts = [
@@ -177,18 +129,15 @@ export const executeAgentOsAttempt = async (
const sessionId = `pi-${input.attemptId}`;
await vm.openSession({
- additionalDirectories: [`${prepared.checkoutPath}/.git`],
additionalInstructions:
- "Work only inside /workspace/repository. This is an isolated checkout of the project repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, push, or open a pull request. Implement the requested change and run focused verification.",
+ "Work only inside /workspace/repository. This is an isolated clone of the project repository. Read the project documentation and conventions before changing code. Never reveal credentials, push, create branches, or open a pull request. Implement the requested change and run focused verification.",
agent: "pi",
cwd: "/workspace/repository",
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
permissionPolicy: "allow_all",
sessionId,
});
- events.push(
- event(3, "harness.started", "Pi implementation session started")
- );
+ events.push(event(3, "harness.started", "Implementation session started"));
const promptResult = await vm.prompt({
content: [{ text: input.prompt, type: "text" }],
idempotencyKey: input.attemptId,
@@ -198,19 +147,19 @@ export const executeAgentOsAttempt = async (
const reason =
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
throw executionError(
- `Pi stopped with ${promptResult.stopReason}`,
+ `Agent stopped with ${promptResult.stopReason}`,
reason,
promptResult.stopReason === "max_tokens" ||
promptResult.stopReason === "max_turn_requests"
);
}
events.push(
- event(4, "harness.progress", "Pi implementation turn completed", {
+ event(4, "harness.progress", "Implementation turn completed", {
stopReason: promptResult.stopReason,
})
);
- const collected = await HostRepositoryWorkspace.collect({
+ const collected = await RepositoryWorkspace.collect({
attemptId: input.attemptId,
baseRevision,
checkoutPath: prepared.checkoutPath,
@@ -221,9 +170,7 @@ export const executeAgentOsAttempt = async (
5,
"repository.changed",
`${changedFiles.length} changed file(s) collected`,
- {
- candidateRevision,
- }
+ { candidateRevision }
),
event(6, "runtime.completed", "AgentOS execution completed")
);
@@ -237,8 +184,8 @@ export const executeAgentOsAttempt = async (
events,
summary:
changedFiles.length > 0
- ? `Pi changed ${changedFiles.length} file(s)`
- : "Pi completed without repository changes",
+ ? `Agent changed ${changedFiles.length} file(s)`
+ : "Agent completed without repository changes",
};
} catch (error) {
throw classifyRuntimeFailure(error);
diff --git a/packages/agents/src/runtime/execution-errors.ts b/packages/agents/src/runtime/execution-errors.ts
new file mode 100644
index 0000000..a1187b4
--- /dev/null
+++ b/packages/agents/src/runtime/execution-errors.ts
@@ -0,0 +1,29 @@
+import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
+
+export const executionError = (
+ message: string,
+ reason: WorkAttemptExecutionError["reason"],
+ retryable: boolean
+) => new WorkAttemptExecutionError({ message, reason, retryable });
+
+export const classifyRuntimeFailure = (
+ cause: unknown
+): WorkAttemptExecutionError => {
+ if (cause instanceof WorkAttemptExecutionError) {
+ return cause;
+ }
+ const message = cause instanceof Error ? cause.message : "Execution failed";
+ if (/auth|credential|401|403/iu.test(message)) {
+ return executionError(message, "Authentication", false);
+ }
+ if (/clone|checkout|repository|git /iu.test(message)) {
+ return executionError(message, "RepositoryFailed", false);
+ }
+ if (/timeout|timed out/iu.test(message)) {
+ return executionError(message, "Timeout", true);
+ }
+ if (/connect|network|unavailable|502|503|504/iu.test(message)) {
+ return executionError(message, "ProviderUnavailable", true);
+ }
+ return executionError(message, "HarnessFailed", false);
+};
diff --git a/packages/agents/src/runtime/host-repository.ts b/packages/agents/src/runtime/repository-workspace.ts
similarity index 87%
rename from packages/agents/src/runtime/host-repository.ts
rename to packages/agents/src/runtime/repository-workspace.ts
index e04dadd..b214415 100644
--- a/packages/agents/src/runtime/host-repository.ts
+++ b/packages/agents/src/runtime/repository-workspace.ts
@@ -15,6 +15,7 @@ import type { PiHomeFiles } from "@code/primitives";
interface PrepareRepositoryInput {
attemptId: string;
+ baseBranch?: string;
cloneUrl: string;
/** Provider credential for the initial clone, stripped after cloning. */
credential?: string;
@@ -68,20 +69,40 @@ const changedFilePath = (line: string): string => {
: filePath.slice(renameIndex + renameSeparator.length);
};
+// Minimal allow-list so untrusted repo code (postinstall scripts, hooks)
+// can never read host secrets like tokens or API keys from the inherited env.
+// PATH is needed for git/bun; locale vars prevent encoding issues.
+// HOME is intentionally excluded — the runner gets its own /home/zopu mount,
+// and forwarding the host HOME would expose host-level dotfiles and credentials.
+const SAFE_ENV_KEYS = [
+ "PATH",
+ "LANG",
+ "LC_ALL",
+ "LC_CTYPE",
+ "TERM",
+ "TZ",
+] as const;
+
+const safeBaseEnv = (): Record => {
+ const env: Record = {};
+ for (const key of SAFE_ENV_KEYS) {
+ const value = process.env[key];
+ if (value !== undefined) {
+ env[key] = value;
+ }
+ }
+ return env;
+};
+
const runProcess = async (
command: string,
args: readonly string[],
cwd: string,
env: Record = {}
): Promise => {
- const environment = Object.fromEntries(
- Object.entries(process.env).filter(
- (entry): entry is [string, string] => entry[1] !== undefined
- )
- );
const child = spawn(command, args, {
cwd,
- env: { ...environment, ...env },
+ env: { ...safeBaseEnv(), ...env },
});
const stderr: Uint8Array[] = [];
const stdout: Uint8Array[] = [];
@@ -107,7 +128,7 @@ const pathExists = async (target: string): Promise => {
}
};
-export class HostRepositoryWorkspace {
+export class RepositoryWorkspace {
readonly #root: string;
readonly #installDependencies: boolean;
@@ -169,12 +190,17 @@ export class HostRepositoryWorkspace {
"Repository clone"
);
} finally {
- // The credential env var is never written to disk or git config.
- // The askpass script is cleaned up with the workspace.
+ // The credential only existed on the local cloneEnv object, never on
+ // process.env. The askpass script is cleaned up with the workspace.
cloneEnv.ZOPU_GIT_TOKEN = "";
- delete process.env.ZOPU_GIT_TOKEN;
}
+ if (input.baseBranch) {
+ requireSuccess(
+ await runGit(checkoutPath, ["checkout", input.baseBranch]),
+ "Base branch checkout"
+ );
+ }
const bunExecutable =
process.env.BUN_EXECUTABLE ??
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
diff --git a/packages/agents/src/tools/create-pr-for-issue.ts b/packages/agents/src/tools/create-pr-for-issue.ts
deleted file mode 100644
index 50f6479..0000000
--- a/packages/agents/src/tools/create-pr-for-issue.ts
+++ /dev/null
@@ -1,572 +0,0 @@
-import { spawnSync } from "node:child_process";
-import type { SpawnSyncOptions } from "node:child_process";
-import { createHash, randomUUID } from "node:crypto";
-import path from "node:path";
-
-import { parseAgentEnv } from "@code/env/agent";
-import { defineTool } from "@flue/runtime";
-import type { JsonValue } from "@flue/runtime";
-import * as v from "valibot";
-
-import { executeAgentOsAttempt } from "../runtime/agent-os";
-
-/**
- * Direct Zopu chat tool that converts a Gitea issue into an AgentOS-implemented
- * pull request. It owns the entire issue → harness → push → PR pipeline so the
- * chat agent never needs (and must not use) shell mutation for this workflow.
- */
-
-// The host checkout the chat agent explores. Tea and Git resolve this repo's
-// Gitea connection; all Tea/Git commands run with this fixed cwd.
-const TRUSTED_REPO_PATH = "/Users/puter/Workspace/zopu/code";
-const REPO_SLUG = "puter/zopu-code";
-
-// Bounded output so a verbose harness/CLI can never blow up a tool result.
-const MAX_OUTPUT = 4000;
-const MAX_ISSUE_BODY = 8000;
-const MAX_CHANGED_FILES = 200;
-const COMMAND_TIMEOUT_MS = 60_000;
-const PUSH_TIMEOUT_MS = 120_000;
-
-interface CommandResult {
- readonly status: number | null;
- readonly stdout: string;
- readonly stderr: string;
-}
-
-interface IssueDetails {
- readonly body: string;
- readonly title: string;
- readonly url: string;
-}
-
-interface ToolFailure {
- readonly [key: string]: JsonValue;
- readonly error: string;
- readonly issue: string;
- readonly stage: string;
-}
-
-interface ToolSuccess {
- readonly [key: string]: JsonValue;
- readonly baseBranch: string;
- readonly changedFileCount: number;
- readonly changedFiles: string[];
- readonly headBranch: string;
- readonly issue: string;
- readonly issueNumber: number;
- readonly ok: true;
- readonly pullRequestUrl: string;
- readonly summary: string;
-}
-interface ToolAccepted {
- readonly [key: string]: JsonValue;
- readonly accepted: true;
- readonly backgroundId: string;
- readonly issue: string;
- readonly issueNumber: number;
- readonly message: string;
- readonly ok: true;
-}
-
-const cap = (value: string, limit = MAX_OUTPUT): string =>
- value.length <= limit ? value : `${value.slice(0, limit)}…[truncated]`;
-
-// Stable prefix for every background pipeline log line so operators can grep
-// for issue→PR outcomes independently of chat traffic.
-const LOG_PREFIX = "[create_pr_for_issue]";
-
-/** Writes one stable, prefixed server log line for a background outcome. */
-const logBackground = (level: "info" | "error", message: string): void => {
- const line = `${LOG_PREFIX} ${message}`;
- if (level === "error") {
- console.error(line);
- } else {
- console.log(line);
- }
-};
-
-/** Logs a structured terminal pipeline result under the stable prefix. */
-const logPipelineResult = (
- backgroundId: string,
- result: ToolFailure | ToolSuccess
-): void => {
- if ("ok" in result && result.ok === true) {
- logBackground(
- "info",
- `background=${backgroundId} completed issue=#${result.issueNumber} stage=pr url=${result.pullRequestUrl} changedFiles=${result.changedFileCount}`
- );
- return;
- }
- const detail =
- "reason" in result && typeof result.reason === "string"
- ? ` reason=${result.reason}`
- : "";
- logBackground(
- "error",
- `background=${backgroundId} failed issue=${result.issue} issueNumber=${result.issueNumber} stage=${result.stage}: ${result.error}${detail}`
- );
-};
-
-/** Last-resort logger for an unexpected throw outside the pipeline's catch. */
-const logPipelineError = (backgroundId: string, error: unknown): void => {
- const message = error instanceof Error ? error.message : String(error);
- logBackground("error", `background=${backgroundId} unexpected: ${message}`);
-};
-
-/**
- * Runs one command as a direct child process. Arguments are passed to the
- * child verbatim — never through a shell — so issue titles, bodies, and
- * revisions cannot inject shell metacharacters.
- */
-const run = (
- command: string,
- args: readonly string[],
- options: {
- cwd?: string;
- env?: Record;
- timeoutMs?: number;
- } = {}
-): CommandResult => {
- const spawnOptions: SpawnSyncOptions = {
- cwd: options.cwd ?? TRUSTED_REPO_PATH,
- env: { ...process.env, ...options.env },
- maxBuffer: 1024 * 1024,
- timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
- };
- const result = spawnSync(command, args, spawnOptions);
- return {
- status: result.status,
- stderr: cap((result.stderr ?? "").toString()),
- stdout: cap((result.stdout ?? "").toString()),
- };
-};
-
-/** Extracts the issue number from an issue number, "#23", or a full issue URL. */
-const parseIssueNumber = (input: string): number | null => {
- // Prefer a /issues/ URL path segment so anchors like #comment-45 do not
- // hijack the number.
- const pathMatch = input.match(/\/issues\/(?\d+)(?:[^/\d]|$)/iu);
- if (pathMatch?.groups?.issueNumber) {
- return Math.trunc(Number(pathMatch.groups.issueNumber));
- }
- const hashMatch = input.match(/#(?\d+)\b/u);
- if (hashMatch?.groups?.issueNumber) {
- return Math.trunc(Number(hashMatch.groups.issueNumber));
- }
- const standalone = input.trim().match(/^\d+$/u);
- if (standalone) {
- return Math.trunc(Number(standalone[0]));
- }
- // Last resort: the trailing integer anywhere in the string.
- const value = Math.trunc(Number(input.match(/\d+/gu)?.at(-1)));
- if (Number.isFinite(value) && value > 0) {
- return value;
- }
- return null;
-};
-
-const readIssue = (issueNumber: number): IssueDetails | ToolFailure => {
- const result = run("tea", [
- "issues",
- String(issueNumber),
- "--fields",
- "index,title,body,url,state",
- "--output",
- "json",
- ]);
- if (result.status !== 0) {
- return {
- error: `tea could not read issue #${issueNumber}.`,
- issue: String(issueNumber),
- issueNumber,
- stage: "read-issue",
- stderr: result.stderr,
- };
- }
- let data: {
- body?: string;
- state?: string;
- title?: string;
- url?: string;
- };
- try {
- data = JSON.parse(result.stdout) as typeof data;
- } catch (error) {
- return {
- error: `Could not parse the issue payload from tea: ${
- error instanceof Error ? error.message : String(error)
- }`,
- issue: String(issueNumber),
- issueNumber,
- stage: "read-issue",
- stderr: result.stdout,
- };
- }
- const title = (data.title ?? "").trim();
- if (title.length === 0) {
- return {
- error: `Issue #${issueNumber} has no title.`,
- issue: String(issueNumber),
- issueNumber,
- stage: "read-issue",
- };
- }
- return {
- body: (data.body ?? "").trim(),
- title,
- url: (data.url ?? "").trim(),
- };
-};
-
-const buildPrompt = (issueNumber: number, issue: IssueDetails): string => {
- const body =
- issue.body.length > MAX_ISSUE_BODY
- ? `${issue.body.slice(0, MAX_ISSUE_BODY)}…[issue body truncated]`
- : issue.body;
- return [
- `Resolve Gitea issue #${issueNumber} in this repository and ship a complete implementation.`,
- "",
- `## Issue #${issueNumber}: ${issue.title}`,
- "",
- body || "_(The issue provided no additional description.)_",
- "",
- "## Instructions",
- "- Read AGENTS.md and the relevant product/architecture specifications before changing code.",
- "- Implement exactly the change described by the issue with focused, correct edits.",
- "- Reuse existing conventions; do not introduce unrelated scope.",
- "- Run focused verification for your change before finishing.",
- "- Do NOT push, open a pull request, rewrite history, or modify the read-only base checkout.",
- " The orchestrator collects your working-tree changes, pushes a candidate branch, and opens the pull request after you finish.",
- ].join("\n");
-};
-
-/** Mirrors HostRepositoryWorkspace's worktree layout to locate the harness checkout. */
-const harnessCheckoutPath = (attemptId: string): string => {
- const root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces";
- const identity = createHash("sha256")
- .update(attemptId)
- .digest("hex")
- .slice(0, 24);
- return path.join(root, identity, "repository");
-};
-
-const detectBaseBranch = (): string => {
- const result = run("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], {
- cwd: TRUSTED_REPO_PATH,
- });
- if (result.status === 0) {
- const ref = result.stdout.trim();
- const branch = ref.startsWith("origin/")
- ? ref.slice("origin/".length)
- : ref;
- if (branch.length > 0) {
- return branch;
- }
- }
- return "master";
-};
-
-const pushCandidate = (
- checkoutPath: string,
- candidateRevision: string,
- pushBranch: string
-): true | ToolFailure => {
- const result = run(
- "git",
- [
- "-C",
- checkoutPath,
- "push",
- "origin",
- `${candidateRevision}:refs/heads/${pushBranch}`,
- ],
- { cwd: checkoutPath, timeoutMs: PUSH_TIMEOUT_MS }
- );
- if (result.status !== 0) {
- return {
- error: `Could not push candidate branch "${pushBranch}" from the harness worktree.`,
- issue: "",
- stage: "push",
- stderr: result.stderr,
- };
- }
- return true;
-};
-
-const findPullUrl = (head: string): string => {
- const result = run("tea", [
- "pulls",
- "list",
- "--state",
- "open",
- "--fields",
- "index,title,head,url",
- "--output",
- "json",
- ]);
- if (result.status !== 0) {
- return "";
- }
- try {
- const pulls = JSON.parse(result.stdout) as {
- head?: string;
- url?: string;
- }[];
- const match = pulls.find(
- (pull) => pull.head === head || (pull.head ?? "").endsWith(`:${head}`)
- );
- return (match?.url ?? "").trim();
- } catch {
- return "";
- }
-};
-
-const createPullRequest = (
- issueNumber: number,
- title: string,
- head: string,
- base: string
-): ToolFailure | { url: string } => {
- const prTitle = `Fix #${issueNumber}: ${title}`;
- const result = run("tea", [
- "pulls",
- "create",
- "--head",
- head,
- "--base",
- base,
- "--title",
- prTitle,
- "--description",
- `Fixes #${issueNumber}`,
- ]);
- if (result.status !== 0) {
- return {
- error: `tea could not create the pull request for branch "${head}".`,
- issue: "",
- stage: "create-pr",
- stderr: result.stderr,
- stdout: result.stdout,
- };
- }
- const combined = `${result.stdout}\n${result.stderr}`;
- const urlMatch = combined.match(/https?:\/\/[^\s)]+\/pulls\/\d+/u);
- if (urlMatch) {
- return { url: urlMatch[0] };
- }
- // Fall back to listing open pulls and matching the head branch.
- const fallback = findPullUrl(head);
- if (fallback.length > 0) {
- return { url: fallback };
- }
- return {
- error:
- "The pull request was created, but its URL could not be captured from tea. Inspect open pulls in Gitea.",
- issue: "",
- stage: "create-pr",
- stdout: result.stdout,
- };
-};
-
-/**
- * Runs the full issue → AgentOS → push → PR pipeline and returns a structured
- * terminal result. Used both by the background scheduler (chat path) and any
- * direct/internal caller that wants the structured outcome. Never rejects: every
- * failure is caught and returned as a {@link ToolFailure}.
- */
-const runIssuePrPipeline = async (params: {
- readonly giteaUrl: string;
- readonly giteaToken: string | undefined;
- readonly issue: string;
- readonly issueNumber: number;
- readonly identifiers: {
- readonly attemptId: string;
- readonly headBranch: string;
- readonly runId: string;
- readonly workspaceKey: string;
- };
-}): Promise => {
- const { giteaUrl, giteaToken, issue, issueNumber, identifiers } = params;
- try {
- const issueOrFailure = readIssue(issueNumber);
- if ("error" in issueOrFailure) {
- return issueOrFailure;
- }
- const issueDetails = issueOrFailure;
-
- const baseBranch = detectBaseBranch();
- const prompt = buildPrompt(issueNumber, issueDetails);
-
- // The fixed-checkout harness path ignores `auth`/`repositoryUrl` for
- // cloning (it always uses the mounted Zopu source worktree); they are
- // still supplied as valid schema values. GITEA_TOKEN is optional.
- const result = await executeAgentOsAttempt({
- attemptId: identifiers.attemptId,
- auth: {
- credential: giteaToken ?? "unused-by-fixed-checkout",
- provider: "gitea",
- serverUrl: giteaUrl,
- },
- baseBranch,
- prompt,
- repositoryUrl: `${giteaUrl}/${REPO_SLUG}`,
- runId: identifiers.runId,
- workId: `issue-${issueNumber}`,
- workspaceKey: identifiers.workspaceKey,
- });
-
- const changedFiles = result.changedFiles.slice(0, MAX_CHANGED_FILES);
- if (
- result.changedFiles.length === 0 ||
- result.candidateRevision === result.baseRevision
- ) {
- return {
- error:
- "The AgentOS session completed without producing any repository changes, so there is nothing to turn into a pull request.",
- issue,
- issueNumber,
- stage: "harness",
- summary: result.summary,
- };
- }
-
- const checkoutPath = harnessCheckoutPath(identifiers.attemptId);
- const pushed = pushCandidate(
- checkoutPath,
- result.candidateRevision,
- identifiers.headBranch
- );
- if (pushed !== true) {
- return {
- ...pushed,
- attemptId: identifiers.attemptId,
- baseRevision: result.baseRevision,
- candidateRevision: result.candidateRevision,
- headBranch: identifiers.headBranch,
- issue,
- issueNumber,
- workspaceKey: identifiers.workspaceKey,
- };
- }
-
- const pr = createPullRequest(
- issueNumber,
- issueDetails.title,
- identifiers.headBranch,
- baseBranch
- );
- if ("error" in pr) {
- return {
- ...pr,
- attemptId: identifiers.attemptId,
- baseRevision: result.baseRevision,
- candidateRevision: result.candidateRevision,
- headBranch: identifiers.headBranch,
- issue,
- issueNumber,
- workspaceKey: identifiers.workspaceKey,
- };
- }
-
- return {
- baseBranch,
- changedFileCount: result.changedFiles.length,
- changedFiles,
- headBranch: identifiers.headBranch,
- issue,
- issueNumber,
- ok: true,
- pullRequestUrl: pr.url,
- summary: result.summary,
- };
- } catch (error) {
- const message =
- error instanceof Error ? error.message : "Unexpected failure";
- const reason =
- error !== null &&
- typeof error === "object" &&
- "reason" in error &&
- typeof (error as { reason: unknown }).reason === "string"
- ? (error as { reason: string }).reason
- : undefined;
- return {
- error: message,
- issue,
- issueNumber,
- ...(reason ? { reason } : {}),
- stage: "harness",
- };
- }
-};
-
-export const createCreatePrForIssueTool = (
- runtimeEnv: Record
-) => {
- const agentEnv = parseAgentEnv(runtimeEnv);
- const giteaUrl = agentEnv.GITEA_URL;
-
- return defineTool({
- description:
- 'Validate a Gitea issue number or URL immediately, then return an accepted acknowledgement right away while an AgentOS coding session implements the issue, pushes a candidate branch, and opens a PR titled "Fix #N: " with body "Fixes #N" in the background. Pass a Gitea issue number or full issue URL. The created PR URL and any failure are logged to the server log with the "[create_pr_for_issue]" prefix rather than returned to chat; malformed input returns a parse failure immediately.',
- input: v.object({
- issue: v.pipe(
- v.string(),
- v.description(
- "A Gitea issue number like 23, or a full issue URL such as https://git.openputer.com/puter/zopu-code/issues/23."
- )
- ),
- }),
- name: "create_pr_for_issue",
- run({ input }): ToolFailure | ToolAccepted {
- const issueRef = input.issue.trim();
- const issueNumber = parseIssueNumber(issueRef);
- if (issueNumber === null) {
- return {
- error: `Could not parse an issue number from "${input.issue}". Provide a Gitea issue number or URL.`,
- issue: input.issue,
- stage: "parse",
- };
- }
-
- // Background identifiers are generated up front so the accepted
- // response can reference them and the pipeline can reuse them.
- const backgroundId = randomUUID();
- const attemptId = `create-pr-${issueNumber}-${randomUUID()}`;
- const workspaceKey = `issue-${issueNumber}-pr-${randomUUID()}`;
- const runId = `issue-pr-${randomUUID()}`;
- const shortId = randomUUID().slice(0, 8);
- const headBranch = `zopu/issue-${issueNumber}-${shortId}`;
-
- // Schedule the full issue → AgentOS → push → PR pipeline on the next
- // microtask boundary so this turn returns immediately. The pipeline
- // catches its own failures and logs a stable prefixed line; the IIFE
- // never rejects, so the originating chat turn cannot fail.
- queueMicrotask(() => {
- void (async () => {
- try {
- const result = await runIssuePrPipeline({
- giteaToken: runtimeEnv.GITEA_TOKEN,
- giteaUrl,
- identifiers: { attemptId, headBranch, runId, workspaceKey },
- issue: input.issue,
- issueNumber,
- });
- logPipelineResult(backgroundId, result);
- } catch (error) {
- logPipelineError(backgroundId, error);
- }
- })();
- });
-
- return {
- accepted: true,
- backgroundId,
- issue: input.issue,
- issueNumber,
- message: `Issue #${issueNumber} accepted. An AgentOS session will implement it, push branch "${headBranch}", and open a pull request in the background. The created PR URL and any failure are logged with the "${LOG_PREFIX}" prefix.`,
- ok: true,
- };
- },
- });
-};
diff --git a/packages/agents/src/tools/slice-one.ts b/packages/agents/src/tools/signal-routing.ts
similarity index 94%
rename from packages/agents/src/tools/slice-one.ts
rename to packages/agents/src/tools/signal-routing.ts
index 7d8e8b1..d10d4aa 100644
--- a/packages/agents/src/tools/slice-one.ts
+++ b/packages/agents/src/tools/signal-routing.ts
@@ -1,9 +1,9 @@
-import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
-import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
+import { createConvexServiceClient } from "../adapters/convex-client";
+
const listProjectsRef = makeFunctionReference<
"query",
{ organizationId: string; token: string },
@@ -52,13 +52,11 @@ const attachSignalRef = makeFunctionReference<
{ attached: boolean; workId: string }
>("works:attachSignalToWork");
-export const createSliceOneTools = (
+export const createSignalRoutingTools = (
organizationId: string,
runtimeEnv: Record
) => {
- const env = parseAgentEnv(runtimeEnv);
- const client = new ConvexHttpClient(env.CONVEX_URL);
- const token = env.FLUE_DB_TOKEN;
+ const { client, token } = createConvexServiceClient(runtimeEnv);
return [
defineTool({
diff --git a/packages/agents/src/tools/work-commands.ts b/packages/agents/src/tools/work-commands.ts
new file mode 100644
index 0000000..8d5dc0e
--- /dev/null
+++ b/packages/agents/src/tools/work-commands.ts
@@ -0,0 +1,79 @@
+import { defineTool } from "@flue/runtime";
+import { makeFunctionReference } from "convex/server";
+import * as v from "valibot";
+
+import { createConvexServiceClient } from "../adapters/convex-client";
+
+const startWorkRef = makeFunctionReference<
+ "mutation",
+ {
+ organizationId: string;
+ sliceId?: string;
+ token: string;
+ workId: string;
+ },
+ { runId: string }
+>("workExecution:start");
+
+const cancelWorkRef = makeFunctionReference<
+ "mutation",
+ { organizationId: string; runId: string; token: string },
+ { cancelled: boolean }
+>("workExecution:cancel");
+
+const retryWorkRef = makeFunctionReference<
+ "mutation",
+ { organizationId: string; runId: string; token: string },
+ { runId: string }
+>("workExecution:retry");
+
+export const createWorkCommandTools = (
+ organizationId: string,
+ runtimeEnv: Record
+) => {
+ const { client, token } = createConvexServiceClient(runtimeEnv);
+
+ return [
+ defineTool({
+ description:
+ "Start execution of a ready Work unit. Returns the runId. Use only when the user explicitly requests implementation.",
+ input: v.object({
+ sliceId: v.optional(v.string()),
+ workId: v.string(),
+ }),
+ name: "start_work",
+ async run({ input }) {
+ return await client.mutation(startWorkRef, {
+ organizationId,
+ sliceId: input.sliceId,
+ token,
+ workId: input.workId,
+ });
+ },
+ }),
+ defineTool({
+ description: "Cancel a running Work execution by runId.",
+ input: v.object({ runId: v.string() }),
+ name: "cancel_work",
+ async run({ input }) {
+ return await client.mutation(cancelWorkRef, {
+ organizationId,
+ runId: input.runId,
+ token,
+ });
+ },
+ }),
+ defineTool({
+ description: "Retry a failed or cancelled Work execution by runId.",
+ input: v.object({ runId: v.string() }),
+ name: "retry_work",
+ async run({ input }) {
+ return await client.mutation(retryWorkRef, {
+ organizationId,
+ runId: input.runId,
+ token,
+ });
+ },
+ }),
+ ];
+};
diff --git a/packages/agents/src/tools/work-planner.ts b/packages/agents/src/tools/work-planner.ts
deleted file mode 100644
index 8c12a68..0000000
--- a/packages/agents/src/tools/work-planner.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { parseAgentEnv } from "@code/env/agent";
-import { defineTool } from "@flue/runtime";
-import { ConvexHttpClient } from "convex/browser";
-import { makeFunctionReference } from "convex/server";
-import * as v from "valibot";
-
-const submitDefinitionRef = makeFunctionReference<
- "mutation",
- { workId: string; payloadJson: string; token: string },
- { version: number }
->("workPlanning:submitDefinitionProposal");
-const submitDesignRef = makeFunctionReference<
- "mutation",
- { workId: string; payloadJson: string; token: string },
- { version: number }
->("workPlanning:submitDesignProposal");
-const submitQuestionRef = makeFunctionReference<
- "mutation",
- { workId: string; questionJson: string; token: string },
- { accepted: boolean }
->("workPlanning:submitQuestion");
-
-export const createWorkPlannerTools = (
- runtimeEnv: Record
-) => {
- const env = parseAgentEnv(runtimeEnv);
- const client = new ConvexHttpClient(env.CONVEX_URL);
- const token = env.FLUE_DB_TOKEN;
- return [
- defineTool({
- description:
- "Submit a versioned Work Definition proposal. This never approves the Definition.",
- input: v.object({ definitionJson: v.string(), workId: v.string() }),
- name: "submit_definition_proposal",
- async run({ input }) {
- return await client.mutation(submitDefinitionRef, {
- payloadJson: input.definitionJson,
- token,
- workId: input.workId,
- });
- },
- }),
- defineTool({
- description:
- "Submit a Design Packet with observable independently verifiable slices. This never approves the Design.",
- input: v.object({ designJson: v.string(), workId: v.string() }),
- name: "submit_design_proposal",
- async run({ input }) {
- return await client.mutation(submitDesignRef, {
- payloadJson: input.designJson,
- token,
- workId: input.workId,
- });
- },
- }),
- defineTool({
- description: "Submit one structured question for human attention.",
- input: v.object({ questionJson: v.string(), workId: v.string() }),
- name: "submit_question",
- async run({ input }) {
- return await client.mutation(submitQuestionRef, {
- questionJson: input.questionJson,
- token,
- workId: input.workId,
- });
- },
- }),
- ];
-};
diff --git a/packages/agents/src/workflows/plan-work.ts b/packages/agents/src/workflows/plan-work.ts
new file mode 100644
index 0000000..8bfb7d0
--- /dev/null
+++ b/packages/agents/src/workflows/plan-work.ts
@@ -0,0 +1,121 @@
+import { parseAgentEnv } from "@code/env/agent";
+import { defineAgent, defineWorkflow } from "@flue/runtime";
+import * as v from "valibot";
+
+export { workflowRoute as route } from "../auth/workflow-route";
+
+// --- Input ---
+
+const questionSchema = v.object({
+ alternatives: v.array(v.string()),
+ id: v.string(),
+ impact: v.picklist(["low", "medium", "high"]),
+ prompt: v.string(),
+ recommendation: v.optional(v.string()),
+});
+
+const planWorkInput = v.object({
+ context: v.object({
+ existingDefinition: v.optional(v.string()),
+ problemStatement: v.string(),
+ }),
+ organizationId: v.string(),
+ phase: v.picklist(["definition", "design"]),
+ requestId: v.string(),
+ workId: v.string(),
+});
+
+// --- Output ---
+
+const definitionSchema = v.object({
+ acceptanceCriteria: v.array(v.string()),
+ affectedUsers: v.array(v.string()),
+ assumptions: v.array(v.string()),
+ constraints: v.array(v.string()),
+ desiredOutcome: v.string(),
+ inScope: v.array(v.string()),
+ outOfScope: v.array(v.string()),
+ problem: v.string(),
+ requiredArtifacts: v.array(v.string()),
+ risk: v.picklist(["low", "medium", "high"]),
+});
+
+const sliceSchema = v.object({
+ codeBoundaries: v.array(v.string()),
+ dependsOn: v.array(v.string()),
+ evidenceRequirements: v.array(v.string()),
+ id: v.string(),
+ objective: v.string(),
+ observableBehavior: v.string(),
+ reviewRequired: v.boolean(),
+ title: v.string(),
+ verification: v.array(v.string()),
+});
+
+const designSchema = v.object({
+ architectureSummary: v.string(),
+ callFlowDelta: v.array(v.string()),
+ concerns: v.array(v.string()),
+ evidenceRequirements: v.array(v.string()),
+ fileTreeDelta: v.array(v.string()),
+ invariants: v.array(v.string()),
+ keyInterfaces: v.array(v.string()),
+ slices: v.array(sliceSchema),
+ tradeoffs: v.array(v.string()),
+});
+
+const planWorkOutput = v.union([
+ v.object({
+ definition: definitionSchema,
+ phase: v.literal("definition"),
+ questions: v.array(questionSchema),
+ }),
+ v.object({
+ design: designSchema,
+ phase: v.literal("design"),
+ questions: v.array(questionSchema),
+ }),
+]);
+
+// --- Agent ---
+
+const planWorkAgent = defineAgent(({ env }) => {
+ const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
+ return {
+ description:
+ "Produces a Work Definition or Design Packet with structured questions.",
+ instructions: `You are Zopu's Work Planner. You receive a Work problem statement and produce either a Definition or a Design Packet depending on the requested phase.
+
+For the definition phase, produce a precise problem statement, desired outcome, acceptance criteria, scope boundaries, assumptions, constraints, and risk level. Ask focused high-impact questions only when a decision genuinely blocks the work.
+
+For the design phase, produce an architecture summary, file tree delta, call flow delta, key interfaces, invariants, evidence requirements, 1-4 observable and independently verifiable slices, tradeoffs, and concerns. Each slice must have a clear objective, observable behavior, verification steps, and code boundaries. Dependencies must reference earlier slices.
+
+Never approve, execute, or claim implementation. Your output is a proposal that Convex validates and persists.`,
+ model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
+ thinkingLevel: "high",
+ };
+});
+
+// --- Workflow ---
+
+export default defineWorkflow({
+ agent: planWorkAgent,
+ input: planWorkInput,
+ output: planWorkOutput,
+ async run({ input, harness }) {
+ const session = await harness.session();
+ const instruction =
+ input.phase === "definition"
+ ? `Produce a Work Definition for the following problem:\n\n${input.context.problemStatement}`
+ : `Produce a Design Packet based on this approved Definition:\n\n${input.context.existingDefinition ?? input.context.problemStatement}`;
+ const response = await session.prompt(instruction, {
+ result: planWorkOutput,
+ });
+ if (response.data.phase !== input.phase) {
+ throw new Error(
+ `Planner returned phase "${response.data.phase}" but "${input.phase}" was requested`
+ );
+ }
+ return response.data;
+ },
+});
diff --git a/packages/backend/convex/README.md b/packages/backend/convex/README.md
index 5dd1ae8..d16fc64 100644
--- a/packages/backend/convex/README.md
+++ b/packages/backend/convex/README.md
@@ -1,6 +1,6 @@
# Convex Backend
-Convex is the only application API used by web, desktop, and mobile clients. The active backend covers the durable control plane through Slice 4: tenancy, projects, conversation turns, Signals, Work planning, approved Design slices, simulated Runs and Attempts, artifacts/delivery metadata, and Flue persistence.
+Convex is the only application API used by web, desktop, and mobile clients. The active backend covers the durable control plane: tenancy, projects, conversation turns, Signals, Work planning (Definition/Design without approval gates), real Runs and Attempts through AgentOS, artifacts/delivery metadata, and Flue persistence.
## Domain relations
@@ -9,9 +9,9 @@ Convex is the only application API used by web, desktop, and mobile clients. The
- `conversations`, `conversationTurns`, `conversationMessages`, and `conversationAttachments` own chat admission and reactive history.
- `signals`, `signalConstraints`, and `signalSources` preserve structured intent and exact evidence.
- `works`, `signalWorkAttachments`, and `workEvents` own proposed Work and provenance.
-- `workDefinitions`, `workQuestions`, and `workApprovals` own versioned outcome contracts and approvals.
+- `workDefinitions` and `workQuestions` own versioned outcome contracts and structured questions.
- `designPackets` and `workSlices` preserve versioned implementation intent and executable slice order.
-- `workRuns`, `workAttempts`, `workAttemptEvents`, and `resolverDecisions` own bounded simulated execution and recovery.
+- `workRuns`, `workAttempts`, `workAttemptEvents`, and `resolverDecisions` own bounded real execution and recovery.
- `workArtifacts` and `workDeliveries` preserve evidence and delivery metadata without performing Git or sandbox work.
The `flue*` tables are infrastructure tables required by Flue's persistence contract. They are deliberately separate from the product relations.
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index cc8f90c..1d3d1b9 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -8,6 +8,7 @@
* @module
*/
+import type * as agentBackend from "../agentBackend.js";
import type * as auth from "../auth.js";
import type * as authz from "../authz.js";
import type * as conversationMessages from "../conversationMessages.js";
@@ -15,7 +16,10 @@ import type * as conversationProjections from "../conversationProjections.js";
import type * as crons from "../crons.js";
import type * as fluePersistence from "../fluePersistence.js";
import type * as gitConnectionData from "../gitConnectionData.js";
+import type * as gitConnectionHealth from "../gitConnectionHealth.js";
import type * as gitConnections from "../gitConnections.js";
+import type * as gitProvisioning from "../gitProvisioning.js";
+import type * as gitWebhooks from "../gitWebhooks.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
import type * as organizations from "../organizations.js";
@@ -25,8 +29,6 @@ import type * as publicGit from "../publicGit.js";
import type * as signalRouting from "../signalRouting.js";
import type * as workArtifacts from "../workArtifacts.js";
import type * as workExecution from "../workExecution.js";
-import type * as workExecutionAgent from "../workExecutionAgent.js";
-import type * as workExecutionWorkflow from "../workExecutionWorkflow.js";
import type * as workPlanning from "../workPlanning.js";
import type * as works from "../works.js";
@@ -37,6 +39,7 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
+ agentBackend: typeof agentBackend;
auth: typeof auth;
authz: typeof authz;
conversationMessages: typeof conversationMessages;
@@ -44,7 +47,10 @@ declare const fullApi: ApiFromModules<{
crons: typeof crons;
fluePersistence: typeof fluePersistence;
gitConnectionData: typeof gitConnectionData;
+ gitConnectionHealth: typeof gitConnectionHealth;
gitConnections: typeof gitConnections;
+ gitProvisioning: typeof gitProvisioning;
+ gitWebhooks: typeof gitWebhooks;
healthCheck: typeof healthCheck;
http: typeof http;
organizations: typeof organizations;
@@ -54,8 +60,6 @@ declare const fullApi: ApiFromModules<{
signalRouting: typeof signalRouting;
workArtifacts: typeof workArtifacts;
workExecution: typeof workExecution;
- workExecutionAgent: typeof workExecutionAgent;
- workExecutionWorkflow: typeof workExecutionWorkflow;
workPlanning: typeof workPlanning;
works: typeof works;
}>;
diff --git a/packages/backend/convex/_generated/server.d.ts b/packages/backend/convex/_generated/server.d.ts
index f072e0e..1ae0398 100644
--- a/packages/backend/convex/_generated/server.d.ts
+++ b/packages/backend/convex/_generated/server.d.ts
@@ -30,10 +30,13 @@ type Env = {
readonly FLUE_URL: string | undefined;
readonly GITEA_TOKEN: string | undefined;
readonly GITEA_URL: string | undefined;
+ readonly GITEA_WEBHOOK_SECRET: string | undefined;
readonly GITHUB_CLIENT_ID: string | undefined;
readonly GITHUB_CLIENT_SECRET: string | undefined;
+ readonly GITHUB_WEBHOOK_SECRET: string | undefined;
readonly GIT_CREDENTIAL_ENCRYPTION_KEY: string | undefined;
readonly NATIVE_APP_URL: string | undefined;
+ readonly PUTER_GIT_ADMIN_TOKEN: string | undefined;
readonly SITE_URL: string;
};
diff --git a/packages/backend/convex/workExecutionAgent.ts b/packages/backend/convex/agentBackend.ts
similarity index 97%
rename from packages/backend/convex/workExecutionAgent.ts
rename to packages/backend/convex/agentBackend.ts
index edcdc17..21c51a7 100644
--- a/packages/backend/convex/workExecutionAgent.ts
+++ b/packages/backend/convex/agentBackend.ts
@@ -30,14 +30,14 @@ export const executeAttempt = internalAction({
args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => {
const running = await ctx.runMutation(
- internal.workExecutionWorkflow.markAttemptRunning,
+ internal.workExecution.markAttemptRunning,
args
);
if (!running) {
throw new ConvexError("Attempt is no longer runnable");
}
const context = await ctx.runQuery(
- internal.workExecutionWorkflow.executionContext,
+ internal.workExecution.executionContext,
args
);
const credential = await decryptCredential(
diff --git a/packages/backend/convex/gitConnectionHealth.ts b/packages/backend/convex/gitConnectionHealth.ts
index 234375e..bf8332f 100644
--- a/packages/backend/convex/gitConnectionHealth.ts
+++ b/packages/backend/convex/gitConnectionHealth.ts
@@ -1,5 +1,4 @@
-"use node";
-
+import { env } from "@code/env/convex";
import {
CREDENTIAL_FRESHNESS_MS,
isCredentialFresh,
@@ -15,13 +14,57 @@ import {
query,
} from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
-import { decryptCredential } from "./gitConnections";
interface VerifyResult {
readonly lastError?: string;
readonly state: "active" | "reauth-required" | "unavailable";
}
+const decodeBase64url = (value: string): Uint8Array => {
+ const padded = value
+ .replaceAll("-", "+")
+ .replaceAll("_", "/")
+ .padEnd(Math.ceil(value.length / 4) * 4, "=");
+ const decoded = atob(padded);
+ const bytes = new Uint8Array(decoded.length);
+ for (let index = 0; index < decoded.length; index += 1) {
+ const codePoint = decoded.codePointAt(index);
+ if (codePoint === undefined) {
+ throw new ConvexError("Invalid credential encoding");
+ }
+ bytes[index] = codePoint;
+ }
+ return bytes;
+};
+
+const credentialEncryptionKey = async (): Promise => {
+ if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) {
+ throw new ConvexError("Git credential encryption is not configured");
+ }
+ const bytes = decodeBase64url(env.GIT_CREDENTIAL_ENCRYPTION_KEY);
+ if (bytes.byteLength !== 32) {
+ throw new ConvexError("Git credential encryption key must be 32 bytes");
+ }
+ return await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [
+ "decrypt",
+ ]);
+};
+
+const decryptCredential = async (
+ credentialCiphertext: string,
+ credentialIv: string
+): Promise => {
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ iv: decodeBase64url(credentialIv),
+ name: "AES-GCM",
+ },
+ await credentialEncryptionKey(),
+ decodeBase64url(credentialCiphertext)
+ );
+ return new TextDecoder().decode(decrypted);
+};
+
const verifyGiteaCredential = async (
serverUrl: string,
token: string
diff --git a/packages/backend/convex/gitProvisioning.ts b/packages/backend/convex/gitProvisioning.ts
index f23eac9..082ed31 100644
--- a/packages/backend/convex/gitProvisioning.ts
+++ b/packages/backend/convex/gitProvisioning.ts
@@ -1,5 +1,3 @@
-"use node";
-
import { env } from "@code/env/convex";
import {
CREDENTIAL_FRESHNESS_MS,
diff --git a/packages/backend/convex/gitWebhooks.ts b/packages/backend/convex/gitWebhooks.ts
index 253dbc0..d9edd90 100644
--- a/packages/backend/convex/gitWebhooks.ts
+++ b/packages/backend/convex/gitWebhooks.ts
@@ -1,5 +1,3 @@
-"use node";
-
import { env } from "@code/env/convex";
import {
isProcessedEvent,
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index a391e65..366ac95 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -21,6 +21,7 @@ const workStatus = v.union(
v.literal("awaiting-design-approval"),
v.literal("ready"),
v.literal("executing"),
+ v.literal("verifying"),
v.literal("needs-input"),
v.literal("blocked"),
v.literal("completed"),
@@ -315,7 +316,6 @@ export default defineSchema({
createdAt: v.number(),
definitionApprovalVersion: v.optional(v.number()),
definitionVersion: v.optional(v.number()),
- designApprovalVersion: v.optional(v.number()),
designVersion: v.optional(v.number()),
objective: v.string(),
organizationId: v.id("organizations"),
@@ -343,18 +343,15 @@ export default defineSchema({
v.literal("work.proposed"),
v.literal("signal.attached"),
v.literal("definition.requested"),
+ v.literal("definition.approved"),
v.literal("definition.saved"),
v.literal("definition.revised"),
- v.literal("definition.approved"),
- v.literal("definition.invalidated"),
v.literal("question.created"),
v.literal("question.answered"),
v.literal("question.withdrawn"),
- v.literal("design.requested"),
v.literal("design.saved"),
- v.literal("design.revised"),
v.literal("design.approved"),
- v.literal("design.invalidated"),
+ v.literal("design.revised"),
v.literal("planner.failed"),
v.literal("slice.started"),
v.literal("slice.completed"),
@@ -367,6 +364,9 @@ export default defineSchema({
v.literal("attempt.completed"),
v.literal("attempt.reconciled"),
v.literal("resolver.decided"),
+ v.literal("verification.started"),
+ v.literal("verification.completed"),
+ v.literal("verification.failed"),
v.literal("artifact.recorded"),
v.literal("delivery.recorded"),
v.literal("delivery.updated")
@@ -418,23 +418,6 @@ export default defineSchema({
"questionId",
]),
- workApprovals: defineTable({
- approvedAt: v.number(),
- approvedBy: v.string(),
- definitionVersion: v.number(),
- designVersion: v.optional(v.number()),
- kind: v.union(v.literal("definition"), v.literal("design")),
- status: v.union(v.literal("active"), v.literal("invalidated")),
- workId: v.id("works"),
- })
- .index("by_workId_and_kind", ["workId", "kind"])
- .index("by_workId_and_kind_and_definitionVersion_and_designVersion", [
- "workId",
- "kind",
- "definitionVersion",
- "designVersion",
- ]),
-
designPackets: defineTable({
createdAt: v.number(),
createdBy: v.string(),
@@ -482,17 +465,8 @@ export default defineSchema({
endedAt: v.optional(v.number()),
kitId: v.string(),
kitVersion: v.string(),
+ scenario: v.optional(v.string()),
environmentId: v.optional(v.string()),
- executionKind: v.optional(
- v.union(v.literal("simulated"), v.literal("real"))
- ),
- scenario: v.union(
- v.literal("success"),
- v.literal("transient-failure-then-success"),
- v.literal("needs-input"),
- v.literal("permanent-failure"),
- v.literal("cancelled")
- ),
sliceId: v.optional(v.string()),
sliceRowId: v.optional(v.id("workSlices")),
startedAt: v.optional(v.number()),
diff --git a/packages/backend/convex/workExecution.test.ts b/packages/backend/convex/workExecution.test.ts
index dfd9fcf..09cd878 100644
--- a/packages/backend/convex/workExecution.test.ts
+++ b/packages/backend/convex/workExecution.test.ts
@@ -1,373 +1,143 @@
import { convexTest } from "convex-test";
-import { anyApi, makeFunctionReference } from "convex/server";
+import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
-import type { Id } from "./_generated/dataModel";
import schema from "./schema";
-// Execution-only module set: avoids loading workPlanning (and its env read) so
-// these tests run without Convex env vars.
-const modules = {
- "./_generated/api.ts": () => import("./_generated/api"),
- "./_generated/server.ts": () => import("./_generated/server"),
- "./authz.ts": () => import("./authz"),
- "./workExecution.ts": () => import("./workExecution"),
-};
-const identity = { tokenIdentifier: "https://convex.test|exec-user" };
-const api = anyApi;
-const claimAttemptRef = makeFunctionReference<
- "mutation",
- { attemptId: Id<"workAttempts">; leaseMs: number; owner: string },
- { number: number } | null
->("workExecution:claimAttempt");
-
-const makeTest = () => convexTest({ modules, schema });
-type TestT = ReturnType;
-
-type Scenario =
- | "success"
- | "transient-failure-then-success"
- | "needs-input"
- | "permanent-failure"
- | "cancelled";
-
-const seedReadyWork = async (options: { secondSlice?: boolean } = {}) => {
- const t = convexTest({ modules, schema });
- const { workId } = await t.withIdentity(identity).run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- await ctx.db.insert("organizationMembers", {
- createdAt: 1,
- organizationId,
- role: "owner",
- userId: identity.tokenIdentifier,
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://example.com/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "example.com",
- sourceUrl: "https://example.com/zopu",
- updatedAt: 1,
- });
- const id = await ctx.db.insert("works", {
- createdAt: 1,
- definitionApprovalVersion: 1,
- definitionVersion: 1,
- designApprovalVersion: 1,
- designVersion: 1,
- objective: "Prove execution",
- organizationId,
- projectId,
- status: "ready",
- title: "Executable work",
- updatedAt: 1,
- });
- await ctx.db.insert("designPackets", {
- createdAt: 1,
- createdBy: "test",
- definitionVersion: 1,
- payloadJson: "{}",
- status: "current",
- version: 1,
- workId: id,
- });
- await ctx.db.insert("workSlices", {
- createdAt: 1,
- designVersion: 1,
- objective: "execute the slice",
- observableBehavior: "terminal run",
- ordinal: 0,
- payloadJson: "{}",
- sliceId: "slice-1",
- status: "ready",
- title: "first",
- workId: id,
- });
- if (options.secondSlice) {
- await ctx.db.insert("workSlices", {
- createdAt: 1,
- designVersion: 1,
- objective: "execute the second slice",
- observableBehavior: "second terminal run",
- ordinal: 1,
- payloadJson: "{}",
- sliceId: "slice-2",
- status: "planned",
- title: "second",
- workId: id,
- });
- }
- return { workId: id };
- });
- return { t, workId };
-};
-
-const runAttempt = async (
- t: TestT,
- attemptId: Id<"workAttempts">,
- scenario: Scenario
-) => t.action(api.workExecution.executeFakeAttempt, { attemptId, scenario });
-
-const attemptNumber = async (
- t: TestT,
- runId: Id<"workRuns">,
- number: number
-): Promise> => {
- const attempt = await t.run(async (ctx) =>
- ctx.db
- .query("workAttempts")
- .withIndex("by_runId_and_number", (q) =>
- q.eq("runId", runId).eq("number", number)
- )
- .unique()
- );
- if (!attempt) {
- throw new Error(`attempt ${number} not found`);
+declare global {
+ interface ImportMeta {
+ readonly glob: (pattern: string) => Record Promise>;
}
- return attempt._id;
-};
+}
-const snapshot = async (t: TestT, workId: Id<"works">, runId: Id<"workRuns">) =>
- t.run(async (ctx) => ({
- attempts: await ctx.db
- .query("workAttempts")
- .withIndex("by_runId_and_number", (q) => q.eq("runId", runId))
- .collect(),
- run: await ctx.db.get(runId),
- slice: await ctx.db
- .query("workSlices")
- .withIndex("by_workId_and_designVersion", (q) =>
- q.eq("workId", workId).eq("designVersion", 1)
- )
- .first(),
- slices: await ctx.db
- .query("workSlices")
- .withIndex("by_workId_and_designVersion", (q) =>
- q.eq("workId", workId).eq("designVersion", 1)
- )
- .collect(),
- work: await ctx.db.get(workId),
- }));
+const modules = import.meta.glob("./**/*.ts");
+const api = anyApi;
+const identity = { tokenIdentifier: "https://convex.test|exec-user" };
-describe("simulated execution resolver", () => {
- test("success settles Work as completed and marks the slice completed", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- sliceId: "slice-1",
- workId,
+describe("Work execution without simulation", () => {
+ test("rejects execution when Work is not ready", async () => {
+ const t = convexTest({ modules, schema });
+ await t.withIdentity(identity).run(async (ctx) => {
+ const organizationId = await ctx.db.insert("organizations", {
+ createdAt: 1,
+ createdBy: identity.tokenIdentifier,
+ kind: "personal",
+ name: "Personal",
});
- await runAttempt(t, started.attemptId, "success");
- const state = await snapshot(t, workId, started.runId);
- expect(state.work?.status).toBe("completed");
- expect(state.run?.terminalClassification).toBe("Succeeded");
- expect(state.attempts).toHaveLength(1);
- expect(state.slice?.status).toBe("completed");
- const decisions = await t.run(async (ctx) =>
- ctx.db.query("resolverDecisions").collect()
- );
- expect(decisions).toHaveLength(1);
- expect(decisions[0]).toMatchObject({
- classification: "Succeeded",
- decision: "terminal",
- resultingWorkStatus: "completed",
+ await ctx.db.insert("organizationMembers", {
+ createdAt: 1,
+ organizationId,
+ role: "owner",
+ userId: identity.tokenIdentifier,
+ });
+ const projectId = await ctx.db.insert("projects", {
+ createdAt: 2,
+ name: "Project",
+ normalizedSourceUrl: "https://git.example.com/test/repo",
+ organizationId,
+ repositoryPath: "test/repo",
+ sourceHost: "gitea",
+ sourceUrl: "https://git.example.com/test/repo.git",
+ updatedAt: 2,
+ });
+ const workId = await ctx.db.insert("works", {
+ createdAt: 3,
+ objective: "Test",
+ organizationId,
+ projectId,
+ status: "proposed",
+ title: "Proposed Work",
+ updatedAt: 3,
+ });
+ await expect(
+ t.withIdentity(identity).mutation(api.workExecution.start, { workId })
+ ).rejects.toThrow(/Ready/u);
});
});
- test("completes Work only after every approved slice succeeds", async () => {
- const { t, workId } = await seedReadyWork({ secondSlice: true });
- const first = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- workId,
+ test("rejects execution without Definition or Design", async () => {
+ const t = convexTest({ modules, schema });
+ await t.withIdentity(identity).run(async (ctx) => {
+ const organizationId = await ctx.db.insert("organizations", {
+ createdAt: 1,
+ createdBy: identity.tokenIdentifier,
+ kind: "personal",
+ name: "Personal",
});
- await runAttempt(t, first.attemptId, "success");
- const afterFirst = await snapshot(t, workId, first.runId);
- expect(afterFirst.work?.status).toBe("ready");
- expect(
- afterFirst.slices.find((slice) => slice.sliceId === "slice-1")?.status
- ).toBe("completed");
- expect(
- afterFirst.slices.find((slice) => slice.sliceId === "slice-2")?.status
- ).toBe("ready");
- await expect(
- t
- .withIdentity(identity)
- .mutation(api.workExecution.retrySimulatedExecution, {
- runId: first.runId,
- })
- ).rejects.toThrow(/retryable state/u);
-
- const second = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- sliceId: "slice-2",
- workId,
+ await ctx.db.insert("organizationMembers", {
+ createdAt: 1,
+ organizationId,
+ role: "owner",
+ userId: identity.tokenIdentifier,
});
- await runAttempt(t, second.attemptId, "success");
- const completed = await snapshot(t, workId, second.runId);
- expect(completed.work?.status).toBe("completed");
- expect(
- completed.slices.every((slice) => slice.status === "completed")
- ).toBe(true);
- });
-
- test("allows only one owner to claim a queued attempt", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- workId,
+ const projectId = await ctx.db.insert("projects", {
+ createdAt: 2,
+ name: "Project",
+ normalizedSourceUrl: "https://git.example.com/test/repo",
+ organizationId,
+ repositoryPath: "test/repo",
+ sourceHost: "gitea",
+ sourceUrl: "https://git.example.com/test/repo.git",
+ updatedAt: 2,
});
- const first = await t.mutation(claimAttemptRef, {
- attemptId: started.attemptId,
- leaseMs: 60_000,
- owner: "worker-a",
+ const workId = await ctx.db.insert("works", {
+ createdAt: 3,
+ objective: "Test",
+ organizationId,
+ projectId,
+ status: "ready",
+ title: "Ready but unplanned",
+ updatedAt: 3,
+ });
+ await expect(
+ t.withIdentity(identity).mutation(api.workExecution.start, { workId })
+ ).rejects.toThrow(/Definition and Design/u);
});
- const second = await t.mutation(claimAttemptRef, {
- attemptId: started.attemptId,
- leaseMs: 60_000,
- owner: "worker-b",
- });
- expect(first?.number).toBe(1);
- expect(second).toBeNull();
});
- test("transient failure retries within the kit budget and then succeeds", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "transient-failure-then-success",
- workId,
+ test("rejects invalid service token", async () => {
+ const t = convexTest({ modules, schema });
+ await t.withIdentity(identity).run(async (ctx) => {
+ const organizationId = await ctx.db.insert("organizations", {
+ createdAt: 1,
+ createdBy: identity.tokenIdentifier,
+ kind: "personal",
+ name: "Personal",
});
- await runAttempt(t, started.attemptId, "transient-failure-then-success");
- // resolver auto-scheduled attempt 2; drive it manually in the test runtime
- const second = await attemptNumber(t, started.runId, 2);
- await runAttempt(t, second, "transient-failure-then-success");
- const state = await snapshot(t, workId, started.runId);
- expect(state.work?.status).toBe("completed");
- expect(state.run?.terminalClassification).toBe("Succeeded");
- expect(state.attempts).toHaveLength(2);
- expect(state.attempts[0]?.classification).toBe("RetryableFailure");
- });
-
- test("permanent failure settles Work as failed, not silently runnable", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "permanent-failure",
- workId,
+ await ctx.db.insert("organizationMembers", {
+ createdAt: 1,
+ organizationId,
+ role: "owner",
+ userId: identity.tokenIdentifier,
});
- await runAttempt(t, started.attemptId, "permanent-failure");
- const state = await snapshot(t, workId, started.runId);
- expect(state.work?.status).toBe("failed");
- expect(state.run?.terminalClassification).toBe("PermanentFailure");
- });
-
- test("needs-input surfaces a blocker instead of looping", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "needs-input",
- workId,
+ const projectId = await ctx.db.insert("projects", {
+ createdAt: 2,
+ name: "Project",
+ normalizedSourceUrl: "https://git.example.com/test/repo",
+ organizationId,
+ repositoryPath: "test/repo",
+ sourceHost: "gitea",
+ sourceUrl: "https://git.example.com/test/repo.git",
+ updatedAt: 2,
});
- await runAttempt(t, started.attemptId, "needs-input");
- const state = await snapshot(t, workId, started.runId);
- expect(state.work?.status).toBe("needs-input");
- expect(state.run?.terminalClassification).toBe("NeedsInput");
- });
-
- test("cancellation terminates the attempt and returns Work to ready", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- workId,
+ const workId = await ctx.db.insert("works", {
+ createdAt: 3,
+ definitionVersion: 1,
+ designVersion: 1,
+ objective: "Test",
+ organizationId,
+ projectId,
+ status: "ready",
+ title: "Ready Work",
+ updatedAt: 3,
});
- await t
- .withIdentity(identity)
- .mutation(api.workExecution.cancelSimulatedExecution, {
- runId: started.runId,
- });
- const state = await snapshot(t, workId, started.runId);
- expect(state.work?.status).toBe("ready");
- expect(state.run?.status).toBe("cancelled");
- expect(state.slice?.status).toBe("ready");
- });
-
- test("a slice that is not part of the current approved Design is rejected", async () => {
- const { t, workId } = await seedReadyWork();
- await expect(
- t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- sliceId: "not-a-real-slice",
+ await expect(
+ t.mutation(api.workExecution.start, {
+ organizationId,
+ token: "wrong-token",
workId,
})
- ).rejects.toThrow(/current approved Design/u);
- });
-
- test("manual retry restarts a failed Run", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "permanent-failure",
- workId,
- });
- await runAttempt(t, started.attemptId, "permanent-failure");
- expect((await snapshot(t, workId, started.runId)).work?.status).toBe(
- "failed"
- );
- const retried = await t
- .withIdentity(identity)
- .mutation(api.workExecution.retrySimulatedExecution, {
- runId: started.runId,
- });
- await runAttempt(t, retried.attemptId as Id<"workAttempts">, "success");
- const state = await snapshot(t, workId, started.runId);
- expect(state.work?.status).toBe("completed");
- expect(retried.number).toBe(2);
- });
-
- test("reconciling an expired lease resumes within budget", async () => {
- const { t, workId } = await seedReadyWork();
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- workId,
- });
- // simulate a worker that claimed but died before finishing
- await t.run(async (ctx) => {
- await ctx.db.patch(started.attemptId, {
- leaseExpiresAt: Date.now() - 1000,
- leaseOwner: "dead-worker",
- status: "claimed",
- });
+ ).rejects.toThrow(/agent control token/u);
});
- await t.mutation(api.workExecution.reconcileExpiredAttempts, {});
- const after = await snapshot(t, workId, started.runId);
- expect(after.attempts.find((a) => a.number === 1)?.status).toBe("terminal");
- expect(
- after.attempts.find((a) => a.number === 2 && a.status === "queued")
- ).toBeTruthy();
});
});
diff --git a/packages/backend/convex/workExecution.ts b/packages/backend/convex/workExecution.ts
index bd14bb2..89a05f3 100644
--- a/packages/backend/convex/workExecution.ts
+++ b/packages/backend/convex/workExecution.ts
@@ -1,77 +1,94 @@
-import { FakeHarnessLive } from "@code/primitives/harness-runtime";
-import type { FakeScenario } from "@code/primitives/harness-runtime";
+import { env } from "@code/env/convex";
+import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
+import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime";
+import {
+ CREDENTIAL_FRESHNESS_MS,
+ providerForHost as forgeForHost,
+} from "@code/primitives/git-provider";
import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
+import type { AttemptClassification } from "@code/primitives/resolver";
import type { WorkEventKind } from "@code/primitives/work";
-import { makeFunctionReference } from "convex/server";
+import { WorkflowManager } from "@convex-dev/workflow";
+import type { WorkflowId } from "@convex-dev/workflow";
import { ConvexError, v } from "convex/values";
-import { Effect } from "effect";
+import { components, internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import {
- internalAction,
internalMutation,
+ internalQuery,
mutation,
query,
} from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireProjectMember } from "./authz";
-const executeRef = makeFunctionReference<
- "action",
- { attemptId: Id<"workAttempts">; scenario: FakeScenario }
->("workExecution:executeFakeAttempt");
-const claimRef = makeFunctionReference<
- "mutation",
- { attemptId: Id<"workAttempts">; leaseMs: number; owner: string },
- { number: number } | null
->("workExecution:claimAttempt");
-const checkpointRef = makeFunctionReference<
- "mutation",
- {
- attemptId: Id<"workAttempts">;
- kind: string;
- message: string;
- metadataJson: string;
- owner: string;
- sequence: number;
- },
- unknown
->("workExecution:checkpointAttempt");
-const finishRef = makeFunctionReference<
- "mutation",
- {
- attemptId: Id<"workAttempts">;
- classification: string;
- owner: string;
- retryable: boolean;
- summary: string;
- },
- unknown
->("workExecution:finishAttempt");
-const now = () => Date.now();
-const attemptClassification = v.union(
- v.literal("Succeeded"),
- v.literal("RetryableFailure"),
- v.literal("NeedsInput"),
- v.literal("Blocked"),
- v.literal("VerificationFailed"),
- v.literal("BudgetExhausted"),
- v.literal("Cancelled"),
- v.literal("PermanentFailure")
-);
-
-const requireWorkForMember = async (
+/** Dual auth: service token for agent calls, user auth for web calls. */
+const authorizeExecution = async (
ctx: MutationCtx,
- workId: Id<"works">
-): Promise> => {
- const work = await ctx.db.get(workId);
- if (!work) {
- throw new ConvexError("Work not found");
+ token: string | undefined,
+ projectId: Id<"projects">
+) => {
+ if (token !== undefined) {
+ if (token !== env.FLUE_DB_TOKEN) {
+ throw new ConvexError("Invalid agent control token");
+ }
+ return;
}
- await requireProjectMember(ctx, work.projectId);
- return work;
+ await requireProjectMember(ctx, projectId);
};
+export const workflow = new WorkflowManager(components.workflow);
+
+const LEASE_MS = 5 * 60_000;
+const now = (): number => Date.now();
+
+const FAILURE_REASON_CLASSIFICATION = {
+ Authentication: "PermanentFailure",
+ Cancelled: "Cancelled",
+ HarnessFailed: "RetryableFailure",
+ InvalidInput: "PermanentFailure",
+ ProviderUnavailable: "RetryableFailure",
+ RepositoryFailed: "PermanentFailure",
+ Timeout: "RetryableFailure",
+} as const satisfies Record<
+ WorkAttemptExecutionErrorReason,
+ AttemptClassification
+>;
+
+export const classifyFailure = (
+ reason: WorkAttemptExecutionErrorReason
+): AttemptClassification => FAILURE_REASON_CLASSIFICATION[reason];
+
+const toExecutionFailure = (
+ error: unknown
+): {
+ message: string;
+ reason: WorkAttemptExecutionErrorReason;
+ retryable: boolean;
+} =>
+ error instanceof WorkAttemptExecutionError
+ ? {
+ message: error.message,
+ reason: error.reason,
+ retryable: error.retryable,
+ }
+ : {
+ message: error instanceof Error ? error.message : "Execution failed",
+ reason: "HarnessFailed",
+ retryable: true,
+ };
+
+const failureReasonValues = v.union(
+ v.literal("Authentication"),
+ v.literal("Cancelled"),
+ v.literal("HarnessFailed"),
+ v.literal("InvalidInput"),
+ v.literal("ProviderUnavailable"),
+ v.literal("RepositoryFailed"),
+ v.literal("Timeout")
+);
+
const appendWorkEvent = async (
ctx: MutationCtx,
workId: Id<"works">,
@@ -87,9 +104,9 @@ const appendWorkEvent = async (
)
.unique();
if (existing) {
- return;
+ return existing._id;
}
- await ctx.db.insert("workEvents", {
+ return await ctx.db.insert("workEvents", {
createdAt: now(),
idempotencyKey,
kind,
@@ -99,101 +116,193 @@ const appendWorkEvent = async (
});
};
-// Slice the Run targets for the currently approved Design. `sliceId` is
-// optional only to let the resolver default to the first ready slice.
-const resolveRunSlice = async (
+const resolveReadySlice = async (
ctx: MutationCtx,
work: Doc<"works">,
sliceId?: string
-): Promise> => {
- const currentDesignVersion = work.designVersion;
- if (currentDesignVersion === undefined) {
- throw new ConvexError("Work has no approved Design to execute");
+) => {
+ if (work.designVersion === undefined) {
+ throw new ConvexError("Work has no current Design to execute");
}
const slices = await ctx.db
.query("workSlices")
.withIndex("by_workId_and_designVersion", (q) =>
- q.eq("workId", work._id).eq("designVersion", currentDesignVersion)
+ q.eq("workId", work._id).eq("designVersion", work.designVersion!)
)
.collect();
- if (slices.length === 0) {
- throw new ConvexError("No slices found for the current approved Design");
- }
const slice = sliceId
- ? slices.find((row) => row.sliceId === sliceId)
+ ? slices.find((candidate) => candidate.sliceId === sliceId)
: slices
- .sort((a, b) => a.ordinal - b.ordinal)
- .find((row) => row.status === "ready");
- if (!slice) {
- throw new ConvexError(
- "Requested slice does not belong to the current approved Design"
- );
- }
- if (slice.status !== "ready") {
+ .sort((left, right) => left.ordinal - right.ordinal)
+ .find((candidate) => candidate.status === "ready");
+ if (!slice || slice.status !== "ready") {
throw new ConvexError("Only the next ready slice can be executed");
}
return slice;
};
-const getRunSlice = async (
+const validateProjectDeployment = async (
ctx: MutationCtx,
- run: Doc<"workRuns">
-): Promise | null> => {
- if (run.sliceRowId) {
- return await ctx.db.get(run.sliceRowId);
+ work: Doc<"works">
+): Promise> => {
+ const project = await ctx.db.get(work.projectId);
+ if (!project) {
+ throw new ConvexError("Project not found");
}
- if (run.designVersion === undefined || run.sliceId === undefined) {
- return null;
+ if (!project.gitConnectionId) {
+ throw new ConvexError("Connect Git credentials to this project first");
}
- return await ctx.db
- .query("workSlices")
- .withIndex("by_workId_and_designVersion_and_sliceId", (q) =>
- q
- .eq("workId", run.workId)
- .eq("designVersion", run.designVersion!)
- .eq("sliceId", run.sliceId!)
- )
- .unique();
+ const connection = await ctx.db.get(project.gitConnectionId);
+ if (!connection) {
+ throw new ConvexError("Git connection not found");
+ }
+ const expected = forgeForHost(project.sourceHost);
+ if (expected && connection.provider !== expected) {
+ throw new ConvexError(
+ `Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
+ );
+ }
+ if (connection.state !== undefined && connection.state !== "active") {
+ throw new ConvexError(
+ `Git connection is ${connection.state}; verify or reconnect credentials before execution`
+ );
+ }
+ const currentTime = now();
+ if (
+ connection.lastVerifiedAt === undefined ||
+ currentTime - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS
+ ) {
+ throw new ConvexError(
+ "Git credentials have not been verified recently; run a connection health check before execution"
+ );
+ }
+ return connection;
};
-const RETRYABLE_WORK_STATUSES = new Set(["failed", "needs-input", "blocked"]);
+const settleSliceAndWork = async (
+ ctx: MutationCtx,
+ run: Doc<"workRuns">,
+ succeeded: boolean,
+ workStatus: Doc<"works">["status"]
+) => {
+ if (!run.sliceRowId) {
+ return;
+ }
+ const slice = await ctx.db.get(run.sliceRowId);
+ if (!slice) {
+ return;
+ }
+ let sliceStatus: "blocked" | "completed" | "ready" = "ready";
+ if (succeeded) {
+ sliceStatus = "completed";
+ } else if (workStatus === "blocked") {
+ sliceStatus = "blocked";
+ }
+ await ctx.db.patch(slice._id, { status: sliceStatus });
+ const work = await ctx.db.get(run.workId);
+ if (!work) {
+ return;
+ }
+ let status = workStatus;
+ if (succeeded && workStatus === "completed") {
+ const slices = await ctx.db
+ .query("workSlices")
+ .withIndex("by_workId_and_designVersion", (q) =>
+ q.eq("workId", work._id).eq("designVersion", slice.designVersion)
+ )
+ .collect();
+ const next = slices
+ .sort((left, right) => left.ordinal - right.ordinal)
+ .find((candidate) => candidate.status === "planned");
+ if (next) {
+ await ctx.db.patch(next._id, { status: "ready" });
+ status = "ready";
+ }
+ }
+ await ctx.db.patch(work._id, { status, updatedAt: now() });
+};
-export const startSimulatedExecution = mutation({
+// --- Workflow definition ---
+
+export const execute = workflow
+ .define({ args: { attemptId: v.id("workAttempts") } })
+ .handler(async (step, args): Promise => {
+ try {
+ const result = await step.runAction(
+ internal.agentBackend.executeAttempt,
+ args,
+ { retry: true }
+ );
+ await step.runMutation(internal.workExecution.completeAttempt, {
+ attemptId: args.attemptId,
+ result: {
+ ...result,
+ changedFiles: [...result.changedFiles],
+ events: result.events.map((item: any) => ({
+ ...item,
+ metadata: { ...item.metadata },
+ })),
+ },
+ });
+ } catch (error) {
+ const failure = toExecutionFailure(error);
+ await step.runMutation(internal.workExecution.failAttempt, {
+ attemptId: args.attemptId,
+ reason: failure.reason,
+ retryable: failure.retryable,
+ summary: failure.message,
+ });
+ }
+ });
+
+// --- Public commands ---
+
+export const start = mutation({
args: {
- scenario: v.union(
- v.literal("success"),
- v.literal("transient-failure-then-success"),
- v.literal("needs-input"),
- v.literal("permanent-failure"),
- v.literal("cancelled")
- ),
+ organizationId: v.optional(v.id("organizations")),
sliceId: v.optional(v.string()),
+ token: v.optional(v.string()),
workId: v.id("works"),
},
- handler: async (ctx, args) => {
- const work = await requireWorkForMember(ctx, args.workId);
- if (work.status !== "ready") {
- throw new ConvexError("Work must be Ready before simulated execution");
+ handler: async (
+ ctx,
+ args
+ ): Promise<{
+ attemptId: Id<"workAttempts">;
+ runId: Id<"workRuns">;
+ workflowId: WorkflowId;
+ }> => {
+ const work = await ctx.db.get(args.workId);
+ if (!work) {
+ throw new ConvexError("Work not found");
}
if (
- work.definitionApprovalVersion !== work.definitionVersion ||
- work.designApprovalVersion !== work.designVersion
+ args.token !== undefined &&
+ args.organizationId !== work.organizationId
) {
- throw new ConvexError(
- "Execution requires exact approved Definition and Design versions"
- );
+ throw new ConvexError("Organization mismatch");
}
- const slice = await resolveRunSlice(ctx, work, args.sliceId);
+ await authorizeExecution(ctx, args.token, work.projectId);
+ if (work.status !== "ready") {
+ throw new ConvexError("Work must be Ready before execution");
+ }
+ if (
+ work.definitionVersion === undefined ||
+ work.designVersion === undefined
+ ) {
+ throw new ConvexError("Work must have a current Definition and Design");
+ }
+ await validateProjectDeployment(ctx, work);
+ const slice = await resolveReadySlice(ctx, work, args.sliceId);
const createdAt = now();
const runId = await ctx.db.insert("workRuns", {
createdAt,
designVersion: slice.designVersion,
kitId: defaultCodingKitV0.id,
kitVersion: defaultCodingKitV0.version,
- scenario: args.scenario,
sliceId: slice.sliceId,
sliceRowId: slice._id,
- status: "ready",
+ status: "running",
workId: work._id,
});
const attemptId = await ctx.db.insert("workAttempts", {
@@ -201,13 +310,18 @@ export const startSimulatedExecution = mutation({
runId,
status: "queued",
workId: work._id,
+ workspaceKey: `work-${work._id}-attempt-pending`,
});
- await ctx.db.patch(work._id, {
- status: "executing",
- updatedAt: createdAt,
- });
+ const workspaceKey = `work-${work._id}-attempt-${attemptId}`;
+ await ctx.db.patch(attemptId, { workspaceKey });
+ const workflowId: WorkflowId = await workflow.start(
+ ctx,
+ internal.workExecution.execute,
+ { attemptId }
+ );
+ await ctx.db.patch(runId, { startedAt: createdAt, workflowId });
await ctx.db.patch(slice._id, { status: "running" });
- await ctx.db.patch(runId, { startedAt: createdAt, status: "running" });
+ await ctx.db.patch(work._id, { status: "executing", updatedAt: createdAt });
await appendWorkEvent(
ctx,
work._id,
@@ -215,428 +329,440 @@ export const startSimulatedExecution = mutation({
`run-started:${runId}`,
String(runId)
);
- await appendWorkEvent(
- ctx,
- work._id,
- "slice.started",
- `slice-started:${runId}:${slice.sliceId}`,
- String(slice._id)
- );
- await ctx.scheduler.runAfter(0, executeRef, {
- attemptId,
- scenario: args.scenario,
- });
- return { attemptId, runId };
+ return { attemptId, runId, workflowId };
},
});
-export const cancelSimulatedExecution = mutation({
- args: { runId: v.id("workRuns") },
+export const cancel = mutation({
+ args: {
+ organizationId: v.optional(v.id("organizations")),
+ runId: v.id("workRuns"),
+ token: v.optional(v.string()),
+ },
handler: async (ctx, args) => {
const run = await ctx.db.get(args.runId);
if (!run) {
throw new ConvexError("Run not found");
}
- const work = await requireWorkForMember(ctx, run.workId);
- if (run.status === "terminal" || run.status === "cancelled") {
+ const work = await ctx.db.get(run.workId);
+ if (!work) {
+ throw new ConvexError("Work not found");
+ }
+ if (
+ args.token !== undefined &&
+ args.organizationId !== work.organizationId
+ ) {
+ throw new ConvexError("Organization mismatch");
+ }
+ await authorizeExecution(ctx, args.token, work.projectId);
+ if (run.status !== "running") {
return { cancelled: false };
}
+ if (run.workflowId) {
+ await workflow.cancel(ctx, run.workflowId as WorkflowId);
+ }
+ const attempts = await ctx.db
+ .query("workAttempts")
+ .withIndex("by_runId_and_number", (q) => q.eq("runId", run._id))
+ .collect();
+ const activeAttempt = attempts.find(
+ (candidate) => candidate.status !== "terminal"
+ );
+ if (activeAttempt?.workspaceKey) {
+ await ctx.scheduler.runAfter(0, internal.agentBackend.cancelAttempt, {
+ attemptId: String(activeAttempt._id),
+ workspaceKey: activeAttempt.workspaceKey,
+ });
+ const cancelledAt = now();
+ await ctx.db.patch(activeAttempt._id, {
+ classification: "Cancelled",
+ endedAt: cancelledAt,
+ failureReason: "Cancelled",
+ leaseExpiresAt: undefined,
+ status: "terminal",
+ summary: "Execution cancelled",
+ });
+ await ctx.db.insert("resolverDecisions", {
+ attemptId: activeAttempt._id,
+ attemptNumber: activeAttempt.number,
+ classification: "Cancelled",
+ createdAt: cancelledAt,
+ decision: "terminal",
+ resultingWorkStatus: "ready",
+ runId: run._id,
+ summary: "Execution cancelled",
+ workId: run.workId,
+ });
+ }
await ctx.db.patch(run._id, {
endedAt: now(),
status: "cancelled",
terminalClassification: "Cancelled",
- terminalSummary: "Simulation cancelled",
+ terminalSummary: "Execution cancelled",
});
- const attempts = await ctx.db
- .query("workAttempts")
- .withIndex("by_runId_and_number", (q) => q.eq("runId", run._id))
- .collect();
- for (const attempt of attempts) {
- if (attempt.status !== "terminal") {
- await ctx.db.patch(attempt._id, {
- classification: "Cancelled",
- endedAt: now(),
- status: "terminal",
- summary: "Simulation cancelled",
- });
- await ctx.db.insert("resolverDecisions", {
- attemptId: attempt._id,
- attemptNumber: attempt.number,
- classification: "Cancelled",
- createdAt: now(),
- decision: "terminal",
- resultingWorkStatus: "ready",
- runId: run._id,
- summary: "Simulation cancelled",
- workId: work._id,
- });
- await appendWorkEvent(
- ctx,
- work._id,
- "resolver.decided",
- `resolver-decided:${attempt._id}`,
- String(attempt._id),
- JSON.stringify({ classification: "Cancelled", decision: "terminal" })
- );
- }
- }
- const slice = await getRunSlice(ctx, run);
- if (slice) {
- await ctx.db.patch(slice._id, { status: "ready" });
+ if (run.sliceRowId) {
+ await ctx.db.patch(run.sliceRowId, { status: "ready" });
}
await ctx.db.patch(work._id, { status: "ready", updatedAt: now() });
- await appendWorkEvent(
- ctx,
- work._id,
- "run.cancelled",
- `run-cancelled:${run._id}`,
- String(run._id)
- );
return { cancelled: true };
},
});
-// User-initiated restart of a terminal Run. Unlike the automatic resolver
-// retry, this is an explicit decision and may exceed the kit budget.
-export const retrySimulatedExecution = mutation({
- args: { runId: v.id("workRuns") },
+export const retry = mutation({
+ args: {
+ organizationId: v.optional(v.id("organizations")),
+ runId: v.id("workRuns"),
+ token: v.optional(v.string()),
+ },
handler: async (ctx, args) => {
const run = await ctx.db.get(args.runId);
if (!run) {
throw new ConvexError("Run not found");
}
- const work = await requireWorkForMember(ctx, run.workId);
- if (run.status !== "terminal") {
- throw new ConvexError("Only terminal Runs can be retried");
+ const work = await ctx.db.get(run.workId);
+ if (!work) {
+ throw new ConvexError("Work not found");
}
- if (!RETRYABLE_WORK_STATUSES.has(work.status)) {
- throw new ConvexError("Work is not in a retryable state");
+ if (
+ args.token !== undefined &&
+ args.organizationId !== work.organizationId
+ ) {
+ throw new ConvexError("Organization mismatch");
+ }
+ await authorizeExecution(ctx, args.token, work.projectId);
+ if (run.status !== "terminal" && run.status !== "cancelled") {
+ throw new ConvexError("Only a terminal or cancelled Run can be retried");
+ }
+ if (run.terminalClassification === "Succeeded") {
+ throw new ConvexError("A successful Run cannot be retried");
}
const attempts = await ctx.db
.query("workAttempts")
.withIndex("by_runId_and_number", (q) => q.eq("runId", run._id))
.collect();
- const number = attempts.length + 1;
+ const nextNumber = attempts.length + 1;
const attemptId = await ctx.db.insert("workAttempts", {
- number,
+ number: nextNumber,
runId: run._id,
status: "queued",
workId: work._id,
+ workspaceKey: `work-${work._id}-attempt-pending`,
});
+ const workspaceKey = `work-${work._id}-attempt-${attemptId}`;
+ await ctx.db.patch(attemptId, { workspaceKey });
await ctx.db.patch(run._id, {
endedAt: undefined,
- startedAt: now(),
status: "running",
terminalClassification: undefined,
terminalSummary: undefined,
});
await ctx.db.patch(work._id, { status: "executing", updatedAt: now() });
- const slice = await getRunSlice(ctx, run);
- if (!slice) {
- throw new ConvexError("Run slice not found");
- }
- await ctx.db.patch(slice._id, { status: "running" });
- await ctx.scheduler.runAfter(0, executeRef, {
- attemptId,
- scenario: run.scenario,
- });
- return { attemptId, number };
+ const workflowId: WorkflowId = await workflow.start(
+ ctx,
+ internal.workExecution.execute,
+ { attemptId }
+ );
+ await ctx.db.patch(run._id, { workflowId });
+ return { runId: run._id };
},
});
-export const claimAttempt = internalMutation({
- args: {
- attemptId: v.id("workAttempts"),
- leaseMs: v.number(),
- owner: v.string(),
- },
+// --- Internal mutations ---
+
+export const executionContext = internalQuery({
+ args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId);
- if (!attempt || attempt.status !== "queued") {
- return null;
+ if (!attempt) {
+ throw new ConvexError("Attempt not found");
+ }
+ const run = await ctx.db.get(attempt.runId);
+ const work = await ctx.db.get(attempt.workId);
+ if (!run || !work || !run.sliceRowId) {
+ throw new ConvexError("Execution records are incomplete");
+ }
+ const [slice, project] = await Promise.all([
+ ctx.db.get(run.sliceRowId),
+ ctx.db.get(work.projectId),
+ ]);
+ if (!slice || !project?.gitConnectionId) {
+ throw new ConvexError("Project execution configuration is incomplete");
+ }
+ const connection = await ctx.db.get(project.gitConnectionId);
+ if (!connection) {
+ throw new ConvexError("Git connection not found");
+ }
+ const expectedForge = forgeForHost(project.sourceHost);
+ if (expectedForge && connection.provider !== expectedForge) {
+ throw new ConvexError(
+ `Git credential provider (${connection.provider}) does not match this project's provider (${expectedForge})`
+ );
+ }
+ if (connection.state !== undefined && connection.state !== "active") {
+ throw new ConvexError(
+ `Git connection is ${connection.state}; execution is blocked`
+ );
+ }
+ return {
+ attempt,
+ connection,
+ project,
+ prompt: [
+ `Implement this slice: ${slice.title}`,
+ `Objective: ${slice.objective}`,
+ `Observable behavior: ${slice.observableBehavior}`,
+ "Inspect the repository instructions first. Make focused changes and run relevant checks.",
+ ].join("\n\n"),
+ run,
+ work,
+ };
+ },
+});
+
+export const markAttemptRunning = internalMutation({
+ args: { attemptId: v.id("workAttempts") },
+ handler: async (ctx, args) => {
+ const attempt = await ctx.db.get(args.attemptId);
+ if (!attempt || attempt.status === "terminal") {
+ return false;
}
const run = await ctx.db.get(attempt.runId);
if (!run || run.status !== "running") {
- return null;
- }
- const claimedAt = now();
- await ctx.db.patch(attempt._id, {
- leaseExpiresAt: claimedAt + args.leaseMs,
- leaseOwner: args.owner,
- startedAt: attempt.startedAt ?? claimedAt,
- status: "claimed",
- });
- await ctx.db.patch(attempt.runId, {
- startedAt: claimedAt,
- status: "running",
- });
- return { ...attempt, leaseOwner: args.owner, status: "claimed" as const };
- },
-});
-
-export const checkpointAttempt = internalMutation({
- args: {
- attemptId: v.id("workAttempts"),
- kind: v.string(),
- message: v.string(),
- metadataJson: v.string(),
- owner: v.string(),
- sequence: v.number(),
- },
- handler: async (ctx, args) => {
- const attempt = await ctx.db.get(args.attemptId);
- if (
- !attempt ||
- attempt.leaseOwner !== args.owner ||
- attempt.status === "terminal" ||
- (attempt.leaseExpiresAt !== undefined && attempt.leaseExpiresAt < now())
- ) {
return false;
}
- const existing = await ctx.db
- .query("workAttemptEvents")
- .withIndex("by_attempt_and_sequence", (q: any) =>
- q.eq("attemptId", attempt._id).eq("sequence", args.sequence)
- )
- .unique();
- if (!existing) {
- await ctx.db.insert("workAttemptEvents", {
- attemptId: attempt._id,
- kind: args.kind,
- message: args.message,
- metadataJson: args.metadataJson,
- occurredAt: now(),
- sequence: args.sequence,
- });
- }
await ctx.db.patch(attempt._id, {
- leaseExpiresAt: now() + 60_000,
+ leaseExpiresAt: now() + LEASE_MS,
+ startedAt: attempt.startedAt ?? now(),
status: "running",
});
return true;
},
});
-// Single resolver mutation: record the attempt outcome, then either schedule
-// the next attempt within the kit retry policy or settle the Run/Work at the
-// terminal classification. Replaces the old completeAttempt that always reset
-// Work to "ready" and ignored the retry policy.
-export const finishAttempt = internalMutation({
+export const completeAttempt = internalMutation({
args: {
attemptId: v.id("workAttempts"),
- classification: attemptClassification,
- owner: v.string(),
+ result: v.object({
+ baseRevision: v.string(),
+ candidateRevision: v.string(),
+ changedFiles: v.array(v.string()),
+ diff: v.string(),
+ environmentId: v.string(),
+ events: v.array(
+ v.object({
+ kind: v.string(),
+ message: v.string(),
+ metadata: v.record(v.string(), v.string()),
+ occurredAt: v.number(),
+ sequence: v.number(),
+ })
+ ),
+ summary: v.string(),
+ }),
+ },
+ handler: async (ctx, args) => {
+ const attempt = await ctx.db.get(args.attemptId);
+ if (!attempt || attempt.status === "terminal") {
+ return;
+ }
+ const run = await ctx.db.get(attempt.runId);
+ const work = await ctx.db.get(attempt.workId);
+ if (!run || !work) {
+ throw new ConvexError("Execution records not found");
+ }
+ if (run.status === "terminal" || run.status === "cancelled") {
+ return;
+ }
+ // Empty-change rejection: a no-op result is not a successful implementation.
+ const noOp =
+ args.result.changedFiles.length === 0 ||
+ args.result.baseRevision === args.result.candidateRevision;
+ if (noOp) {
+ await ctx.db.patch(attempt._id, {
+ classification: "PermanentFailure",
+ endedAt: now(),
+ failureReason: "InvalidInput",
+ leaseExpiresAt: undefined,
+ status: "terminal",
+ summary: "Execution produced no repository changes",
+ });
+ await ctx.db.patch(run._id, {
+ endedAt: now(),
+ status: "terminal",
+ terminalClassification: "PermanentFailure",
+ terminalSummary: "Execution produced no repository changes",
+ });
+ await settleSliceAndWork(ctx, run, false, "failed");
+ return;
+ }
+ for (const item of args.result.events) {
+ await ctx.db.insert("workAttemptEvents", {
+ attemptId: attempt._id,
+ kind: item.kind,
+ message: item.message,
+ metadataJson: JSON.stringify(item.metadata),
+ occurredAt: item.occurredAt,
+ sequence: item.sequence,
+ });
+ }
+ const endedAt = now();
+ await ctx.db.patch(attempt._id, {
+ classification: "Succeeded",
+ endedAt,
+ leaseExpiresAt: undefined,
+ status: "terminal",
+ summary: args.result.summary,
+ });
+ await ctx.db.patch(run._id, {
+ baseRevision: args.result.baseRevision,
+ candidateRevision: args.result.candidateRevision,
+ endedAt,
+ environmentId: args.result.environmentId,
+ status: "terminal",
+ terminalClassification: "Succeeded",
+ terminalSummary: args.result.summary,
+ });
+ await ctx.db.insert("workArtifacts", {
+ attemptId: attempt._id,
+ createdAt: endedAt,
+ designVersion: run.designVersion,
+ environmentId: args.result.environmentId,
+ idempotencyKey: `diff:${attempt._id}`,
+ kind: "diff",
+ metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }),
+ organizationId: work.organizationId,
+ producer: "agentos",
+ projectId: work.projectId,
+ provenanceJson: JSON.stringify({
+ baseRevision: args.result.baseRevision,
+ }),
+ runId: run._id,
+ sliceId: run.sliceId,
+ sourceRevision: args.result.candidateRevision,
+ title: "Implementation diff",
+ verificationStatus: "unverified",
+ workId: work._id,
+ ...(args.result.diff.length > 0
+ ? {
+ uri: `data:text/plain;charset=utf-8,${encodeURIComponent(args.result.diff.slice(0, 50_000))}`,
+ }
+ : {}),
+ });
+ // A successful AgentOS response alone must never mark Work complete.
+ // Move to verifying so an independent step can confirm the candidate.
+ await settleSliceAndWork(ctx, run, false, "verifying");
+ await appendWorkEvent(
+ ctx,
+ work._id,
+ "verification.started",
+ `verification-started:${run._id}`,
+ String(run._id)
+ );
+ },
+});
+
+export const launchAttemptWorkflow = internalMutation({
+ args: { attemptId: v.id("workAttempts") },
+ handler: async (ctx, args) => {
+ const attempt = await ctx.db.get(args.attemptId);
+ if (!attempt || attempt.status === "terminal") {
+ return;
+ }
+ const run = await ctx.db.get(attempt.runId);
+ if (!run || run.status !== "running") {
+ return;
+ }
+ const workflowId: WorkflowId = await workflow.start(
+ ctx,
+ internal.workExecution.execute,
+ args
+ );
+ await ctx.db.patch(run._id, { workflowId });
+ },
+});
+
+export const failAttempt = internalMutation({
+ args: {
+ attemptId: v.id("workAttempts"),
+ reason: failureReasonValues,
retryable: v.boolean(),
summary: v.string(),
},
handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId);
- if (
- !attempt ||
- attempt.leaseOwner !== args.owner ||
- attempt.status === "terminal" ||
- (attempt.leaseExpiresAt !== undefined && attempt.leaseExpiresAt < now())
- ) {
- return null;
+ if (!attempt || attempt.status === "terminal") {
+ return;
}
+ const run = await ctx.db.get(attempt.runId);
+ if (!run || run.status === "terminal" || run.status === "cancelled") {
+ return;
+ }
+ const classification = classifyFailure(args.reason);
const endedAt = now();
await ctx.db.patch(attempt._id, {
- classification: args.classification,
+ classification,
endedAt,
+ failureReason: args.reason,
leaseExpiresAt: undefined,
status: "terminal",
summary: args.summary,
});
- const outcome = {
- classification: args.classification,
- retryable: args.retryable,
- summary: args.summary,
- };
- const resolution = resolveOutcome(
- outcome,
+ const outcome = resolveOutcome(
+ { classification, retryable: args.retryable, summary: args.summary },
attempt.number,
defaultCodingKitV0.retryPolicy
);
- const run = await ctx.db.get(attempt.runId);
- if (!run) {
- return null;
- }
- await appendWorkEvent(
- ctx,
- attempt.workId,
- "attempt.completed",
- `attempt-completed:${attempt._id}`,
- String(attempt._id),
- JSON.stringify({
- classification: args.classification,
- retried: resolution.kind === "retry",
- summary: args.summary,
- })
- );
- await appendWorkEvent(
- ctx,
- attempt.workId,
- "resolver.decided",
- `resolver-decided:${attempt._id}`,
- String(attempt._id),
- JSON.stringify({
- classification: args.classification,
- decision: resolution.kind,
- })
- );
- if (resolution.kind === "retry") {
- await ctx.db.insert("resolverDecisions", {
- attemptId: attempt._id,
- attemptNumber: attempt.number,
- classification: args.classification,
- createdAt: endedAt,
- decision: "retry",
- runId: run._id,
- summary: args.summary,
- workId: attempt.workId,
- });
- // Keep Run running and Work executing; spin the next attempt.
+ await ctx.db.insert("resolverDecisions", {
+ attemptId: attempt._id,
+ attemptNumber: attempt.number,
+ classification,
+ createdAt: endedAt,
+ decision: outcome.kind,
+ ...(outcome.kind === "terminal"
+ ? { resultingWorkStatus: outcome.workStatus }
+ : {}),
+ runId: run._id,
+ summary: args.summary,
+ workId: attempt.workId,
+ });
+ if (outcome.kind === "retry") {
const nextAttemptId = await ctx.db.insert("workAttempts", {
number: attempt.number + 1,
runId: run._id,
status: "queued",
workId: attempt.workId,
+ workspaceKey: attempt.workspaceKey,
});
- await ctx.scheduler.runAfter(0, executeRef, {
- attemptId: nextAttemptId,
- scenario: run.scenario,
- });
- return { nextAttemptId, retried: true };
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.workExecution.launchAttemptWorkflow,
+ { attemptId: nextAttemptId }
+ );
+ return;
}
await ctx.db.patch(run._id, {
endedAt,
status: "terminal",
- terminalClassification: args.classification,
+ terminalClassification: classification,
terminalSummary: args.summary,
});
+ await settleSliceAndWork(ctx, run, false, outcome.workStatus);
const work = await ctx.db.get(run.workId);
- const slice = await getRunSlice(ctx, run);
- let resultingWorkStatus = resolution.workStatus;
- if (args.classification === "Succeeded" && slice) {
- await ctx.db.patch(slice._id, { status: "completed" });
+ if (work) {
await appendWorkEvent(
ctx,
- attempt.workId,
- "slice.completed",
- `slice-completed:${run._id}:${slice.sliceId}`,
- String(slice._id)
+ work._id,
+ "run.completed",
+ `run-failed:${run._id}`,
+ String(run._id),
+ JSON.stringify({ classification })
);
- const slices = await ctx.db
- .query("workSlices")
- .withIndex("by_workId_and_designVersion", (q) =>
- q
- .eq("workId", attempt.workId)
- .eq("designVersion", slice.designVersion)
- )
- .collect();
- const nextSlice = slices
- .sort((left, right) => left.ordinal - right.ordinal)
- .find((candidate) => candidate.status === "planned");
- if (nextSlice) {
- await ctx.db.patch(nextSlice._id, { status: "ready" });
- resultingWorkStatus = "ready";
- await appendWorkEvent(
- ctx,
- attempt.workId,
- "slice.ready",
- `slice-ready:${run._id}:${nextSlice.sliceId}`,
- String(nextSlice._id)
- );
- }
- } else if (slice) {
- const blocked = ["NeedsInput", "Blocked", "VerificationFailed"].includes(
- args.classification
- );
- await ctx.db.patch(slice._id, { status: blocked ? "blocked" : "ready" });
}
- if (work) {
- await ctx.db.patch(work._id, {
- status: resultingWorkStatus,
- updatedAt: endedAt,
- });
- }
- await ctx.db.insert("resolverDecisions", {
- attemptId: attempt._id,
- attemptNumber: attempt.number,
- classification: args.classification,
- createdAt: endedAt,
- decision: "terminal",
- resultingWorkStatus,
- runId: run._id,
- summary: args.summary,
- workId: attempt.workId,
- });
- await appendWorkEvent(
- ctx,
- attempt.workId,
- "run.completed",
- `run-completed:${run._id}`,
- String(run._id),
- JSON.stringify({
- classification: args.classification,
- workStatus: resultingWorkStatus,
- })
- );
- return { retried: false };
},
});
-export const executeFakeAttempt = internalAction({
- args: {
- attemptId: v.id("workAttempts"),
- scenario: v.union(
- v.literal("success"),
- v.literal("transient-failure-then-success"),
- v.literal("needs-input"),
- v.literal("permanent-failure"),
- v.literal("cancelled")
- ),
- },
- handler: async (ctx, args) => {
- const owner = `fake-worker:${args.attemptId}`;
- const claimed = await ctx.runMutation(claimRef, {
- attemptId: args.attemptId,
- leaseMs: 60_000,
- owner,
- });
- if (!claimed) {
- return null;
- }
- const [events, outcome] = await Effect.runPromise(
- FakeHarnessLive().run({
- attemptNumber: claimed.number,
- scenario: args.scenario,
- })
- );
- for (const item of events) {
- await ctx.runMutation(checkpointRef, {
- attemptId: args.attemptId,
- kind: item.kind,
- message: item.message,
- metadataJson: JSON.stringify(item.metadata),
- owner,
- sequence: item.sequence,
- });
- }
- await ctx.runMutation(finishRef, {
- attemptId: args.attemptId,
- classification: outcome.classification,
- owner,
- retryable: outcome.retryable,
- summary: outcome.summary,
- });
- return null;
- },
-});
+// --- Lease reconciliation (cron target) ---
-// Bounded recovery: a claimed/running attempt whose lease expired means the
-// worker died mid-flight. Terminate that attempt, then either resume within
-// the kit budget or settle the Run/Work so nothing stays "running" forever.
export const reconcileExpiredAttempts = internalMutation({
args: {},
handler: async (ctx) => {
@@ -690,14 +816,14 @@ export const reconcileExpiredAttempts = internalMutation({
.collect();
const withinBudget =
attempts.length < defaultCodingKitV0.retryPolicy.maxAttempts;
- const retry = run.status === "running" && withinBudget;
+ const retryRun = run.status === "running" && withinBudget;
await ctx.db.insert("resolverDecisions", {
attemptId: attempt._id,
attemptNumber: attempt.number,
classification: "Blocked",
createdAt: now(),
- decision: retry ? "retry" : "terminal",
- resultingWorkStatus: retry ? undefined : "blocked",
+ decision: retryRun ? "retry" : "terminal",
+ resultingWorkStatus: retryRun ? undefined : "blocked",
runId: run._id,
summary: "Attempt lease expired and was reconciled",
workId: attempt.workId,
@@ -710,20 +836,22 @@ export const reconcileExpiredAttempts = internalMutation({
String(attempt._id),
JSON.stringify({
classification: "Blocked",
- decision: retry ? "retry" : "terminal",
+ decision: retryRun ? "retry" : "terminal",
})
);
- if (retry) {
+ if (retryRun) {
const nextAttemptId = await ctx.db.insert("workAttempts", {
number: attempts.length + 1,
runId: run._id,
status: "queued",
workId: attempt.workId,
+ workspaceKey: attempt.workspaceKey,
});
- await ctx.scheduler.runAfter(0, executeRef, {
- attemptId: nextAttemptId,
- scenario: run.scenario,
- });
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.workExecution.launchAttemptWorkflow,
+ { attemptId: nextAttemptId }
+ );
} else {
await ctx.db.patch(run._id, {
endedAt: now(),
@@ -738,10 +866,6 @@ export const reconcileExpiredAttempts = internalMutation({
updatedAt: now(),
});
}
- const slice = await getRunSlice(ctx, run);
- if (slice) {
- await ctx.db.patch(slice._id, { status: "blocked" });
- }
}
}
}
@@ -760,13 +884,11 @@ export const listRunEvents = query({
if (!work) {
return [];
}
- await requireProjectMember(ctx, work.projectId);
return await ctx.db
.query("workAttemptEvents")
.withIndex("by_attempt_and_sequence", (q) =>
- q.eq("attemptId", args.attemptId)
+ q.eq("attemptId", attempt._id)
)
- .order("asc")
.collect();
},
});
diff --git a/packages/backend/convex/workExecutionWorkflow.test.ts b/packages/backend/convex/workExecutionWorkflow.test.ts
deleted file mode 100644
index 9c47973..0000000
--- a/packages/backend/convex/workExecutionWorkflow.test.ts
+++ /dev/null
@@ -1,396 +0,0 @@
-import { convexTest } from "convex-test";
-import { anyApi } from "convex/server";
-import { describe, expect, test } from "vitest";
-
-import schema from "./schema";
-
-const modules = import.meta.glob("./**/*.ts");
-const api = anyApi;
-
-type TestContext = ReturnType;
-
-interface Seeded {
- attemptId: string;
- runId: string;
- t: TestContext;
- workId: string;
-}
-
-// Seed an in-flight real attempt so each mutation under test starts from a
-// realistic running state: Work "executing", Run "running", Attempt "running".
-const seedRunningAttempt = async (
- overrides: {
- attemptStatus?: "queued" | "claimed" | "running" | "terminal";
- runStatus?: "ready" | "running" | "terminal" | "cancelled";
- } = {}
-): Promise => {
- const t = convexTest({ modules, schema });
- const seeded = await t.run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: "user",
- kind: "personal",
- name: "Personal",
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://github.com/puter/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "github.com",
- sourceUrl: "https://github.com/puter/zopu",
- updatedAt: 1,
- });
- const workId = await ctx.db.insert("works", {
- createdAt: 1,
- objective: "Implement a real slice",
- organizationId,
- projectId,
- status: "executing",
- title: "Real execution",
- updatedAt: 1,
- });
- const sliceRowId = await ctx.db.insert("workSlices", {
- designVersion: 1,
- objective: "Change the repo",
- observableBehavior: "A diff exists",
- ordinal: 0,
- payloadJson: "{}",
- sliceId: "slice-1",
- status: "running",
- title: "Implementation",
- workId,
- });
- const runId = await ctx.db.insert("workRuns", {
- createdAt: 1,
- designVersion: 1,
- executionKind: "real",
- kitId: "coding-v0",
- kitVersion: "1",
- scenario: "success",
- sliceId: "slice-1",
- sliceRowId,
- status: overrides.runStatus ?? "running",
- workId,
- });
- const attemptId = await ctx.db.insert("workAttempts", {
- number: 1,
- runId,
- status: overrides.attemptStatus ?? "running",
- workId,
- workspaceKey: "workspace-1",
- });
- return { attemptId, runId, workId };
- });
- return { ...seeded, t };
-};
-
-const successResult = {
- baseRevision: "base123",
- candidateRevision: "candidate456",
- changedFiles: ["src/index.ts"],
- diff: "+export const ready = true;",
- environmentId: "workspace-1",
- events: [
- {
- kind: "runtime.completed",
- message: "completed",
- metadata: {},
- occurredAt: 2,
- sequence: 0,
- },
- ],
- summary: "Changed one file",
-};
-
-describe("real work execution persistence", () => {
- test("records revisions, activity, diff, and terminal state atomically", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.mutation(api.workExecutionWorkflow.completeAttempt, {
- attemptId: seeded.attemptId,
- result: successResult,
- });
-
- const state = await t.run(async (ctx) => ({
- artifacts: await ctx.db.query("workArtifacts").collect(),
- attempt: await ctx.db.get(seeded.attemptId as any),
- run: await ctx.db.get(seeded.runId as any),
- work: await ctx.db.get(seeded.workId as any),
- }));
- expect(state.attempt?.classification).toBe("Succeeded");
- expect(state.run).toMatchObject({
- baseRevision: "base123",
- candidateRevision: "candidate456",
- terminalClassification: "Succeeded",
- });
- expect(state.work?.status).toBe("completed");
- expect(state.artifacts[0]).toMatchObject({
- kind: "diff",
- sourceRevision: "candidate456",
- });
- });
-});
-
-describe("classified failure mapping", () => {
- test("maps a transient runtime reason to a retryable classification", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.mutation(api.workExecutionWorkflow.failAttempt, {
- attemptId: seeded.attemptId,
- reason: "ProviderUnavailable",
- retryable: true,
- summary: "model provider timed out",
- });
- const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
- expect(attempt?.classification).toBe("RetryableFailure");
- expect(attempt?.failureReason).toBe("ProviderUnavailable");
- });
-
- test("queues a new attempt without terminalizing a retryable run", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.mutation(api.workExecutionWorkflow.failAttempt, {
- attemptId: seeded.attemptId,
- reason: "ProviderUnavailable",
- retryable: true,
- summary: "provider unavailable",
- });
- const state = await t.run(async (ctx) => ({
- attempts: await ctx.db.query("workAttempts").collect(),
- run: await ctx.db.get(seeded.runId as any),
- work: await ctx.db.get(seeded.workId as any),
- }));
- expect(state.attempts).toHaveLength(2);
- expect(state.attempts[1]).toMatchObject({
- number: 2,
- status: "queued",
- workspaceKey: "workspace-1",
- });
- expect(state.run?.status).toBe("running");
- expect(state.work?.status).toBe("executing");
- });
- test("maps an authentication reason to a permanent failure", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.mutation(api.workExecutionWorkflow.failAttempt, {
- attemptId: seeded.attemptId,
- reason: "Authentication",
- retryable: false,
- summary: "token rejected",
- });
- const state = await t.run(async (ctx) => ({
- attempt: await ctx.db.get(seeded.attemptId as any),
- run: await ctx.db.get(seeded.runId as any),
- work: await ctx.db.get(seeded.workId as any),
- }));
- expect(state.attempt?.classification).toBe("PermanentFailure");
- expect(state.attempt?.failureReason).toBe("Authentication");
- expect(state.run?.terminalClassification).toBe("PermanentFailure");
- expect(state.work?.status).toBe("failed");
- });
-});
-
-describe("cancellation fencing", () => {
- test("a cancelled attempt cannot later settle success", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- // Simulate cancellation marking the attempt/run terminal as Cancelled.
- await t.run(async (ctx) => {
- await ctx.db.patch(seeded.attemptId as any, {
- classification: "Cancelled",
- endedAt: 5,
- status: "terminal",
- summary: "Execution cancelled",
- });
- await ctx.db.patch(seeded.runId as any, {
- endedAt: 5,
- status: "cancelled",
- terminalClassification: "Cancelled",
- terminalSummary: "Execution cancelled",
- });
- });
- // A late-arriving completion must be ignored.
- await t.mutation(api.workExecutionWorkflow.completeAttempt, {
- attemptId: seeded.attemptId,
- result: successResult,
- });
- const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
- expect(attempt?.classification).toBe("Cancelled");
- expect(attempt?.status).toBe("terminal");
- });
-
- test("a late failure does not overwrite a cancelled run", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.run(async (ctx) => {
- await ctx.db.patch(seeded.attemptId as any, {
- classification: "Cancelled",
- endedAt: 5,
- status: "terminal",
- });
- await ctx.db.patch(seeded.runId as any, { status: "cancelled" });
- });
- await t.mutation(api.workExecutionWorkflow.failAttempt, {
- attemptId: seeded.attemptId,
- reason: "HarnessFailed",
- retryable: true,
- summary: "late failure",
- });
- const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
- expect(attempt?.classification).toBe("Cancelled");
- });
-});
-
-describe("empty-change rejection", () => {
- test("a no-op result with no changed files fails as permanent", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.mutation(api.workExecutionWorkflow.completeAttempt, {
- attemptId: seeded.attemptId,
- result: {
- ...successResult,
- changedFiles: [],
- candidateRevision: "base123",
- },
- });
- const state = await t.run(async (ctx) => ({
- attempt: await ctx.db.get(seeded.attemptId as any),
- work: await ctx.db.get(seeded.workId as any),
- }));
- expect(state.attempt?.classification).toBe("PermanentFailure");
- expect(state.attempt?.failureReason).toBe("InvalidInput");
- expect(state.work?.status).toBe("failed");
- });
-
- test("identical base and candidate revisions are rejected", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- await t.mutation(api.workExecutionWorkflow.completeAttempt, {
- attemptId: seeded.attemptId,
- result: {
- ...successResult,
- baseRevision: "same",
- candidateRevision: "same",
- },
- });
- const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
- expect(attempt?.classification).toBe("PermanentFailure");
- });
-});
-
-describe("attempt re-entry", () => {
- test("markAttemptRunning re-claims an already-running attempt", async () => {
- const { t, ...seeded } = await seedRunningAttempt();
- // First claim (already "running" from seed) must still succeed so a
- // workflow replay can re-enter the same live attempt.
- const first = await t.mutation(
- api.workExecutionWorkflow.markAttemptRunning,
- { attemptId: seeded.attemptId }
- );
- expect(first).toBe(true);
- const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
- expect(attempt?.status).toBe("running");
- expect(attempt?.leaseExpiresAt).toBeGreaterThan(0);
- });
-
- test("a terminal attempt is not re-runnable", async () => {
- const { t, ...seeded } = await seedRunningAttempt({
- attemptStatus: "terminal",
- });
- const result = await t.mutation(
- api.workExecutionWorkflow.markAttemptRunning,
- { attemptId: seeded.attemptId }
- );
- expect(result).toBe(false);
- });
- test("a cancelled run cannot re-enter a queued attempt", async () => {
- const { t, ...seeded } = await seedRunningAttempt({
- attemptStatus: "queued",
- runStatus: "cancelled",
- });
- const result = await t.mutation(
- api.workExecutionWorkflow.markAttemptRunning,
- { attemptId: seeded.attemptId }
- );
- expect(result).toBe(false);
- const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
- expect(attempt?.status).toBe("queued");
- });
-});
-
-describe("forge credential validation", () => {
- const identity = { tokenIdentifier: "https://convex.test|forge-user" };
-
- const seedForgableProject = async (
- provider: "github" | "gitea",
- sourceHost: string
- ) => {
- const t = convexTest({ modules, schema }).withIdentity(identity);
- const ids = await t.run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- await ctx.db.insert("organizationMembers", {
- createdAt: 1,
- organizationId,
- role: "owner",
- userId: identity.tokenIdentifier,
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: `https://${sourceHost}/puter/zopu`,
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost,
- sourceUrl: `https://${sourceHost}/puter/zopu`,
- updatedAt: 1,
- });
- const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
- createdAt: 1,
- externalAccountId: "ext-1",
- externalUsername: "zopu",
- organizationId,
- provider,
- serverUrl: `https://${sourceHost}`,
- status: "active",
- updatedAt: 1,
- userId: identity.tokenIdentifier,
- });
- const connectionId = await ctx.db.insert("gitConnections", {
- connectedAt: 1,
- creatorUserId: identity.tokenIdentifier,
- credentialCiphertext: "x",
- credentialIv: "y",
- credentialKind: provider === "github" ? "oauth" : "token",
- gitProviderAccountId: providerAccountId,
- lastVerifiedAt: Date.now(),
- organizationId,
- provider,
- serverUrl: `https://${sourceHost}`,
- state: "active",
- updatedAt: 1,
- username: "zopu",
- });
- return { connectionId, projectId };
- });
- return { t, ids };
- };
-
- test("rejects a git connection whose provider mismatches the project forge", async () => {
- // Gitea token attached to a GitHub-hosted project.
- const { t, ids } = await seedForgableProject("gitea", "github.com");
- await expect(
- t.mutation(api.gitConnectionData.attachToProject, {
- connectionId: ids.connectionId,
- projectId: ids.projectId,
- })
- ).rejects.toThrow(/does not match this project.s provider/u);
- });
-
- test("accepts a matching provider for the project forge", async () => {
- const { t, ids } = await seedForgableProject("github", "github.com");
- const result = await t.mutation(api.gitConnectionData.attachToProject, {
- connectionId: ids.connectionId,
- projectId: ids.projectId,
- });
- expect(result).toMatchObject({ attached: true });
- });
-});
diff --git a/packages/backend/convex/workExecutionWorkflow.ts b/packages/backend/convex/workExecutionWorkflow.ts
deleted file mode 100644
index e01ad13..0000000
--- a/packages/backend/convex/workExecutionWorkflow.ts
+++ /dev/null
@@ -1,654 +0,0 @@
-import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
-import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime";
-import {
- CREDENTIAL_FRESHNESS_MS,
- providerForHost as forgeForHost,
-} from "@code/primitives/git-provider";
-import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
-import type { AttemptClassification } from "@code/primitives/resolver";
-import { WorkflowManager } from "@convex-dev/workflow";
-import type { WorkflowId } from "@convex-dev/workflow";
-import { ConvexError, v } from "convex/values";
-
-import { components, internal } from "./_generated/api";
-import type { Doc, Id } from "./_generated/dataModel";
-import { internalMutation, internalQuery, mutation } from "./_generated/server";
-import type { MutationCtx } from "./_generated/server";
-import { requireProjectMember } from "./authz";
-
-export const workflow = new WorkflowManager(components.workflow);
-
-// Lease window for a running real attempt. Long enough to outlast a single
-// agent turn; the workflow's own retry/lease reconciliation covers crashes.
-const LEASE_MS = 5 * 60_000;
-
-// Runtime failure reason -> durable attempt classification. The Convex
-// workflow stores classifications, not provider-specific reasons, so the
-// resolver and Work lifecycle stay forge-agnostic. `retryable` from the
-// runtime is carried alongside and consulted by the kit retry policy.
-const FAILURE_REASON_CLASSIFICATION = {
- Authentication: "PermanentFailure",
- Cancelled: "Cancelled",
- HarnessFailed: "RetryableFailure",
- InvalidInput: "PermanentFailure",
- ProviderUnavailable: "RetryableFailure",
- RepositoryFailed: "PermanentFailure",
- Timeout: "RetryableFailure",
-} as const satisfies Record<
- WorkAttemptExecutionErrorReason,
- AttemptClassification
->;
-
-export const classifyFailure = (
- reason: WorkAttemptExecutionErrorReason
-): AttemptClassification => FAILURE_REASON_CLASSIFICATION[reason];
-
-// Normalize an error thrown from the agent action into the durable failure
-// triple (reason/retryable/summary). WorkAttemptExecutionError is the
-// classified runtime failure; anything else is a Convex/infrastructure error
-// treated as a transient HarnessFailed so the workflow retry policy decides.
-const toExecutionFailure = (
- error: unknown
-): {
- message: string;
- reason: WorkAttemptExecutionErrorReason;
- retryable: boolean;
-} =>
- error instanceof WorkAttemptExecutionError
- ? {
- message: error.message,
- reason: error.reason,
- retryable: error.retryable,
- }
- : {
- message: error instanceof Error ? error.message : "Execution failed",
- reason: "HarnessFailed",
- retryable: true,
- };
-
-const failureReasonValues = v.union(
- v.literal("Authentication"),
- v.literal("Cancelled"),
- v.literal("HarnessFailed"),
- v.literal("InvalidInput"),
- v.literal("ProviderUnavailable"),
- v.literal("RepositoryFailed"),
- v.literal("Timeout")
-);
-
-const resolveReadySlice = async (
- ctx: MutationCtx,
- work: Doc<"works">,
- sliceId?: string
-) => {
- if (work.designVersion === undefined) {
- throw new ConvexError("Work has no approved Design to execute");
- }
- const slices = await ctx.db
- .query("workSlices")
- .withIndex("by_workId_and_designVersion", (q) =>
- q.eq("workId", work._id).eq("designVersion", work.designVersion!)
- )
- .collect();
- const slice = sliceId
- ? slices.find((candidate) => candidate.sliceId === sliceId)
- : slices
- .sort((left, right) => left.ordinal - right.ordinal)
- .find((candidate) => candidate.status === "ready");
- if (!slice || slice.status !== "ready") {
- throw new ConvexError("Only the next ready slice can be executed");
- }
- return slice;
-};
-
-// Deployment prerequisite gate for real execution. Rejects before any
-// workflow/sandbox starts when the project lacks an attached, forge-matched
-// Git connection with a cloneable source URL and default branch. Returns the
-// validated connection so the caller can pass it to the agent unchanged.
-const validateProjectDeployment = async (
- ctx: MutationCtx,
- work: Doc<"works">
-): Promise> => {
- const project = await ctx.db.get(work.projectId);
- if (!project) {
- throw new ConvexError("Project not found");
- }
- if (!project.gitConnectionId) {
- throw new ConvexError("Connect Git credentials to this project first");
- }
- const connection = await ctx.db.get(project.gitConnectionId);
- if (!connection) {
- throw new ConvexError("Git connection not found");
- }
- const expected = forgeForHost(project.sourceHost);
- if (expected && connection.provider !== expected) {
- throw new ConvexError(
- `Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
- );
- }
- if (connection.state !== undefined && connection.state !== "active") {
- throw new ConvexError(
- `Git connection is ${connection.state}; verify or reconnect credentials before execution`
- );
- }
- const now = Date.now();
- if (
- connection.lastVerifiedAt === undefined ||
- now - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS
- ) {
- throw new ConvexError(
- "Git credentials have not been verified recently; run a connection health check before execution"
- );
- }
- return connection;
-};
-
-export const execute = workflow
- .define({ args: { attemptId: v.id("workAttempts") } })
- .handler(async (step, args): Promise => {
- try {
- const result = await step.runAction(
- internal.workExecutionAgent.executeAttempt,
- args,
- { retry: true }
- );
- await step.runMutation(internal.workExecutionWorkflow.completeAttempt, {
- attemptId: args.attemptId,
- result: {
- ...result,
- changedFiles: [...result.changedFiles],
- events: result.events.map((item) => ({
- ...item,
- metadata: { ...item.metadata },
- })),
- },
- });
- } catch (error) {
- const failure = toExecutionFailure(error);
- await step.runMutation(internal.workExecutionWorkflow.failAttempt, {
- attemptId: args.attemptId,
- reason: failure.reason,
- retryable: failure.retryable,
- summary: failure.message,
- });
- }
- });
-
-export const startExecution = mutation({
- args: {
- sliceId: v.optional(v.string()),
- workId: v.id("works"),
- },
- handler: async (
- ctx,
- args
- ): Promise<{
- attemptId: Id<"workAttempts">;
- runId: Id<"workRuns">;
- workflowId: WorkflowId;
- }> => {
- const work = await ctx.db.get(args.workId);
- if (!work) {
- throw new ConvexError("Work not found");
- }
- await requireProjectMember(ctx, work.projectId);
- if (work.status !== "ready") {
- throw new ConvexError("Work must be Ready before execution");
- }
- if (
- work.definitionApprovalVersion !== work.definitionVersion ||
- work.designApprovalVersion !== work.designVersion
- ) {
- throw new ConvexError("Execution requires exact approved versions");
- }
- await validateProjectDeployment(ctx, work);
- const slice = await resolveReadySlice(ctx, work, args.sliceId);
- const createdAt = Date.now();
- const runId = await ctx.db.insert("workRuns", {
- createdAt,
- designVersion: slice.designVersion,
- executionKind: "real",
- kitId: defaultCodingKitV0.id,
- kitVersion: defaultCodingKitV0.version,
- scenario: "success",
- sliceId: slice.sliceId,
- sliceRowId: slice._id,
- status: "running",
- workId: work._id,
- });
- const workspaceKey = `work-${work._id}-run-${runId}`;
- const attemptId = await ctx.db.insert("workAttempts", {
- number: 1,
- runId,
- status: "queued",
- workId: work._id,
- workspaceKey,
- });
- const workflowId: WorkflowId = await workflow.start(
- ctx,
- internal.workExecutionWorkflow.execute,
- { attemptId }
- );
- await ctx.db.patch(runId, { startedAt: createdAt, workflowId });
- await ctx.db.patch(slice._id, { status: "running" });
- await ctx.db.patch(work._id, { status: "executing", updatedAt: createdAt });
- await ctx.db.insert("workEvents", {
- createdAt,
- idempotencyKey: `real-run-started:${runId}`,
- kind: "run.started",
- referenceId: String(runId),
- workId: work._id,
- });
- return { attemptId, runId, workflowId };
- },
-});
-
-export const executionContext = internalQuery({
- args: { attemptId: v.id("workAttempts") },
- handler: async (ctx, args) => {
- const attempt = await ctx.db.get(args.attemptId);
- if (!attempt) {
- throw new ConvexError("Attempt not found");
- }
- const run = await ctx.db.get(attempt.runId);
- const work = await ctx.db.get(attempt.workId);
- if (!run || !work || !run.sliceRowId) {
- throw new ConvexError("Execution records are incomplete");
- }
- const [slice, project] = await Promise.all([
- ctx.db.get(run.sliceRowId),
- ctx.db.get(work.projectId),
- ]);
- if (!slice || !project?.gitConnectionId) {
- throw new ConvexError("Project execution configuration is incomplete");
- }
- const connection = await ctx.db.get(project.gitConnectionId);
- if (!connection) {
- throw new ConvexError("Git connection not found");
- }
- const expectedForge = forgeForHost(project.sourceHost);
- if (expectedForge && connection.provider !== expectedForge) {
- throw new ConvexError(
- `Git credential provider (${connection.provider}) does not match this project's provider (${expectedForge})`
- );
- }
- if (connection.state !== undefined && connection.state !== "active") {
- throw new ConvexError(
- `Git connection is ${connection.state}; execution is blocked`
- );
- }
- return {
- attempt,
- connection,
- project,
- prompt: [
- `Implement this approved Zopu slice: ${slice.title}`,
- `Objective: ${slice.objective}`,
- `Observable behavior: ${slice.observableBehavior}`,
- "Inspect the repository instructions first. Make focused changes and run relevant checks.",
- ].join("\n\n"),
- run,
- work,
- };
- },
-});
-
-export const markAttemptRunning = internalMutation({
- args: { attemptId: v.id("workAttempts") },
- handler: async (ctx, args) => {
- const attempt = await ctx.db.get(args.attemptId);
- if (!attempt || attempt.status === "terminal") {
- return false;
- }
- const run = await ctx.db.get(attempt.runId);
- if (!run || run.status !== "running") {
- return false;
- }
- await ctx.db.patch(attempt._id, {
- leaseExpiresAt: Date.now() + LEASE_MS,
- startedAt: attempt.startedAt ?? Date.now(),
- status: "running",
- });
- return true;
- },
-});
-
-const settleSliceAndWork = async (
- ctx: MutationCtx,
- run: Doc<"workRuns">,
- succeeded: boolean,
- workStatus: Doc<"works">["status"]
-) => {
- if (!run.sliceRowId) {
- return;
- }
- const slice = await ctx.db.get(run.sliceRowId);
- if (!slice) {
- return;
- }
- let sliceStatus: "blocked" | "completed" | "ready" = "ready";
- if (succeeded) {
- sliceStatus = "completed";
- } else if (workStatus === "blocked") {
- sliceStatus = "blocked";
- }
- await ctx.db.patch(slice._id, { status: sliceStatus });
- const work = await ctx.db.get(run.workId);
- if (!work) {
- return;
- }
- let status = workStatus;
- if (succeeded) {
- const slices = await ctx.db
- .query("workSlices")
- .withIndex("by_workId_and_designVersion", (q) =>
- q.eq("workId", work._id).eq("designVersion", slice.designVersion)
- )
- .collect();
- const next = slices
- .sort((left, right) => left.ordinal - right.ordinal)
- .find((candidate) => candidate.status === "planned");
- if (next) {
- await ctx.db.patch(next._id, { status: "ready" });
- status = "ready";
- }
- }
- await ctx.db.patch(work._id, { status, updatedAt: Date.now() });
-};
-
-export const completeAttempt = internalMutation({
- args: {
- attemptId: v.id("workAttempts"),
- result: v.object({
- baseRevision: v.string(),
- candidateRevision: v.string(),
- changedFiles: v.array(v.string()),
- diff: v.string(),
- environmentId: v.string(),
- events: v.array(
- v.object({
- kind: v.string(),
- message: v.string(),
- metadata: v.record(v.string(), v.string()),
- occurredAt: v.number(),
- sequence: v.number(),
- })
- ),
- summary: v.string(),
- }),
- },
- handler: async (ctx, args) => {
- const attempt = await ctx.db.get(args.attemptId);
- // Cancellation fencing: a terminal or cancelled attempt/run must never
- // later settle success, even if the agent action raced to completion.
- if (!attempt || attempt.status === "terminal") {
- return;
- }
- const run = await ctx.db.get(attempt.runId);
- const work = await ctx.db.get(attempt.workId);
- if (!run || !work) {
- throw new ConvexError("Execution records not found");
- }
- if (run.status === "terminal" || run.status === "cancelled") {
- return;
- }
- // Empty-change rejection: a no-op result (no changed files, or identical
- // base/candidate revisions) is not a successful implementation. Fail it
- // as a permanent InvalidInput so the resolver does not mark Work done.
- const noOp =
- args.result.changedFiles.length === 0 ||
- args.result.baseRevision === args.result.candidateRevision;
- if (noOp) {
- await ctx.db.patch(attempt._id, {
- classification: "PermanentFailure",
- endedAt: Date.now(),
- failureReason: "InvalidInput",
- leaseExpiresAt: undefined,
- status: "terminal",
- summary: "Execution produced no repository changes",
- });
- await ctx.db.patch(run._id, {
- endedAt: Date.now(),
- status: "terminal",
- terminalClassification: "PermanentFailure",
- terminalSummary: "Execution produced no repository changes",
- });
- await settleSliceAndWork(ctx, run, false, "failed");
- return;
- }
- for (const item of args.result.events) {
- await ctx.db.insert("workAttemptEvents", {
- attemptId: attempt._id,
- kind: item.kind,
- message: item.message,
- metadataJson: JSON.stringify(item.metadata),
- occurredAt: item.occurredAt,
- sequence: item.sequence,
- });
- }
- const endedAt = Date.now();
- await ctx.db.patch(attempt._id, {
- classification: "Succeeded",
- endedAt,
- leaseExpiresAt: undefined,
- status: "terminal",
- summary: args.result.summary,
- });
- await ctx.db.patch(run._id, {
- baseRevision: args.result.baseRevision,
- candidateRevision: args.result.candidateRevision,
- endedAt,
- environmentId: args.result.environmentId,
- status: "terminal",
- terminalClassification: "Succeeded",
- terminalSummary: args.result.summary,
- });
- await ctx.db.insert("workArtifacts", {
- attemptId: attempt._id,
- createdAt: endedAt,
- designVersion: run.designVersion,
- environmentId: args.result.environmentId,
- idempotencyKey: `real-diff:${attempt._id}`,
- kind: "diff",
- metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }),
- organizationId: work.organizationId,
- producer: "agentos-pi",
- projectId: work.projectId,
- provenanceJson: JSON.stringify({
- baseRevision: args.result.baseRevision,
- }),
- runId: run._id,
- sliceId: run.sliceId,
- sourceRevision: args.result.candidateRevision,
- title: "Implementation diff",
- verificationStatus: "unverified",
- workId: work._id,
- ...(args.result.diff.length > 0
- ? {
- uri: `data:text/plain;charset=utf-8,${encodeURIComponent(args.result.diff.slice(0, 50_000))}`,
- }
- : {}),
- });
- await settleSliceAndWork(ctx, run, true, "completed");
- await ctx.db.insert("workEvents", {
- createdAt: endedAt,
- idempotencyKey: `real-run-completed:${run._id}`,
- kind: "run.completed",
- payloadJson: JSON.stringify({ classification: "Succeeded" }),
- referenceId: String(run._id),
- workId: work._id,
- });
- },
-});
-
-export const launchAttemptWorkflow = internalMutation({
- args: { attemptId: v.id("workAttempts") },
- handler: async (ctx, args) => {
- const attempt = await ctx.db.get(args.attemptId);
- if (!attempt || attempt.status === "terminal") {
- return;
- }
- const run = await ctx.db.get(attempt.runId);
- if (!run || run.status !== "running") {
- return;
- }
- const workflowId: WorkflowId = await workflow.start(
- ctx,
- internal.workExecutionWorkflow.execute,
- args
- );
- await ctx.db.patch(run._id, { workflowId });
- },
-});
-
-export const failAttempt = internalMutation({
- args: {
- attemptId: v.id("workAttempts"),
- reason: failureReasonValues,
- retryable: v.boolean(),
- summary: v.string(),
- },
- handler: async (ctx, args) => {
- const attempt = await ctx.db.get(args.attemptId);
- // Cancellation fencing: once an attempt or run is terminal/cancelled, a
- // late failure from the workflow catch block must not overwrite the
- // settled state. This also prevents a cancelled attempt from settling
- // as a different classification after cancelExecution ran.
- if (!attempt || attempt.status === "terminal") {
- return;
- }
- const run = await ctx.db.get(attempt.runId);
- if (!run || run.status === "terminal" || run.status === "cancelled") {
- return;
- }
- const classification = classifyFailure(args.reason);
- const endedAt = Date.now();
- await ctx.db.patch(attempt._id, {
- classification,
- endedAt,
- failureReason: args.reason,
- leaseExpiresAt: undefined,
- status: "terminal",
- summary: args.summary,
- });
- const outcome = resolveOutcome(
- { classification, retryable: args.retryable, summary: args.summary },
- attempt.number,
- defaultCodingKitV0.retryPolicy
- );
- await ctx.db.insert("resolverDecisions", {
- attemptId: attempt._id,
- attemptNumber: attempt.number,
- classification,
- createdAt: endedAt,
- decision: outcome.kind,
- ...(outcome.kind === "terminal"
- ? { resultingWorkStatus: outcome.workStatus }
- : {}),
- runId: run._id,
- summary: args.summary,
- workId: attempt.workId,
- });
- if (outcome.kind === "retry") {
- const nextAttemptId = await ctx.db.insert("workAttempts", {
- number: attempt.number + 1,
- runId: run._id,
- status: "queued",
- workId: attempt.workId,
- workspaceKey: attempt.workspaceKey,
- });
- await ctx.scheduler.runAfter(
- 0,
- internal.workExecutionWorkflow.launchAttemptWorkflow,
- { attemptId: nextAttemptId }
- );
- return;
- }
- await ctx.db.patch(run._id, {
- endedAt,
- status: "terminal",
- terminalClassification: classification,
- terminalSummary: args.summary,
- });
- await settleSliceAndWork(ctx, run, false, outcome.workStatus);
- const work = await ctx.db.get(run.workId);
- if (work) {
- await ctx.db.insert("workEvents", {
- createdAt: endedAt,
- idempotencyKey: `real-run-failed:${run._id}`,
- kind: "run.completed",
- payloadJson: JSON.stringify({ classification }),
- referenceId: String(run._id),
- workId: work._id,
- });
- }
- },
-});
-
-export const cancelExecution = mutation({
- args: { runId: v.id("workRuns") },
- handler: async (ctx, args) => {
- const run = await ctx.db.get(args.runId);
- if (!run) {
- throw new ConvexError("Run not found");
- }
- await requireProjectMember(ctx, (await ctx.db.get(run.workId))!.projectId);
- if (run.status !== "running") {
- return { cancelled: false };
- }
- if (run.workflowId) {
- await workflow.cancel(ctx, run.workflowId as WorkflowId);
- }
- const attempts = await ctx.db
- .query("workAttempts")
- .withIndex("by_runId_and_number", (q) => q.eq("runId", run._id))
- .collect();
- const attempt = attempts.find(
- (candidate) => candidate.status !== "terminal"
- );
- if (attempt?.workspaceKey) {
- await ctx.scheduler.runAfter(
- 0,
- internal.workExecutionAgent.cancelAttempt,
- {
- attemptId: String(attempt._id),
- workspaceKey: attempt.workspaceKey,
- }
- );
- const cancelledAt = Date.now();
- await ctx.db.patch(attempt._id, {
- classification: "Cancelled",
- endedAt: cancelledAt,
- failureReason: "Cancelled",
- leaseExpiresAt: undefined,
- status: "terminal",
- summary: "Execution cancelled",
- });
- await ctx.db.insert("resolverDecisions", {
- attemptId: attempt._id,
- attemptNumber: attempt.number,
- classification: "Cancelled",
- createdAt: cancelledAt,
- decision: "terminal",
- resultingWorkStatus: "ready",
- runId: run._id,
- summary: "Execution cancelled",
- workId: run.workId,
- });
- }
- await ctx.db.patch(run._id, {
- endedAt: Date.now(),
- status: "cancelled",
- terminalClassification: "Cancelled",
- terminalSummary: "Execution cancelled",
- });
- if (run.sliceRowId) {
- await ctx.db.patch(run.sliceRowId, { status: "ready" });
- }
- const work = await ctx.db.get(run.workId);
- if (work) {
- await ctx.db.patch(work._id, { status: "ready", updatedAt: Date.now() });
- }
- return { cancelled: true };
- },
-});
diff --git a/packages/backend/convex/workPlanning.test.ts b/packages/backend/convex/workPlanning.test.ts
index e6473db..326999c 100644
--- a/packages/backend/convex/workPlanning.test.ts
+++ b/packages/backend/convex/workPlanning.test.ts
@@ -1,8 +1,7 @@
import { convexTest } from "convex-test";
-import { anyApi, makeFunctionReference } from "convex/server";
+import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
-import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
@@ -13,402 +12,386 @@ declare global {
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
-const executeFakeAttemptRef = makeFunctionReference<
- "action",
- { attemptId: string; scenario: "success" }
->("workExecution:executeFakeAttempt");
const identity = { tokenIdentifier: "https://convex.test|planner-user" };
-describe("Work planning and execution commands", () => {
- test("binds approvals to exact versions and admits a fake Run only from Ready", async () => {
- const t = convexTest({ modules, schema });
- const fixture = await t.withIdentity(identity).run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- await ctx.db.insert("organizationMembers", {
- createdAt: 1,
- organizationId,
- role: "owner",
- userId: identity.tokenIdentifier,
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://example.com/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "example.com",
- sourceUrl: "https://example.com/zopu",
- updatedAt: 1,
- });
- const workId = await ctx.db.insert("works", {
- createdAt: 1,
- objective: "Prove the flow",
- organizationId,
- projectId,
- status: "proposed",
- title: "Executable work",
- updatedAt: 1,
- });
- return { projectId, workId };
+const seedProject = async (t: ReturnType) =>
+ await t.withIdentity(identity).run(async (ctx) => {
+ const organizationId = await ctx.db.insert("organizations", {
+ createdAt: 1,
+ createdBy: identity.tokenIdentifier,
+ kind: "personal",
+ name: "Personal",
});
-
- await t
- .withIdentity(identity)
- .mutation(api.workPlanning.requestDefinition, { workId: fixture.workId });
- const definition = await t
- .withIdentity(identity)
- .mutation(api.workPlanning.saveDefinitionProposal, {
- payloadJson: JSON.stringify({
- acceptanceCriteria: ["terminal"],
- affectedUsers: ["user"],
- assumptions: [],
- constraints: [],
- desiredOutcome: "A terminal simulation",
- inScope: ["fake"],
- outOfScope: [],
- problem: "Need a proof",
- questions: [],
- requiredArtifacts: ["events"],
- risk: "low",
- }),
- workId: fixture.workId,
- });
- await t
- .withIdentity(identity)
- .mutation(api.workPlanning.approveDefinition, {
- version: definition.version,
- workId: fixture.workId,
- });
- const design = await t
- .withIdentity(identity)
- .mutation(api.workPlanning.saveDesignProposal, {
- payloadJson: JSON.stringify({
- architectureSummary: "fake",
- callFlowDelta: [],
- concerns: [],
- evidenceRequirements: ["event"],
- fileTreeDelta: [],
- impactMap: { files: [], modules: [], risks: [], summary: "small" },
- invariants: ["terminal"],
- keyInterfaces: [],
- slices: [
- {
- codeBoundaries: ["runtime"],
- dependsOn: [],
- evidenceRequirements: ["event"],
- id: "slice-1",
- objective: "fake",
- observableBehavior: "event",
- reviewRequired: false,
- title: "fake",
- verification: ["assert"],
- },
- ],
- tradeoffs: [],
- }),
- workId: fixture.workId,
- });
- await t.withIdentity(identity).mutation(api.workPlanning.approveDesign, {
- definitionVersion: definition.version,
- designVersion: design.version,
- workId: fixture.workId,
+ await ctx.db.insert("organizationMembers", {
+ createdAt: 1,
+ organizationId,
+ role: "owner",
+ userId: identity.tokenIdentifier,
});
- const started = await t
- .withIdentity(identity)
- .mutation(api.workExecution.startSimulatedExecution, {
- scenario: "success",
- sliceId: "slice-1",
- workId: fixture.workId,
- });
- expect(started.runId).toBeTruthy();
- const state = await t.run(async (ctx) => ctx.db.get(fixture.workId));
- expect(state?.status).toBe("executing");
- expect(state?.definitionApprovalVersion).toBe(definition.version);
- expect(state?.designApprovalVersion).toBe(design.version);
- await t.action(executeFakeAttemptRef, {
- attemptId: String(started.attemptId),
- scenario: "success",
+ const projectId = await ctx.db.insert("projects", {
+ createdAt: 2,
+ defaultBranch: "main",
+ name: "Test Project",
+ normalizedSourceUrl: "https://git.example.com/test/repo",
+ organizationId,
+ repositoryPath: "test/repo",
+ sourceHost: "gitea",
+ sourceUrl: "https://git.example.com/test/repo.git",
+ updatedAt: 2,
});
- const completed = await t.run(async (ctx) => ({
- attempt: await ctx.db
- .query("workAttempts")
- .withIndex("by_runId_and_number", (q) => q.eq("runId", started.runId))
- .unique(),
- events: await ctx.db.query("workAttemptEvents").collect(),
- run: await ctx.db.get(started.runId as Id<"workRuns">),
- work: await ctx.db.get(fixture.workId),
- }));
- expect(completed.work?.status).toBe("completed");
- expect(completed.run?.terminalClassification).toBe("Succeeded");
- expect(completed.attempt?.status).toBe("terminal");
- expect(completed.events.length).toBeGreaterThan(0);
- });
-});
-
-const validDefinitionPayload = () => ({
- acceptanceCriteria: ["terminal outcome"],
- affectedUsers: ["user"],
- assumptions: [],
- constraints: [],
- desiredOutcome: "A terminal simulation",
- inScope: ["fake"],
- outOfScope: [],
- problem: "Need a proof",
- questions: [],
- requiredArtifacts: ["events"],
- risk: "low",
-});
-
-const validDesignPayload = (sliceId = "slice-1") => ({
- architectureSummary: "fake",
- callFlowDelta: [],
- concerns: [],
- evidenceRequirements: ["event"],
- fileTreeDelta: [],
- impactMap: { files: [], modules: [], risks: [], summary: "small" },
- invariants: ["terminal"],
- keyInterfaces: [],
- slices: [
- {
- codeBoundaries: ["runtime"],
- dependsOn: [],
- evidenceRequirements: ["event"],
- id: sliceId,
- objective: "fake",
- observableBehavior: "event",
- reviewRequired: false,
- title: "fake",
- verification: ["assert"],
- },
- ],
- tradeoffs: [],
-});
-
-describe("Work planning guards", () => {
- test("persists high-impact questions but blocks approval", async () => {
- const t = convexTest({ modules, schema });
- const { workId } = await t.withIdentity(identity).run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- await ctx.db.insert("organizationMembers", {
- createdAt: 1,
- organizationId,
- role: "owner",
- userId: identity.tokenIdentifier,
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://example.com/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "example.com",
- sourceUrl: "https://example.com/zopu",
- updatedAt: 1,
- });
- const id = await ctx.db.insert("works", {
- createdAt: 1,
- objective: "Resolve a critical question",
- organizationId,
- projectId,
- status: "defining",
- title: "Question gate",
- updatedAt: 1,
- });
- return { workId: id };
- });
- const saved = await t
- .withIdentity(identity)
- .mutation(api.workPlanning.saveDefinitionProposal, {
- payloadJson: JSON.stringify({
- ...validDefinitionPayload(),
- questions: [
- {
- alternatives: ["public", "private"],
- id: "visibility",
- impact: "high",
- prompt: "Should the endpoint be public?",
- status: "open",
- },
- ],
- }),
- workId,
- });
- await expect(
- t.withIdentity(identity).mutation(api.workPlanning.approveDefinition, {
- version: saved.version,
- workId,
- })
- ).rejects.toThrow(/High-impact open questions/u);
- const stored = await t.run(async (ctx) =>
- ctx.db.query("workQuestions").collect()
- );
- expect(stored).toHaveLength(1);
- expect(stored[0]?.status).toBe("open");
+ return { organizationId, projectId };
});
- test("keeps prior Design slices as versioned history", async () => {
- const t = convexTest({ modules, schema });
- const { workId } = await t.withIdentity(identity).run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- await ctx.db.insert("organizationMembers", {
- createdAt: 1,
- organizationId,
- role: "owner",
- userId: identity.tokenIdentifier,
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://example.com/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "example.com",
- sourceUrl: "https://example.com/zopu",
- updatedAt: 1,
- });
- const id = await ctx.db.insert("works", {
- createdAt: 1,
- definitionApprovalVersion: 1,
+const seedReadyWork = async (
+ t: ReturnType,
+ organizationId: string,
+ projectId: string,
+ workIdOverride?: string
+) =>
+ await t.withIdentity(identity).run(async (ctx) => {
+ const workId =
+ workIdOverride ??
+ (await ctx.db.insert("works", {
+ createdAt: 3,
definitionVersion: 1,
- objective: "Preserve Design history",
- organizationId,
- projectId,
- status: "designing",
- title: "Versioned Design",
- updatedAt: 1,
- });
- return { workId: id };
- });
- await t
- .withIdentity(identity)
- .mutation(api.workPlanning.saveDesignProposal, {
- payloadJson: JSON.stringify(validDesignPayload("slice-v1")),
- workId,
- });
- await t
- .withIdentity(identity)
- .mutation(api.workPlanning.saveDesignProposal, {
- payloadJson: JSON.stringify(validDesignPayload("slice-v2")),
- workId,
- });
- const slices = await t.run(async (ctx) =>
- ctx.db.query("workSlices").collect()
- );
- expect(slices.map((slice) => slice.designVersion).sort()).toEqual([1, 2]);
- });
-
- test("a Definition cannot be revised while a Run is executing", async () => {
- const t = convexTest({ modules, schema });
- const { workId } = await t.withIdentity(identity).run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- await ctx.db.insert("organizationMembers", {
- createdAt: 1,
- organizationId,
- role: "owner",
- userId: identity.tokenIdentifier,
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://example.com/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "example.com",
- sourceUrl: "https://example.com/zopu",
- updatedAt: 1,
- });
- const id = await ctx.db.insert("works", {
- createdAt: 1,
- definitionApprovalVersion: 1,
- definitionVersion: 1,
- designApprovalVersion: 1,
designVersion: 1,
- objective: "Prove the guard",
+ objective: "Build it",
organizationId,
projectId,
- status: "executing",
- title: "Guarded work",
- updatedAt: 1,
- });
- return { workId: id };
- });
- await expect(
- t
- .withIdentity(identity)
- .mutation(api.workPlanning.saveDefinitionProposal, {
- payloadJson: JSON.stringify(validDefinitionPayload()),
- workId,
- })
- ).rejects.toThrow(/while a Run is executing/u);
- });
-
- test("planner questions are persisted as durable Work questions", async () => {
- const t = convexTest({ modules, schema });
- const { workId } = await t.withIdentity(identity).run(async (ctx) => {
- const organizationId = await ctx.db.insert("organizations", {
- createdAt: 1,
- createdBy: identity.tokenIdentifier,
- kind: "personal",
- name: "Personal",
- });
- const projectId = await ctx.db.insert("projects", {
- createdAt: 1,
- name: "Zopu",
- normalizedSourceUrl: "https://example.com/zopu",
- organizationId,
- repositoryPath: "puter/zopu",
- sourceHost: "example.com",
- sourceUrl: "https://example.com/zopu",
- updatedAt: 1,
- });
- const id = await ctx.db.insert("works", {
- createdAt: 1,
- definitionVersion: 1,
- objective: "Prove questions",
- organizationId,
- projectId,
- status: "awaiting-definition-approval",
- title: "Questionable work",
- updatedAt: 1,
- });
- return { workId: id };
- });
- await t.mutation(api.workPlanning.submitQuestion, {
- questionJson: JSON.stringify({
- alternatives: [],
- id: "q1",
- impact: "medium",
- prompt: "Which runtime?",
- status: "open",
+ status: "ready",
+ title: "Ready Work",
+ updatedAt: 3,
+ }));
+ await ctx.db.insert("workDefinitions", {
+ createdAt: 3,
+ createdBy: "test",
+ payloadJson: JSON.stringify({
+ acceptanceCriteria: ["Works"],
+ assumptions: [],
+ constraints: [],
+ desiredOutcome: "Ship",
+ inScope: [],
+ outOfScope: [],
+ problem: "Missing",
+ questions: [],
+ requiredArtifacts: [],
+ risk: "low",
+ version: 1,
}),
- token: "test-token",
+ risk: "low",
+ status: "current",
+ version: 1,
workId,
});
- const questions = await t.run(async (ctx) =>
- ctx.db.query("workQuestions").collect()
+ await ctx.db.insert("designPackets", {
+ createdAt: 3,
+ createdBy: "test",
+ definitionVersion: 1,
+ payloadJson: JSON.stringify({
+ architectureSummary: "Simple",
+ callFlowDelta: [],
+ concerns: [],
+ definitionVersion: 1,
+ evidenceRequirements: ["Tests pass"],
+ fileTreeDelta: [],
+ impactMap: {
+ files: [],
+ modules: [],
+ risks: [],
+ summary: "Low",
+ },
+ invariants: ["No breaking changes"],
+ keyInterfaces: [],
+ slices: [
+ {
+ codeBoundaries: ["src/index.ts"],
+ dependsOn: [],
+ evidenceRequirements: ["Build succeeds"],
+ id: "s1",
+ objective: "Implement",
+ observableBehavior: "Feature works",
+ reviewRequired: false,
+ title: "Slice 1",
+ verification: ["Run tests"],
+ },
+ ],
+ tradeoffs: [],
+ version: 1,
+ }),
+ status: "current",
+ version: 1,
+ workId,
+ });
+ await ctx.db.insert("workSlices", {
+ createdAt: 3,
+ designVersion: 1,
+ objective: "Implement",
+ observableBehavior: "It works",
+ ordinal: 0,
+ payloadJson: "{}",
+ sliceId: "s1",
+ status: "ready",
+ title: "Slice 1",
+ workId,
+ });
+ return workId;
+ });
+
+describe("Work planning without approvals", () => {
+ test("creates proposed Work and starts Definition planning on request", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await t.run(
+ async (ctx) =>
+ await ctx.db.insert("works", {
+ createdAt: 4,
+ objective: "Implement the feature",
+ organizationId,
+ projectId,
+ status: "proposed",
+ title: "Feature Work",
+ updatedAt: 4,
+ })
);
- expect(questions).toHaveLength(1);
- expect(questions[0]?.questionId).toBe("q1");
- expect(questions[0]?.definitionVersion).toBe(1);
+ await t
+ .withIdentity(identity)
+ .mutation(api.workPlanning.requestDefinition, {
+ workId,
+ });
+ const work = await t.run(async (ctx) => await ctx.db.get(workId));
+ expect(work?.status).toBe("defining");
+ });
+
+ test("Definition persistence auto-advances to Design phase", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await t.run(
+ async (ctx) =>
+ await ctx.db.insert("works", {
+ createdAt: 1,
+ objective: "Build it",
+ organizationId,
+ projectId,
+ status: "defining",
+ title: "Auto-advance test",
+ updatedAt: 1,
+ })
+ );
+ const definition = {
+ acceptanceCriteria: ["Works end to end"],
+ assumptions: [],
+ constraints: [],
+ desiredOutcome: "Feature is live",
+ inScope: ["Backend"],
+ outOfScope: ["Mobile"],
+ problem: "Missing feature",
+ questions: [],
+ requiredArtifacts: ["PR"],
+ risk: "low",
+ };
+ await t
+ .withIdentity(identity)
+ .mutation(api.workPlanning.saveDefinitionProposal, {
+ payloadJson: JSON.stringify(definition),
+ workId,
+ });
+ const work = await t.run(async (ctx) => await ctx.db.get(workId));
+ expect(work?.status).toBe("designing");
+ expect(work?.definitionVersion).toBe(1);
+ });
+
+ test("Design persistence makes Work ready for execution", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await t.run(
+ async (ctx) =>
+ await ctx.db.insert("works", {
+ createdAt: 1,
+ definitionVersion: 1,
+ objective: "Build it",
+ organizationId,
+ projectId,
+ status: "designing",
+ title: "Ready test",
+ updatedAt: 1,
+ })
+ );
+ await t.run(async (ctx) => {
+ await ctx.db.insert("workDefinitions", {
+ createdAt: 2,
+ createdBy: "test",
+ payloadJson: JSON.stringify({
+ acceptanceCriteria: [],
+ assumptions: [],
+ constraints: [],
+ desiredOutcome: "Ship",
+ inScope: [],
+ outOfScope: [],
+ problem: "Missing",
+ questions: [],
+ requiredArtifacts: [],
+ risk: "low",
+ version: 1,
+ }),
+ risk: "low",
+ status: "current",
+ version: 1,
+ workId,
+ });
+ });
+ const design = {
+ architectureSummary: "Simple",
+ callFlowDelta: [],
+ concerns: [],
+ evidenceRequirements: ["Tests pass"],
+ fileTreeDelta: [],
+ impactMap: {
+ files: [],
+ modules: [],
+ risks: [],
+ summary: "Low",
+ },
+ invariants: ["No breaking changes"],
+ keyInterfaces: [],
+ slices: [
+ {
+ codeBoundaries: ["src/index.ts"],
+ dependsOn: [],
+ evidenceRequirements: ["Build succeeds"],
+ id: "s1",
+ objective: "Implement",
+ observableBehavior: "Feature works",
+ reviewRequired: false,
+ title: "Slice 1",
+ verification: ["Run tests"],
+ },
+ ],
+ tradeoffs: [],
+ };
+ await t
+ .withIdentity(identity)
+ .mutation(api.workPlanning.saveDesignProposal, {
+ payloadJson: JSON.stringify(design),
+ workId,
+ });
+ const work = await t.run(async (ctx) => await ctx.db.get(workId));
+ expect(work?.status).toBe("ready");
+ expect(work?.designVersion).toBe(1);
+ });
+
+ test("revising a Definition invalidates existing Design and restarts planning", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await t.run(
+ async (ctx) =>
+ await ctx.db.insert("works", {
+ createdAt: 1,
+ definitionVersion: 1,
+ designVersion: 1,
+ objective: "Build it",
+ organizationId,
+ projectId,
+ status: "designing",
+ title: "Revision test",
+ updatedAt: 1,
+ })
+ );
+ await t.run(async (ctx) => {
+ await ctx.db.insert("designPackets", {
+ createdAt: 2,
+ createdBy: "test",
+ definitionVersion: 1,
+ payloadJson: "{}",
+ status: "current",
+ version: 1,
+ workId,
+ });
+ });
+ const revisedDefinition = {
+ acceptanceCriteria: ["Works"],
+ assumptions: [],
+ constraints: [],
+ desiredOutcome: "Ship",
+ inScope: ["All"],
+ outOfScope: [],
+ problem: "Missing",
+ questions: [],
+ requiredArtifacts: [],
+ risk: "medium",
+ };
+ await t
+ .withIdentity(identity)
+ .mutation(api.workPlanning.saveDefinitionProposal, {
+ payloadJson: JSON.stringify(revisedDefinition),
+ workId,
+ });
+ const work = await t.run(async (ctx) => await ctx.db.get(workId));
+ expect(work?.status).toBe("designing");
+ expect(work?.definitionVersion).toBe(2);
+ expect(work?.designVersion).toBeUndefined();
+ const designs = await t.run(
+ async (ctx) =>
+ await ctx.db
+ .query("designPackets")
+ .withIndex("by_work_and_version", (q) => q.eq("workId", workId))
+ .collect()
+ );
+ expect(designs.every((d) => d.status === "superseded")).toBe(true);
+ });
+
+ test("rejects execution when Work is not ready", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await t.run(
+ async (ctx) =>
+ await ctx.db.insert("works", {
+ createdAt: 3,
+ objective: "Test",
+ organizationId,
+ projectId,
+ status: "proposed",
+ title: "Proposed Work",
+ updatedAt: 3,
+ })
+ );
+ await expect(
+ t.withIdentity(identity).mutation(api.workExecution.start, { workId })
+ ).rejects.toThrow(/Ready/u);
+ });
+
+ test("rejects execution when Work lacks Definition or Design", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await t.run(
+ async (ctx) =>
+ await ctx.db.insert("works", {
+ createdAt: 3,
+ objective: "Test",
+ organizationId,
+ projectId,
+ status: "ready",
+ title: "Ready but unplanned",
+ updatedAt: 3,
+ })
+ );
+ await expect(
+ t.withIdentity(identity).mutation(api.workExecution.start, { workId })
+ ).rejects.toThrow(/Definition and Design/u);
+ });
+
+ test("execution mutations accept service token for agent calls", async () => {
+ const t = convexTest({ modules, schema });
+ const { organizationId, projectId } = await seedProject(t);
+ const workId = await seedReadyWork(t, organizationId, projectId);
+ // Service-auth path: token + organizationId, no user identity needed.
+ // This should not throw an auth error (it may fail later for missing
+ // git connection, but auth itself must pass).
+ await expect(
+ t.mutation(api.workExecution.start, {
+ organizationId,
+ token: "invalid-token",
+ workId,
+ })
+ ).rejects.toThrow(/agent control token/u);
});
});
diff --git a/packages/backend/convex/workPlanning.ts b/packages/backend/convex/workPlanning.ts
index b3241ba..62d7c92 100644
--- a/packages/backend/convex/workPlanning.ts
+++ b/packages/backend/convex/workPlanning.ts
@@ -1,10 +1,8 @@
import type { WorkEventKind } from "@code/primitives/work";
import {
decodeDefinition,
- validateDefinition,
WorkQuestion,
} from "@code/primitives/work-definition";
-import type { WorkDefinition } from "@code/primitives/work-definition";
import { validateDesignPacket } from "@code/primitives/work-design";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
@@ -26,10 +24,26 @@ const plannerRef = makeFunctionReference<
"action",
{ organizationId: Id<"organizations">; workId: Id<"works"> }
>("workPlanning:runPlanner");
+const submitDefinitionRef = makeFunctionReference<
+ "mutation",
+ { payloadJson: string; token: string; workId: Id<"works"> },
+ { definitionId: Id<"workDefinitions">; version: number }
+>("workPlanning:submitDefinitionProposal");
+const submitDesignRef = makeFunctionReference<
+ "mutation",
+ { payloadJson: string; token: string; workId: Id<"works"> },
+ { designId: Id<"designPackets">; version: number }
+>("workPlanning:submitDesignProposal");
const plannerContextRef = makeFunctionReference<
"query",
{ workId: Id<"works"> },
- { objective: string; status: string; title: string } | null
+ {
+ existingDefinition?: string;
+ objective: string;
+ phase: "definition" | "design";
+ status: string;
+ title: string;
+ } | null
>("workPlanning:getPlannerContext");
const plannerFailureRef = makeFunctionReference<
"mutation",
@@ -91,43 +105,43 @@ const appendEvent = async (
});
};
-const invalidateApprovalsAndDesign = async (
+const supersedePriorDefinitions = async (
ctx: MutationCtx,
workId: Id<"works">
) => {
- const approvals = await ctx.db
- .query("workApprovals")
- .withIndex("by_workId_and_kind", (q) => q.eq("workId", workId))
- .collect();
- for (const approval of approvals) {
- if (approval.status === "active") {
- await ctx.db.patch(approval._id, { status: "invalidated" });
- }
- }
- const designs = await ctx.db
- .query("designPackets")
+ const previous = await ctx.db
+ .query("workDefinitions")
.withIndex("by_work_and_version", (q) => q.eq("workId", workId))
.collect();
- for (const design of designs) {
- if (design.status === "current") {
- await ctx.db.patch(design._id, { status: "superseded" });
+ for (const row of previous) {
+ if (row.status === "current") {
+ await ctx.db.patch(row._id, { status: "superseded" });
}
}
};
+const supersedePriorDesigns = async (ctx: MutationCtx, workId: Id<"works">) => {
+ const prior = await ctx.db
+ .query("designPackets")
+ .withIndex("by_work_and_version", (q) => q.eq("workId", workId))
+ .collect();
+ for (const row of prior) {
+ if (row.status === "current") {
+ await ctx.db.patch(row._id, { status: "superseded" });
+ }
+ }
+};
+
+/**
+ * Starts Definition planning for a proposed Work. No approval is needed.
+ * The planner workflow produces a Definition; persistence auto-advances to Design.
+ */
export const requestDefinition = mutation({
args: { workId: v.id("works") },
handler: async (ctx, args) => {
const work = await requireWork(ctx, args.workId);
await requireProjectMember(ctx, work.projectId);
- if (
- work.status !== "proposed" &&
- work.status !== "defining" &&
- !(
- work.status === "blocked" &&
- work.definitionApprovalVersion !== work.definitionVersion
- )
- ) {
+ if (work.status !== "proposed" && work.status !== "defining") {
throw new ConvexError("Work is not available for definition");
}
const now = Date.now();
@@ -152,8 +166,10 @@ const saveDefinition = async (
payloadJson: string,
createdBy: string
) => {
- if (work.status === "executing") {
- throw new ConvexError("Cannot revise Work while a Run is executing");
+ if (work.status === "executing" || work.status === "verifying") {
+ throw new ConvexError(
+ "Cannot revise Work during execution or verification"
+ );
}
const decoded = await Effect.runPromise(
decodeDefinition({
@@ -166,16 +182,9 @@ const saveDefinition = async (
);
});
const { version } = decoded;
- await invalidateApprovalsAndDesign(ctx, work._id);
- const previous = await ctx.db
- .query("workDefinitions")
- .withIndex("by_work_and_version", (q) => q.eq("workId", work._id))
- .collect();
- for (const row of previous) {
- if (row.status === "current") {
- await ctx.db.patch(row._id, { status: "superseded" });
- }
- }
+ await supersedePriorDefinitions(ctx, work._id);
+ // Revising a Definition invalidates any existing Design so planning restarts.
+ await supersedePriorDesigns(ctx, work._id);
const definitionId = await ctx.db.insert("workDefinitions", {
createdAt: Date.now(),
createdBy,
@@ -199,12 +208,11 @@ const saveDefinition = async (
workId: work._id,
});
}
+ // Auto-advance: Definition persistence starts the Design phase immediately.
await ctx.db.patch(work._id, {
- definitionApprovalVersion: undefined,
definitionVersion: version,
- designApprovalVersion: undefined,
designVersion: undefined,
- status: "awaiting-definition-approval",
+ status: "designing",
updatedAt: Date.now(),
});
await appendEvent(
@@ -215,6 +223,11 @@ const saveDefinition = async (
String(definitionId),
JSON.stringify(decoded)
);
+ // Kick off Design planning automatically.
+ await ctx.scheduler.runAfter(0, plannerRef, {
+ organizationId: work.organizationId,
+ workId: work._id,
+ });
return { definitionId, version };
};
@@ -229,82 +242,6 @@ export const saveDefinitionProposal = mutation({
export const reviseDefinition = saveDefinitionProposal;
-export const approveDefinition = mutation({
- args: { version: v.number(), workId: v.id("works") },
- handler: async (ctx, args) => {
- const work = await requireWork(ctx, args.workId);
- await requireProjectMember(ctx, work.projectId);
- if (
- work.definitionVersion !== args.version ||
- work.status !== "awaiting-definition-approval"
- ) {
- throw new ConvexError("Definition version is not current or approvable");
- }
- const row = await ctx.db
- .query("workDefinitions")
- .withIndex("by_work_and_version", (q) =>
- q.eq("workId", work._id).eq("version", args.version)
- )
- .unique();
- if (!row) {
- throw new ConvexError("Definition not found");
- }
- const definition = JSON.parse(row.payloadJson) as WorkDefinition;
- const valid = await Effect.runPromise(validateDefinition(definition)).catch(
- (error: unknown) => {
- throw new ConvexError(
- error instanceof Error
- ? error.message
- : "Definition cannot be approved"
- );
- }
- );
- const questions = await ctx.db
- .query("workQuestions")
- .withIndex("by_workId_and_definitionVersion", (q) =>
- q.eq("workId", work._id).eq("definitionVersion", args.version)
- )
- .collect();
- if (
- questions.some(
- (question) => question.status === "open" && question.impact === "high"
- )
- ) {
- throw new ConvexError(
- "High-impact open questions must be resolved before approval"
- );
- }
- const identity = await ctx.auth.getUserIdentity();
- if (!identity) {
- throw new ConvexError("Authentication required");
- }
- await ctx.db.insert("workApprovals", {
- approvedAt: Date.now(),
- approvedBy: identity.tokenIdentifier,
- definitionVersion: args.version,
- kind: "definition",
- status: "active",
- workId: work._id,
- });
- await ctx.db.patch(work._id, {
- definitionApprovalVersion: valid.version,
- status: "designing",
- updatedAt: Date.now(),
- });
- await appendEvent(
- ctx,
- work._id,
- "definition.approved",
- `definition-approved:${args.version}`
- );
- await ctx.scheduler.runAfter(0, plannerRef, {
- organizationId: work.organizationId,
- workId: work._id,
- });
- return { status: "designing" as const, version: args.version };
- },
-});
-
const reviseQuestion = async (
ctx: MutationCtx,
work: Doc<"works">,
@@ -320,7 +257,7 @@ const reviseQuestion = async (
if (!current) {
throw new ConvexError("Current Definition not found");
}
- const definition = JSON.parse(current.payloadJson) as WorkDefinition;
+ const definition = JSON.parse(current.payloadJson);
const persistedQuestions = await ctx.db
.query("workQuestions")
.withIndex("by_workId_and_definitionVersion", (q) =>
@@ -386,48 +323,19 @@ export const withdrawQuestion = mutation({
},
});
-export const requestDesign = mutation({
- args: { workId: v.id("works") },
- handler: async (ctx, args) => {
- const work = await requireWork(ctx, args.workId);
- await requireProjectMember(ctx, work.projectId);
- if (
- (work.status !== "designing" && work.status !== "blocked") ||
- work.definitionApprovalVersion !== work.definitionVersion
- ) {
- throw new ConvexError("Definition must be approved before Design");
- }
- if (work.status === "blocked") {
- await ctx.db.patch(work._id, {
- status: "designing",
- updatedAt: Date.now(),
- });
- }
- await appendEvent(
- ctx,
- work._id,
- "design.requested",
- `design-requested:${work.definitionVersion}`
- );
- await ctx.scheduler.runAfter(0, plannerRef, {
- organizationId: work.organizationId,
- workId: work._id,
- });
- return { status: "designing" as const };
- },
-});
-
const saveDesign = async (
ctx: MutationCtx,
work: Doc<"works">,
payloadJson: string,
createdBy: string
) => {
- if (work.status === "executing") {
- throw new ConvexError("Cannot revise Work while a Run is executing");
+ if (work.status === "executing" || work.status === "verifying") {
+ throw new ConvexError(
+ "Cannot revise Work during execution or verification"
+ );
}
- if (work.definitionApprovalVersion !== work.definitionVersion) {
- throw new ConvexError("Design must bind an approved Definition version");
+ if (!work.definitionVersion) {
+ throw new ConvexError("Design requires a current Definition");
}
const decoded = await Effect.runPromise(
validateDesignPacket({
@@ -440,26 +348,7 @@ const saveDesign = async (
error instanceof Error ? error.message : "Invalid Design Packet"
);
});
- const prior = await ctx.db
- .query("designPackets")
- .withIndex("by_work_and_version", (q) => q.eq("workId", work._id))
- .collect();
- for (const row of prior) {
- if (row.status === "current") {
- await ctx.db.patch(row._id, { status: "superseded" });
- }
- }
- const designApprovals = await ctx.db
- .query("workApprovals")
- .withIndex("by_workId_and_kind", (q) =>
- q.eq("workId", work._id).eq("kind", "design")
- )
- .collect();
- for (const approval of designApprovals) {
- if (approval.status === "active") {
- await ctx.db.patch(approval._id, { status: "invalidated" });
- }
- }
+ await supersedePriorDesigns(ctx, work._id);
const designId = await ctx.db.insert("designPackets", {
createdAt: Date.now(),
createdBy,
@@ -483,10 +372,10 @@ const saveDesign = async (
workId: work._id,
});
}
+ // Auto-advance: Design persistence makes the Work ready for execution.
await ctx.db.patch(work._id, {
- designApprovalVersion: undefined,
designVersion: decoded.version,
- status: "awaiting-design-approval",
+ status: "ready",
updatedAt: Date.now(),
});
await appendEvent(
@@ -511,52 +400,6 @@ export const saveDesignProposal = mutation({
export const reviseDesign = saveDesignProposal;
-export const approveDesign = mutation({
- args: {
- definitionVersion: v.number(),
- designVersion: v.number(),
- workId: v.id("works"),
- },
- handler: async (ctx, args) => {
- const work = await requireWork(ctx, args.workId);
- await requireProjectMember(ctx, work.projectId);
- if (
- work.definitionApprovalVersion !== args.definitionVersion ||
- work.designVersion !== args.designVersion ||
- work.status !== "awaiting-design-approval"
- ) {
- throw new ConvexError(
- "Design approval must bind current Definition and Design versions"
- );
- }
- const identity = await ctx.auth.getUserIdentity();
- if (!identity) {
- throw new ConvexError("Authentication required");
- }
- await ctx.db.insert("workApprovals", {
- approvedAt: Date.now(),
- approvedBy: identity.tokenIdentifier,
- definitionVersion: args.definitionVersion,
- designVersion: args.designVersion,
- kind: "design",
- status: "active",
- workId: work._id,
- });
- await ctx.db.patch(work._id, {
- designApprovalVersion: args.designVersion,
- status: "ready",
- updatedAt: Date.now(),
- });
- await appendEvent(
- ctx,
- work._id,
- "design.approved",
- `design-approved:${args.definitionVersion}:${args.designVersion}`
- );
- return { status: "ready" as const };
- },
-});
-
export const submitDefinitionProposal = mutation({
args: { payloadJson: v.string(), token: v.string(), workId: v.id("works") },
handler: async (ctx, args) => {
@@ -589,13 +432,8 @@ export const submitQuestion = mutation({
if (work.definitionVersion === undefined) {
throw new ConvexError("Work has no Definition to attach a question to");
}
- if (
- work.status !== "defining" &&
- work.status !== "awaiting-definition-approval"
- ) {
- throw new ConvexError(
- "Questions can only be submitted while the Work is being defined"
- );
+ if (work.status !== "defining" && work.status !== "designing") {
+ throw new ConvexError("Questions can only be submitted during planning");
}
const question = await Effect.runPromise(
Schema.decodeUnknownEffect(WorkQuestion)(parsePayload(args.questionJson))
@@ -671,6 +509,11 @@ export const recordPlannerFailure = internalMutation({
},
});
+/**
+ * Calls the private Flue `plan-work` workflow for the current planning phase.
+ * The workflow returns a structured Definition or Design Packet; the planner
+ * worker (Flue side) submits it through the authenticated Convex mutations.
+ */
export const runPlanner = internalAction({
args: { organizationId: v.id("organizations"), workId: v.id("works") },
handler: async (ctx, args) => {
@@ -690,38 +533,71 @@ export const runPlanner = internalAction({
return null;
}
const endpoint = new URL(
- `agents/work-planner/${encodeURIComponent(String(args.organizationId))}`,
- `${flueUrl.replace(/\/+$/u, "")}/`
+ "workflows/plan-work",
+ `${flueUrl.replace(/\/+/u, "")}/`
);
endpoint.searchParams.set("wait", "result");
try {
const response = await fetch(endpoint, {
body: JSON.stringify({
- message: `Plan Work ${String(args.workId)} titled "${context.title}" with objective "${context.objective}". Current state is ${context.status}. If the Work is defining, submit only a Definition proposal and questions. If the Work is designing, submit only a Design Packet proposal bound to the approved Definition. Never approve or execute.`,
+ input: {
+ context: {
+ ...(context.existingDefinition
+ ? { existingDefinition: context.existingDefinition }
+ : {}),
+ problemStatement: context.objective,
+ },
+ organizationId: String(args.organizationId),
+ phase: context.phase,
+ requestId: `planner:${args.workId}:${context.phase}`,
+ workId: String(args.workId),
+ },
}),
headers: {
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
"content-type": "application/json",
"x-zopu-organization-id": String(args.organizationId),
- "x-zopu-request-id": `work-planner:${args.workId}`,
},
method: "POST",
});
- if (response.ok) {
+ if (!response.ok) {
+ await ctx.runMutation(plannerFailureRef, {
+ error: `Plan-work workflow request failed (${response.status})`,
+ workId: args.workId,
+ });
return null;
}
- await ctx.runMutation(plannerFailureRef, {
- error: `Work Planner request failed (${response.status})`,
- workId: args.workId,
- });
+ const raw = (await response.json()) as Record;
+ const data = (raw.data as Record | undefined) ?? raw;
+ const phase = data.phase as string | undefined;
+ if (phase === "definition") {
+ const payload = {
+ ...(data.definition as Record),
+ questions: data.questions ?? [],
+ };
+ await ctx.runMutation(submitDefinitionRef, {
+ payloadJson: JSON.stringify(payload),
+ token: env.FLUE_DB_TOKEN,
+ workId: args.workId,
+ });
+ } else if (phase === "design") {
+ await ctx.runMutation(submitDesignRef, {
+ payloadJson: JSON.stringify(data.design),
+ token: env.FLUE_DB_TOKEN,
+ workId: args.workId,
+ });
+ } else {
+ await ctx.runMutation(plannerFailureRef, {
+ error: `Unexpected planner phase: ${String(phase)}`,
+ workId: args.workId,
+ });
+ }
} catch (error) {
await ctx.runMutation(plannerFailureRef, {
error: error instanceof Error ? error.message : String(error),
workId: args.workId,
});
}
- // The private worker submits typed proposals through Convex mutations; it
- // never approves or advances Work itself.
return null;
},
});
@@ -730,9 +606,30 @@ export const getPlannerContext = internalQuery({
args: { workId: v.id("works") },
handler: async (ctx, args) => {
const work = await ctx.db.get(args.workId);
- return work
- ? { objective: work.objective, status: work.status, title: work.title }
- : null;
+ if (!work) {
+ return null;
+ }
+ const phase: "definition" | "design" =
+ work.status === "designing" ? "design" : "definition";
+ let existingDefinition: string | undefined;
+ if (phase === "design") {
+ const current = await ctx.db
+ .query("workDefinitions")
+ .withIndex("by_work_and_version", (q) =>
+ q.eq("workId", work._id).eq("version", work.definitionVersion ?? 0)
+ )
+ .unique();
+ if (current) {
+ existingDefinition = current.payloadJson;
+ }
+ }
+ return {
+ objective: work.objective,
+ phase,
+ status: work.status,
+ title: work.title,
+ ...(existingDefinition ? { existingDefinition } : {}),
+ };
},
});
diff --git a/packages/backend/convex/works.ts b/packages/backend/convex/works.ts
index 09c64b5..585723f 100644
--- a/packages/backend/convex/works.ts
+++ b/packages/backend/convex/works.ts
@@ -4,6 +4,7 @@ import {
workDraftFromSignal,
workProposedEventFromSignal,
} from "@code/primitives/work";
+import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
@@ -18,6 +19,11 @@ const requireAgent = (token: string) => {
}
};
+const plannerRef = makeFunctionReference<
+ "action",
+ { organizationId: Id<"organizations">; workId: Id<"works"> }
+>("workPlanning:runPlanner");
+
const requireSignal = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
@@ -187,6 +193,11 @@ export const createFromSignal = mutation({
signalId: signal._id,
workId,
});
+ // Auto-start Definition planning for newly proposed Work.
+ await ctx.scheduler.runAfter(0, plannerRef, {
+ organizationId: signal.organizationId,
+ workId,
+ });
return { created: true, workId };
},
});
diff --git a/packages/primitives/src/agent-os-git-software.test.ts b/packages/primitives/src/agent-os-git-software.test.ts
index 484ff07..5e99da0 100644
--- a/packages/primitives/src/agent-os-git-software.test.ts
+++ b/packages/primitives/src/agent-os-git-software.test.ts
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
import {
getExecutableGitSoftware,
makeGitExecutablesRunnable,
-} from "./agent-os-git-software";
+} from "./agent-os-git-software.ts";
const TAR_BLOCK_SIZE = 512;
const READ_ONLY_EXECUTABLE_MODE = Buffer.from([0xa4, 0x81, 0x00, 0x00]);
diff --git a/packages/primitives/src/execution-runtime.test.ts b/packages/primitives/src/execution-runtime.test.ts
index c682997..af5b882 100644
--- a/packages/primitives/src/execution-runtime.test.ts
+++ b/packages/primitives/src/execution-runtime.test.ts
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
import {
decodeGitConnectionInput,
decodeWorkAttemptExecutionResult,
-} from "./execution-runtime";
+} from "./execution-runtime.ts";
describe("execution runtime contracts", () => {
test("accepts one authenticated Gitea connection", async () => {
diff --git a/packages/primitives/src/execution-runtime.ts b/packages/primitives/src/execution-runtime.ts
index a97f90d..a05d27b 100644
--- a/packages/primitives/src/execution-runtime.ts
+++ b/packages/primitives/src/execution-runtime.ts
@@ -1,9 +1,9 @@
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
import { Effect, Schema } from "effect";
-import { GitProvider } from "./git-provider";
+import { GitProvider } from "./git-provider.ts";
-export type { GitProvider } from "./git-provider";
+export type { GitProvider } from "./git-provider.ts";
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
diff --git a/packages/primitives/src/git-local-runtime.test.ts b/packages/primitives/src/git-local-runtime.test.ts
index 81c82e4..b78f671 100644
--- a/packages/primitives/src/git-local-runtime.test.ts
+++ b/packages/primitives/src/git-local-runtime.test.ts
@@ -12,7 +12,7 @@ import {
currentBranch,
nodeShell,
push,
-} from "./git-local-runtime";
+} from "./git-local-runtime.ts";
const run = (eff: Effect.Effect) => Effect.runPromise(eff);
diff --git a/packages/primitives/src/git-provider.test.ts b/packages/primitives/src/git-provider.test.ts
index 51c9b6a..3449691 100644
--- a/packages/primitives/src/git-provider.test.ts
+++ b/packages/primitives/src/git-provider.test.ts
@@ -9,7 +9,7 @@ import {
isProviderHostCompatible,
normalizeRepositoryUrl,
providerForHost,
-} from "./git-provider";
+} from "./git-provider.ts";
import {
canSafelyReplaceRepository,
idempotencyKeyFor,
@@ -18,8 +18,8 @@ import {
validatePuterOrgName,
validatePuterUsername,
validateRepositoryName,
-} from "./git-provisioning";
-import { isProcessedEvent } from "./git-webhook";
+} from "./git-provisioning.ts";
+import { isProcessedEvent } from "./git-webhook.ts";
describe("git-provider normalization", () => {
test("normalizes a standard HTTPS GitHub URL", async () => {
diff --git a/packages/primitives/src/git-provisioning.ts b/packages/primitives/src/git-provisioning.ts
index 9178de1..37a667b 100644
--- a/packages/primitives/src/git-provisioning.ts
+++ b/packages/primitives/src/git-provisioning.ts
@@ -1,12 +1,12 @@
/* eslint-disable max-classes-per-file -- provisioning commands, results, and errors form one validated-command module. */
import { Effect, Schema } from "effect";
-import type { GitProvider, ProviderAccount } from "./git-provider";
+import type { GitProvider, ProviderAccount } from "./git-provider.ts";
import {
GitProviderError,
RepositoryPrivacy,
PUTER_GIT_SERVER_URL,
-} from "./git-provider";
+} from "./git-provider.ts";
// ---------------------------------------------------------------------------
// Shared filters
diff --git a/packages/primitives/src/git-remote-runtime.test.ts b/packages/primitives/src/git-remote-runtime.test.ts
index 63a2eea..899f1ca 100644
--- a/packages/primitives/src/git-remote-runtime.test.ts
+++ b/packages/primitives/src/git-remote-runtime.test.ts
@@ -7,8 +7,8 @@ import {
createPullRequest,
getIssue,
listIssues,
-} from "./git-remote-runtime";
-import type { GitRemoteConfig, GitRemoteTransport } from "./git-remote-runtime";
+} from "./git-remote-runtime.ts";
+import type { GitRemoteConfig, GitRemoteTransport } from "./git-remote-runtime.ts";
const config: GitRemoteConfig = {
baseUrl: "https://git.example.com",
diff --git a/packages/primitives/src/git-webhook.ts b/packages/primitives/src/git-webhook.ts
index df8b290..514afb3 100644
--- a/packages/primitives/src/git-webhook.ts
+++ b/packages/primitives/src/git-webhook.ts
@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file -- webhook event envelopes and errors form one normalized module. */
import { Effect, Schema } from "effect";
-import { GitProvider } from "./git-provider";
+import { GitProvider } from "./git-provider.ts";
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
diff --git a/packages/primitives/src/git.test.ts b/packages/primitives/src/git.test.ts
index cfa42c9..9aa6862 100644
--- a/packages/primitives/src/git.test.ts
+++ b/packages/primitives/src/git.test.ts
@@ -6,7 +6,7 @@ import {
inspectGitWorkspace,
makeCommitMessage,
validatePullRequestMetadata,
-} from "./git";
+} from "./git.ts";
const inspectionInput = {
branch: "work/issue-5",
diff --git a/packages/primitives/src/project-issue.test.ts b/packages/primitives/src/project-issue.test.ts
index 514a72c..fad66c7 100644
--- a/packages/primitives/src/project-issue.test.ts
+++ b/packages/primitives/src/project-issue.test.ts
@@ -8,7 +8,7 @@ import {
projectIssueDraftFromSignal,
queueProjectIssue,
validateProjectIssueDraft,
-} from "./project-issue";
+} from "./project-issue.ts";
describe("project issue primitives", () => {
it("normalizes an explicit project request", () => {
diff --git a/packages/primitives/src/project-work.test.ts b/packages/primitives/src/project-work.test.ts
index 8e7acde..4c58995 100644
--- a/packages/primitives/src/project-work.test.ts
+++ b/packages/primitives/src/project-work.test.ts
@@ -4,7 +4,7 @@ import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
-} from "./project-work";
+} from "./project-work.ts";
describe("project work state mapping", () => {
test("maps issue and run statuses to the same product language", () => {
diff --git a/packages/primitives/src/project-workspace.test.ts b/packages/primitives/src/project-workspace.test.ts
index c2a3116..cdd1749 100644
--- a/packages/primitives/src/project-workspace.test.ts
+++ b/packages/primitives/src/project-workspace.test.ts
@@ -8,7 +8,7 @@ import {
WorkspaceExecutionError,
WorkspaceStateError,
WorkspaceValidationError,
-} from "./project-workspace";
+} from "./project-workspace.ts";
const makeInput = () => ({
artifacts: [
diff --git a/packages/primitives/src/project.ts b/packages/primitives/src/project.ts
index dbdb12d..461778d 100644
--- a/packages/primitives/src/project.ts
+++ b/packages/primitives/src/project.ts
@@ -1,9 +1,9 @@
/* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */
import { Context, Effect, Layer, Schema } from "effect";
-import { OrganizationId, ProjectId, TimestampMs } from "./signal";
+import { OrganizationId, ProjectId, TimestampMs } from "./signal.ts";
-export type { OrganizationId, ProjectId } from "./signal";
+export type { OrganizationId, ProjectId } from "./signal.ts";
// ---------------------------------------------------------------------------
// Domain constants
diff --git a/packages/primitives/src/signal.test.ts b/packages/primitives/src/signal.test.ts
index 0fad741..8323e7e 100644
--- a/packages/primitives/src/signal.test.ts
+++ b/packages/primitives/src/signal.test.ts
@@ -1,8 +1,8 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
-import { composeSignal, SignalValidationError } from "./signal";
-import type { SignalValidationReason } from "./signal";
+import { composeSignal, SignalValidationError } from "./signal.ts";
+import type { SignalValidationReason } from "./signal.ts";
type TestSignalScope =
| {
diff --git a/packages/primitives/src/smoke.test.ts b/packages/primitives/src/smoke.test.ts
index cdc862e..a6bef90 100644
--- a/packages/primitives/src/smoke.test.ts
+++ b/packages/primitives/src/smoke.test.ts
@@ -13,7 +13,7 @@ import {
redactSmokeText,
selectSmokeModelRoles,
validateSmokeCapabilities,
-} from "./smoke";
+} from "./smoke.ts";
const validRoleSelection = () => ({
plannerReviewerModel: "glm-5.2",
diff --git a/packages/primitives/src/work-artifact.test.ts b/packages/primitives/src/work-artifact.test.ts
index eac438c..d4e00c2 100644
--- a/packages/primitives/src/work-artifact.test.ts
+++ b/packages/primitives/src/work-artifact.test.ts
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
import {
decodeWorkArtifactDraft,
decodeWorkDeliveryDraft,
-} from "./work-artifact";
+} from "./work-artifact.ts";
describe("Work artifact contracts", () => {
test("accepts traceable artifact and delivery metadata", async () => {
diff --git a/packages/primitives/src/work-lifecycle.ts b/packages/primitives/src/work-lifecycle.ts
index fe9555c..2a5da35 100644
--- a/packages/primitives/src/work-lifecycle.ts
+++ b/packages/primitives/src/work-lifecycle.ts
@@ -8,6 +8,7 @@ export const WorkStatus = Schema.Literals([
"awaiting-design-approval",
"ready",
"executing",
+ "verifying",
"needs-input",
"blocked",
"completed",
@@ -19,15 +20,16 @@ export type WorkStatus = typeof WorkStatus.Type;
export const WORK_TRANSITIONS: Readonly<
Record
> = {
- "awaiting-definition-approval": ["designing", "defining"],
- "awaiting-design-approval": ["ready", "designing"],
- blocked: ["ready", "executing", "cancelled"],
+ "awaiting-definition-approval": ["defining", "cancelled"],
+ "awaiting-design-approval": ["designing", "cancelled"],
+ blocked: ["ready", "defining", "designing", "executing", "cancelled"],
cancelled: ["ready"],
completed: [],
- defining: ["awaiting-definition-approval", "proposed"],
- designing: ["awaiting-design-approval", "awaiting-definition-approval"],
+ defining: ["designing", "proposed"],
+ designing: ["ready", "defining"],
executing: [
"ready",
+ "verifying",
"needs-input",
"blocked",
"completed",
@@ -38,6 +40,14 @@ export const WORK_TRANSITIONS: Readonly<
"needs-input": ["ready", "executing", "cancelled"],
proposed: ["defining"],
ready: ["executing", "designing", "cancelled"],
+ verifying: [
+ "ready",
+ "executing",
+ "completed",
+ "blocked",
+ "failed",
+ "cancelled",
+ ],
};
export const canTransitionWork = (from: WorkStatus, to: WorkStatus): boolean =>
@@ -51,15 +61,3 @@ export const assertWorkTransition = (
throw new Error(`Invalid Work transition: ${from} -> ${to}`);
}
};
-
-export const definitionRevisionInvalidation = {
- definitionApproval: "invalidate" as const,
- dependentDesign: "invalidate" as const,
- designApproval: "invalidate" as const,
- workStatus: "defining" as const,
-};
-
-export const designRevisionInvalidation = {
- designApproval: "invalidate" as const,
- workStatus: "designing" as const,
-};
diff --git a/packages/primitives/src/work-resolution.test.ts b/packages/primitives/src/work-resolution.test.ts
index 55bf862..9f09b69 100644
--- a/packages/primitives/src/work-resolution.test.ts
+++ b/packages/primitives/src/work-resolution.test.ts
@@ -1,11 +1,11 @@
import { Effect } from "effect";
import { describe, expect, test } from "vitest";
-import { FakeHarnessLive } from "./harness-runtime";
-import { defaultCodingKitV0, resolveOutcome, shouldRetry } from "./resolver";
-import { decodeDefinition, validateDefinition } from "./work-definition";
-import { validateDesignPacket } from "./work-design";
-import { canTransitionWork } from "./work-lifecycle";
+import { FakeHarnessLive } from "./harness-runtime.ts";
+import { defaultCodingKitV0, resolveOutcome, shouldRetry } from "./resolver.ts";
+import { decodeDefinition, validateDefinition } from "./work-definition.ts";
+import { validateDesignPacket } from "./work-design.ts";
+import { canTransitionWork } from "./work-lifecycle.ts";
const definition = {
acceptanceCriteria: ["terminal outcome"],
diff --git a/packages/primitives/src/work.test.ts b/packages/primitives/src/work.test.ts
index 92cf1d7..e9386ec 100644
--- a/packages/primitives/src/work.test.ts
+++ b/packages/primitives/src/work.test.ts
@@ -6,7 +6,7 @@ import {
signalAttachedEvent,
workDraftFromSignal,
workProposedEventFromSignal,
-} from "./work";
+} from "./work.ts";
describe("workDraftFromSignal", () => {
test("creates a proposed Work draft from Signal meaning", async () => {
diff --git a/packages/primitives/src/work.ts b/packages/primitives/src/work.ts
index ee9070b..e6d63ae 100644
--- a/packages/primitives/src/work.ts
+++ b/packages/primitives/src/work.ts
@@ -1,7 +1,7 @@
import { Array as EffectArray, Effect, Order, Schema } from "effect";
-export { WorkStatus } from "./work-lifecycle";
-export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle";
+export { WorkStatus } from "./work-lifecycle.ts";
+export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle.ts";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
@@ -92,18 +92,15 @@ export const WorkEventKind = Schema.Literals([
"work.proposed",
"signal.attached",
"definition.requested",
+ "definition.approved",
"definition.saved",
"definition.revised",
- "definition.approved",
- "definition.invalidated",
"question.created",
"question.answered",
"question.withdrawn",
- "design.requested",
"design.saved",
- "design.revised",
"design.approved",
- "design.invalidated",
+ "design.revised",
"planner.failed",
"slice.started",
"slice.completed",
@@ -116,6 +113,9 @@ export const WorkEventKind = Schema.Literals([
"attempt.completed",
"attempt.reconciled",
"resolver.decided",
+ "verification.started",
+ "verification.completed",
+ "verification.failed",
"artifact.recorded",
"delivery.recorded",
"delivery.updated",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9e0e0e7..3565eb5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,6 +12,12 @@ catalogs:
'@convex-dev/better-auth':
specifier: 0.12.5
version: 0.12.5
+ '@rivet-dev/agentos':
+ specifier: 0.2.14
+ version: 0.2.14
+ '@rivet-dev/agentos-core':
+ specifier: 0.2.14
+ version: 0.2.14
'@tailwindcss/postcss':
specifier: 4.3.3
version: 4.3.3
@@ -107,14 +113,17 @@ importers:
specifier: workspace:*
version: link:packages/primitives
'@flue/sdk':
- specifier: 1.0.0-beta.9
- version: 1.0.0-beta.9
+ specifier: npm:@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2
+ version: '@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2'
'@types/bun':
specifier: 'catalog:'
version: 1.3.14
'@types/node':
specifier: 'catalog:'
version: 22.20.1
+ concurrently:
+ specifier: ^9.1.2
+ version: 9.2.4
convex:
specifier: 'catalog:'
version: 1.42.3(react@19.2.8)
@@ -239,12 +248,6 @@ importers:
packages/agents:
dependencies:
- '@agentos-software/git':
- specifier: 0.3.3
- version: 0.3.3
- '@code/backend':
- specifier: workspace:*
- version: link:../backend
'@code/env':
specifier: workspace:*
version: link:../env
@@ -252,23 +255,20 @@ importers:
specifier: workspace:*
version: link:../primitives
'@flue/runtime':
- specifier: latest
- version: 1.0.0-beta.9(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(effect@4.0.0-beta.99)(supports-color@10.2.2)(typebox@1.1.38)(typescript@7.0.2)(ws@8.21.1)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)
+ specifier: npm:@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2
+ version: '@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(effect@4.0.0-beta.99)(supports-color@10.2.2)(typebox@1.1.38)(typescript@7.0.2)(ws@8.21.1)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)'
'@rivet-dev/agentos':
- specifier: 0.2.14
- version: 0.2.14(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(supports-color@10.2.2)(ws@8.21.1)
+ specifier: 'catalog:'
+ version: 0.2.14(@cfworker/json-schema@4.1.1)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(supports-color@10.2.2)(ws@8.21.1)
'@rivet-dev/agentos-core':
- specifier: 0.2.14
- version: 0.2.14(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)
+ specifier: 'catalog:'
+ version: 0.2.14(@cfworker/json-schema@4.1.1)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)
convex:
specifier: 'catalog:'
version: 1.42.3(react@19.2.8)
hono:
specifier: 'catalog:'
version: 4.12.32
- rivetkit:
- specifier: 2.3.9
- version: 2.3.9(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(sql.js@1.14.1)(ws@8.21.1)
valibot:
specifier: 'catalog:'
version: 1.4.2(typescript@7.0.2)
@@ -277,8 +277,11 @@ importers:
specifier: workspace:*
version: link:../config
'@flue/cli':
- specifier: latest
- version: 1.0.0-beta.9(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@22.20.1)(effect@4.0.0-beta.99)(esbuild@0.28.1)(hono@4.12.32)(jiti@2.7.0)(supports-color@10.2.2)(terser@5.49.0)(typebox@1.1.38)(typescript@7.0.2)(wrangler@4.114.0)(ws@8.21.1)(yaml@2.9.0)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)
+ specifier: npm:@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2
+ version: '@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@22.20.1)(effect@4.0.0-beta.99)(esbuild@0.28.1)(hono@4.12.32)(jiti@2.7.0)(supports-color@10.2.2)(terser@5.49.0)(typebox@1.1.38)(typescript@7.0.2)(wrangler@4.114.0)(ws@8.21.1)(yaml@2.9.0)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)'
+ '@rivetkit/engine-cli':
+ specifier: 2.3.9
+ version: 2.3.9
'@types/bun':
specifier: 'catalog:'
version: 1.3.14
@@ -427,10 +430,10 @@ importers:
version: 0.3.3
'@agentos-software/pi':
specifier: 0.2.7
- version: 0.2.7(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ version: 0.2.7(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
'@rivet-dev/agentos':
specifier: 0.2.14
- version: 0.2.14(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(supports-color@10.2.2)(ws@8.21.1)
+ version: 0.2.14(@cfworker/json-schema@4.1.1)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(supports-color@10.2.2)(ws@8.21.1)
effect:
specifier: 'catalog:'
version: 4.0.0-beta.99
@@ -491,7 +494,7 @@ importers:
version: 19.2.8(react@19.2.8)
shadcn:
specifier: ^4.12.0
- version: 4.16.0(supports-color@10.2.2)(typescript@7.0.2)
+ version: 4.16.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(typescript@7.0.2)
shiki:
specifier: ^4.3.1
version: 4.3.1
@@ -1323,6 +1326,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@cfworker/json-schema@4.1.1':
+ resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
+
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
@@ -1429,6 +1435,11 @@ packages:
engines: {node: '>=22.19.0'}
hasBin: true
+ '@earendil-works/pi-ai@0.81.1':
+ resolution: {integrity: sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==}
+ engines: {node: '>=22.19.0'}
+ hasBin: true
+
'@egjs/hammerjs@2.0.17':
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
engines: {node: '>=0.8.0'}
@@ -2114,18 +2125,6 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
- '@flue/cli@1.0.0-beta.9':
- resolution: {integrity: sha512-RPv28ecD7lQ2XBZbuohte3rXDqHHrdXTpCGC5mSxYQsEkOeDN2LOmImQZbJqCrkIAFmMgntzOhaF2+14R61C4Q==}
- engines: {node: '>=22.19.0'}
- hasBin: true
-
- '@flue/runtime@1.0.0-beta.9':
- resolution: {integrity: sha512-ksh0ZkTVyqQnGvU3OnbVX6luAJwe6tt8q7O0vn99b7Cx6XcPTXzY/YEkXrOtCHzV6ZwfSdO9ZfaWbhTD1tdQuQ==}
- engines: {node: '>=22.19.0'}
-
- '@flue/sdk@1.0.0-beta.9':
- resolution: {integrity: sha512-EkGWF1Yw6baM4ekmp+3xFkc+LmDKcIAA0KQSZSpaSqQRYyixXB+IwEbhePS83HCgrR0/jup9l8rgld4Q2IGNOw==}
-
'@google/genai@1.52.0':
resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==}
engines: {node: '>=20.0.0'}
@@ -3657,6 +3656,18 @@ packages:
react-dom:
optional: true
+ '@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2':
+ resolution: {integrity: sha512-5nQEh0Pj4koF/lOyNQ/aTZGLPmXAtiHJmFuevfg6oP9J8G65YPlx64SJAMdc37frUSNlIoYx/yNKDXcBE46RtQ==}
+ engines: {node: '>=22.19.0'}
+ hasBin: true
+
+ '@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2':
+ resolution: {integrity: sha512-sIbIrYXGnCZ3S80NU+9/4qYEGzhT5RBCjHKkrBckiYlIrEW0Y0mohR+hcqt1bSDuk8Jd6EUGF0FCOVSA0CVTxA==}
+ engines: {node: '>=22.19.0'}
+
+ '@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2':
+ resolution: {integrity: sha512-Hts14CWUNUSB9FMTg21maatBEoqbpX80UDmTuMp8N1HNdkEow1pBhIg122ZnfaUyNdzx6vzrQaNhKCfDb7YkeA==}
+
'@rivetkit/bare-ts@0.6.2':
resolution: {integrity: sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -5636,6 +5647,11 @@ packages:
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
engines: {node: '>= 0.8.0'}
+ concurrently@9.2.4:
+ resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
conf@10.2.0:
resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==}
engines: {node: '>=12'}
@@ -9014,6 +9030,9 @@ packages:
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
@@ -9123,6 +9142,10 @@ packages:
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
engines: {node: '>= 0.4'}
+ shell-quote@1.9.0:
+ resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
+ engines: {node: '>= 0.4'}
+
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
@@ -9488,6 +9511,10 @@ packages:
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -10068,6 +10095,10 @@ packages:
resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
engines: {node: '>=10'}
+ yargs@17.7.2:
+ resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ engines: {node: '>=12'}
+
yargs@17.7.3:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
@@ -10113,10 +10144,10 @@ snapshots:
dependencies:
zod: 4.4.3
- '@agentos-software/claude-code@0.2.7(supports-color@10.2.2)':
+ '@agentos-software/claude-code@0.2.7(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)':
dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.4.3)
- '@anthropic-ai/claude-agent-sdk': 0.2.87(supports-color@10.2.2)(zod@4.4.3)
+ '@anthropic-ai/claude-agent-sdk': 0.2.87(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)
zod: 4.4.3
transitivePeerDependencies:
- '@cfworker/json-schema'
@@ -10153,11 +10184,11 @@ snapshots:
'@agentos-software/opencode@0.2.7': {}
- '@agentos-software/pi@0.2.7(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ '@agentos-software/pi@0.2.7(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.4.3)
- '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
- '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -10196,10 +10227,10 @@ snapshots:
package-manager-detector: 1.8.0
tinyexec: 1.2.4
- '@anthropic-ai/claude-agent-sdk@0.2.87(supports-color@10.2.2)(zod@4.4.3)':
+ '@anthropic-ai/claude-agent-sdk@0.2.87(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.74.0(zod@4.4.3)
- '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3)
+ '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
@@ -10285,7 +10316,7 @@ snapshots:
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.30.0
'@smithy/fetch-http-handler': 5.6.11
- '@smithy/node-http-handler': 4.7.3
+ '@smithy/node-http-handler': 4.9.11
'@smithy/types': 4.16.1
tslib: 2.8.1
@@ -11118,6 +11149,8 @@ snapshots:
'@cbor-extract/cbor-extract-win32-x64@2.2.2':
optional: true
+ '@cfworker/json-schema@4.1.1': {}
+
'@chevrotain/types@11.1.2': {}
'@clack/core@1.4.3':
@@ -11246,9 +11279,9 @@ snapshots:
'@microsoft/fetch-event-source': 2.0.1
fastq: 1.20.1
- '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
dependencies:
- '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
ignore: 7.0.5
typebox: 1.1.38
yaml: 2.9.0
@@ -11260,11 +11293,32 @@ snapshots:
- ws
- zod
- '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.4.3)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
- '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)
+ '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)
+ '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
+ '@opentelemetry/api': 1.9.0
+ '@smithy/node-http-handler': 4.7.3
+ http-proxy-agent: 7.0.2(supports-color@10.2.2)
+ https-proxy-agent: 7.0.6(supports-color@10.2.2)
+ openai: 6.26.0(ws@8.21.1)(zod@4.4.3)
+ partial-json: 0.1.7
+ typebox: 1.1.38
+ transitivePeerDependencies:
+ - '@modelcontextprotocol/sdk'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - ws
+ - zod
+
+ '@earendil-works/pi-ai@0.81.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ dependencies:
+ '@anthropic-ai/sdk': 0.91.1(zod@4.4.3)
+ '@aws-sdk/client-bedrock-runtime': 3.1048.0
+ '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)
'@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
'@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3
@@ -12051,102 +12105,14 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
- '@flue/cli@1.0.0-beta.9(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@22.20.1)(effect@4.0.0-beta.99)(esbuild@0.28.1)(hono@4.12.32)(jiti@2.7.0)(supports-color@10.2.2)(terser@5.49.0)(typebox@1.1.38)(typescript@7.0.2)(wrangler@4.114.0)(ws@8.21.1)(yaml@2.9.0)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)':
- dependencies:
- '@cloudflare/vite-plugin': 1.47.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(typescript@7.0.2)(yaml@2.9.0))(wrangler@4.114.0)
- '@flue/runtime': 1.0.0-beta.9(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(effect@4.0.0-beta.99)(supports-color@10.2.2)(typebox@1.1.38)(typescript@7.0.2)(ws@8.21.1)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)
- '@flue/sdk': 1.0.0-beta.9
- '@hono/node-server': 2.0.12(hono@4.12.32)
- '@vercel/detect-agent': 1.2.3
- debug: 4.4.3(supports-color@10.2.2)
- minisearch: 7.2.0
- package-up: 5.0.0
- picocolors: 1.1.1
- ulidx: 2.4.1
- valibot: 1.4.2(typescript@7.0.2)
- vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(typescript@7.0.2)(yaml@2.9.0)'
- transitivePeerDependencies:
- - '@arethetypeswrong/core'
- - '@cfworker/json-schema'
- - '@standard-schema/spec'
- - '@types/json-schema'
- - '@types/node'
- - '@vitejs/devtools'
- - arktype
- - bufferutil
- - effect
- - esbuild
- - hono
- - jiti
- - less
- - publint
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - supports-color
- - sury
- - terser
- - tsx
- - typebox
- - typescript
- - unplugin-unused
- - unrun
- - utf-8-validate
- - wrangler
- - ws
- - yaml
- - zod
- - zod-openapi
- - zod-to-json-schema
-
- '@flue/runtime@1.0.0-beta.9(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(effect@4.0.0-beta.99)(supports-color@10.2.2)(typebox@1.1.38)(typescript@7.0.2)(ws@8.21.1)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)':
- dependencies:
- '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
- '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
- '@hono/node-server': 2.0.12(hono@4.12.32)
- '@hono/standard-validator': 0.2.3(@standard-schema/spec@1.1.0)(hono@4.12.32)
- '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3)
- '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)
- '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(effect@4.0.0-beta.99)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)
- '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(typescript@7.0.2))
- hono: 4.12.32
- hono-openapi: 1.3.1(@hono/standard-validator@0.2.3(@standard-schema/spec@1.1.0)(hono@4.12.32))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(effect@4.0.0-beta.99)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.32)(openapi-types@12.1.3)
- js-yaml: 4.3.0
- just-bash: 3.2.0(supports-color@10.2.2)
- openapi-types: 12.1.3
- quansync: 0.2.11
- ulidx: 2.4.1
- valibot: 1.4.2(typescript@7.0.2)
- transitivePeerDependencies:
- - '@cfworker/json-schema'
- - '@standard-schema/spec'
- - '@types/json-schema'
- - arktype
- - bufferutil
- - effect
- - supports-color
- - sury
- - typebox
- - typescript
- - utf-8-validate
- - ws
- - zod
- - zod-openapi
- - zod-to-json-schema
-
- '@flue/sdk@1.0.0-beta.9':
- dependencies:
- '@durable-streams/client': 0.2.6
-
- '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)':
+ '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)':
dependencies:
google-auth-library: 10.9.1(supports-color@10.2.2)
p-retry: 4.6.2
protobufjs: 7.6.5
ws: 8.21.1
optionalDependencies:
- '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3)
+ '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -12477,9 +12443,9 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.2.0
- '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
dependencies:
- '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -12488,11 +12454,11 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-ai@0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ '@mariozechner/pi-ai@0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.4.3)
'@aws-sdk/client-bedrock-runtime': 3.1096.0
- '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)
+ '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)
'@mistralai/mistralai': 1.14.1
'@sinclair/typebox': 0.34.52
ajv: 8.20.0
@@ -12511,11 +12477,11 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
+ '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)':
dependencies:
'@mariozechner/jiti': 2.6.5
- '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
- '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
'@mariozechner/pi-tui': 0.60.0
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@@ -12581,7 +12547,7 @@ snapshots:
'@mixmark-io/domino@2.2.0': {}
- '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@3.25.76)':
+ '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@3.25.76)':
dependencies:
'@hono/node-server': 2.0.12(hono@4.12.32)
ajv: 8.20.0
@@ -12600,10 +12566,12 @@ snapshots:
raw-body: 3.0.2
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
+ optionalDependencies:
+ '@cfworker/json-schema': 4.1.1
transitivePeerDependencies:
- supports-color
- '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)':
+ '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)':
dependencies:
'@hono/node-server': 2.0.12(hono@4.12.32)
ajv: 8.20.0
@@ -12622,6 +12590,8 @@ snapshots:
raw-body: 3.0.2
zod: 4.4.3
zod-to-json-schema: 3.25.2(zod@4.4.3)
+ optionalDependencies:
+ '@cfworker/json-schema': 4.1.1
transitivePeerDependencies:
- supports-color
@@ -13355,15 +13325,15 @@ snapshots:
dependencies:
'@secure-exec/core': 0.2.1
- '@rivet-dev/agentos-core@0.2.14(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)':
+ '@rivet-dev/agentos-core@0.2.14(@cfworker/json-schema@4.1.1)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)':
dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.4.3)
- '@agentos-software/claude-code': 0.2.7(supports-color@10.2.2)
+ '@agentos-software/claude-code': 0.2.7(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)
'@agentos-software/codex-cli': 0.3.4
'@agentos-software/common': 0.2.14
'@agentos-software/manifest': 0.2.14
'@agentos-software/opencode': 0.2.7
- '@agentos-software/pi': 0.2.7(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@agentos-software/pi': 0.2.7(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
'@aws-sdk/client-s3': 3.1096.0
'@rivet-dev/agentos-runtime-core': 0.2.14
'@rivet-dev/agentos-sidecar': 0.2.14
@@ -13430,11 +13400,11 @@ snapshots:
'@rivet-dev/agentos-sidecar-linux-arm64-gnu': 0.2.14
'@rivet-dev/agentos-sidecar-linux-x64-gnu': 0.2.14
- '@rivet-dev/agentos@0.2.14(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(supports-color@10.2.2)(ws@8.21.1)':
+ '@rivet-dev/agentos@0.2.14(@cfworker/json-schema@4.1.1)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(supports-color@10.2.2)(ws@8.21.1)':
dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.4.3)
'@agentos-software/common': 0.2.14
- '@rivet-dev/agentos-core': 0.2.14(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)
+ '@rivet-dev/agentos-core': 0.2.14(@cfworker/json-schema@4.1.1)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)
'@rivetkit/react': 2.3.9(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sql.js@1.14.1)(ws@8.21.1)
rivetkit: 2.3.9(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.29.4)(sql.js@1.14.1)(ws@8.21.1)
zod: 4.4.3
@@ -13482,6 +13452,93 @@ snapshots:
- utf-8-validate
- ws
+ '@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@22.20.1)(effect@4.0.0-beta.99)(esbuild@0.28.1)(hono@4.12.32)(jiti@2.7.0)(supports-color@10.2.2)(terser@5.49.0)(typebox@1.1.38)(typescript@7.0.2)(wrangler@4.114.0)(ws@8.21.1)(yaml@2.9.0)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)':
+ dependencies:
+ '@cloudflare/vite-plugin': 1.47.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(typescript@7.0.2)(yaml@2.9.0))(wrangler@4.114.0)
+ '@flue/runtime': '@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(effect@4.0.0-beta.99)(supports-color@10.2.2)(typebox@1.1.38)(typescript@7.0.2)(ws@8.21.1)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)'
+ '@flue/sdk': '@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2'
+ '@hono/node-server': 2.0.12(hono@4.12.32)
+ '@vercel/detect-agent': 1.2.3
+ debug: 4.4.3(supports-color@10.2.2)
+ minisearch: 7.2.0
+ package-up: 5.0.0
+ picocolors: 1.1.1
+ ulidx: 2.4.1
+ valibot: 1.4.2(typescript@7.0.2)
+ vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(typescript@7.0.2)(yaml@2.9.0)'
+ transitivePeerDependencies:
+ - '@arethetypeswrong/core'
+ - '@standard-schema/spec'
+ - '@types/json-schema'
+ - '@types/node'
+ - '@vitejs/devtools'
+ - arktype
+ - bufferutil
+ - effect
+ - esbuild
+ - hono
+ - jiti
+ - less
+ - publint
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - sury
+ - terser
+ - tsx
+ - typebox
+ - typescript
+ - unplugin-unused
+ - unrun
+ - utf-8-validate
+ - wrangler
+ - ws
+ - yaml
+ - zod
+ - zod-openapi
+ - zod-to-json-schema
+
+ '@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(effect@4.0.0-beta.99)(supports-color@10.2.2)(typebox@1.1.38)(typescript@7.0.2)(ws@8.21.1)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)':
+ dependencies:
+ '@cfworker/json-schema': 4.1.1
+ '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@earendil-works/pi-ai': 0.81.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)
+ '@hono/node-server': 2.0.12(hono@4.12.32)
+ '@hono/standard-validator': 0.2.3(@standard-schema/spec@1.1.0)(hono@4.12.32)
+ '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)
+ '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)
+ '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(effect@4.0.0-beta.99)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)
+ '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(typescript@7.0.2))
+ hono: 4.12.32
+ hono-openapi: 1.3.1(@hono/standard-validator@0.2.3(@standard-schema/spec@1.1.0)(hono@4.12.32))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@7.0.2)))(effect@4.0.0-beta.99)(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(effect@4.0.0-beta.99)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.32)(openapi-types@12.1.3)
+ js-yaml: 4.3.0
+ just-bash: 3.2.0(supports-color@10.2.2)
+ openapi-types: 12.1.3
+ quansync: 0.2.11
+ ulidx: 2.4.1
+ valibot: 1.4.2(typescript@7.0.2)
+ transitivePeerDependencies:
+ - '@standard-schema/spec'
+ - '@types/json-schema'
+ - arktype
+ - bufferutil
+ - effect
+ - supports-color
+ - sury
+ - typebox
+ - typescript
+ - utf-8-validate
+ - ws
+ - zod
+ - zod-openapi
+ - zod-to-json-schema
+
+ '@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2':
+ dependencies:
+ '@durable-streams/client': 0.2.6
+
'@rivetkit/bare-ts@0.6.2': {}
'@rivetkit/engine-cli-darwin-arm64@2.3.9':
@@ -15450,6 +15507,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ concurrently@9.2.4:
+ dependencies:
+ chalk: 4.1.2
+ rxjs: 7.8.2
+ shell-quote: 1.9.0
+ supports-color: 8.1.1
+ tree-kill: 1.2.2
+ yargs: 17.7.2
+
conf@10.2.0:
dependencies:
ajv: 8.20.0
@@ -17630,7 +17696,7 @@ snapshots:
md5.js@1.3.5:
dependencies:
- hash-base: 3.0.5
+ hash-base: 3.1.2
inherits: 2.0.4
safe-buffer: 5.2.1
@@ -19704,6 +19770,10 @@ snapshots:
rw@1.3.3: {}
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
@@ -19816,14 +19886,14 @@ snapshots:
safe-buffer: 5.2.1
to-buffer: 1.2.2
- shadcn@4.16.0(supports-color@10.2.2)(typescript@7.0.2):
+ shadcn@4.16.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(typescript@7.0.2):
dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/parser': 7.29.7
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@dotenvx/dotenvx': 1.75.1
- '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@3.25.76)
+ '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.7
commander: 14.0.3
@@ -19896,6 +19966,8 @@ snapshots:
shell-quote@1.10.0: {}
+ shell-quote@1.9.0: {}
+
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0
@@ -20277,6 +20349,8 @@ snapshots:
tr46@0.0.3: {}
+ tree-kill@1.2.2: {}
+
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -20877,6 +20951,16 @@ snapshots:
y18n: 5.0.8
yargs-parser: 20.2.9
+ yargs@17.7.2:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
yargs@17.7.3:
dependencies:
cliui: 8.0.1
diff --git a/scripts/wait-for-port.mjs b/scripts/wait-for-port.mjs
new file mode 100644
index 0000000..c37d662
--- /dev/null
+++ b/scripts/wait-for-port.mjs
@@ -0,0 +1,40 @@
+import net from "node:net";
+
+const [host, portStr, timeoutStr] = process.argv.slice(2);
+const port = Number.parseInt(portStr ?? "", 10);
+const timeoutMs = Number.parseInt(timeoutStr ?? "30000", 10);
+
+if (!host || Number.isNaN(port)) {
+ console.error("Usage: node wait-for-port.mjs [timeoutMs]");
+ process.exit(1);
+}
+
+const start = Date.now();
+
+function tryConnect() {
+ const socket = new net.Socket();
+ socket.setTimeout(1000);
+ socket.once("connect", () => {
+ socket.destroy();
+ process.exit(0);
+ });
+ socket.once("error", () => {
+ socket.destroy();
+ if (Date.now() - start > timeoutMs) {
+ console.error(`Timed out waiting for ${host}:${port}`);
+ process.exit(1);
+ }
+ setTimeout(tryConnect, 500);
+ });
+ socket.once("timeout", () => {
+ socket.destroy();
+ if (Date.now() - start > timeoutMs) {
+ console.error(`Timed out waiting for ${host}:${port}`);
+ process.exit(1);
+ }
+ setTimeout(tryConnect, 500);
+ });
+ socket.connect(port, host);
+}
+
+tryConnect();
diff --git a/t3.json b/t3.json
new file mode 100644
index 0000000..e9ca004
--- /dev/null
+++ b/t3.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://t3.codes/schema/t3.json",
+ "scripts": [
+ {
+ "name": "Bootstrap worktree (portable)",
+ "icon": "configure",
+ "command": "set -e\nworktree=\"${T3CODE_WORKTREE_PATH:-$PWD}\"\nroot=\"${T3CODE_PROJECT_ROOT:-}\"\nif [ -z \"$root\" ] || [ ! -d \"$root\" ]; then\n root=$(git -C \"$worktree\" worktree list --porcelain | sed -n '1{s/^worktree //;p;}')\nfi\nif [ -z \"$root\" ] || [ ! -d \"$root\" ]; then\n printf '%s\\n' 'Could not find the source checkout for worktree bootstrap.' >&2\n exit 1\nfi\ncd \"$worktree\"\nfor relative in .env packages/backend/.env.local packages/backend/.env.staging; do\n source_path=\"$root/$relative\"\n target_path=\"$worktree/$relative\"\n if [ -f \"$source_path\" ]; then\n mkdir -p \"$(dirname \"$target_path\")\"\n command cp -f \"$source_path\" \"$target_path\"\n fi\ndone\nprintf '%s\\n' 'Copied project environment files.'\ncommand pnpm install --frozen-lockfile",
+ "runOnWorktreeCreate": true
+ },
+ {
+ "name": "Start local stack (Herdr script)",
+ "icon": "play",
+ "command": "bash scripts/start-local-stack.sh",
+ "previewUrl": "http://localhost:5173",
+ "autoOpenPreview": true
+ }
+ ]
+}