diff --git a/.convoys/polish-dag-tiers-cost.md b/.convoys/polish-dag-tiers-cost.md new file mode 100644 index 0000000..800d2fd --- /dev/null +++ b/.convoys/polish-dag-tiers-cost.md @@ -0,0 +1,58 @@ +--- +name: polish-dag-tiers-cost +classification: server-only +success_metric: Cost caps + DAG viz + tiers + preflight + checks panel ship; Epic 7 and model-provider follow-ons close +skip: + - ia + - a11y + - visual + - smoke +status: open +created: 2026-08-25 +model_policy: + roles: + role-conductor: claude-planning + role-architect: claude-planning + role-implementer: qwen-coder + role-doc-writer: qwen-coder + role-reviewer: qwen-coder + escalate_to: claude-planning +--- + +# Polish — DAG + tiers + cost caps + preflight + checks (closing follow-ons) + +## Why + +Second-brain v0.1 + worker-closeout + owner-cockpit left `draft` follow-ons that block a clean `Plan-automation-foundation` `done`: `Task-cost-budget-ui-and-caps` Epic 1, `Task-repo-setup-preflight` Epic 3, `Task-pr-checks-recovery-panel` Epic 8, `Task-convoy-dag-visualization` + `Task-automation-tiers` Epic 5. This convoy closes them with `role-doc-writer` for SOPs. + +Per `plans/Plan-automation-foundation/Plan-automation-foundation.md:332` Polish section and `docs/decisions/2026-08-25-second-brain-os-pivot.md`. + +## Scope + +1. **Cost budget caps** `Task-cost-budget-ui-and-caps` — month-to-date spend per `model_providers.metadata.tier`, `Budget caps` table `workspace_id` + `cap_usd` + `period: monthly`, dispatch pause when exceeded, tRPC `getSpend` + UI `Settings → AI → Budgets`. +2. **DAG visualization** `Task-convoy-dag-visualization` — `components/mission-control/convoy-dag.tsx` small DAG for `ready/in_progress` brief `sliceDependencies`, `GET /mission-control?dag=1` panel. +3. **Automation tiers** `Task-automation-tiers` — `automation_tier enum: manual|assist|auto` per convoy or workspace, `convoys.create` adds `tier`, Mission control badge. +4. **Repo preflight** `Task-repo-setup-preflight` — `POST /api/connected-repos/preflight` checks `git ls-remote` + `worktree` writability before clone, UI shows result. +5. **PR checks recovery panel** `Task-pr-checks-recovery-panel` — reuses webhook `prUrl`, shows checks status + `Retry` button (calls forge adapter `getChecks` stub). + +Out of scope: Epic 11 screenshot capture (needs browser), full LiteLLM. + +## Roles invoked + +Conductor → Architect (5 briefs) → Implementer per brief (disjoint) → Doc-writer (SOP for caps/tiers) → Reviewer. + +## Todos + +- [ ] Brief 1 cost caps +- [ ] Brief 2 DAG viz +- [ ] Brief 3 tiers +- [ ] Brief 4 preflight +- [ ] Brief 5 checks panel + +## IA + +Skipped (`ia` in skip). New table only for budget caps; DAG/tiers reuse `objects.content`. + +## UX + +Partial — `ux` not skipped for DAG: simple SVG, not full canvas. diff --git a/.convoys/polish-dag-tiers-cost/brief-1-cost-caps.md b/.convoys/polish-dag-tiers-cost/brief-1-cost-caps.md new file mode 100644 index 0000000..04a340a --- /dev/null +++ b/.convoys/polish-dag-tiers-cost/brief-1-cost-caps.md @@ -0,0 +1,37 @@ +--- +brief: 1 +convoy: polish-dag-tiers-cost +role: role-implementer +title: Cost budget UI + caps — month-to-date spend and dispatch pause +status: ready +priority: P1 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 1 — `Task-cost-budget-ui-and-caps` (Epic 1 follow-on) + +## Goal + +Month-to-date spend per alias/tier visible, caps pause dispatch when exceeded. Autensa benchmark row `cost tracking & budget caps` `plans/Plan-automation-foundation/Plan-automation-foundation.md:236`. + +## Files + +- `packages/database/src/schema/budget_caps.ts` NEW — `id uuid, workspace_id fk, cap_usd numeric(12,2), period varchar monthly, enabled bool, created_at`. + `spend_ledger` view or `convoy_events` sum query. +- `apps/web/server/routers/budgets.ts` NEW — `getSpend(workspaceId)` sums `convoy_events.estimated_cost_usd` month-to-date grouped by `model_alias`; `getCap`/`upsertCap`/`deleteCap` admin only; `shouldPauseDispatch` helper for orchestrator. +- `apps/orchestrator/src/tick.ts` — call `shouldPauseDispatch` before `enqueueJob`, return `pause` action when cap exceeded. +- `apps/web/app/(app)/[workspaceSlug]/settings/ai/budgets/page.tsx` NEW — table cap + spend chart (simple bar), Test. + +## Acceptance + +- `/settings/ai/budgets` shows MTD $42/$100 (example), `upsertCap cap_usd:100` → orchestrator `tick` returns `pause reason: budget cap` when sum > cap. +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-model-provider-registry/Task-cost-budget-ui-and-caps.md`, `packages/database/src/schema/convoy_events.ts`, `apps/orchestrator/src/tick.ts`, `docs/adr-model-routing.md`. Keep ledger query simple (sum `estimated_cost_usd` where `occurred_at >= month_start`). + +## Depends + +None. diff --git a/.convoys/polish-dag-tiers-cost/brief-2-dag-viz.md b/.convoys/polish-dag-tiers-cost/brief-2-dag-viz.md new file mode 100644 index 0000000..163099f --- /dev/null +++ b/.convoys/polish-dag-tiers-cost/brief-2-dag-viz.md @@ -0,0 +1,36 @@ +--- +brief: 2 +convoy: polish-dag-tiers-cost +role: role-implementer +title: Convoy DAG visualization for brief dependencies +status: ready +priority: P2 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 2 — `Task-convoy-dag-visualization` (Epic 5) + +## Goal + +Small DAG for `ready/in_progress` brief `sliceDependencies` in Mission control detail — Autensa benchmark row `convoy DAG visualization`. + +## Files + +- `apps/web/components/mission-control/convoy-dag.tsx` NEW — SVG DAG (nodes = briefNumber, edges = sliceDependencies), uses `convoyBriefs` from `apps/web/server/lib/convoy/convoy-service.ts` `getConvoyBriefs`, highlights `completedBriefIds` green, `eligible` blue. +- `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` — embed `ConvoyDag` in detail panel when `briefs.length >1`. +- No DB migration. + +## Acceptance + +- DAG renders for 3 briefs where `brief 2 depends [1]`, `brief 3 depends [2]` — edges drawn, no overlap. +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Task-convoy-dag-visualization.md`, `packages/shared/src/orchestrator/convoy-driver.ts`, `apps/web/components/mission-control/convoy-detail-panel.tsx`. Keep SVG, no external lib. + +## Depends + +None. diff --git a/.convoys/polish-dag-tiers-cost/brief-3-tiers.md b/.convoys/polish-dag-tiers-cost/brief-3-tiers.md new file mode 100644 index 0000000..eb3e458 --- /dev/null +++ b/.convoys/polish-dag-tiers-cost/brief-3-tiers.md @@ -0,0 +1,36 @@ +--- +brief: 3 +convoy: polish-dag-tiers-cost +role: role-implementer +title: Automation tiers — manual|assist|auto per convoy +status: ready +priority: P2 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 3 — `Task-automation-tiers` (Epic 5) + +## Goal + +`automation_tier enum manual|assist|auto` per convoy or workspace, Mission control badge, orchestrator respects `manual` → pause. + +## Files + +- `packages/shared/src/convoy-lifecycle/schemas.ts` — add `automationTierSchema` `manual|assist|auto`, add to `convoyObjectContentSchema` `automationTier` optional default `assist`. +- `apps/web/server/routers/convoys.ts` — `create` accepts `automationTier`, `list` returns it, `orchestrator/src/tick.ts` if `tier===manual` return `pause reason: manual tier`. +- `apps/web/components/mission-control/convoy-detail-panel.tsx` — badge `Tier: auto/assist/manual`. + +## Acceptance + +- `create convoy tier:manual` appears `Tier: manual` badge; orchestrator `tick` on that convoy returns `pause` not `dispatch`. +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Task-automation-tiers.md`, `packages/shared/src/convoy-lifecycle/schemas.ts`, `apps/orchestrator/src/tick.ts`. Keep tier default `assist` for backward compat. + +## Depends + +None. diff --git a/.convoys/polish-dag-tiers-cost/brief-4-preflight.md b/.convoys/polish-dag-tiers-cost/brief-4-preflight.md new file mode 100644 index 0000000..f3dedfb --- /dev/null +++ b/.convoys/polish-dag-tiers-cost/brief-4-preflight.md @@ -0,0 +1,35 @@ +--- +brief: 4 +convoy: polish-dag-tiers-cost +role: role-implementer +title: Repo preflight check before clone +status: ready +priority: P2 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 4 — `Task-repo-setup-preflight` (Epic 3) + +## Goal + +`POST /api/connected-repos/preflight` checks `git ls-remote` + worktree writability before clone, UI shows result — Autensa benchmark row `repo setup preflight`. + +## Files + +- `apps/web/server/routers/connected-repos.ts` — add `preflight` procedure `input:{url}` → `git ls-remote url` (timeout 5s) + `fs.access(WORKER_WORKTREE_BASE, W_OK)`, returns `{ok, reason}`. Reuse `forge adapters`. +- `apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx` — call `preflight` on paste, show green/red before `create`. + +## Acceptance + +- Invalid URL `https://nope.invalid/repo.git` → `ok:false reason: ls-remote failed`; valid `https://github.com/octocat/Hello-World.git` → `ok:true`. +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 4. Read `plans/Plan-automation-foundation/Epic-connected-repos/Task-repo-setup-preflight.md`, `apps/web/server/routers/connected-repos.ts`, `apps/worker/src/git/worktree.ts`. Keep timeout small, no creds leak. + +## Depends + +None. diff --git a/.convoys/polish-dag-tiers-cost/brief-5-checks-panel.md b/.convoys/polish-dag-tiers-cost/brief-5-checks-panel.md new file mode 100644 index 0000000..9a182cd --- /dev/null +++ b/.convoys/polish-dag-tiers-cost/brief-5-checks-panel.md @@ -0,0 +1,36 @@ +--- +brief: 5 +convoy: polish-dag-tiers-cost +role: role-implementer +title: PR checks recovery panel +status: ready +priority: P2 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 5 — `Task-pr-checks-recovery-panel` (Epic 8) + +## Goal + +Mission control detail shows PR checks + Retry — `PR checks recovery` benchmark. + +## Files + +- `apps/web/server/lib/forge/adapters.ts` — add `getChecks({owner, repo, sha})` stub (GitHub `GET /repos/{owner}/{repo}/commits/{sha}/check-runs` with token, Forgejo `GET /repos/{owner}/{repo}/commits/{sha}/statuses`), returns `{checks: [{name, status, conclusion}], canRetry}`. +- `apps/web/server/routers/convoys.ts` — add `getChecks` query that loads `convoy.prUrl` + resolves sha via `git ls-remote`, calls adapter. +- `apps/web/components/mission-control/convoy-detail-panel.tsx` — panel `Checks` tab when `prUrl` present, shows list + `Retry` button (stub re-run if forge supports). + +## Acceptance + +- Detail panel `Checks` tab shows checks for PR `sha` (mock if no token → `no token` message, not error). +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 5. Read `plans/Plan-automation-foundation/Epic-forge-webhooks/Task-pr-checks-recovery-panel.md`, `apps/web/server/lib/forge/adapters.ts`. Keep stub simple, never expose token. + +## Depends + +None — needs PR `prUrl` from prior convoys but works without. diff --git a/apps/orchestrator/src/db/convoy.ts b/apps/orchestrator/src/db/convoy.ts index d471997..1aadac4 100644 --- a/apps/orchestrator/src/db/convoy.ts +++ b/apps/orchestrator/src/db/convoy.ts @@ -12,6 +12,8 @@ import type { BriefSummary } from "@tasks/shared/orchestrator"; type Db = typeof dbInstance; +import type { AutomationTier } from "@tasks/shared/convoy-lifecycle"; + export type LoadedConvoy = { id: string; workspaceId: string; @@ -21,6 +23,7 @@ export type LoadedConvoy = { briefs: BriefSummary[]; completedRoles: Set; completedBriefIds: Set; + automationTier?: AutomationTier; }; function parseStatus(raw: string | null): ConvoyStatus { @@ -85,6 +88,7 @@ export async function listActiveConvoys(db: Db): Promise { briefs, completedRoles, completedBriefIds, + automationTier: content.data.automationTier ?? "assist", }); } diff --git a/apps/orchestrator/src/tick.ts b/apps/orchestrator/src/tick.ts index d4bdf13..207679e 100644 --- a/apps/orchestrator/src/tick.ts +++ b/apps/orchestrator/src/tick.ts @@ -6,6 +6,7 @@ import { type OrchestratorAction, type OrchestratorJob, } from "@tasks/shared/orchestrator"; +import type { AutomationTier } from "@tasks/shared/convoy-lifecycle"; import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js"; import { enqueueJob } from "./queue/redis-queue.js"; @@ -113,6 +114,7 @@ export async function runOrchestratorTick( briefs: convoy.briefs, completedRoles: convoy.completedRoles, completedBriefIds: convoy.completedBriefIds, + automationTier: convoy.automationTier, }); results.push(await applyAction(db, redis, env, convoy, action)); diff --git a/apps/web/components/mission-control/convoy-dag.tsx b/apps/web/components/mission-control/convoy-dag.tsx new file mode 100644 index 0000000..5d29fc9 --- /dev/null +++ b/apps/web/components/mission-control/convoy-dag.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { BriefSummary } from "@tasks/shared/orchestrator"; + +interface ConvoyDagProps { + briefs: Array<{ + id: string; + briefNumber: number; + sliceDependencies: number[]; + status: string | null; + }>; + completedBriefIds: Set; +} + +export function ConvoyDag({ briefs, completedBriefIds }: ConvoyDagProps) { + if (briefs.length === 0) return null; + + const briefByNum = new Map(briefs.map((b) => [b.briefNumber, b])); + + const nodeSize = 60; + const spacing = 180; + const startX = 80; + const startY = 80; + + const getPosition = (num: number) => ({ + x: startX + ((num - 1) % 4) * spacing, + y: startY + Math.floor((num - 1) / 4) * 140, + }); + + const briefsSorted = [...briefs].sort((a, b) => a.briefNumber - b.briefNumber); + + return ( +
+ + + + + + + + {briefsSorted.map((brief) => { + const pos = getPosition(brief.briefNumber); + const isComplete = completedBriefIds.has(brief.id); + const isEligible = !isComplete && brief.sliceDependencies.every((dep) => { + const depBrief = briefByNum.get(dep); + return depBrief && completedBriefIds.has(depBrief.id); + }); + + brief.sliceDependencies.forEach((depNum) => { + const dep = briefByNum.get(depNum); + if (dep) { + const depPos = getPosition(depNum); + return ( + + ); + } + }); + + return ( + + + + B{brief.briefNumber} + + {isComplete && ( + + )} + + ); + })} + + +

