Unified · Split
| @@ -208,7 +208,7 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 207 unmodified lines | ||
| 208 | 208 | New workspace |
| 209 | 209 | </DropdownMenuItem> |
| 210 | 210 | <DropdownMenuLabel className="text-[10px] text-muted-foreground"> |
| 211 | − Invites via {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "local provider"} | |
| 211 | + Invites via WorkOS | |
| 212 | 212 | </DropdownMenuLabel> |
| 213 | 213 | </> |
| 214 | 214 | ) : null} |
| @@ -18,7 +18,6 @@ const ROLE_OPTIONS: Role[] = ["owner", "admin", "member", "billing_admin"]; · 17 unmodified lines | ||
| 18 | 18 | export function MembersView() { |
| 19 | 19 | const { |
| 20 | 20 | context, |
| 21 | − clientConfig, | |
| 22 | 21 | workspaces, |
| 23 | 22 | } = useSession(); |
| 24 | 23 | |
| @@ -145,9 +144,7 @@ export function MembersView() { · 120 unmodified lines | ||
| 145 | 144 | {inviteState === "sending" ? "Sending..." : "Send invite"} |
| 146 | 145 | </Button> |
| 147 | 146 | </div> |
| 148 | − <p className="text-xs text-muted-foreground"> | |
| 149 | − Provider: {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "Local invite flow"} | |
| 150 | − </p> | |
| 147 | + <p className="text-xs text-muted-foreground">Provider: WorkOS</p> | |
| 151 | 148 | {inviteMessage ? ( |
| 152 | 149 | <p className={inviteState === "failed" ? "text-xs text-destructive" : "text-xs text-muted-foreground"}> |
| 153 | 150 | {inviteMessage} |
| @@ -8,7 +8,7 @@ export const getClientConfig = query({ · 7 unmodified lines | ||
| 8 | 8 | handler: async () => { |
| 9 | 9 | return { |
| 10 | 10 | authProviderMode: workosEnabled ? "workos" : "local", |
| 11 | − invitesProvider: workosEnabled ? "workos" : "local", | |
| 11 | + invitesProvider: workosEnabled ? "workos" : "disabled", | |
| 12 | 12 | features: { |
| 13 | 13 | organizations: true, |
| 14 | 14 | billing: true, |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; |
| 2 | −import { v } from "convex/values"; | |
| 3 | 2 | import { components, internal } from "./_generated/api"; |
| 4 | 3 | import type { DataModel, Doc, Id } from "./_generated/dataModel"; |
| 5 | 4 | import type { MutationCtx } from "./_generated/server"; |
| @@ -16,10 +15,10 @@ const workosEnabled = Boolean( · 10 unmodified lines | ||
| 16 | 15 | process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET, |
| 17 | 16 | ); |
| 18 | 17 | |
| 19 | −const authFunctions = (internal as any).auth as AuthFunctions; | |
| 18 | +const authFunctions: AuthFunctions = internal.auth; | |
| 20 | 19 | |
| 21 | 20 | const authKitInstance = workosEnabled |
| 22 | − ? new AuthKit<DataModel>((components as any).workOSAuthKit, { | |
| 21 | + ? new AuthKit<DataModel>(components.workOSAuthKit, { | |
| 23 | 22 | authFunctions, |
| 24 | 23 | additionalEventTypes: [ |
| 25 | 24 | "organization.created", |
| @@ -233,7 +232,7 @@ function getIdentityString(identity: Record<string, unknown>, keys: string[]): s · 207 unmodified lines | ||
| 233 | 232 | |
| 234 | 233 | async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { |
| 235 | 234 | try { |
| 236 | − return await ctx.runQuery((components as any).workOSAuthKit.lib.getAuthUser, { | |
| 235 | + return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | |
| 237 | 236 | id: workosUserId, |
| 238 | 237 | }); |
| 239 | 238 | } catch { |
| @@ -241,7 +240,7 @@ async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { · 1 unmodified line | ||
| 241 | 240 | } |
| 242 | 241 | } |
| 243 | 242 | |
| 244 | −const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => Promise<void>> = { | |
| 243 | +const workosEventHandlers = { | |
| 245 | 244 | "user.created": async (ctx, event) => { |
| 246 | 245 | const now = Date.now(); |
| 247 | 246 | const data = event.data; |
| @@ -572,15 +571,16 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 324 unmodified lines | ||
| 572 | 571 | |
| 573 | 572 | await ctx.db.delete(membership._id); |
| 574 | 573 | }, |
| 575 | −}; | |
| 576 | − | |
| 577 | −export const authKitEvent = | |
| 578 | − workosEnabled && authKitInstance | |
| 579 | − ? (authKitInstance.events(workosEventHandlers as any) as any).authKitEvent | |
| 580 | − : internalMutation({ | |
| 581 | − args: {}, | |
| 582 | − handler: async () => null, | |
| 583 | − }); | |
| 574 | +} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>; | |
| 575 | + | |
| 576 | +const authKitEvents = workosEnabled && authKitInstance | |
| 577 | + ? authKitInstance.events(workosEventHandlers) | |
| 578 | + : null; | |
| 579 | + | |
| 580 | +export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ | |
| 581 | + args: {}, | |
| 582 | + handler: async () => null, | |
| 583 | +}); | |
| 584 | 584 | |
| 585 | 585 | export const bootstrapCurrentWorkosAccount = mutation({ |
| 586 | 586 | args: {}, |
| @@ -8,6 +8,18 @@ const DEFAULT_TIMEOUT_MS = 15_000; · 7 unmodified lines | ||
| 8 | 8 | type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; |
| 9 | 9 | type OrganizationMemberStatus = "active" | "pending" | "removed"; |
| 10 | 10 | |
| 11 | +const completedTaskStatusValidator = v.union( | |
| 12 | + v.literal("completed"), | |
| 13 | + v.literal("failed"), | |
| 14 | + v.literal("timed_out"), | |
| 15 | + v.literal("denied"), | |
| 16 | +); | |
| 17 | +const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | |
| 18 | +const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | |
| 19 | +const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | |
| 20 | +const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | |
| 21 | +const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed")); | |
| 22 | + | |
| 11 | 23 | function normalizeOptional(value?: string): string { |
| 12 | 24 | if (typeof value !== "string") { |
| 13 | 25 | return ""; |
| @@ -426,7 +438,7 @@ export const markTaskRunning = mutation({ · 412 unmodified lines | ||
| 426 | 438 | export const markTaskFinished = mutation({ |
| 427 | 439 | args: { |
| 428 | 440 | taskId: v.string(), |
| 429 | − status: v.string(), | |
| 441 | + status: completedTaskStatusValidator, | |
| 430 | 442 | stdout: v.string(), |
| 431 | 443 | stderr: v.string(), |
| 432 | 444 | exitCode: v.optional(v.number()), |
| @@ -502,7 +514,7 @@ export const getApproval = query({ · 69 unmodified lines | ||
| 502 | 514 | export const listApprovals = query({ |
| 503 | 515 | args: { |
| 504 | 516 | workspaceId: v.string(), |
| 505 | − status: v.optional(v.string()), | |
| 517 | + status: v.optional(approvalStatusValidator), | |
| 506 | 518 | }, |
| 507 | 519 | handler: async (ctx, args) => { |
| 508 | 520 | if (args.status) { |
| @@ -570,7 +582,7 @@ export const listPendingApprovals = query({ · 61 unmodified lines | ||
| 570 | 582 | export const resolveApproval = mutation({ |
| 571 | 583 | args: { |
| 572 | 584 | approvalId: v.string(), |
| 573 | − decision: v.string(), | |
| 585 | + decision: v.union(v.literal("approved"), v.literal("denied")), | |
| 574 | 586 | reviewerId: v.optional(v.string()), |
| 575 | 587 | reason: v.optional(v.string()), |
| 576 | 588 | }, |
| @@ -673,7 +685,7 @@ export const getAgentTask = query({ · 96 unmodified lines | ||
| 673 | 685 | export const updateAgentTask = mutation({ |
| 674 | 686 | args: { |
| 675 | 687 | agentTaskId: v.string(), |
| 676 | − status: v.optional(v.string()), | |
| 688 | + status: v.optional(agentTaskStatusValidator), | |
| 677 | 689 | resultText: v.optional(v.string()), |
| 678 | 690 | error: v.optional(v.string()), |
| 679 | 691 | codeRuns: v.optional(v.number()), |
| @@ -772,7 +784,7 @@ export const upsertAccessPolicy = mutation({ · 92 unmodified lines | ||
| 772 | 784 | actorId: v.optional(v.string()), |
| 773 | 785 | clientId: v.optional(v.string()), |
| 774 | 786 | toolPathPattern: v.string(), |
| 775 | − decision: v.string(), | |
| 787 | + decision: policyDecisionValidator, | |
| 776 | 788 | priority: v.optional(v.number()), |
| 777 | 789 | }, |
| 778 | 790 | handler: async (ctx, args) => { |
| @@ -842,7 +854,7 @@ export const upsertCredential = mutation({ · 63 unmodified lines | ||
| 842 | 854 | id: v.optional(v.string()), |
| 843 | 855 | workspaceId: v.string(), |
| 844 | 856 | sourceKey: v.string(), |
| 845 | − scope: v.string(), | |
| 857 | + scope: credentialScopeValidator, | |
| 846 | 858 | actorId: v.optional(v.string()), |
| 847 | 859 | secretJson: v.any(), |
| 848 | 860 | }, |
| @@ -914,7 +926,7 @@ export const resolveCredential = query({ · 65 unmodified lines | ||
| 914 | 926 | args: { |
| 915 | 927 | workspaceId: v.string(), |
| 916 | 928 | sourceKey: v.string(), |
| 917 | − scope: v.string(), | |
| 929 | + scope: credentialScopeValidator, | |
| 918 | 930 | actorId: v.optional(v.string()), |
| 919 | 931 | }, |
| 920 | 932 | handler: async (ctx, args) => { |
| @@ -958,7 +970,7 @@ export const upsertToolSource = mutation({ · 37 unmodified lines | ||
| 958 | 970 | id: v.optional(v.string()), |
| 959 | 971 | workspaceId: v.string(), |
| 960 | 972 | name: v.string(), |
| 961 | − type: v.string(), | |
| 973 | + type: toolSourceTypeValidator, | |
| 962 | 974 | config: v.any(), |
| 963 | 975 | enabled: v.optional(v.boolean()), |
| 964 | 976 | }, |
| @@ -2,8 +2,7 @@ import { WorkOS } from "@workos-inc/node"; · 1 unmodified line | ||
| 2 | 2 | import { v } from "convex/values"; |
| 3 | 3 | import { internal } from "./_generated/api"; |
| 4 | 4 | import { internalAction, internalMutation, internalQuery } from "./_generated/server"; |
| 5 | −import { getOrganizationMembership } from "./lib/identity"; | |
| 6 | −import { authedMutation, organizationMutation, organizationQuery } from "./lib/functionBuilders"; | |
| 5 | +import { organizationMutation, organizationQuery } from "./lib/functionBuilders"; | |
| 7 | 6 | |
| 8 | 7 | const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY); |
| 9 | 8 | const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null; |
| @@ -16,14 +15,6 @@ const organizationRoleValidator = v.union( · 6 unmodified lines | ||
| 16 | 15 | v.literal("billing_admin"), |
| 17 | 16 | ); |
| 18 | 17 | |
| 19 | −async function sha256Hex(value: string): Promise<string> { | |
| 20 | − const bytes = new TextEncoder().encode(value); | |
| 21 | − const digest = await crypto.subtle.digest("SHA-256", bytes); | |
| 22 | − return Array.from(new Uint8Array(digest)) | |
| 23 | − .map((value) => value.toString(16).padStart(2, "0")) | |
| 24 | − .join(""); | |
| 25 | −} | |
| 26 | − | |
| 27 | 18 | type WorkosInvitationResponse = { |
| 28 | 19 | id: string; |
| 29 | 20 | state: string; |
| @@ -136,8 +127,6 @@ export const create = organizationMutation({ · 106 unmodified lines | ||
| 136 | 127 | } |
| 137 | 128 | const inviterWorkosUserId = ctx.account.providerAccountId; |
| 138 | 129 | |
| 139 | − const token = `invite_${crypto.randomUUID()}`; | |
| 140 | − const tokenHash = await sha256Hex(token); | |
| 141 | 130 | const provider = "workos"; |
| 142 | 131 | |
| 143 | 132 | const inviteId = await ctx.db.insert("invites", { |
| @@ -146,7 +135,6 @@ export const create = organizationMutation({ · 2 unmodified lines | ||
| 146 | 135 | email: normalizedEmail, |
| 147 | 136 | role: args.role, |
| 148 | 137 | status: "pending", |
| 149 | − tokenHash, | |
| 150 | 138 | provider, |
| 151 | 139 | invitedByAccountId: ctx.account._id, |
| 152 | 140 | expiresAt, |
| @@ -174,7 +162,6 @@ export const create = organizationMutation({ · 21 unmodified lines | ||
| 174 | 162 | role: invite.role, |
| 175 | 163 | status: invite.status, |
| 176 | 164 | expiresAt: invite.expiresAt, |
| 177 | − token, | |
| 178 | 165 | }, |
| 179 | 166 | delivery: { |
| 180 | 167 | provider, |
| @@ -388,69 +375,3 @@ export const markInviteDeliveryFailed = internalMutation({ · 207 unmodified lines | ||
| 388 | 375 | }); |
| 389 | 376 | }, |
| 390 | 377 | }); |
| 391 | − | |
| 392 | −export const accept = authedMutation({ | |
| 393 | − args: { | |
| 394 | − token: v.string(), | |
| 395 | − }, | |
| 396 | − handler: async (ctx, args) => { | |
| 397 | − const tokenHash = await sha256Hex(args.token); | |
| 398 | − | |
| 399 | − const invite = await ctx.db | |
| 400 | − .query("invites") | |
| 401 | − .withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash)) | |
| 402 | − .unique(); | |
| 403 | − | |
| 404 | − if (!invite || invite.status !== "pending") { | |
| 405 | − throw new Error("Invite is invalid or expired"); | |
| 406 | − } | |
| 407 | − if (invite.expiresAt < Date.now()) { | |
| 408 | − await ctx.db.patch(invite._id, { | |
| 409 | − status: "expired", | |
| 410 | − updatedAt: Date.now(), | |
| 411 | − }); | |
| 412 | − throw new Error("Invite has expired"); | |
| 413 | − } | |
| 414 | − | |
| 415 | − const existing = await getOrganizationMembership(ctx, invite.organizationId, ctx.account._id); | |
| 416 | − if (existing) { | |
| 417 | − await ctx.db.patch(existing._id, { | |
| 418 | − role: invite.role, | |
| 419 | − status: "active", | |
| 420 | − joinedAt: existing.joinedAt ?? Date.now(), | |
| 421 | − updatedAt: Date.now(), | |
| 422 | − }); | |
| 423 | − } else { | |
| 424 | − await ctx.db.insert("organizationMembers", { | |
| 425 | − organizationId: invite.organizationId, | |
| 426 | − accountId: ctx.account._id, | |
| 427 | − role: invite.role, | |
| 428 | − status: "active", | |
| 429 | − billable: true, | |
| 430 | − invitedByAccountId: invite.invitedByAccountId, | |
| 431 | − joinedAt: Date.now(), | |
| 432 | − createdAt: Date.now(), | |
| 433 | − updatedAt: Date.now(), | |
| 434 | − }); | |
| 435 | − } | |
| 436 | − | |
| 437 | − await ctx.db.patch(invite._id, { | |
| 438 | − status: "accepted", | |
| 439 | − acceptedAt: Date.now(), | |
| 440 | − updatedAt: Date.now(), | |
| 441 | − }); | |
| 442 | − | |
| 443 | − const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, { | |
| 444 | − organizationId: invite.organizationId, | |
| 445 | − }); | |
| 446 | − await ctx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, { | |
| 447 | − organizationId: invite.organizationId, | |
| 448 | − expectedVersion: nextVersion, | |
| 449 | − }); | |
| 450 | − | |
| 451 | − return { | |
| 452 | − ok: true, | |
| 453 | − organizationId: invite.organizationId, | |
| 454 | − }; | |
| 455 | − }, | |
| 456 | −}); | |
| @@ -8,6 +8,16 @@ const orgRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("membe · 7 unmodified lines | ||
| 8 | 8 | const orgMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed")); |
| 9 | 9 | const workspaceMemberRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member")); |
| 10 | 10 | const workspaceMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed")); |
| 11 | +const billingSubscriptionStatus = v.union( | |
| 12 | + v.literal("incomplete"), | |
| 13 | + v.literal("incomplete_expired"), | |
| 14 | + v.literal("trialing"), | |
| 15 | + v.literal("active"), | |
| 16 | + v.literal("past_due"), | |
| 17 | + v.literal("canceled"), | |
| 18 | + v.literal("unpaid"), | |
| 19 | + v.literal("paused"), | |
| 20 | +); | |
| 11 | 21 | const inviteStatus = v.union( |
| 12 | 22 | v.literal("pending"), |
| 13 | 23 | v.literal("accepted"), |
| @@ -16,6 +26,19 @@ const inviteStatus = v.union( · 2 unmodified lines | ||
| 16 | 26 | v.literal("failed"), |
| 17 | 27 | ); |
| 18 | 28 | const inviteProvider = v.literal("workos"); |
| 29 | +const taskStatus = v.union( | |
| 30 | + v.literal("queued"), | |
| 31 | + v.literal("running"), | |
| 32 | + v.literal("completed"), | |
| 33 | + v.literal("failed"), | |
| 34 | + v.literal("timed_out"), | |
| 35 | + v.literal("denied"), | |
| 36 | +); | |
| 37 | +const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | |
| 38 | +const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | |
| 39 | +const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); | |
| 40 | +const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | |
| 41 | +const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed")); | |
| 19 | 42 | |
| 20 | 43 | export default defineSchema({ |
| 21 | 44 | accounts: defineTable({ |
| @@ -102,7 +125,6 @@ export default defineSchema({ · 80 unmodified lines | ||
| 102 | 125 | email: v.string(), |
| 103 | 126 | role: orgRole, |
| 104 | 127 | status: inviteStatus, |
| 105 | − tokenHash: v.string(), | |
| 106 | 128 | provider: inviteProvider, |
| 107 | 129 | providerInviteId: v.optional(v.string()), |
| 108 | 130 | invitedByAccountId: v.id("accounts"), |
| @@ -111,7 +133,6 @@ export default defineSchema({ · 2 unmodified lines | ||
| 111 | 133 | createdAt: v.number(), |
| 112 | 134 | updatedAt: v.number(), |
| 113 | 135 | }) |
| 114 | − .index("by_token_hash", ["tokenHash"]) | |
| 115 | 136 | .index("by_org", ["organizationId"]) |
| 116 | 137 | .index("by_org_status_created", ["organizationId", "status", "createdAt"]) |
| 117 | 138 | .index("by_org_email_status", ["organizationId", "email", "status"]), |
| @@ -129,7 +150,7 @@ export default defineSchema({ · 11 unmodified lines | ||
| 129 | 150 | organizationId: v.id("organizations"), |
| 130 | 151 | stripeSubscriptionId: v.string(), |
| 131 | 152 | stripePriceId: v.string(), |
| 132 | − status: v.string(), | |
| 153 | + status: billingSubscriptionStatus, | |
| 133 | 154 | currentPeriodStart: v.optional(v.number()), |
| 134 | 155 | currentPeriodEnd: v.optional(v.number()), |
| 135 | 156 | cancelAtPeriodEnd: v.boolean(), |
| @@ -158,7 +179,7 @@ export default defineSchema({ · 22 unmodified lines | ||
| 158 | 179 | workspaceId: v.string(), |
| 159 | 180 | actorId: v.string(), |
| 160 | 181 | clientId: v.string(), |
| 161 | − status: v.string(), | |
| 182 | + status: taskStatus, | |
| 162 | 183 | timeoutMs: v.number(), |
| 163 | 184 | metadata: v.any(), |
| 164 | 185 | error: v.optional(v.string()), |
| @@ -180,7 +201,7 @@ export default defineSchema({ · 15 unmodified lines | ||
| 180 | 201 | workspaceId: v.string(), |
| 181 | 202 | toolPath: v.string(), |
| 182 | 203 | input: v.any(), |
| 183 | − status: v.string(), | |
| 204 | + status: approvalStatus, | |
| 184 | 205 | reason: v.optional(v.string()), |
| 185 | 206 | reviewerId: v.optional(v.string()), |
| 186 | 207 | createdAt: v.number(), |
| @@ -208,7 +229,7 @@ export default defineSchema({ · 21 unmodified lines | ||
| 208 | 229 | actorId: v.string(), |
| 209 | 230 | clientId: v.string(), |
| 210 | 231 | toolPathPattern: v.string(), |
| 211 | − decision: v.string(), | |
| 232 | + decision: policyDecision, | |
| 212 | 233 | priority: v.number(), |
| 213 | 234 | createdAt: v.number(), |
| 214 | 235 | updatedAt: v.number(), |
| @@ -220,7 +241,7 @@ export default defineSchema({ · 5 unmodified lines | ||
| 220 | 241 | credentialId: v.string(), |
| 221 | 242 | workspaceId: v.string(), |
| 222 | 243 | sourceKey: v.string(), |
| 223 | − scope: v.string(), | |
| 244 | + scope: credentialScope, | |
| 224 | 245 | actorId: v.string(), |
| 225 | 246 | secretJson: v.any(), |
| 226 | 247 | createdAt: v.number(), |
| @@ -234,7 +255,7 @@ export default defineSchema({ · 7 unmodified lines | ||
| 234 | 255 | sourceId: v.string(), |
| 235 | 256 | workspaceId: v.string(), |
| 236 | 257 | name: v.string(), |
| 237 | − type: v.string(), | |
| 258 | + type: toolSourceType, | |
| 238 | 259 | config: v.any(), |
| 239 | 260 | enabled: v.boolean(), |
| 240 | 261 | createdAt: v.number(), |
| @@ -251,7 +272,7 @@ export default defineSchema({ · 10 unmodified lines | ||
| 251 | 272 | requesterId: v.string(), |
| 252 | 273 | workspaceId: v.string(), |
| 253 | 274 | actorId: v.string(), |
| 254 | − status: v.string(), // "running" | "completed" | "failed" | |
| 275 | + status: agentTaskStatus, | |
| 255 | 276 | resultText: v.optional(v.string()), |
| 256 | 277 | error: v.optional(v.string()), |
| 257 | 278 | codeRuns: v.number(), |