Files
zopu-code/packages/backend/convex/signals.ts
-Puter d645d3e08b test(signals): linked-signals query coverage and deterministic ordering
Add Convex backend tests for listLinkedSignalsForProject covering:
- Per-issue grouping with correct source counts
- Project membership/authz (denies non-member access)
- Cross-project signal suppression (project1 cannot see project2 signals)
- Multi-source signal source-count accuracy
- Empty project returns empty record
- Issues without linked signals are omitted from result

Make linked signals deterministically ordered newest-first within
each issue group via toSorted(createdAt descending).
2026-07-24 21:36:54 +05:30

506 lines
16 KiB
TypeScript

import { composeSignal, SignalValidationError } from "@code/primitives/signal";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import type { Doc, Id } from "./_generated/dataModel";
import {
type MutationCtx,
mutation,
type QueryCtx,
query,
} from "./_generated/server";
import { requireOrganizationMember, requireProjectMember } from "./authz";
const PROBLEM_STATEMENT = v.object({
title: v.string(),
summary: v.string(),
desiredOutcome: v.string(),
constraints: v.array(v.string()),
});
interface SignalSourceView {
readonly _id: Id<"signalSources">;
readonly _creationTime: number;
readonly signalId: Id<"signals">;
readonly organizationId: Id<"organizations">;
readonly conversationId: string;
readonly messageId: string;
readonly ordinal: number;
readonly rawTextSnapshot: string;
readonly sourceCreatedAt: number;
readonly submissionId: string | null;
}
interface SignalView {
readonly _id: Id<"signals">;
readonly _creationTime: number;
readonly organizationId: Id<"organizations">;
readonly projectId: Id<"projects"> | null;
readonly conversationId: string;
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
};
readonly processedByAgentName: string;
readonly processedByAgentInstanceId: string;
readonly createdAt: number;
}
interface ComposedSignal {
readonly signal: SignalView;
readonly sources: readonly SignalSourceView[];
}
/**
* An admitted user conversation message presented to the agent as candidate
* Signal evidence. The agent selects from these by `messageId`; raw text and
* timestamps are copied server-side and never accepted from the agent.
*/
interface ConversationMessageSourceView {
readonly messageId: string;
readonly clientRequestId: string;
readonly rawText: string;
readonly submissionId: string | null;
readonly createdAt: number;
}
const toSignalView = (doc: Doc<"signals">): SignalView => ({
_creationTime: doc._creationTime,
_id: doc._id,
conversationId: doc.conversationId,
createdAt: doc.createdAt,
organizationId: doc.organizationId,
problemStatement: doc.problemStatement,
processedByAgentInstanceId: doc.processedByAgentInstanceId,
processedByAgentName: doc.processedByAgentName,
projectId: doc.projectId ?? null,
});
const toSourceView = (doc: Doc<"signalSources">): SignalSourceView => ({
_creationTime: doc._creationTime,
_id: doc._id,
conversationId: doc.conversationId,
messageId: doc.messageId,
ordinal: doc.ordinal,
organizationId: doc.organizationId,
rawTextSnapshot: doc.rawTextSnapshot,
signalId: doc.signalId,
sourceCreatedAt: doc.sourceCreatedAt,
submissionId: doc.submissionId ?? null,
});
const toConversationMessageSourceView = (
doc: Doc<"conversationMessages">
): ConversationMessageSourceView => ({
clientRequestId: doc.clientRequestId,
createdAt: doc.createdAt,
messageId: doc.messageId,
rawText: doc.rawText,
submissionId: doc.submissionId ?? null,
});
/**
* Build a deterministic, collision-safe key from the organization, the
* conversation, and the ordered message selection. The key is the JSON
* encoding of `[organizationId, conversationId, ...messageIds]`, so no
* delimiter inside arbitrary message IDs can ever cause a collision and the
* caller's selection order is part of the identity.
*/
const buildSourceKey = (
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
/**
* Resolve all selected conversation messages server-side. Each message must
* belong to the same organization and conversation, be a `user` message, be
* `admitted`, and appear exactly once in the ordered selection. Raw text,
* timestamps, and submission ids are copied from the stored rows; the agent
* never supplies them. Returns the messages in the caller's requested order.
*/
const resolveSources = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): Promise<Doc<"conversationMessages">[]> => {
if (messageIds.length === 0) {
throw new ConvexError("At least one source message is required");
}
if (new Set(messageIds).size !== messageIds.length) {
throw new ConvexError("Source message ids must be unique");
}
const resolved: Doc<"conversationMessages">[] = [];
for (const messageId of messageIds) {
const doc = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", organizationId)
.eq("conversationId", conversationId)
.eq("messageId", messageId)
)
.unique();
if (!doc) {
throw new ConvexError(`Source message not found: ${messageId}`);
}
if (doc.role !== "user") {
throw new ConvexError(
`Source message is not a user message: ${messageId}`
);
}
if (doc.status !== "admitted") {
throw new ConvexError(`Source message is not admitted: ${messageId}`);
}
resolved.push(doc);
}
return resolved;
};
/**
* Verify a project belongs to the same organization. Projects now carry an
* explicit `organizationId`; the caller is already proven to be a member, so
* this is a direct invariant check with no cross-tenant leakage.
*/
const authorizeProject = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<void> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new ConvexError("Project not found");
}
if (project.organizationId !== organizationId) {
throw new ConvexError("Project does not belong to the target organization");
}
};
/**
* Atomically create one idempotent Signal from an ordered selection of user
* messages plus a structured problem statement produced by the agent.
*
* Authorization: the authenticated identity must be a member of the target
* organization. When a project is supplied it must belong to the same
* organization. Every message id is resolved server-side from
* `conversationMessages` under the same organization and conversation; raw
* text, timestamps, and submission ids are copied from those rows and never
* accepted from the agent.
*
* Idempotency: if a Signal with the same `(organizationId, sourceKey)` already
* exists, it is returned as-is without creating duplicate sources. The whole
* lookup-or-insert runs in one Convex mutation transaction, so retries cannot
* produce duplicates.
*
* Returns the new (or existing) signal id.
*/
export const createFromMessages = mutation({
args: {
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
conversationId: v.string(),
messageIds: v.array(v.string()),
problemStatement: PROBLEM_STATEMENT,
processedBy: v.object({
agentName: v.string(),
agentInstanceId: v.string(),
}),
},
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
await requireOrganizationMember(ctx, args.organizationId);
if (args.projectId) {
await authorizeProject(ctx, args.organizationId, args.projectId);
}
// The global conversation id equals the organization id; the caller's
// conversation id must match.
if (args.conversationId !== args.organizationId) {
throw new ConvexError("Conversation does not belong to organization");
}
const sourceKey = buildSourceKey(
args.organizationId,
args.conversationId,
args.messageIds
);
const existing = await ctx.db
.query("signals")
.withIndex("by_organization_and_sourceKey", (q) =>
q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey)
)
.unique();
if (existing) {
return { signalId: existing._id };
}
const sources = await resolveSources(
ctx,
args.organizationId,
args.conversationId,
args.messageIds
);
// Compose the pure Effect Signal before writes, using a prospective id.
// `composeSignal` decodes an `unknown` input, so pass plain objects.
const now = Date.now();
const prospectiveId = crypto.randomUUID();
const scope =
args.projectId === undefined
? { _tag: "Organization" as const, organizationId: args.organizationId }
: {
_tag: "Project" as const,
organizationId: args.organizationId,
projectId: args.projectId,
};
const signal = await Effect.runPromise(
composeSignal({
createdAt: now,
id: prospectiveId,
problemStatement: args.problemStatement,
processedBy: args.processedBy,
scope,
sourceMessages: sources.map((doc) => ({
conversationId: doc.conversationId,
createdAt: doc.createdAt,
messageId: doc.messageId,
rawText: doc.rawText,
})),
})
).catch((error: unknown) => {
if (error instanceof SignalValidationError) {
throw new ConvexError(`Invalid signal: ${error.message}`);
}
throw new ConvexError(
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
);
});
const signalId = await ctx.db.insert("signals", {
conversationId: args.conversationId,
createdAt: now,
organizationId: args.organizationId,
problemStatement: {
constraints: [...signal.problemStatement.constraints],
desiredOutcome: signal.problemStatement.desiredOutcome,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
},
processedByAgentInstanceId: args.processedBy.agentInstanceId,
processedByAgentName: args.processedBy.agentName,
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
sourceKey,
});
await Promise.all(
sources.map((doc, ordinal) =>
ctx.db.insert("signalSources", {
conversationId: doc.conversationId,
messageId: doc.messageId,
ordinal,
organizationId: args.organizationId,
rawTextSnapshot: doc.rawText,
signalId,
...(doc.submissionId === undefined
? {}
: { submissionId: doc.submissionId }),
sourceCreatedAt: doc.createdAt,
})
)
);
return { signalId };
},
});
/**
* Load the composed source rows for a signal in ordinal order.
*/
const loadComposedSources = async (
ctx: QueryCtx,
signal: Doc<"signals">
): Promise<SignalSourceView[]> => {
const rows = await ctx.db
.query("signalSources")
.withIndex("by_signal_and_ordinal", (q) => q.eq("signalId", signal._id))
.collect();
// `collect` does not guarantee index order across all backends; sort by
// ordinal to be deterministic.
rows.sort((a, b) => a.ordinal - b.ordinal);
return rows.map(toSourceView);
};
/**
* List the Signals for the authenticated user's organization, newest first.
* Membership is required; cross-organization access is denied. Each entry
* includes its composed source records in ordinal order.
*/
export const list = query({
args: {
organizationId: v.id("organizations"),
},
handler: async (ctx, args): Promise<ComposedSignal[]> => {
await requireOrganizationMember(ctx, args.organizationId);
const signals = await ctx.db
.query("signals")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", args.organizationId)
)
.order("desc")
.take(50);
return Promise.all(
signals.map(async (signal) => ({
signal: toSignalView(signal),
sources: await loadComposedSources(ctx, signal),
}))
);
},
});
/**
* List the bounded set of admitted user messages in the organization's
* conversation that have not yet been consumed as a Signal source, newest
* first. These are the candidate messages an agent should select from when
* composing a new Signal.
*
* Authorization: the authenticated identity must be a member of the target
* organization. The conversation id equals the organization id; the caller
* never supplies tenancy. Cross-organization access is denied.
*
* Bounded: capped at 100 rows to keep agent evidence selection tractable.
*/
export const listAdmittedUnused = query({
args: {
organizationId: v.id("organizations"),
},
handler: async (ctx, args): Promise<ConversationMessageSourceView[]> => {
await requireOrganizationMember(ctx, args.organizationId);
const conversationId = args.organizationId;
const admitted = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
)
.order("desc")
.take(100);
const candidates = admitted.filter(
(message) => message.role === "user" && message.status === "admitted"
);
const unused: ConversationMessageSourceView[] = [];
for (const message of candidates) {
const consumed = await ctx.db
.query("signalSources")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
.eq("messageId", message.messageId)
)
.first();
if (!consumed) {
unused.push(toConversationMessageSourceView(message));
}
}
return unused;
},
});
/**
* Get one Signal by id with its composed sources. Membership in the signal's
* organization is required; access to another organization's signal is denied.
*/
export const get = query({
args: {
signalId: v.id("signals"),
},
handler: async (ctx, args): Promise<ComposedSignal | null> => {
const signal = await ctx.db.get(args.signalId);
if (!signal) {
return null;
}
await requireOrganizationMember(ctx, signal.organizationId);
return {
signal: toSignalView(signal),
sources: await loadComposedSources(ctx, signal),
};
},
});
// ---------------------------------------------------------------------------
// Linked Signal query for the Work OS surface.
// ---------------------------------------------------------------------------
export interface LinkedSignalView {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
/**
* List all Signal-issue attachments for a project, grouped by issue id.
* User-authenticated: the caller must be a member of the project organization.
* Used by the Work OS card list to show per-issue linked Signal counts.
*/
export const listLinkedSignalsForProject = query({
args: {
projectId: v.id("projects"),
},
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
const { organizationId } = await requireProjectMember(ctx, args.projectId);
const issues = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.take(100);
const result: Record<string, LinkedSignalView[]> = {};
for (const issue of issues) {
const attachments = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.collect();
if (attachments.length === 0) {
continue;
}
const signals = await Promise.all(
attachments.map(async (attachment) => {
const signal = await ctx.db.get(attachment.signalId);
if (!signal || signal.organizationId !== organizationId) {
return null;
}
const sources = await ctx.db
.query("signalSources")
.withIndex("by_signal_and_ordinal", (q) =>
q.eq("signalId", signal._id)
)
.collect();
return {
createdAt: signal.createdAt,
signalId: signal._id,
sourceCount: sources.length,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
} satisfies LinkedSignalView;
})
);
result[String(issue._id)] = signals
.filter((entry): entry is LinkedSignalView => entry !== null)
// Deterministic order: newest linked Signal first.
.toSorted((a, b) => b.createdAt - a.createdAt);
}
return result;
},
});