+ Green = Complete | Blue = Ready | Gray = Blocked +

+
+ ); +} + +function getPosition(num: number) { + const nodeSize = 60; + const spacing = 180; + const startX = 80; + const startY = 80; + return { + x: 80 + ((num - 1) % 4) * 180, + y: 80 + Math.floor((num - 1) / 4) * 140, + }; +} \ No newline at end of file diff --git a/apps/web/server/root.ts b/apps/web/server/root.ts index 9f88026..f76e11b 100644 --- a/apps/web/server/root.ts +++ b/apps/web/server/root.ts @@ -19,6 +19,7 @@ import { connectedReposRouter } from "@/server/routers/connected-repos"; import { convoysRouter } from "@/server/routers/convoys"; import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot"; import { harnessesRouter } from "@/server/routers/harnesses"; +import { budgetsRouter } from "@/server/routers/budgets"; export const appRouter = router({ health: healthRouter, @@ -42,6 +43,7 @@ export const appRouter = router({ harnesses: harnessesRouter, copilot: copilotRouter, opsCopilot: opsCopilotRouter, + budgets: budgetsRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/budgets.ts b/apps/web/server/routers/budgets.ts new file mode 100644 index 0000000..7702abb --- /dev/null +++ b/apps/web/server/routers/budgets.ts @@ -0,0 +1,143 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, gte, sum } from "drizzle-orm"; +import { z } from "zod"; + +import { budgetCaps, convoyEvents } from "@tasks/database/schema"; + +import { router, workspaceProcedure } from "@/server/trpc"; + +function startOfMonth(d = new Date()): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +export const budgetsRouter = router({ + getSpend: workspaceProcedure.query(async ({ ctx }) => { + const monthStart = startOfMonth(); + const rows = await ctx.db + .select({ + modelAlias: convoyEvents.modelAlias, + providerType: convoyEvents.providerType, + modelTier: convoyEvents.modelTier, + totalCost: sum(convoyEvents.estimatedCostUsd).as("total_cost"), + }) + .from(convoyEvents) + .where( + and( + eq(convoyEvents.workspaceId, ctx.workspace.id), + gte(convoyEvents.occurredAt, monthStart), + ), + ) + .groupBy( + convoyEvents.modelAlias, + convoyEvents.providerType, + convoyEvents.modelTier, + ); + + return rows.map((r) => ({ + modelAlias: r.modelAlias ?? "unknown", + providerType: r.providerType ?? "unknown", + modelTier: r.modelTier ?? "unknown", + estimatedCostUsd: Number(r.totalCost ?? 0), + })); + }), + + getCap: workspaceProcedure.query(async ({ ctx }) => { + const cap = await ctx.db.query.budgetCaps.findFirst({ + where: (c, { eq }) => eq(c.workspaceId, ctx.workspace.id), + }); + return cap ?? { enabled: false, capUsd: null, period: "monthly" }; + }), + + upsertCap: workspaceProcedure + .input( + z.object({ + capUsd: z.number().positive().max(1_000_000), + period: z.enum(["monthly"]).default("monthly"), + enabled: z.boolean().default(true), + }), + ) + .mutation(async ({ ctx, input }) => { + if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owners and admins can set budget caps.", + }); + } + + const [upserted] = await ctx.db + .insert(budgetCaps) + .values({ + workspaceId: ctx.workspace.id, + capUsd: input.capUsd.toString(), + period: input.period, + enabled: input.enabled, + }) + .onConflictDoUpdate({ + target: [budgetCaps.workspaceId, budgetCaps.period], + set: { + capUsd: input.capUsd.toString(), + enabled: input.enabled, + updatedAt: new Date(), + }, + }) + .returning(); + + return upserted; + }), + + deleteCap: workspaceProcedure.mutation(async ({ ctx }) => { + if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owners and admins can delete budget caps.", + }); + } + + await ctx.db + .delete(budgetCaps) + .where(eq(budgetCaps.workspaceId, ctx.workspace.id)); + + return { ok: true }; + }), + + shouldPauseDispatch: workspaceProcedure + .input(z.object({ workspaceId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + if (input.workspaceId !== ctx.workspace.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Workspace mismatch.", + }); + } + + const cap = await ctx.db.query.budgetCaps.findFirst({ + where: (c, { and, eq }) => and(eq(c.workspaceId, input.workspaceId), eq(c.enabled, true)), + }); + + if (!cap) return { shouldPause: false, reason: null, spent: 0, cap: null }; + + const monthStart = startOfMonth(); + const spend = await ctx.db + .select({ total: sum(convoyEvents.estimatedCostUsd) }) + .from(convoyEvents) + .where( + and( + eq(convoyEvents.workspaceId, input.workspaceId), + gte(convoyEvents.occurredAt, monthStart), + ), + ); + + const spent = Number(spend[0]?.total ?? 0); + const capUsd = Number(cap.capUsd); + const shouldPause = spent >= capUsd; + + return { + shouldPause, + reason: shouldPause ? `Monthly budget cap $${capUsd} exceeded ($${spent} spent)` : null, + spent, + cap: capUsd, + }; + }), +}); + +export type BudgetsRouter = typeof budgetsRouter; \ No newline at end of file diff --git a/apps/web/server/routers/convoys.ts b/apps/web/server/routers/convoys.ts index c4cd97e..cb11686 100644 --- a/apps/web/server/routers/convoys.ts +++ b/apps/web/server/routers/convoys.ts @@ -9,6 +9,7 @@ import { workspaces, } from "@tasks/database/schema"; import { + automationTierSchema, convoyClassificationSchema, convoyObjectContentSchema, ConvoyTransitionError, @@ -214,6 +215,7 @@ export const convoysRouter = router({ backlogItemId: z.string().uuid().optional(), repoPath: z.string().min(1).optional(), briefFiles: z.array(z.string()).optional(), + automationTier: z.enum(["manual", "assist", "auto"]).default("assist"), }), ) .mutation(async ({ ctx, input }) => { @@ -237,6 +239,7 @@ export const convoysRouter = router({ backlogItemId: input.backlogItemId, repoPath: input.repoPath, briefFiles: input.briefFiles ?? (input.repoPath ? [input.repoPath] : undefined), + automationTier: input.automationTier, }); const [created] = await ctx.db diff --git a/packages/database/src/schema/budget_caps.ts b/packages/database/src/schema/budget_caps.ts new file mode 100644 index 0000000..f64c1e3 --- /dev/null +++ b/packages/database/src/schema/budget_caps.ts @@ -0,0 +1,59 @@ +import { + boolean, + index, + numeric, + pgTable, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { workspaces } from "./workspaces"; + +export const budgetCaps = pgTable( + "budget_caps", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + capUsd: numeric("cap_usd", { precision: 12, scale: 2 }).notNull(), + period: varchar("period", { length: 20 }).notNull().default("monthly"), + enabled: boolean("enabled").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + workspaceIdIdx: index("budget_caps_workspace_id_idx").on(table.workspaceId), + workspacePeriodUnique: uniqueIndex("budget_caps_workspace_id_period_unique").on( + table.workspaceId, + table.period, + ), + }), +); + +export const spendLedger = pgTable( + "spend_ledger", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + modelAlias: varchar("model_alias", { length: 120 }).notNull(), + providerType: varchar("provider_type", { length: 40 }).notNull(), + modelTier: varchar("model_tier", { length: 20 }), + estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }).notNull(), + occurredAt: timestamp("occurred_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + workspaceOccurredIdx: index("spend_ledger_workspace_id_occurred_at_idx").on( + table.workspaceId, + table.occurredAt.desc(), + ), + modelOccurredIdx: index("spend_ledger_model_alias_occurred_at_idx").on( + table.modelAlias, + table.occurredAt.desc(), + ), + }), +); \ No newline at end of file diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index b18ed49..17fd5bb 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -21,3 +21,4 @@ export * from "./worker_checkpoints"; export * from "./harness_providers"; export * from "./convoy_operator_messages"; export * from "./completion_artifacts"; +export * from "./budget_caps"; diff --git a/packages/shared/src/convoy-lifecycle/index.ts b/packages/shared/src/convoy-lifecycle/index.ts index d98f1aa..c286aa9 100644 --- a/packages/shared/src/convoy-lifecycle/index.ts +++ b/packages/shared/src/convoy-lifecycle/index.ts @@ -34,3 +34,8 @@ export { humanGateActorForStatus, type KanbanColumn, } from "./kanban"; + +export { + automationTierSchema, + type AutomationTier, +} from "./schemas"; diff --git a/packages/shared/src/convoy-lifecycle/schemas.ts b/packages/shared/src/convoy-lifecycle/schemas.ts index a92db6d..d9e0dbe 100644 --- a/packages/shared/src/convoy-lifecycle/schemas.ts +++ b/packages/shared/src/convoy-lifecycle/schemas.ts @@ -50,6 +50,10 @@ export const convoyEventTypeSchema = z.enum([ export type ConvoyEventType = z.infer; +export const automationTierSchema = z.enum(["manual", "assist", "auto"]); + +export type AutomationTier = z.infer; + export const convoyObjectContentSchema = z.object({ convoySlug: z.string().min(1).max(120), classification: convoyClassificationSchema, @@ -63,6 +67,7 @@ export const convoyObjectContentSchema = z.object({ briefFiles: z.array(z.string()).optional(), prUrl: z.string().url().optional(), prNumber: z.number().int().optional(), + automationTier: automationTierSchema.default("assist"), }); export type ConvoyObjectContent = z.infer; diff --git a/packages/shared/src/orchestrator/convoy-driver.ts b/packages/shared/src/orchestrator/convoy-driver.ts index 7bad528..6c1493a 100644 --- a/packages/shared/src/orchestrator/convoy-driver.ts +++ b/packages/shared/src/orchestrator/convoy-driver.ts @@ -1,4 +1,4 @@ -import type { ConvoyStatus } from "../convoy-lifecycle/schemas"; +import type { ConvoyStatus, AutomationTier } from "../convoy-lifecycle/schemas"; export type BriefSummary = { id: string; @@ -84,7 +84,17 @@ export function planOrchestratorAction(input: { briefs: BriefSummary[]; completedRoles: Set; completedBriefIds: Set; + automationTier?: AutomationTier; }): OrchestratorAction { + // Manual tier pauses dispatch + if (input.automationTier === "manual") { + return { + type: "pause", + reason: "Manual automation tier — dispatch paused", + gate: null, + }; + } + const gate = GATE_STATUSES[input.status]; if (gate) { return {