import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ daemonDefinitions: defineTable({ daemonId: v.string(), name: v.string(), desiredState: v.union(v.literal("online"), v.literal("offline")), agentOsConfig: v.any(), configVersion: v.number(), createdAt: v.number(), updatedAt: v.number(), }).index("by_daemonId", ["daemonId"]), daemonPresence: defineTable({ daemonId: v.string(), sessionId: v.string(), status: v.union( v.literal("online"), v.literal("draining"), v.literal("offline") ), hostname: v.string(), platform: v.string(), architecture: v.string(), version: v.string(), startedAt: v.number(), lastHeartbeatAt: v.number(), }) .index("by_daemonId", ["daemonId"]) .index("by_sessionId", ["sessionId"]), daemonCommands: defineTable({ daemonId: v.string(), method: v.string(), args: v.any(), actorKey: v.array(v.string()), status: v.union( v.literal("queued"), v.literal("claimed"), v.literal("running"), v.literal("succeeded"), v.literal("failed"), v.literal("cancelled") ), attempts: v.number(), claimedBySessionId: v.optional(v.string()), leaseExpiresAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()), result: v.optional(v.any()), error: v.optional(v.string()), }) .index("by_daemonId_and_status", ["daemonId", "status"]) .index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]), daemonCommandEvents: defineTable({ daemonId: v.string(), commandId: v.optional(v.id("daemonCommands")), kind: v.string(), data: v.any(), createdAt: v.number(), }) .index("by_daemonId_and_createdAt", ["daemonId", "createdAt"]) .index("by_commandId_and_createdAt", ["commandId", "createdAt"]), projects: defineTable({ organizationId: v.id("organizations"), name: v.string(), description: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }).index("by_organization_and_createdAt", ["organizationId", "createdAt"]), projectSources: defineTable({ organizationId: v.id("organizations"), projectId: v.id("projects"), kind: v.literal("git"), url: v.string(), normalizedUrl: v.string(), host: v.string(), repositoryPath: v.string(), defaultBranch: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }) .index("by_project", ["projectId"]) .index("by_organization_and_normalizedUrl", [ "organizationId", "normalizedUrl", ]) .index("by_organization_and_createdAt", ["organizationId", "createdAt"]), projectContextDocuments: defineTable({ organizationId: v.id("organizations"), projectId: v.id("projects"), kind: v.union( v.literal("readme"), v.literal("agents"), v.literal("product"), v.literal("business"), v.literal("design"), v.literal("tech") ), path: v.string(), content: v.string(), revision: v.number(), origin: v.union( v.literal("repository"), v.literal("paste"), v.literal("upload"), v.literal("public-url") ), sourceUrl: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }) .index("by_project", ["projectId"]) .index("by_project_and_kind", ["projectId", "kind"]) .index("by_project_and_path", ["projectId", "path"]), projectArtifacts: defineTable({ projectId: v.id("projects"), path: v.string(), content: v.string(), revision: v.number(), createdAt: v.number(), updatedAt: v.number(), }) .index("by_project", ["projectId"]) .index("by_project_and_path", ["projectId", "path"]), projectIssues: defineTable({ projectId: v.id("projects"), number: v.number(), title: v.string(), body: v.string(), status: v.union( v.literal("open"), v.literal("queued"), v.literal("working"), v.literal("needs-input"), v.literal("completed"), v.literal("failed") ), createdAt: v.number(), updatedAt: v.number(), }) .index("by_project_and_number", ["projectId", "number"]) .index("by_project_and_status", ["projectId", "status"]), projectWorkRuns: defineTable({ baseBranch: v.optional(v.string()), branchName: v.optional(v.string()), checkoutPath: v.optional(v.string()), projectId: v.id("projects"), issueId: v.id("projectIssues"), agentId: v.string(), daemonId: v.string(), actorKey: v.array(v.string()), sourceUrl: v.optional(v.string()), status: v.union( v.literal("ready"), v.literal("queued"), v.literal("working"), v.literal("needs-input"), v.literal("completed"), v.literal("failed") ), summary: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()), }) .index("by_issue", ["issueId"]) .index("by_project_and_createdAt", ["projectId", "createdAt"]), projectEvents: defineTable({ projectId: v.id("projects"), issueId: v.optional(v.id("projectIssues")), runId: v.optional(v.id("projectWorkRuns")), kind: v.string(), data: v.any(), createdAt: v.number(), }) .index("by_project_and_createdAt", ["projectId", "createdAt"]) .index("by_issue_and_createdAt", ["issueId", "createdAt"]), todos: defineTable({ text: v.string(), completed: v.boolean(), }), // ----------------------------------------------------------------- // Organization tenancy. Organizations are hard tenant boundaries; deny by // default. The membership user ID is the Better Auth Convex identity // `tokenIdentifier`. // ----------------------------------------------------------------- organizations: defineTable({ name: v.string(), kind: v.union(v.literal("personal"), v.literal("team")), createdBy: v.string(), createdAt: v.number(), }).index("by_createdBy_and_kind", ["createdBy", "kind"]), organizationMembers: defineTable({ organizationId: v.id("organizations"), userId: v.string(), role: v.union(v.literal("owner"), v.literal("member")), createdAt: v.number(), }) .index("by_userId", ["userId"]) .index("by_organizationId", ["organizationId"]) .index("by_organizationId_and_userId", ["organizationId", "userId"]), // ----------------------------------------------------------------- // Normalized conversation message evidence. Each row is one user // message captured at the conversation ingress, before/with Flue // admission. The raw text is stored exactly as the user typed it // (never trimmed or rewritten). Organization scope is authoritative; // an agent instance may only observe its own organization's messages. // ----------------------------------------------------------------- conversationMessages: defineTable({ organizationId: v.id("organizations"), conversationId: v.string(), messageId: v.string(), submissionId: v.optional(v.string()), clientRequestId: v.string(), role: v.literal("user"), rawText: v.string(), status: v.union( v.literal("admitting"), v.literal("admitted"), v.literal("failed") ), createdAt: v.number(), }) .index("by_organization_and_message", [ "organizationId", "conversationId", "messageId", ]) .index("by_organization_and_request", [ "organizationId", "clientRequestId", ]), // ----------------------------------------------------------------- // Product Signals. A Signal is the agent-produced interpretation of one // or more exact user messages into a structured problem statement. It is // organization-scoped, optionally project-scoped, and idempotent by // organization + ordered source-message selection (`sourceKey`). Raw text // is never accepted from the agent: it is copied server-side from the // normalized conversation messages into immutable source snapshots. // ----------------------------------------------------------------- signals: defineTable({ organizationId: v.id("organizations"), projectId: v.optional(v.id("projects")), conversationId: v.string(), sourceKey: v.string(), problemStatement: v.object({ title: v.string(), summary: v.string(), desiredOutcome: v.string(), constraints: v.array(v.string()), }), processedByAgentName: v.string(), processedByAgentInstanceId: v.string(), createdAt: v.number(), }) .index("by_organization_and_createdAt", ["organizationId", "createdAt"]) .index("by_organization_and_sourceKey", ["organizationId", "sourceKey"]) .index("by_project_and_createdAt", ["projectId", "createdAt"]), // Immutable ordered source snapshots copied from `conversationMessages`. // `ordinal` is the zero-based position in the agent's ordered selection. signalSources: defineTable({ signalId: v.id("signals"), organizationId: v.id("organizations"), conversationId: v.string(), messageId: v.string(), ordinal: v.number(), rawTextSnapshot: v.string(), sourceCreatedAt: v.number(), submissionId: v.optional(v.string()), }) .index("by_signal_and_ordinal", ["signalId", "ordinal"]) .index("by_organization_and_message", [ "organizationId", "conversationId", "messageId", ]), // ----------------------------------------------------------------- // Signal-to-issue attachments. A proper relation that links one Signal // to one ProjectIssue, idempotent by (signalId, issueId). Multiple // Signals may attach to the same ProjectIssue. The relation is scoped // by organization and project to prevent cross-tenant leakage. // ----------------------------------------------------------------- signalIssueAttachments: defineTable({ signalId: v.id("signals"), issueId: v.id("projectIssues"), organizationId: v.id("organizations"), projectId: v.id("projects"), createdAt: v.number(), }) .index("by_signal", ["signalId"]) .index("by_issue", ["issueId"]) .index("by_signal_and_issue", ["signalId", "issueId"]), // ----------------------------------------------------------------- // Flue persistence stores (schema/format version 4). // ----------------------------------------------------------------- // One-row key/value metadata table, keyed with `schema_version` for the // persisted-store versioning obligation. flueMeta: defineTable({ key: v.string(), value: v.string(), }).index("by_key", ["key"]), // Durable agent-submission lifecycle: one row per submission. Settlement // obligations live on the same row so reserve/finalize are atomic with the // status transition they gate. flueSubmissions: defineTable({ submissionId: v.string(), sessionKey: v.string(), sequence: v.number(), kind: v.union(v.literal("dispatch"), v.literal("direct")), // Client-supplied canonical input/chunks encoded by Flue helpers; exact // string compared on idempotent replay. inputJson: v.string(), chunksJson: v.string(), traceCarrierJson: v.optional(v.string()), // Admit-time canonical record (Flue ConversationCreatedRecord) for the // default session of the default harness. recordJson: v.optional(v.string()), status: v.union( v.literal("queued"), v.literal("running"), v.literal("terminalizing"), v.literal("settled") ), acceptedAt: v.number(), canonicalReadyAt: v.optional(v.number()), attemptId: v.optional(v.string()), inputAppliedAt: v.optional(v.number()), recoveryRequestedAt: v.optional(v.number()), abortRequestedAt: v.optional(v.number()), startedAt: v.optional(v.number()), error: v.optional(v.string()), attemptCount: v.number(), maxRetry: v.number(), timeoutAt: v.number(), ownerId: v.optional(v.string()), leaseExpiresAt: v.number(), // Settlement obligation (reserved/finalized on this row). settlementRecordId: v.optional(v.string()), settlementRecordJson: v.optional(v.string()), settledOutcome: v.optional( v.union(v.literal("completed"), v.literal("failed"), v.literal("aborted")) ), updatedAt: v.number(), }) .index("by_submissionId", ["submissionId"]) .index("by_sessionKey_and_sequence", ["sessionKey", "sequence"]) .index("by_status_and_sequence", ["status", "sequence"]) .index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]) .index("by_status_and_settlementRecordId", [ "status", "settlementRecordId", ]), // Durable evidence that a submission attempt started and has not yet // settled. Append-once by (submissionId, attemptId). flueAttemptMarkers: defineTable({ submissionId: v.string(), attemptId: v.string(), createdAt: v.number(), }) .index("by_submissionId_and_attemptId", ["submissionId", "attemptId"]) .index("by_submissionId", ["submissionId"]), // Conversation-stream metadata: one row per stream path. flueConversationStreams: defineTable({ path: v.string(), identityJson: v.string(), incarnation: v.string(), producerId: v.optional(v.string()), producerEpoch: v.number(), nextProducerSequence: v.number(), nextOffset: v.number(), closed: v.boolean(), createdAt: v.number(), }).index("by_path", ["path"]), // Conversation-stream batches: one row per appended batch. Offset is // 0-based; `seq` is the row's position in the stream. flueConversationBatches: defineTable({ path: v.string(), seq: v.number(), producerId: v.string(), producerEpoch: v.number(), producerSequence: v.number(), recordsJson: v.string(), submissionId: v.optional(v.string()), attemptId: v.optional(v.string()), appendedAt: v.number(), }) .index("by_path_and_seq", ["path", "seq"]) .index("by_path_producer_epoch_producerSequence", [ "path", "producerId", "producerEpoch", "producerSequence", ]), // Event-stream metadata: one row per stream path. flueEventStreams: defineTable({ path: v.string(), nextSeq: v.number(), closed: v.boolean(), createdAt: v.number(), }).index("by_path", ["path"]), // Event-stream entries: one row per appended event. `onceKey` carries the // idempotency key for `appendEventOnce` and is null for plain appends. flueEventEntries: defineTable({ path: v.string(), seq: v.number(), onceKey: v.optional(v.string()), dataJson: v.string(), appendedAt: v.number(), }) .index("by_path_and_seq", ["path", "seq"]) .index("by_path_and_onceKey", ["path", "onceKey"]), // Workflow run records. flueRuns: defineTable({ runId: v.string(), workflowName: v.string(), status: v.union( v.literal("active"), v.literal("completed"), v.literal("errored") ), startedAt: v.string(), inputJson: v.optional(v.string()), traceCarrierJson: v.optional(v.string()), endedAt: v.optional(v.string()), isError: v.optional(v.boolean()), durationMs: v.optional(v.number()), resultJson: v.optional(v.string()), errorJson: v.optional(v.string()), }) .index("by_runId", ["runId"]) .index("by_startedAt_and_runId", ["startedAt", "runId"]) .index("by_workflowName_and_startedAt_and_runId", [ "workflowName", "startedAt", "runId", ]) .index("by_status_startedAt_runId", ["status", "startedAt", "runId"]) .index("by_status_workflowName_startedAt_runId", [ "status", "workflowName", "startedAt", "runId", ]), // Immutable attachment bytes. Identity is (streamPath, attachmentId); reads // are additionally scoped by conversationId. flueAttachments: defineTable({ streamPath: v.string(), conversationId: v.string(), attachmentId: v.string(), mimeType: v.string(), size: v.number(), digest: v.string(), filename: v.optional(v.string()), bytes: v.bytes(), createdAt: v.number(), }) .index("by_streamPath", ["streamPath"]) .index("by_streamPath_and_attachmentId", ["streamPath", "attachmentId"]) .index("by_streamPath_and_conversationId_and_attachmentId", [ "streamPath", "conversationId", "attachmentId", ]), });