Files
zopu-code/packages/backend/convex/schema.ts
-Puter 03fc32bd6d feat(signals): project-scoped Zopu routing loop with secure tools
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
2026-07-24 20:48:18 +05:30

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",
]),
});