Add the complete project-scoped Zopu chat, Signal extraction, and work-routing loop: Backend (signalRouting.ts): - Agent-token-gated functions for the full routing cycle: list evidence, create signal, list signals, list active issues, attach signal to issue, create issue from signal, begin issue, get project context, list projects - attachSignalToIssue requires signal.projectId to exist and equal issue.projectId (org equality alone is insufficient) - createIssueFromSignal is idempotent: queries existing attachments before creating, returns created/reused flag - Duplicate attach retries return existing relation without emitting a duplicate event - All functions deny-by-default with agent token validation Schema: - Add signalIssueAttachments table with indexes by signal, issue, and the composite (signalId, issueId) for idempotent lookups Agent tools (tools/signals.ts): - Nine Flue tool definitions bound to the organization-scoped agent instance ID, calling the agent-gated Convex functions - Organization ID never accepted as free input; always from the bound instance Zopu agent (zopu.ts): - Comprehensive routing-loop instructions: assess actionability, identify project, create signal only when actionable, route via attach or create, explain outcome, optionally begin - Wiring of routing tools into the agent definition Tests (signalRouting.test.ts): - Authentication: invalid token rejected on every function - Project scoping: evidence, signals, issues scoped correctly - Idempotency: duplicate attach, duplicate create-from-signal, duplicate signal creation - Attach vs create: both paths verified - Cross-project rejection: signal and issue must be in same project - Org-scoped signal attach rejected (no projectId) - Provenance: exact raw text preserved through routing loop - Begin issue: transition and auth verification
483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
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",
|
|
]),
|
|
});
|