Convex control plane (Slices 1-4 prerequisites for Slice 5): - Versioned Definition/Design persistence: revisions preserve history instead of deleting prior slices/approvals - Fenced conversation turn queue: attempt-numbered leases prevent stale worker overwrites; expired turns reconciled by cron - Fenced Run/Attempt claiming: only queued attempts from running Runs can be claimed; lease expiry checks on every finish/checkpoint - Multi-slice progression: successful slice marks next slice ready instead of completing the whole Work; completed Runs blocked from retry - Typed AttemptClassification in schema and resolver decisions table - Durable resolver decisions for normal outcomes, cancellation, and lease-expiry recovery - Persistent artifacts and delivery metadata with provenance, source revision, verification status, and controlled delivery transitions - High-impact open questions block definition approval - Question submission gated to defining/awaiting-approval states only - Delivery status updates validate JSON metadata before persisting - Planner failures recorded as durable blocked events - Approval identity uses canonical tokenIdentifier Primitives: - decodeDefinition separates decode from validate - Design validation enforces 1-4 slices with unique IDs and valid deps - Artifact and delivery draft schemas with verified-revision binding - Delivery status transition table Package management: - Consolidated shared deps into single catalog (hono, valibot, streamdown, @types/react, @types/node, @tailwindcss/*, react-native) - Removed cross-version Hono boundary: flue() exported as Fetchable - Deleted bun.lock and regenerated from clean catalog resolution Lint/format: - Removed .eslintignore; oxc uses native ignorePatterns in oxlint.config.ts - Deleted redundant packages/backend/.oxlintrc.json - Added repos/** and scripts/** to ignore patterns - Convex-specific rule overrides for ES2022 target constraints - Fixed all oxc errors in fluePersistence.ts and agents/src/db.ts - Fixed all formatting across changed files
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { env } from "@code/env/convex";
|
|
import {
|
|
canTransitionWorkDelivery,
|
|
decodeWorkArtifactDraft,
|
|
decodeWorkDeliveryDraft,
|
|
} from "@code/primitives/work-artifact";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Effect } from "effect";
|
|
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { mutation, query } from "./_generated/server";
|
|
import type { MutationCtx } from "./_generated/server";
|
|
import { requireProjectMember } from "./authz";
|
|
|
|
const artifactKind = v.union(
|
|
v.literal("definition"),
|
|
v.literal("design"),
|
|
v.literal("diff"),
|
|
v.literal("test-report"),
|
|
v.literal("verification-report"),
|
|
v.literal("runtime-log"),
|
|
v.literal("screenshot"),
|
|
v.literal("video"),
|
|
v.literal("commit"),
|
|
v.literal("branch"),
|
|
v.literal("pull-request"),
|
|
v.literal("preview"),
|
|
v.literal("deployment"),
|
|
v.literal("other")
|
|
);
|
|
const verificationStatus = v.union(
|
|
v.literal("unverified"),
|
|
v.literal("verified"),
|
|
v.literal("rejected")
|
|
);
|
|
const deliveryKind = v.union(
|
|
v.literal("branch"),
|
|
v.literal("commit"),
|
|
v.literal("pull-request"),
|
|
v.literal("preview"),
|
|
v.literal("deployment")
|
|
);
|
|
const deliveryStatus = v.union(
|
|
v.literal("recorded"),
|
|
v.literal("ready"),
|
|
v.literal("approved"),
|
|
v.literal("delivered"),
|
|
v.literal("failed"),
|
|
v.literal("cancelled")
|
|
);
|
|
|
|
const requireAgent = (token: string): void => {
|
|
if (token !== env.FLUE_DB_TOKEN) {
|
|
throw new ConvexError("Invalid agent control token");
|
|
}
|
|
};
|
|
|
|
const appendEvent = async (
|
|
ctx: MutationCtx,
|
|
workId: Id<"works">,
|
|
kind: "artifact.recorded" | "delivery.recorded" | "delivery.updated",
|
|
idempotencyKey: string,
|
|
referenceId: string,
|
|
payloadJson: string
|
|
): Promise<void> => {
|
|
const existing = await ctx.db
|
|
.query("workEvents")
|
|
.withIndex("by_work_and_idempotencyKey", (q) =>
|
|
q.eq("workId", workId).eq("idempotencyKey", idempotencyKey)
|
|
)
|
|
.unique();
|
|
if (existing) {
|
|
return;
|
|
}
|
|
await ctx.db.insert("workEvents", {
|
|
createdAt: Date.now(),
|
|
idempotencyKey,
|
|
kind,
|
|
payloadJson,
|
|
referenceId,
|
|
workId,
|
|
});
|
|
};
|
|
|
|
export const recordArtifact = mutation({
|
|
args: {
|
|
attemptId: v.optional(v.id("workAttempts")),
|
|
designVersion: v.optional(v.number()),
|
|
draft: v.object({
|
|
contentHash: v.optional(v.string()),
|
|
environmentId: v.optional(v.string()),
|
|
idempotencyKey: v.string(),
|
|
kind: artifactKind,
|
|
metadataJson: v.string(),
|
|
producer: v.string(),
|
|
provenanceJson: v.string(),
|
|
sourceRevision: v.optional(v.string()),
|
|
title: v.string(),
|
|
uri: v.optional(v.string()),
|
|
verificationStatus,
|
|
}),
|
|
runId: v.optional(v.id("workRuns")),
|
|
sliceId: v.optional(v.string()),
|
|
token: v.string(),
|
|
workId: v.id("works"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
requireAgent(args.token);
|
|
const work = await ctx.db.get(args.workId);
|
|
if (!work) {
|
|
throw new ConvexError("Work not found");
|
|
}
|
|
const draft = await Effect.runPromise(
|
|
decodeWorkArtifactDraft(args.draft)
|
|
).catch((error: unknown) => {
|
|
throw new ConvexError(
|
|
error instanceof Error ? error.message : "Invalid Work artifact"
|
|
);
|
|
});
|
|
const attempt = args.attemptId ? await ctx.db.get(args.attemptId) : null;
|
|
if (args.attemptId && !attempt) {
|
|
throw new ConvexError("Attempt not found");
|
|
}
|
|
const referencedRunId = args.runId ?? attempt?.runId;
|
|
const run = referencedRunId ? await ctx.db.get(referencedRunId) : null;
|
|
if (referencedRunId && !run) {
|
|
throw new ConvexError("Run not found");
|
|
}
|
|
if (run && run.workId !== work._id) {
|
|
throw new ConvexError("Run does not belong to Work");
|
|
}
|
|
if (
|
|
attempt &&
|
|
(attempt.workId !== work._id ||
|
|
(run !== null && attempt.runId !== run._id))
|
|
) {
|
|
throw new ConvexError("Attempt does not belong to Work Run");
|
|
}
|
|
if (
|
|
run &&
|
|
((args.designVersion !== undefined &&
|
|
args.designVersion !== run.designVersion) ||
|
|
(args.sliceId !== undefined && args.sliceId !== run.sliceId))
|
|
) {
|
|
throw new ConvexError("Artifact provenance conflicts with Work Run");
|
|
}
|
|
const designVersion = args.designVersion ?? run?.designVersion;
|
|
const sliceId = args.sliceId ?? run?.sliceId;
|
|
if (designVersion !== undefined && sliceId !== undefined) {
|
|
const slice = await ctx.db
|
|
.query("workSlices")
|
|
.withIndex("by_workId_and_designVersion_and_sliceId", (q) =>
|
|
q
|
|
.eq("workId", work._id)
|
|
.eq("designVersion", designVersion)
|
|
.eq("sliceId", sliceId)
|
|
)
|
|
.unique();
|
|
if (!slice) {
|
|
throw new ConvexError("Artifact slice not found");
|
|
}
|
|
} else if (designVersion !== undefined) {
|
|
const design = await ctx.db
|
|
.query("designPackets")
|
|
.withIndex("by_work_and_version", (q) =>
|
|
q.eq("workId", work._id).eq("version", designVersion)
|
|
)
|
|
.unique();
|
|
if (!design) {
|
|
throw new ConvexError("Artifact Design not found");
|
|
}
|
|
}
|
|
const existing = await ctx.db
|
|
.query("workArtifacts")
|
|
.withIndex("by_workId_and_idempotencyKey", (q) =>
|
|
q.eq("workId", work._id).eq("idempotencyKey", draft.idempotencyKey)
|
|
)
|
|
.unique();
|
|
if (existing) {
|
|
const exactReplay =
|
|
existing.kind === draft.kind &&
|
|
existing.title === draft.title &&
|
|
existing.uri === draft.uri &&
|
|
existing.contentHash === draft.contentHash &&
|
|
existing.sourceRevision === draft.sourceRevision &&
|
|
existing.environmentId === draft.environmentId &&
|
|
existing.producer === draft.producer &&
|
|
existing.provenanceJson === draft.provenanceJson &&
|
|
existing.metadataJson === draft.metadataJson &&
|
|
existing.verificationStatus === draft.verificationStatus &&
|
|
existing.designVersion === designVersion &&
|
|
existing.sliceId === sliceId &&
|
|
existing.runId === run?._id &&
|
|
existing.attemptId === attempt?._id;
|
|
if (!exactReplay) {
|
|
throw new ConvexError("Artifact idempotency key has conflicting data");
|
|
}
|
|
return { artifactId: existing._id, created: false };
|
|
}
|
|
const createdAt = Date.now();
|
|
const artifactId = await ctx.db.insert("workArtifacts", {
|
|
...draft,
|
|
attemptId: attempt?._id,
|
|
createdAt,
|
|
designVersion,
|
|
organizationId: work.organizationId,
|
|
projectId: work.projectId,
|
|
runId: run?._id,
|
|
sliceId,
|
|
workId: work._id,
|
|
});
|
|
await appendEvent(
|
|
ctx,
|
|
work._id,
|
|
"artifact.recorded",
|
|
`artifact:${draft.idempotencyKey}`,
|
|
String(artifactId),
|
|
JSON.stringify({ kind: draft.kind, title: draft.title })
|
|
);
|
|
return { artifactId, created: true };
|
|
},
|
|
});
|
|
|
|
export const recordDelivery = mutation({
|
|
args: {
|
|
artifactId: v.optional(v.id("workArtifacts")),
|
|
draft: v.object({
|
|
externalId: v.string(),
|
|
idempotencyKey: v.string(),
|
|
kind: deliveryKind,
|
|
metadataJson: v.string(),
|
|
provider: v.string(),
|
|
sourceRevision: v.string(),
|
|
status: deliveryStatus,
|
|
target: v.string(),
|
|
url: v.optional(v.string()),
|
|
}),
|
|
token: v.string(),
|
|
workId: v.id("works"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
requireAgent(args.token);
|
|
const work = await ctx.db.get(args.workId);
|
|
if (!work) {
|
|
throw new ConvexError("Work not found");
|
|
}
|
|
const draft = await Effect.runPromise(
|
|
decodeWorkDeliveryDraft(args.draft)
|
|
).catch((error: unknown) => {
|
|
throw new ConvexError(
|
|
error instanceof Error ? error.message : "Invalid Work delivery"
|
|
);
|
|
});
|
|
const artifact = args.artifactId ? await ctx.db.get(args.artifactId) : null;
|
|
if (args.artifactId && !artifact) {
|
|
throw new ConvexError("Artifact not found");
|
|
}
|
|
if (artifact && artifact.workId !== work._id) {
|
|
throw new ConvexError("Artifact does not belong to Work");
|
|
}
|
|
const existing = await ctx.db
|
|
.query("workDeliveries")
|
|
.withIndex("by_workId_and_idempotencyKey", (q) =>
|
|
q.eq("workId", work._id).eq("idempotencyKey", draft.idempotencyKey)
|
|
)
|
|
.unique();
|
|
if (existing) {
|
|
const exactReplay =
|
|
existing.kind === draft.kind &&
|
|
existing.provider === draft.provider &&
|
|
existing.externalId === draft.externalId &&
|
|
existing.url === draft.url &&
|
|
existing.sourceRevision === draft.sourceRevision &&
|
|
existing.target === draft.target &&
|
|
existing.status === draft.status &&
|
|
existing.metadataJson === draft.metadataJson &&
|
|
existing.artifactId === artifact?._id;
|
|
if (!exactReplay) {
|
|
throw new ConvexError("Delivery idempotency key has conflicting data");
|
|
}
|
|
return { created: false, deliveryId: existing._id };
|
|
}
|
|
const timestamp = Date.now();
|
|
const deliveryId = await ctx.db.insert("workDeliveries", {
|
|
...draft,
|
|
artifactId: artifact?._id,
|
|
createdAt: timestamp,
|
|
organizationId: work.organizationId,
|
|
projectId: work.projectId,
|
|
updatedAt: timestamp,
|
|
workId: work._id,
|
|
});
|
|
await appendEvent(
|
|
ctx,
|
|
work._id,
|
|
"delivery.recorded",
|
|
`delivery:${draft.idempotencyKey}`,
|
|
String(deliveryId),
|
|
JSON.stringify({ kind: draft.kind, sourceRevision: draft.sourceRevision })
|
|
);
|
|
return { created: true, deliveryId };
|
|
},
|
|
});
|
|
|
|
export const updateDeliveryStatus = mutation({
|
|
args: {
|
|
deliveryId: v.id("workDeliveries"),
|
|
metadataJson: v.string(),
|
|
status: deliveryStatus,
|
|
token: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
requireAgent(args.token);
|
|
// Validate JSON before persisting, matching recordDelivery's decode path.
|
|
const metadata = JSON.parse(args.metadataJson) as unknown;
|
|
if (typeof metadata !== "object" || metadata === null) {
|
|
throw new ConvexError("Delivery metadata must be a JSON object");
|
|
}
|
|
const delivery = await ctx.db.get(args.deliveryId);
|
|
if (!delivery) {
|
|
throw new ConvexError("Delivery not found");
|
|
}
|
|
if (delivery.status === args.status) {
|
|
return { changed: false, status: delivery.status };
|
|
}
|
|
if (!canTransitionWorkDelivery(delivery.status, args.status)) {
|
|
throw new ConvexError(
|
|
`Invalid delivery transition: ${delivery.status} -> ${args.status}`
|
|
);
|
|
}
|
|
await ctx.db.patch(delivery._id, {
|
|
metadataJson: args.metadataJson,
|
|
status: args.status,
|
|
updatedAt: Date.now(),
|
|
});
|
|
await appendEvent(
|
|
ctx,
|
|
delivery.workId,
|
|
"delivery.updated",
|
|
`delivery-updated:${delivery._id}:${args.status}`,
|
|
String(delivery._id),
|
|
JSON.stringify({ from: delivery.status, to: args.status })
|
|
);
|
|
return { changed: true, status: args.status };
|
|
},
|
|
});
|
|
|
|
export const listForWork = query({
|
|
args: { workId: v.id("works") },
|
|
handler: async (ctx, args) => {
|
|
const work = await ctx.db.get(args.workId);
|
|
if (!work) {
|
|
return null;
|
|
}
|
|
await requireProjectMember(ctx, work.projectId);
|
|
const [artifacts, deliveries] = await Promise.all([
|
|
ctx.db
|
|
.query("workArtifacts")
|
|
.withIndex("by_workId_and_createdAt", (q) => q.eq("workId", work._id))
|
|
.order("desc")
|
|
.take(100),
|
|
ctx.db
|
|
.query("workDeliveries")
|
|
.withIndex("by_workId_and_createdAt", (q) => q.eq("workId", work._id))
|
|
.order("desc")
|
|
.take(50),
|
|
]);
|
|
return { artifacts, deliveries };
|
|
},
|
|
});
|