diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..15adc7848 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Required for the assistant to call Claude +ANTHROPIC_API_KEY=sk-ant-... +# Or use Claude Max OAuth token instead: +# ANTHROPIC_OAUTH_TOKEN=... + +# Required for the Discord bot +DISCORD_BOT_TOKEN=... + +# Optional: executor URL (defaults to http://localhost:4001) +# EXECUTOR_URL=http://localhost:4001 + +# Optional: assistant server URL for the bot (defaults to http://localhost:3000) +# ASSISTANT_SERVER_URL=http://localhost:3000 + +# Optional: assistant server port (defaults to 3000) +# PORT=3000 + +# Optional: executor server port (defaults to 4001) +# Configured in executor/.env or as env var + +# Optional: tool source API keys +# POSTHOG_PERSONAL_API_KEY=... +# POSTHOG_PROJECT_ID=... +# OPENASSISTANT_GITHUB_TOKEN=... +# VERCEL_TOKEN=... diff --git a/.gitignore b/.gitignore index 82ed1fdf7..3ba593815 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,8 @@ coverage # logs logs -_.log -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json +*.log +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # dotenv environment variable files .env @@ -34,3 +34,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +# Convex local backend state +convex_local_backend.sqlite3 +convex_local_storage/ + +# Generated push env file +executor/.env.executor-push diff --git a/README.md b/README.md index 616cb3f01..fe7d4a4b0 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,35 @@ The active prototype work is in `executor/`. ```bash bun install --cwd executor +``` + +Terminal 1: + +```bash +bun run --cwd executor dev:convex +``` + +Terminal 2: + +```bash bun run --cwd executor dev ``` +Terminal 3: + +```bash +bun run --cwd executor dev:worker +``` + +Terminal 4: + +```bash +bun run --cwd executor dev:web +``` + This starts: -- Executor API server (task execution, approvals, SQLite storage) +- Local Convex backend +- Executor API server (control plane + internal runtime callbacks) +- Executor worker (queued task execution) - Executor web UI (pending approvals + task history) diff --git a/assistant/README.md b/assistant/README.md index 328620f6e..621c1f907 100644 --- a/assistant/README.md +++ b/assistant/README.md @@ -2,5 +2,4 @@ Assistant-side code lives here. -- `packages/agent-executor-adapter`: lightweight adapter for talking to the executor API. - `legacy/openassistant`: previous prototype code moved out of the executor-focused workspace. diff --git a/assistant/legacy/openassistant/NEW_PLAN.md b/assistant/legacy/openassistant/NEW_PLAN.md deleted file mode 100644 index b87cc9c0a..000000000 --- a/assistant/legacy/openassistant/NEW_PLAN.md +++ /dev/null @@ -1,344 +0,0 @@ -# OpenAssistant — Architecture Plan - -## What This Is - -An AI assistant that takes natural language prompts and executes them as typed TypeScript code against registered tools. Unlike CLI-based agents (OpenClaw, ClawdBot), the agent generates TypeScript code rather than shell commands, giving precise control over what gets approved, what runs, and what the agent can access. - -## Example Prompts - -| Prompt | Task Type | Duration | -|---|---|---| -| "On AnswerOverflow/AnswerOverflow, close all issues older than 30 days" | One-shot | Seconds/minutes | -| "Every day at 9am check if my site has hit 10M visitors with PostHog and if it has let me know" | Recurring + conditional exit | Days/weeks | -| "Tell me how many people have subscribed every day at 5pm" | Recurring, no exit | Forever | -| "When I get an email, check if it's spam and if it is delete it" | Event-triggered | Forever | - -## Architecture - -``` - Clients (pull events, push decisions) - ────────────────────────────────────── - Discord bot Web UI CLI Slack - │ │ │ │ - └────────────┴────────┴───────┘ - │ - Event stream (SSE / WS) - + REST for decisions - │ - Server (gateway) - ────────────────────────────────────── - Bun.serve() - ├── SSE event stream per task - ├── REST API (/api/tasks, /api/approvals, /api/hooks) - ├── Workflow .well-known routes (durability) - ├── Agent loop (Claude + codemode runner) - ├── Tool plugins (hand-written + MCP + OpenAPI) - └── Persistence (bun:sqlite for MVP) -``` - -### Key Principle: Server Emits Events, Clients Subscribe - -The server never POSTs to clients. Instead: -1. Every task produces a stream of `TaskEvent`s (persisted, resumable) -2. Clients subscribe via SSE/WebSocket/polling -3. When a tool needs approval, the server emits an `approval_request` event -4. Any client renders approval UI and POSTs the decision to `POST /api/approvals/:callId` -5. The server picks up the decision and continues execution - -This is client-agnostic. Discord, web, CLI — they all implement the same protocol. - -## Execution Sandbox - -LLM-generated code runs in a `node:vm` sandbox with `Object.create(null)` as the context base: - -- **Allowed:** `tools` (injected), `JSON`, `Math`, `Date`, `console` (no-op) -- **Blocked:** `fetch`, `process`, `Bun`, `require`, `import()`, all Node/Bun APIs -- **Constructor chain escape:** Safe — returns the sandbox global which has nothing dangerous -- **Prototype pollution:** Stays in sandbox, does not leak to host -- **Execution timeout:** `vm.runInContext` `timeout` option -- **Memory limits:** Not controllable with `node:vm` (would need rquickjs, deferred to post-MVP) - -Tool functions are wrapped so `toString()` doesn't leak source code. - -## Durability — Vercel Workflow - -[Vercel Workflow DevKit](https://github.com/vercel/workflow) handles: -- **Durable scheduling:** `sleep("1 day")` suspends the workflow, consumes zero compute, survives restarts -- **Durable event waiting:** `createHook()` + `for await` loop for webhook-triggered tasks -- **Crash recovery:** Workflow state is persisted; on restart, replays from the event log -- **Observability:** Every workflow run, every step, every retry is traced - -### Three Workflow Templates (compile-time) - -All workflows are generic. The `prompt` argument (interpreted by Claude at runtime) determines behavior. - -```ts -// workflows/one-shot.ts — "close all stale issues" -export async function oneShotWorkflow(prompt, requesterId, channelId, taskId) { - "use workflow"; - const result = await runAgentTurn(prompt, requesterId, channelId, taskId); - return result; -} - -// workflows/recurring.ts — "every day at 9am check X" -export async function recurringWorkflow(prompt, requesterId, channelId, taskId, intervalMs) { - "use workflow"; - while (true) { - await sleep(intervalMs); - await runAgentTurn(prompt, requesterId, channelId, taskId); - } -} - -// workflows/event-triggered.ts — "when I get an email, do X" -export async function eventTriggeredWorkflow(prompt, requesterId, channelId, taskId, hookToken) { - "use workflow"; - const hook = createHook>({ token: hookToken }); - for await (const payload of hook) { - const enrichedPrompt = `${prompt}\n\nEvent data:\n${JSON.stringify(payload, null, 2)}`; - await runAgentTurn(enrichedPrompt, requesterId, channelId, taskId); - } -} -``` - -### The Shared Step - -```ts -async function runAgentTurn(prompt, requesterId, channelId, taskId) { - "use step"; - // Full runtime access here — this is where the agent loop runs - // Creates runner, calls Claude, executes code, collects receipts - // Emits TaskEvents to the stream (including approval_request) - // Polls for approval decisions from clients -} -``` - -## Tool System - -### defineTool API - -```ts -import { z } from "zod"; - -defineTool({ - description: "Close a GitHub issue", - approval: "required", - args: z.object({ - owner: z.string(), - repo: z.string(), - issueNumber: z.number(), - }), - returns: z.object({ - number: z.number(), - title: z.string(), - state: z.string(), - }), - run: async (input) => { ... }, - formatApproval: (input) => ({ - title: `Close ${input.owner}/${input.repo}#${input.issueNumber}`, - }), -}); -``` - -- `args` (Zod schema) → validates input at invocation + generates TypeScript declarations for typechecker + generates LLM prompt guidance -- `returns` (Zod schema) → generates TypeScript return type -- `description` → LLM prompt guidance -- `approval: "auto" | "required"` → determines approval flow -- `run` → plain `async` function (not Effect in the public API) -- `formatApproval` → optional, provides rich approval presentation - -### Three Tool Sources - -All produce the same `ToolTree` + TypeScript declarations. The runner doesn't know the difference. - -**1. Hand-written plugins** -```ts -export const githubTools = { - github: { - issues: { - close: defineTool({ ... }), - } - } -}; -``` - -**2. MCP servers** (auto-generated at startup) -```ts -// Config: { type: "mcp", name: "answeroverflow", url: "https://www.answeroverflow.com/mcp" } -// → Introspects tools/list -// → Generates: tools.answeroverflow.search_answeroverflow({ query }) -// → Each tool wraps client.callTool(name, input) -``` - -**3. OpenAPI specs** (auto-generated at startup) -```ts -// Config: { type: "openapi", name: "fastspring", spec: "https://...", auth: { ... } } -// → Parses spec, groups by tag -// → GET → approval: "auto", POST/PUT/DELETE → approval: "required" -// → Generates: tools.fastspring.accounts.get({ account_id }) -``` - -### Tool Source Config - -```ts -const toolSources = [ - createGitHubPlugin(), - { type: "mcp", name: "answeroverflow", url: "https://www.answeroverflow.com/mcp" }, - { type: "openapi", name: "fastspring", spec: "https://...", auth: { type: "basic", credentials: "FASTSPRING_CREDENTIALS" } }, -]; -``` - -## Approval Flow - -### One-shot tasks (user is active in conversation) - -1. Agent generates code that calls `tools.github.issues.close(...)` -2. Runner hits the approval gate for `approval: "required"` tools -3. Runner emits `{ type: "approval_request", id, toolPath, preview }` to the task event stream -4. Runner polls `getApprovalDecision(callId)` in a loop -5. Client (Discord/web/CLI) sees the event, renders approval UI -6. User clicks Approve → client POSTs to `POST /api/approvals/:callId` -7. Decision is written to DB -8. Runner's poll picks it up, execution continues - -### Background tasks (recurring/event-triggered, no active conversation) - -Same flow, but the client sends a proactive DM/notification with approval buttons. The user approves asynchronously. - -### Batch approval (future enhancement) - -For tasks that need many approvals (close 15 issues), a batch approval mode: show all proposed actions upfront, user approves once. - -## Task Event Stream - -Every task run produces events. Clients subscribe and render them however they want. - -```ts -type TaskEvent = - | { type: "status"; message: string } - | { type: "code_generated"; code: string } - | { type: "approval_request"; id: string; toolPath: string; input: unknown; preview: ApprovalPresentation } - | { type: "approval_resolved"; id: string; decision: "approved" | "denied" } - | { type: "tool_result"; id: string; toolPath: string; status: "succeeded" | "failed" | "denied"; preview?: string } - | { type: "agent_message"; text: string } - | { type: "error"; error: string } - | { type: "completed" } -``` - -## Task Management - -The agent has task management tools (so the user speaks natural language): - -``` -tools.tasks.list() → active tasks for this user -tools.tasks.createRecurring(...) → starts a recurring workflow -tools.tasks.createEventTrigger(...) → starts an event-triggered workflow -tools.tasks.cancel({ taskId }) → cancels a workflow run -tools.tasks.history({ taskId }) → recent events from a task's stream -``` - -## Persistence - -**bun:sqlite** for MVP. Single file, zero ops. Used for: -- Workflow world (adapter needed, or use Workflow's local filesystem world) -- Approval decisions (polling table) -- Task registry (what tasks exist, who owns them) -- Auth credentials (env vars for MVP, DB profiles later) - -## Agent Loop - -1. Build system prompt from tool descriptions + type declarations + task context -2. Call Claude with the prompt and the `run_code` tool -3. Claude responds with `run_code({ code: "..." })` -4. Typecheck the code against generated TypeScript declarations -5. If typecheck fails: feed error back to Claude, retry (up to 3 times) -6. Execute code in `node:vm` sandbox -7. Collect receipts from all tool calls -8. Feed receipts back to Claude -9. Claude may call `run_code` again, or produce final text response -10. Emit `agent_message` event to the stream - -## Effect Usage - -Effect is used throughout the codebase, properly: -- **Services + Layers** for dependency injection (database, config, clients) -- **Tagged errors** for typed error handling -- **Tracing spans** for observability -- **Fibers** where concurrency is needed - -The public `defineTool` API uses plain `async` functions — Effect is an internal implementation detail, not exposed to tool authors or the sandbox. - -## Server Routes - -``` -POST /.well-known/workflow/v1/flow Workflow flow handler -POST /.well-known/workflow/v1/step Workflow step handler -* /.well-known/workflow/v1/webhook/:token Workflow webhook handler - -POST /api/tasks Create a new task -GET /api/tasks List tasks for a user -GET /api/tasks/:id Get task status -GET /api/tasks/:id/events SSE stream of TaskEvents -POST /api/tasks/:id/cancel Cancel a task - -POST /api/approvals/:callId Resolve an approval decision - -POST /api/hooks/:token Webhook ingestion (external services POST here) -``` - -## Monorepo Layout - -``` -apps/ - server/ Bun.serve() — the brain - index.ts Routes: REST + Workflow + SSE + webhooks - workflows/ - one-shot.ts "use workflow" - recurring.ts "use workflow" - event-triggered.ts "use workflow" - steps.ts "use step" — shared (runAgentTurn, notifyUser) - - bot/ Discord client - index.ts discord.js, subscribes to event streams - views/ React components via Reacord - -packages/ - core/ - tools.ts defineTool, ToolTree, ToolCallReceipt types - runner.ts node:vm sandbox execution - typechecker.ts TypeScript type checking (auto-gen from Zod) - agent.ts Agent loop (Claude + run_code + retries) - events.ts TaskEvent types - approval.ts Approval types + logic - - tool-gen/ - mcp.ts MCP server introspection → ToolTree - openapi.ts OpenAPI spec parsing → ToolTree - type-gen.ts Zod schema → TypeScript declaration strings - - reacord/ (existing) React reconciler for Discord -``` - -## Build Order - -1. **packages/core** — defineTool, ToolTree, runner (node:vm), typechecker, agent loop, TaskEvent types. Tested with real Claude calls. -2. **packages/tool-gen** — MCP + OpenAPI generation. Uses existing parsers (@modelcontextprotocol/sdk, swagger-parser). -3. **apps/server** — Bun.serve + Workflow integration + REST API + SSE streams. -4. **apps/bot** — Discord client consuming event streams. - -## Testing - -Real tests, no mocks. Claude calls use Claude Max credentials. - -- **Unit tests:** Runner executes code in sandbox, collects receipts. Tool definitions validate with Zod. Typechecker catches bad code. -- **Integration tests:** Agent loop generates and executes code for simple tasks. Tool-gen produces correct ToolTree from MCP/OpenAPI specs. -- **E2E tests:** Full workflow: create task → subscribe to events → approve → get results. - -## What's Deferred (post-MVP) - -- Hard memory limits (rquickjs) -- Credential broker / ephemeral tokens -- Batch approval UX -- Ops web UI -- CLI client -- Plugin discovery from convention directories -- Auto-compaction for long conversation contexts diff --git a/assistant/legacy/openassistant/README.md b/assistant/legacy/openassistant/README.md deleted file mode 100644 index 65e0cefc6..000000000 --- a/assistant/legacy/openassistant/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# OpenAssistant - -## Install - -```bash -bun install -``` - -## Local MVP (remote executor mode) - -Start the executor (private code runner): - -```bash -bun run --filter '@openassistant/executor' dev -``` - -Start the server (agent + approvals + tool broker): - -```bash -OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 \ -OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3000 \ -bun run --filter '@openassistant/server' dev -``` - -Or run all services together: - -```bash -bun run dev -``` - -## Notes - -- Untrusted generated code executes in the executor service. -- Tool calls are proxied back to the server via `/internal/runs/:runId/invoke`. -- Approval flow and secret-backed tool execution stay on the server side. diff --git a/assistant/legacy/openassistant/apps/bot/package.json b/assistant/legacy/openassistant/apps/bot/package.json deleted file mode 100644 index 93f394546..000000000 --- a/assistant/legacy/openassistant/apps/bot/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@openassistant/bot", - "private": true, - "type": "module", - "scripts": { - "with-env": "bun run --env-file=../../.env --", - "dev": "bun run with-env bun --hot src/index.ts", - "start": "bun run with-env bun src/index.ts", - "typecheck": "tsgo --noEmit", - "test": "bun test src/" - }, - "dependencies": { - "@openassistant/core": "workspace:*", - "@openassistant/server": "workspace:*", - "@openassistant/reacord": "workspace:*", - "@elysiajs/eden": "latest", - "discord.js": "^14.17.3", - "effect": "latest", - "react": "^19.0.0" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/react": "^19.0.0" - } -} diff --git a/assistant/legacy/openassistant/apps/bot/src/views/task-message.tsx b/assistant/legacy/openassistant/apps/bot/src/views/task-message.tsx deleted file mode 100644 index 48174c5db..000000000 --- a/assistant/legacy/openassistant/apps/bot/src/views/task-message.tsx +++ /dev/null @@ -1,522 +0,0 @@ -/** - * TaskMessage — self-contained live-updating Discord message for a task. - * - * Subscribes to the server's SSE stream via Eden Treaty on mount, - * reduces TaskEvents into local state, and re-renders reactively. - * - * The command handler just mounts this component and walks away: - * - */ - -import { useState, useEffect } from "react"; -import { - Container, - TextDisplay, - Separator, - ActionRow, - Button, - ModalButton, - Loading, - useInstance, - type ModalField, - type ModalFieldValues, -} from "@openassistant/reacord"; -import type { Client as ApiClient } from "@openassistant/server/client"; -import { unwrap } from "@openassistant/server/client"; -import type { TaskEvent } from "@openassistant/core/events"; -import type { ApprovalPresentation } from "@openassistant/core/tools"; - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -interface PendingApproval { - readonly id: string; - readonly toolPath: string; - readonly input: unknown; - readonly preview: ApprovalPresentation; -} - -interface TaskState { - readonly status: "running" | "completed" | "failed" | "cancelled"; - readonly statusMessage: string; - readonly lastCode: string | null; - readonly toolResults: string[]; - readonly pendingApprovals: PendingApproval[]; - readonly agentMessage: string | null; - readonly error: string | null; - /** Whether an approval rule has been created for this task. */ - readonly hasRule: boolean; -} - -const INITIAL_STATE: TaskState = { - status: "running", - statusMessage: "Thinking...", - lastCode: null, - toolResults: [], - pendingApprovals: [], - agentMessage: null, - error: null, - hasRule: false, -}; - -function reduceEvent(state: TaskState, event: TaskEvent): TaskState { - switch (event.type) { - case "status": - return { ...state, statusMessage: event.message }; - - case "code_generated": - return { ...state, lastCode: event.code, statusMessage: "Running code..." }; - - case "approval_request": - // Deduplicate — if we already have this approval (e.g. from SSE replay), skip it - if (state.pendingApprovals.some((a) => a.id === event.id)) return state; - return { - ...state, - pendingApprovals: [ - ...state.pendingApprovals, - { id: event.id, toolPath: event.toolPath, input: event.input, preview: event.preview }, - ], - statusMessage: "Waiting for approval...", - }; - - case "approval_resolved": - return { - ...state, - pendingApprovals: state.pendingApprovals.filter((a) => a.id !== event.id), - statusMessage: event.decision === "approved" ? "Approved, continuing..." : "Denied.", - }; - - case "tool_result": { - const r = event.receipt; - const icon = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c"; - const shortName = formatToolName(r.toolPath); - const output = formatToolOutput(r.toolPath, r.outputPreview); - const line = `${icon} ${shortName}${output}`; - return { ...state, toolResults: [...state.toolResults, line] }; - } - - case "agent_message": - return { ...state, agentMessage: event.text, statusMessage: "Done" }; - - case "error": - return { ...state, status: "failed", error: event.error, statusMessage: "Failed" }; - - case "completed": - return { ...state, status: "completed", statusMessage: "Completed" }; - } -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export interface TaskMessageProps { - readonly taskId: string; - readonly prompt: string; - readonly api: ApiClient; - readonly executionMode: "local" | "remote"; -} - -export function TaskMessage({ taskId, prompt, api, executionMode }: TaskMessageProps) { - const instance = useInstance(); - const [state, setState] = useState(INITIAL_STATE); - - // Subscribe to SSE stream on mount - useEffect(() => { - let cancelled = false; - - const subscribe = async () => { - try { - const { data: stream, error } = await api.api.tasks({ id: taskId }).events.get(); - - if (error || !stream) { - if (!cancelled) { - setState((s) => ({ ...s, status: "failed", error: "Failed to connect to event stream", statusMessage: "Failed" })); - } - return; - } - - for await (const sse of stream as AsyncIterable<{ event: string; data: TaskEvent }>) { - if (cancelled) break; - setState((s) => reduceEvent(s, sse.data)); - } - } catch (err) { - if (!cancelled) { - setState((s) => ({ - ...s, - status: "failed", - error: err instanceof Error ? err.message : String(err), - statusMessage: "Failed", - })); - } - } - }; - - subscribe(); - - return () => { - cancelled = true; - }; - }, [taskId]); - - // Deactivate buttons once done - useEffect(() => { - if (state.status !== "running") { - const timer = setTimeout(() => instance.deactivate(), 5000); - return () => clearTimeout(timer); - } - }, [state.status]); - - const isDone = state.status !== "running"; - const accentColor = isDone - ? state.status === "completed" ? 0x57f287 : 0xed4245 - : 0x5865f2; - - return ( - - {`${statusEmoji(state.status)} **${state.statusMessage}**`} - {`Runner: ${executionMode === "remote" ? "cloud executor" : "in-process local"}`} - {`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`} - - {state.lastCode && ( - <> - - {`\`\`\`ts\n${state.lastCode.length > 800 ? state.lastCode.slice(0, 800) + "\n// ..." : state.lastCode}\n\`\`\``} - - )} - - {state.toolResults.length > 0 && ( - <> - - {collapseToolResults(state.toolResults, 10).join("\n")} - - )} - - {state.pendingApprovals.map((approval) => ( - setState((s) => ({ ...s, hasRule: true }))} - /> - ))} - - {state.error && ( - <> - - {`\u274c **Error:** ${state.error.slice(0, 500)}`} - - )} - - {state.agentMessage && ( - <> - - - {state.agentMessage.length > 1800 - ? state.agentMessage.slice(0, 1800) + "..." - : state.agentMessage} - - - )} - - {state.status === "running" && state.pendingApprovals.length === 0 && ( - - )} - - ); -} - -// --------------------------------------------------------------------------- -// Approval buttons -// --------------------------------------------------------------------------- - -function ApprovalButtons({ - approval, - api, - taskId, - hasRule, - onRuleCreated, -}: { - approval: PendingApproval; - api: ApiClient; - taskId: string; - hasRule: boolean; - onRuleCreated: () => void; -}) { - const [resolved, setResolved] = useState(false); - - const handle = async (decision: "approved" | "denied") => { - setResolved(true); - try { - await unwrap(api.api.approvals({ callId: approval.id }).post({ decision })); - } catch (err) { - console.error(`[approval ${approval.id}]`, err); - } - }; - - // Extract field names from the input for the rule builder - const inputFields = extractFieldNames(approval.input); - - const ruleFields: ModalField[] = [ - { - type: "stringSelect", - id: "field", - label: "Field", - description: "Which input field to check", - placeholder: "Select a field...", - required: true, - options: inputFields.map((f) => ({ - label: f.name, - value: f.path, - description: f.preview ? `Current: ${f.preview}` : undefined, - })), - }, - { - type: "stringSelect", - id: "operator", - label: "Condition", - description: "How to compare the field value", - required: true, - options: [ - { label: "equals", value: "equals" }, - { label: "does not equal", value: "not_equals" }, - { label: "includes", value: "includes" }, - { label: "does not include", value: "not_includes" }, - ], - }, - { - type: "textInput", - id: "value", - label: "Value", - description: "Value to compare against", - placeholder: "e.g. rhys.dev", - style: "short", - required: true, - }, - { - type: "stringSelect", - id: "decision", - label: "Action", - description: "What to do when the rule matches", - required: true, - options: [ - { label: "Approve matching", value: "approved" }, - { label: "Deny matching", value: "denied" }, - ], - }, - ]; - - const handleRuleSubmit = async (values: ModalFieldValues) => { - const field = values.getStringSelect("field")?.[0]; - const operator = values.getStringSelect("operator")?.[0]; - const value = values.getTextInput("value"); - const decision = values.getStringSelect("decision")?.[0]; - - if (!field || !operator || value === undefined || !decision) return; - - try { - await unwrap( - api.api.tasks({ id: taskId })["approval-rules"].post({ - toolPath: approval.toolPath, - field, - operator: operator as "equals" | "not_equals" | "includes" | "not_includes", - value, - decision: decision as "approved" | "denied", - }), - ); - onRuleCreated(); - } catch (err) { - console.error(`[rule creation]`, err); - } - }; - - return ( - <> - - - {formatApprovalDisplay(approval)} - - {!resolved && ( - - - -
-
-
- - {context.workspaceId} + + {activeWorkspace?.iconUrl ? ( + {activeWorkspaceLabel} + ) : ( + + {activeWorkspaceInitial} + + )} + + {activeWorkspaceLabel} + {mode === "workos" ? ( + {activeOrganizationLabel} + ) : null} + + + + + + {mode === "workos" + ? ( + <> + {organizationGroups.map((group, index) => ( +
+ {showOrganizationHeaders ? ( + + {group.organization.name} + + ) : null} + {group.workspaces.map((workspace) => { + const isActive = workspace.id === context?.workspaceId; + return ( + switchWorkspace(workspace.id)} + className="text-xs" + > + + {workspace.iconUrl ? ( + {workspace.name} + ) : ( + + {(workspace.name[0] ?? "W").toUpperCase()} + + )} + {workspace.name} + + ); + })} + {showOrganizationHeaders && index < organizationGroups.length - 1 ? : null} +
+ ))} + + {personalWorkspaces.length > 0 ? ( + <> + {organizationGroups.length > 0 ? : null} + + Personal + + {personalWorkspaces.map((workspace) => { + const isActive = workspace.id === context?.workspaceId; + return ( + switchWorkspace(workspace.id)} + className="text-xs" + > + + {workspace.iconUrl ? ( + {workspace.name} + ) : ( + + {(workspace.name[0] ?? "W").toUpperCase()} + + )} + {workspace.name} + + ); + })} + + ) : null} + + {organizationGroups.length === 0 && personalWorkspaces.length === 0 ? ( + + No workspaces + + ) : null} + + ) + : ( + + Guest workspace + + )} + {mode === "workos" ? ( + <> + + + + New workspace + + + Invites via {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "local provider"} + + + ) : null} +
+ + + + +
+ + Create workspace + + Create a new personal workspace for your account. + + +
+ { + setCreateError(null); + setNewWorkspaceName(event.target.value); + }} + placeholder="Acme Labs" + maxLength={64} + /> + { + setCreateError(null); + setNewWorkspaceIcon(event.target.files?.[0] ?? null); + }} + /> + {newWorkspaceIcon ? ( +

+ Icon: {newWorkspaceIcon.name} +

+ ) : null} + {createError ? ( +

{createError}

+ ) : null} +
+ + + + +
+
+
+ + ); +} + +function SessionInfo() { + const { loading, clientConfig, isSignedInToWorkos, workosProfile } = useSession(); + const avatarUrl = workosProfile?.avatarUrl ?? null; + const avatarLabel = workosProfile?.name || workosProfile?.email || "User"; + const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase(); + + if (loading) { + return ( +
+ Loading session... +
+ ); + } + + return ( +
+ {isSignedInToWorkos ? ( + + + + + + + Account + + + + Sign out + + + + ) : ( +
+

Guest mode

+
+ )} + + {!isSignedInToWorkos && workosEnabled ? ( +
+ + +
- - {context.actorId} - + ) : null} +
+

+ Auth: {clientConfig?.authProviderMode === "workos" ? "WorkOS" : "local"} +

-
); } @@ -99,11 +417,8 @@ function SessionInfo() { function Sidebar() { return (