Slices 2-4: Work becomes executable
Domain primitives: versioned WorkDefinition/DesignPacket with risk, questions, and approval gates; lifecycle transition table with invalidation rules; Resolver (Run/Attempt/outcomes/CodingKitV0); provider-neutral HarnessRuntime with normalized events and a deterministic FakeHarnessLive that always reaches a terminal classification and never claims implementation. Convex durable model: workDefinitions, workQuestions, workApprovals, designPackets, workSlices, workRuns, workAttempts, workAttemptEvents tables; broadened works.status union; generalized workEvents with optional signalId, referenceId, and payloadJson. Authenticated commands for definition request/ save/revise/approve, question answer/withdraw, design save/revise/approve, and simulated execution start/cancel/retry with leased attempts, checkpointed events, and expired-lease reconciliation. Private work-planner FLUE agent with proposal-only tools (definition, design, question). Convex validates and stores every proposal; FLUE never approves or advances Work. Expanded Work card with Outcome, Design, and Build sections wired to the new mutations. Slice 1 conversation and provenance preserved.
This commit is contained in:
@@ -123,7 +123,24 @@ export default defineSchema({
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
objective: v.string(),
|
||||
status: v.literal("proposed"),
|
||||
status: v.union(
|
||||
v.literal("proposed"),
|
||||
v.literal("defining"),
|
||||
v.literal("awaiting-definition-approval"),
|
||||
v.literal("designing"),
|
||||
v.literal("awaiting-design-approval"),
|
||||
v.literal("ready"),
|
||||
v.literal("executing"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("blocked"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
definitionVersion: v.optional(v.number()),
|
||||
definitionApprovalVersion: v.optional(v.number()),
|
||||
designVersion: v.optional(v.number()),
|
||||
designApprovalVersion: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
@@ -141,14 +158,164 @@ export default defineSchema({
|
||||
|
||||
workEvents: defineTable({
|
||||
workId: v.id("works"),
|
||||
signalId: v.id("signals"),
|
||||
kind: v.union(v.literal("work.proposed"), v.literal("signal.attached")),
|
||||
signalId: v.optional(v.id("signals")),
|
||||
kind: v.union(
|
||||
v.literal("work.proposed"),
|
||||
v.literal("signal.attached"),
|
||||
v.literal("definition.requested"),
|
||||
v.literal("definition.saved"),
|
||||
v.literal("definition.revised"),
|
||||
v.literal("definition.approved"),
|
||||
v.literal("definition.invalidated"),
|
||||
v.literal("question.answered"),
|
||||
v.literal("question.withdrawn"),
|
||||
v.literal("design.requested"),
|
||||
v.literal("design.saved"),
|
||||
v.literal("design.revised"),
|
||||
v.literal("design.approved"),
|
||||
v.literal("design.invalidated"),
|
||||
v.literal("run.started"),
|
||||
v.literal("run.cancelled"),
|
||||
v.literal("attempt.claimed"),
|
||||
v.literal("attempt.event"),
|
||||
v.literal("attempt.completed"),
|
||||
v.literal("attempt.reconciled")
|
||||
),
|
||||
referenceId: v.optional(v.string()),
|
||||
payloadJson: v.optional(v.string()),
|
||||
idempotencyKey: v.string(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_work_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_work_and_idempotencyKey", ["workId", "idempotencyKey"]),
|
||||
|
||||
workDefinitions: defineTable({
|
||||
workId: v.id("works"),
|
||||
version: v.number(),
|
||||
payloadJson: v.string(),
|
||||
risk: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||
status: v.union(
|
||||
v.literal("proposed"),
|
||||
v.literal("current"),
|
||||
v.literal("superseded")
|
||||
),
|
||||
createdBy: v.string(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_work_and_version", ["workId", "version"])
|
||||
.index("by_work_and_status", ["workId", "status"]),
|
||||
|
||||
workQuestions: defineTable({
|
||||
workId: v.id("works"),
|
||||
definitionVersion: v.number(),
|
||||
questionId: v.string(),
|
||||
prompt: v.string(),
|
||||
impact: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||
recommendation: v.optional(v.string()),
|
||||
alternativesJson: v.string(),
|
||||
status: v.union(
|
||||
v.literal("open"),
|
||||
v.literal("answered"),
|
||||
v.literal("withdrawn")
|
||||
),
|
||||
answer: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
}).index("by_work_and_definitionVersion", ["workId", "definitionVersion"]),
|
||||
|
||||
workApprovals: defineTable({
|
||||
workId: v.id("works"),
|
||||
kind: v.union(v.literal("definition"), v.literal("design")),
|
||||
definitionVersion: v.number(),
|
||||
designVersion: v.optional(v.number()),
|
||||
approvedBy: v.string(),
|
||||
approvedAt: v.number(),
|
||||
status: v.union(v.literal("active"), v.literal("invalidated")),
|
||||
}).index("by_work_and_kind", ["workId", "kind"]),
|
||||
|
||||
designPackets: defineTable({
|
||||
workId: v.id("works"),
|
||||
version: v.number(),
|
||||
definitionVersion: v.number(),
|
||||
payloadJson: v.string(),
|
||||
status: v.union(
|
||||
v.literal("proposed"),
|
||||
v.literal("current"),
|
||||
v.literal("superseded")
|
||||
),
|
||||
createdBy: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_work_and_version", ["workId", "version"]),
|
||||
|
||||
workSlices: defineTable({
|
||||
workId: v.id("works"),
|
||||
designVersion: v.number(),
|
||||
sliceId: v.string(),
|
||||
ordinal: v.number(),
|
||||
title: v.string(),
|
||||
objective: v.string(),
|
||||
observableBehavior: v.string(),
|
||||
payloadJson: v.string(),
|
||||
status: v.union(
|
||||
v.literal("planned"),
|
||||
v.literal("ready"),
|
||||
v.literal("running"),
|
||||
v.literal("completed"),
|
||||
v.literal("blocked")
|
||||
),
|
||||
}).index("by_work_and_designVersion", ["workId", "designVersion"]),
|
||||
|
||||
workRuns: defineTable({
|
||||
workId: v.id("works"),
|
||||
sliceId: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("running"),
|
||||
v.literal("terminal"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
scenario: v.union(
|
||||
v.literal("success"),
|
||||
v.literal("transient-failure-then-success"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("permanent-failure"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
kitId: v.string(),
|
||||
kitVersion: v.string(),
|
||||
createdAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
endedAt: v.optional(v.number()),
|
||||
terminalClassification: v.optional(v.string()),
|
||||
terminalSummary: v.optional(v.string()),
|
||||
}).index("by_work_and_createdAt", ["workId", "createdAt"]),
|
||||
|
||||
workAttempts: defineTable({
|
||||
runId: v.id("workRuns"),
|
||||
workId: v.id("works"),
|
||||
number: v.number(),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("terminal")
|
||||
),
|
||||
leaseOwner: v.optional(v.string()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
startedAt: v.optional(v.number()),
|
||||
endedAt: v.optional(v.number()),
|
||||
classification: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
}).index("by_run_and_number", ["runId", "number"]),
|
||||
|
||||
workAttemptEvents: defineTable({
|
||||
attemptId: v.id("workAttempts"),
|
||||
sequence: v.number(),
|
||||
kind: v.string(),
|
||||
message: v.string(),
|
||||
metadataJson: v.string(),
|
||||
occurredAt: v.number(),
|
||||
}).index("by_attempt_and_sequence", ["attemptId", "sequence"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Flue persistence stores (schema/format version 4).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
419
packages/backend/convex/workExecution.ts
Normal file
419
packages/backend/convex/workExecution.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
FakeHarnessLive,
|
||||
type FakeScenario,
|
||||
} from "@code/primitives/harness-runtime";
|
||||
import { defaultCodingKitV0 } from "@code/primitives/resolver";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import {
|
||||
internalAction,
|
||||
internalMutation,
|
||||
mutation,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const executeRef = makeFunctionReference<
|
||||
"action",
|
||||
{ attemptId: Id<"workAttempts">; scenario: FakeScenario }
|
||||
>("workExecution:executeFakeAttempt");
|
||||
const claimRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ attemptId: Id<"workAttempts">; leaseMs: number; owner: string },
|
||||
{ number: number } | null
|
||||
>("workExecution:claimAttempt");
|
||||
const checkpointRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
attemptId: Id<"workAttempts">;
|
||||
kind: string;
|
||||
message: string;
|
||||
metadataJson: string;
|
||||
owner: string;
|
||||
sequence: number;
|
||||
},
|
||||
unknown
|
||||
>("workExecution:checkpointAttempt");
|
||||
const completeRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
attemptId: Id<"workAttempts">;
|
||||
classification: string;
|
||||
owner: string;
|
||||
summary: string;
|
||||
},
|
||||
unknown
|
||||
>("workExecution:completeAttempt");
|
||||
const now = () => Date.now();
|
||||
|
||||
const requireWorkForMember = async (ctx: any, workId: Id<"works">) => {
|
||||
const work = await ctx.db.get(workId);
|
||||
if (!work) throw new ConvexError("Work not found");
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
return work;
|
||||
};
|
||||
|
||||
const appendWorkEvent = async (
|
||||
ctx: any,
|
||||
workId: Id<"works">,
|
||||
kind: any,
|
||||
idempotencyKey: string,
|
||||
referenceId?: string,
|
||||
payloadJson?: string
|
||||
) => {
|
||||
const existing = await ctx.db
|
||||
.query("workEvents")
|
||||
.withIndex("by_work_and_idempotencyKey", (q: any) =>
|
||||
q.eq("workId", workId).eq("idempotencyKey", idempotencyKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) return;
|
||||
await ctx.db.insert("workEvents", {
|
||||
workId,
|
||||
kind,
|
||||
idempotencyKey,
|
||||
createdAt: now(),
|
||||
...(referenceId ? { referenceId } : {}),
|
||||
...(payloadJson ? { payloadJson } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const startSimulatedExecution = mutation({
|
||||
args: {
|
||||
workId: v.id("works"),
|
||||
scenario: v.union(
|
||||
v.literal("success"),
|
||||
v.literal("transient-failure-then-success"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("permanent-failure"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
sliceId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWorkForMember(ctx, args.workId);
|
||||
if (work.status !== "ready")
|
||||
throw new ConvexError("Work must be Ready before simulated execution");
|
||||
if (
|
||||
work.definitionApprovalVersion !== work.definitionVersion ||
|
||||
work.designApprovalVersion !== work.designVersion
|
||||
)
|
||||
throw new ConvexError(
|
||||
"Execution requires exact approved Definition and Design versions"
|
||||
);
|
||||
const createdAt = now();
|
||||
const runId = await ctx.db.insert("workRuns", {
|
||||
workId: work._id,
|
||||
sliceId: args.sliceId,
|
||||
status: "ready",
|
||||
scenario: args.scenario,
|
||||
kitId: defaultCodingKitV0.id,
|
||||
kitVersion: defaultCodingKitV0.version,
|
||||
createdAt,
|
||||
});
|
||||
const attemptId = await ctx.db.insert("workAttempts", {
|
||||
runId,
|
||||
workId: work._id,
|
||||
number: 1,
|
||||
status: "queued",
|
||||
});
|
||||
await ctx.db.patch(work._id, { status: "executing", updatedAt: createdAt });
|
||||
await ctx.db.patch(runId, { status: "running", startedAt: createdAt });
|
||||
await appendWorkEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"run.started",
|
||||
`run-started:${runId}`,
|
||||
String(runId)
|
||||
);
|
||||
await ctx.scheduler.runAfter(0, executeRef, {
|
||||
attemptId,
|
||||
scenario: args.scenario,
|
||||
});
|
||||
return { runId, attemptId };
|
||||
},
|
||||
});
|
||||
|
||||
export const cancelSimulatedExecution = mutation({
|
||||
args: { runId: v.id("workRuns") },
|
||||
handler: async (ctx, args) => {
|
||||
const run = await ctx.db.get(args.runId);
|
||||
if (!run) throw new ConvexError("Run not found");
|
||||
const work = await requireWorkForMember(ctx, run.workId);
|
||||
if (run.status === "terminal" || run.status === "cancelled")
|
||||
return { cancelled: false };
|
||||
await ctx.db.patch(run._id, {
|
||||
status: "cancelled",
|
||||
endedAt: now(),
|
||||
terminalClassification: "Cancelled",
|
||||
terminalSummary: "Simulation cancelled",
|
||||
});
|
||||
const attempts = await ctx.db
|
||||
.query("workAttempts")
|
||||
.withIndex("by_run_and_number", (q: any) => q.eq("runId", run._id))
|
||||
.collect();
|
||||
for (const attempt of attempts)
|
||||
if (attempt.status !== "terminal")
|
||||
await ctx.db.patch(attempt._id, {
|
||||
status: "terminal",
|
||||
endedAt: now(),
|
||||
classification: "Cancelled",
|
||||
summary: "Simulation cancelled",
|
||||
});
|
||||
await ctx.db.patch(work._id, { status: "ready", updatedAt: now() });
|
||||
await appendWorkEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"run.cancelled",
|
||||
`run-cancelled:${run._id}`,
|
||||
String(run._id)
|
||||
);
|
||||
return { cancelled: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const retrySimulatedExecution = mutation({
|
||||
args: { runId: v.id("workRuns") },
|
||||
handler: async (ctx, args) => {
|
||||
const run = await ctx.db.get(args.runId);
|
||||
if (!run) throw new ConvexError("Run not found");
|
||||
const work = await requireWorkForMember(ctx, run.workId);
|
||||
if (run.status !== "terminal")
|
||||
throw new ConvexError("Only terminal Runs can be retried");
|
||||
const attempts = await ctx.db
|
||||
.query("workAttempts")
|
||||
.withIndex("by_run_and_number", (q: any) => q.eq("runId", run._id))
|
||||
.collect();
|
||||
const number = attempts.length + 1;
|
||||
const attemptId = await ctx.db.insert("workAttempts", {
|
||||
runId: run._id,
|
||||
workId: work._id,
|
||||
number,
|
||||
status: "queued",
|
||||
});
|
||||
await ctx.db.patch(run._id, {
|
||||
status: "running",
|
||||
startedAt: now(),
|
||||
endedAt: undefined,
|
||||
terminalClassification: undefined,
|
||||
terminalSummary: undefined,
|
||||
});
|
||||
await ctx.db.patch(work._id, { status: "executing", updatedAt: now() });
|
||||
await ctx.scheduler.runAfter(0, executeRef, {
|
||||
attemptId,
|
||||
scenario: run.scenario,
|
||||
});
|
||||
return { attemptId, number };
|
||||
},
|
||||
});
|
||||
|
||||
export const claimAttempt = internalMutation({
|
||||
args: {
|
||||
attemptId: v.id("workAttempts"),
|
||||
owner: v.string(),
|
||||
leaseMs: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const attempt = await ctx.db.get(args.attemptId);
|
||||
if (!attempt || attempt.status === "terminal") return null;
|
||||
const claimedAt = now();
|
||||
await ctx.db.patch(attempt._id, {
|
||||
status: "claimed",
|
||||
leaseOwner: args.owner,
|
||||
leaseExpiresAt: claimedAt + args.leaseMs,
|
||||
startedAt: attempt.startedAt ?? claimedAt,
|
||||
});
|
||||
await ctx.db.patch(attempt.runId, {
|
||||
status: "running",
|
||||
startedAt: claimedAt,
|
||||
});
|
||||
return { ...attempt, status: "claimed" as const, leaseOwner: args.owner };
|
||||
},
|
||||
});
|
||||
|
||||
export const checkpointAttempt = internalMutation({
|
||||
args: {
|
||||
attemptId: v.id("workAttempts"),
|
||||
owner: v.string(),
|
||||
sequence: v.number(),
|
||||
kind: v.string(),
|
||||
message: v.string(),
|
||||
metadataJson: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const attempt = await ctx.db.get(args.attemptId);
|
||||
if (
|
||||
!attempt ||
|
||||
attempt.leaseOwner !== args.owner ||
|
||||
attempt.status === "terminal"
|
||||
)
|
||||
return false;
|
||||
const existing = await ctx.db
|
||||
.query("workAttemptEvents")
|
||||
.withIndex("by_attempt_and_sequence", (q: any) =>
|
||||
q.eq("attemptId", attempt._id).eq("sequence", args.sequence)
|
||||
)
|
||||
.unique();
|
||||
if (!existing)
|
||||
await ctx.db.insert("workAttemptEvents", {
|
||||
attemptId: attempt._id,
|
||||
sequence: args.sequence,
|
||||
kind: args.kind,
|
||||
message: args.message,
|
||||
metadataJson: args.metadataJson,
|
||||
occurredAt: now(),
|
||||
});
|
||||
await ctx.db.patch(attempt._id, {
|
||||
status: "running",
|
||||
leaseExpiresAt: now() + 60_000,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const completeAttempt = internalMutation({
|
||||
args: {
|
||||
attemptId: v.id("workAttempts"),
|
||||
owner: v.string(),
|
||||
classification: v.string(),
|
||||
summary: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const attempt = await ctx.db.get(args.attemptId);
|
||||
if (
|
||||
!attempt ||
|
||||
attempt.leaseOwner !== args.owner ||
|
||||
attempt.status === "terminal"
|
||||
)
|
||||
return false;
|
||||
const endedAt = now();
|
||||
await ctx.db.patch(attempt._id, {
|
||||
status: "terminal",
|
||||
endedAt,
|
||||
classification: args.classification,
|
||||
summary: args.summary,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
const run = await ctx.db.get(attempt.runId);
|
||||
if (!run) return false;
|
||||
await ctx.db.patch(run._id, {
|
||||
status: "terminal",
|
||||
endedAt,
|
||||
terminalClassification: args.classification,
|
||||
terminalSummary: args.summary,
|
||||
});
|
||||
const work = await ctx.db.get(run.workId);
|
||||
if (work)
|
||||
await ctx.db.patch(work._id, {
|
||||
status: args.classification === "NeedsInput" ? "needs-input" : "ready",
|
||||
updatedAt: endedAt,
|
||||
});
|
||||
if (work)
|
||||
await ctx.db.insert("workEvents", {
|
||||
workId: work._id,
|
||||
kind: "attempt.completed",
|
||||
idempotencyKey: `attempt-completed:${attempt._id}`,
|
||||
referenceId: String(attempt._id),
|
||||
payloadJson: JSON.stringify({
|
||||
classification: args.classification,
|
||||
summary: args.summary,
|
||||
}),
|
||||
createdAt: endedAt,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const executeFakeAttempt = internalAction({
|
||||
args: {
|
||||
attemptId: v.id("workAttempts"),
|
||||
scenario: v.union(
|
||||
v.literal("success"),
|
||||
v.literal("transient-failure-then-success"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("permanent-failure"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const owner = `fake-worker:${args.attemptId}`;
|
||||
const claimed = await ctx.runMutation(claimRef, {
|
||||
attemptId: args.attemptId,
|
||||
owner,
|
||||
leaseMs: 60_000,
|
||||
});
|
||||
if (!claimed) return null;
|
||||
const [events, outcome] = await Effect.runPromise(
|
||||
FakeHarnessLive().run({
|
||||
attemptNumber: claimed.number,
|
||||
scenario: args.scenario,
|
||||
})
|
||||
);
|
||||
for (const item of events)
|
||||
await ctx.runMutation(checkpointRef, {
|
||||
attemptId: args.attemptId,
|
||||
owner,
|
||||
sequence: item.sequence,
|
||||
kind: item.kind,
|
||||
message: item.message,
|
||||
metadataJson: JSON.stringify(item.metadata),
|
||||
});
|
||||
await ctx.runMutation(completeRef, {
|
||||
attemptId: args.attemptId,
|
||||
owner,
|
||||
classification: outcome.classification,
|
||||
summary: outcome.summary,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const reconcileExpiredAttempts = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const active = await ctx.db.query("workAttempts").collect();
|
||||
let reconciled = 0;
|
||||
for (const attempt of active) {
|
||||
if (
|
||||
(attempt.status === "claimed" || attempt.status === "running") &&
|
||||
attempt.leaseExpiresAt !== undefined &&
|
||||
attempt.leaseExpiresAt < now()
|
||||
) {
|
||||
await ctx.db.patch(attempt._id, {
|
||||
status: "queued",
|
||||
leaseOwner: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
const run = await ctx.db.get(attempt.runId);
|
||||
if (run)
|
||||
await ctx.scheduler.runAfter(0, executeRef, {
|
||||
attemptId: attempt._id,
|
||||
scenario: run.scenario,
|
||||
});
|
||||
reconciled += 1;
|
||||
}
|
||||
}
|
||||
return { reconciled };
|
||||
},
|
||||
});
|
||||
|
||||
export const listRunEvents = query({
|
||||
args: { attemptId: v.id("workAttempts") },
|
||||
handler: async (ctx, args) => {
|
||||
const attempt = await ctx.db.get(args.attemptId);
|
||||
if (!attempt) return [];
|
||||
const work = await ctx.db.get(attempt.workId);
|
||||
if (!work) return [];
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
return await ctx.db
|
||||
.query("workAttemptEvents")
|
||||
.withIndex("by_attempt_and_sequence", (q: any) =>
|
||||
q.eq("attemptId", args.attemptId)
|
||||
)
|
||||
.order("asc")
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
151
packages/backend/convex/workPlanning.test.ts
Normal file
151
packages/backend/convex/workPlanning.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi, makeFunctionReference } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
const executeFakeAttemptRef = makeFunctionReference<
|
||||
"action",
|
||||
{ attemptId: string; scenario: "success" }
|
||||
>("workExecution:executeFakeAttempt");
|
||||
const identity = { tokenIdentifier: "https://convex.test|planner-user" };
|
||||
|
||||
describe("Work planning and execution commands", () => {
|
||||
test("binds approvals to exact versions and admits a fake Run only from Ready", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const fixture = await t.withIdentity(identity).run(async (ctx) => {
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: identity.tokenIdentifier,
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
await ctx.db.insert("organizationMembers", {
|
||||
organizationId,
|
||||
userId: identity.tokenIdentifier,
|
||||
role: "owner",
|
||||
createdAt: 1,
|
||||
});
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: 1,
|
||||
name: "Zopu",
|
||||
normalizedSourceUrl: "https://example.com/zopu",
|
||||
organizationId,
|
||||
repositoryPath: "puter/zopu",
|
||||
sourceHost: "example.com",
|
||||
sourceUrl: "https://example.com/zopu",
|
||||
updatedAt: 1,
|
||||
});
|
||||
const workId = await ctx.db.insert("works", {
|
||||
organizationId,
|
||||
projectId,
|
||||
title: "Executable work",
|
||||
objective: "Prove the flow",
|
||||
status: "proposed",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
return { projectId, workId };
|
||||
});
|
||||
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.workPlanning.requestDefinition, { workId: fixture.workId });
|
||||
const definition = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.workPlanning.saveDefinitionProposal, {
|
||||
workId: fixture.workId,
|
||||
payloadJson: JSON.stringify({
|
||||
problem: "Need a proof",
|
||||
desiredOutcome: "A terminal simulation",
|
||||
affectedUsers: ["user"],
|
||||
inScope: ["fake"],
|
||||
outOfScope: [],
|
||||
acceptanceCriteria: ["terminal"],
|
||||
constraints: [],
|
||||
assumptions: [],
|
||||
questions: [],
|
||||
risk: "low",
|
||||
requiredArtifacts: ["events"],
|
||||
}),
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.workPlanning.approveDefinition, {
|
||||
workId: fixture.workId,
|
||||
version: definition.version,
|
||||
});
|
||||
const design = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.workPlanning.saveDesignProposal, {
|
||||
workId: fixture.workId,
|
||||
payloadJson: JSON.stringify({
|
||||
impactMap: { files: [], modules: [], risks: [], summary: "small" },
|
||||
architectureSummary: "fake",
|
||||
fileTreeDelta: [],
|
||||
callFlowDelta: [],
|
||||
keyInterfaces: [],
|
||||
invariants: ["terminal"],
|
||||
concerns: [],
|
||||
slices: [
|
||||
{
|
||||
id: "slice-1",
|
||||
title: "fake",
|
||||
objective: "fake",
|
||||
observableBehavior: "event",
|
||||
codeBoundaries: ["runtime"],
|
||||
evidenceRequirements: ["event"],
|
||||
verification: ["assert"],
|
||||
reviewRequired: false,
|
||||
dependsOn: [],
|
||||
},
|
||||
],
|
||||
evidenceRequirements: ["event"],
|
||||
tradeoffs: [],
|
||||
}),
|
||||
});
|
||||
await t.withIdentity(identity).mutation(api.workPlanning.approveDesign, {
|
||||
workId: fixture.workId,
|
||||
definitionVersion: definition.version,
|
||||
designVersion: design.version,
|
||||
});
|
||||
const started = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.workExecution.startSimulatedExecution, {
|
||||
workId: fixture.workId,
|
||||
scenario: "success",
|
||||
sliceId: "slice-1",
|
||||
});
|
||||
expect(started.runId).toBeTruthy();
|
||||
const state = await t.run(async (ctx) => ctx.db.get(fixture.workId));
|
||||
expect(state?.status).toBe("executing");
|
||||
expect(state?.definitionApprovalVersion).toBe(definition.version);
|
||||
expect(state?.designApprovalVersion).toBe(design.version);
|
||||
await t.action(executeFakeAttemptRef, {
|
||||
attemptId: String(started.attemptId),
|
||||
scenario: "success",
|
||||
});
|
||||
const completed = await t.run(async (ctx) => ({
|
||||
attempt: await ctx.db
|
||||
.query("workAttempts")
|
||||
.withIndex("by_run_and_number", (q) => q.eq("runId", started.runId))
|
||||
.unique(),
|
||||
events: await ctx.db.query("workAttemptEvents").collect(),
|
||||
run: await ctx.db.get(started.runId as Id<"workRuns">),
|
||||
work: await ctx.db.get(fixture.workId),
|
||||
}));
|
||||
expect(completed.work?.status).toBe("ready");
|
||||
expect(completed.run?.terminalClassification).toBe("Succeeded");
|
||||
expect(completed.attempt?.status).toBe("terminal");
|
||||
expect(completed.events.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
645
packages/backend/convex/workPlanning.ts
Normal file
645
packages/backend/convex/workPlanning.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import {
|
||||
validateDefinition,
|
||||
type WorkDefinition,
|
||||
} from "@code/primitives/work-definition";
|
||||
import { validateDesignPacket } from "@code/primitives/work-design";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
env,
|
||||
internalAction,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const plannerRef = makeFunctionReference<
|
||||
"action",
|
||||
{ organizationId: Id<"organizations">; workId: Id<"works"> }
|
||||
>("workPlanning:runPlanner");
|
||||
const plannerContextRef = makeFunctionReference<
|
||||
"query",
|
||||
{ workId: Id<"works"> },
|
||||
{ objective: string; status: string; title: string } | null
|
||||
>("workPlanning:getPlannerContext");
|
||||
|
||||
const parsePayload = (payloadJson: string): unknown => {
|
||||
try {
|
||||
return JSON.parse(payloadJson) as unknown;
|
||||
} catch {
|
||||
throw new ConvexError("Proposal payload must be valid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const objectPayload = (payloadJson: string): Record<string, unknown> => {
|
||||
const value = parsePayload(payloadJson);
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw new ConvexError("Proposal payload must be an object");
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const requireWork = async (ctx: { db: any }, workId: Id<"works">) => {
|
||||
const work = (await ctx.db.get(workId)) as Doc<"works"> | null;
|
||||
if (!work) throw new ConvexError("Work not found");
|
||||
return work;
|
||||
};
|
||||
|
||||
const appendEvent = async (
|
||||
ctx: { db: any },
|
||||
workId: Id<"works">,
|
||||
kind: any,
|
||||
idempotencyKey: string,
|
||||
referenceId?: string,
|
||||
payloadJson?: string
|
||||
) => {
|
||||
const existing = await ctx.db
|
||||
.query("workEvents")
|
||||
.withIndex("by_work_and_idempotencyKey", (q: any) =>
|
||||
q.eq("workId", workId).eq("idempotencyKey", idempotencyKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) return existing._id;
|
||||
return await ctx.db.insert("workEvents", {
|
||||
workId,
|
||||
kind,
|
||||
idempotencyKey,
|
||||
createdAt: Date.now(),
|
||||
...(referenceId ? { referenceId } : {}),
|
||||
...(payloadJson ? { payloadJson } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const invalidateApprovalsAndDesign = async (
|
||||
ctx: { db: any },
|
||||
workId: Id<"works">
|
||||
) => {
|
||||
const approvals = await ctx.db
|
||||
.query("workApprovals")
|
||||
.withIndex("by_work_and_kind", (q: any) => q.eq("workId", workId))
|
||||
.collect();
|
||||
for (const approval of approvals) {
|
||||
if (approval.status === "active")
|
||||
await ctx.db.patch(approval._id, { status: "invalidated" });
|
||||
}
|
||||
const designs = await ctx.db
|
||||
.query("designPackets")
|
||||
.withIndex("by_work_and_version", (q: any) => q.eq("workId", workId))
|
||||
.collect();
|
||||
for (const design of designs) {
|
||||
if (design.status === "current")
|
||||
await ctx.db.patch(design._id, { status: "superseded" });
|
||||
}
|
||||
const slices = await ctx.db
|
||||
.query("workSlices")
|
||||
.withIndex("by_work_and_designVersion", (q: any) => q.eq("workId", workId))
|
||||
.collect();
|
||||
for (const slice of slices) await ctx.db.delete(slice._id);
|
||||
};
|
||||
|
||||
export const requestDefinition = mutation({
|
||||
args: { workId: v.id("works") },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
if (work.status !== "proposed" && work.status !== "defining")
|
||||
throw new ConvexError("Work is not available for definition");
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(work._id, { status: "defining", updatedAt: now });
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"definition.requested",
|
||||
`definition-requested:${work._id}`
|
||||
);
|
||||
await ctx.scheduler.runAfter(0, plannerRef, {
|
||||
organizationId: work.organizationId,
|
||||
workId: work._id,
|
||||
});
|
||||
return { status: "defining" as const };
|
||||
},
|
||||
});
|
||||
|
||||
const saveDefinition = async (
|
||||
ctx: { db: any },
|
||||
work: Doc<"works">,
|
||||
payloadJson: string,
|
||||
createdBy: string
|
||||
) => {
|
||||
const decoded = await Effect.runPromise(
|
||||
validateDefinition({
|
||||
...objectPayload(payloadJson),
|
||||
version: (work.definitionVersion ?? 0) + 1,
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Definition"
|
||||
);
|
||||
});
|
||||
const version = decoded.version;
|
||||
await invalidateApprovalsAndDesign(ctx, work._id);
|
||||
const previous = await ctx.db
|
||||
.query("workDefinitions")
|
||||
.withIndex("by_work_and_version", (q: any) => q.eq("workId", work._id))
|
||||
.collect();
|
||||
for (const row of previous)
|
||||
if (row.status === "current")
|
||||
await ctx.db.patch(row._id, { status: "superseded" });
|
||||
const definitionId = await ctx.db.insert("workDefinitions", {
|
||||
workId: work._id,
|
||||
version,
|
||||
payloadJson: JSON.stringify(decoded),
|
||||
risk: decoded.risk,
|
||||
status: "current",
|
||||
createdBy,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const questions = await ctx.db
|
||||
.query("workQuestions")
|
||||
.withIndex("by_work_and_definitionVersion", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.collect();
|
||||
for (const question of questions)
|
||||
if (question.definitionVersion !== version)
|
||||
await ctx.db.delete(question._id);
|
||||
for (const question of decoded.questions) {
|
||||
await ctx.db.insert("workQuestions", {
|
||||
workId: work._id,
|
||||
definitionVersion: version,
|
||||
questionId: question.id,
|
||||
prompt: question.prompt,
|
||||
impact: question.impact,
|
||||
recommendation: question.recommendation,
|
||||
alternativesJson: JSON.stringify(question.alternatives),
|
||||
status: question.status,
|
||||
answer: question.answer,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
await ctx.db.patch(work._id, {
|
||||
definitionVersion: version,
|
||||
definitionApprovalVersion: undefined,
|
||||
designVersion: undefined,
|
||||
designApprovalVersion: undefined,
|
||||
status: "awaiting-definition-approval",
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
work.definitionVersion ? "definition.revised" : "definition.saved",
|
||||
`definition:${version}`,
|
||||
String(definitionId),
|
||||
JSON.stringify(decoded)
|
||||
);
|
||||
return { definitionId, version };
|
||||
};
|
||||
|
||||
export const saveDefinitionProposal = mutation({
|
||||
args: { workId: v.id("works"), payloadJson: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
return await saveDefinition(ctx, work, args.payloadJson, "user");
|
||||
},
|
||||
});
|
||||
|
||||
export const reviseDefinition = saveDefinitionProposal;
|
||||
|
||||
export const approveDefinition = mutation({
|
||||
args: { workId: v.id("works"), version: v.number() },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
if (
|
||||
work.definitionVersion !== args.version ||
|
||||
work.status !== "awaiting-definition-approval"
|
||||
)
|
||||
throw new ConvexError("Definition version is not current or approvable");
|
||||
const row = await ctx.db
|
||||
.query("workDefinitions")
|
||||
.withIndex("by_work_and_version", (q: any) =>
|
||||
q.eq("workId", work._id).eq("version", args.version)
|
||||
)
|
||||
.unique();
|
||||
if (!row) throw new ConvexError("Definition not found");
|
||||
const definition = JSON.parse(row.payloadJson) as WorkDefinition;
|
||||
const valid = await Effect.runPromise(validateDefinition(definition)).catch(
|
||||
(error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Definition cannot be approved"
|
||||
);
|
||||
}
|
||||
);
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) throw new ConvexError("Authentication required");
|
||||
await ctx.db.insert("workApprovals", {
|
||||
workId: work._id,
|
||||
kind: "definition",
|
||||
definitionVersion: args.version,
|
||||
approvedBy: identity.subject,
|
||||
approvedAt: Date.now(),
|
||||
status: "active",
|
||||
});
|
||||
await ctx.db.patch(work._id, {
|
||||
status: "designing",
|
||||
definitionApprovalVersion: valid.version,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"definition.approved",
|
||||
`definition-approved:${args.version}`
|
||||
);
|
||||
await ctx.scheduler.runAfter(0, plannerRef, {
|
||||
organizationId: work.organizationId,
|
||||
workId: work._id,
|
||||
});
|
||||
return { status: "designing" as const, version: args.version };
|
||||
},
|
||||
});
|
||||
|
||||
const reviseQuestion = async (
|
||||
ctx: { db: any },
|
||||
work: Doc<"works">,
|
||||
questionId: string,
|
||||
update: { status: "answered" | "withdrawn"; answer?: string }
|
||||
) => {
|
||||
const current = await ctx.db
|
||||
.query("workDefinitions")
|
||||
.withIndex("by_work_and_version", (q: any) =>
|
||||
q.eq("workId", work._id).eq("version", work.definitionVersion ?? 0)
|
||||
)
|
||||
.unique();
|
||||
if (!current) throw new ConvexError("Current Definition not found");
|
||||
const definition = JSON.parse(current.payloadJson) as WorkDefinition;
|
||||
const next = {
|
||||
...definition,
|
||||
questions: definition.questions.map((question) =>
|
||||
question.id === questionId ? { ...question, ...update } : question
|
||||
),
|
||||
};
|
||||
return await saveDefinition(ctx, work, JSON.stringify(next), "user");
|
||||
};
|
||||
|
||||
export const answerQuestion = mutation({
|
||||
args: { workId: v.id("works"), questionId: v.string(), answer: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
const result = await reviseQuestion(ctx, work, args.questionId, {
|
||||
status: "answered",
|
||||
answer: args.answer,
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"question.answered",
|
||||
`question-answered:${args.questionId}:${result.version}`
|
||||
);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const withdrawQuestion = mutation({
|
||||
args: { workId: v.id("works"), questionId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
const result = await reviseQuestion(ctx, work, args.questionId, {
|
||||
status: "withdrawn",
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"question.withdrawn",
|
||||
`question-withdrawn:${args.questionId}:${result.version}`
|
||||
);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const requestDesign = mutation({
|
||||
args: { workId: v.id("works") },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
if (
|
||||
work.status !== "designing" ||
|
||||
work.definitionApprovalVersion !== work.definitionVersion
|
||||
)
|
||||
throw new ConvexError("Definition must be approved before Design");
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"design.requested",
|
||||
`design-requested:${work.definitionVersion}`
|
||||
);
|
||||
return { status: work.status };
|
||||
},
|
||||
});
|
||||
|
||||
const saveDesign = async (
|
||||
ctx: { db: any },
|
||||
work: Doc<"works">,
|
||||
payloadJson: string,
|
||||
createdBy: string
|
||||
) => {
|
||||
if (work.definitionApprovalVersion !== work.definitionVersion)
|
||||
throw new ConvexError("Design must bind an approved Definition version");
|
||||
const decoded = await Effect.runPromise(
|
||||
validateDesignPacket({
|
||||
...objectPayload(payloadJson),
|
||||
version: (work.designVersion ?? 0) + 1,
|
||||
definitionVersion: work.definitionVersion,
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Design Packet"
|
||||
);
|
||||
});
|
||||
const prior = await ctx.db
|
||||
.query("designPackets")
|
||||
.withIndex("by_work_and_version", (q: any) => q.eq("workId", work._id))
|
||||
.collect();
|
||||
for (const row of prior)
|
||||
if (row.status === "current")
|
||||
await ctx.db.patch(row._id, { status: "superseded" });
|
||||
const designId = await ctx.db.insert("designPackets", {
|
||||
workId: work._id,
|
||||
version: decoded.version,
|
||||
definitionVersion: decoded.definitionVersion,
|
||||
payloadJson: JSON.stringify(decoded),
|
||||
status: "current",
|
||||
createdBy,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const priorSlices = await ctx.db
|
||||
.query("workSlices")
|
||||
.withIndex("by_work_and_designVersion", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.collect();
|
||||
for (const slice of priorSlices) await ctx.db.delete(slice._id);
|
||||
for (const [ordinal, slice] of decoded.slices.entries())
|
||||
await ctx.db.insert("workSlices", {
|
||||
workId: work._id,
|
||||
designVersion: decoded.version,
|
||||
sliceId: slice.id,
|
||||
ordinal,
|
||||
title: slice.title,
|
||||
objective: slice.objective,
|
||||
observableBehavior: slice.observableBehavior,
|
||||
payloadJson: JSON.stringify(slice),
|
||||
status: ordinal === 0 ? "ready" : "planned",
|
||||
});
|
||||
await ctx.db.patch(work._id, {
|
||||
designVersion: decoded.version,
|
||||
designApprovalVersion: undefined,
|
||||
status: "awaiting-design-approval",
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
work.designVersion ? "design.revised" : "design.saved",
|
||||
`design:${decoded.version}`,
|
||||
String(designId),
|
||||
JSON.stringify(decoded)
|
||||
);
|
||||
return { designId, version: decoded.version };
|
||||
};
|
||||
|
||||
export const saveDesignProposal = mutation({
|
||||
args: { workId: v.id("works"), payloadJson: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
return await saveDesign(ctx, work, args.payloadJson, "user");
|
||||
},
|
||||
});
|
||||
|
||||
export const reviseDesign = saveDesignProposal;
|
||||
|
||||
export const approveDesign = mutation({
|
||||
args: {
|
||||
workId: v.id("works"),
|
||||
definitionVersion: v.number(),
|
||||
designVersion: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
if (
|
||||
work.definitionApprovalVersion !== args.definitionVersion ||
|
||||
work.designVersion !== args.designVersion ||
|
||||
work.status !== "awaiting-design-approval"
|
||||
)
|
||||
throw new ConvexError(
|
||||
"Design approval must bind current Definition and Design versions"
|
||||
);
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) throw new ConvexError("Authentication required");
|
||||
await ctx.db.insert("workApprovals", {
|
||||
workId: work._id,
|
||||
kind: "design",
|
||||
definitionVersion: args.definitionVersion,
|
||||
designVersion: args.designVersion,
|
||||
approvedBy: identity.subject,
|
||||
approvedAt: Date.now(),
|
||||
status: "active",
|
||||
});
|
||||
await ctx.db.patch(work._id, {
|
||||
status: "ready",
|
||||
designApprovalVersion: args.designVersion,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"design.approved",
|
||||
`design-approved:${args.definitionVersion}:${args.designVersion}`
|
||||
);
|
||||
return { status: "ready" as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const submitDefinitionProposal = mutation({
|
||||
args: { workId: v.id("works"), payloadJson: v.string(), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
if (args.token !== env.FLUE_DB_TOKEN)
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
return await saveDefinition(ctx, work, args.payloadJson, "work-planner");
|
||||
},
|
||||
});
|
||||
|
||||
export const submitDesignProposal = mutation({
|
||||
args: { workId: v.id("works"), payloadJson: v.string(), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
if (args.token !== env.FLUE_DB_TOKEN)
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
const work = await requireWork(ctx, args.workId);
|
||||
return await saveDesign(ctx, work, args.payloadJson, "work-planner");
|
||||
},
|
||||
});
|
||||
|
||||
export const submitQuestion = mutation({
|
||||
args: { workId: v.id("works"), questionJson: v.string(), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
if (args.token !== env.FLUE_DB_TOKEN)
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
return { accepted: Boolean(JSON.parse(args.questionJson)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const runPlanner = internalAction({
|
||||
args: { organizationId: v.id("organizations"), workId: v.id("works") },
|
||||
handler: async (ctx, args) => {
|
||||
const flueUrl = (env as typeof env & { readonly FLUE_URL?: string })
|
||||
.FLUE_URL;
|
||||
if (!flueUrl) return null;
|
||||
const context = await ctx.runQuery(plannerContextRef, {
|
||||
workId: args.workId,
|
||||
});
|
||||
if (!context) return null;
|
||||
const endpoint = new URL(
|
||||
`agents/work-planner/${encodeURIComponent(String(args.organizationId))}`,
|
||||
`${flueUrl.replace(/\/+$/u, "")}/`
|
||||
);
|
||||
endpoint.searchParams.set("wait", "result");
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
"x-zopu-organization-id": String(args.organizationId),
|
||||
"x-zopu-request-id": `work-planner:${args.workId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: `Plan Work ${String(args.workId)} titled "${context.title}" with objective "${context.objective}". Current state is ${context.status}. If the Work is defining, submit only a Definition proposal and questions. If the Work is designing, submit only a Design Packet proposal bound to the approved Definition. Never approve or execute.`,
|
||||
}),
|
||||
});
|
||||
// The private worker submits typed proposals through Convex mutations; it
|
||||
// never approves or advances Work itself.
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const getPlannerContext = internalQuery({
|
||||
args: { workId: v.id("works") },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await ctx.db.get(args.workId);
|
||||
return work
|
||||
? { objective: work.objective, status: work.status, title: work.title }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
export const listForProject = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const works = await ctx.db
|
||||
.query("works")
|
||||
.withIndex("by_project_and_createdAt", (q: any) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
return await Promise.all(
|
||||
works.map(async (work) => {
|
||||
const definitions = await ctx.db
|
||||
.query("workDefinitions")
|
||||
.withIndex("by_work_and_version", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(10);
|
||||
const designs = await ctx.db
|
||||
.query("designPackets")
|
||||
.withIndex("by_work_and_version", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(10);
|
||||
const slices = await ctx.db
|
||||
.query("workSlices")
|
||||
.withIndex("by_work_and_designVersion", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.collect();
|
||||
const runs = await ctx.db
|
||||
.query("workRuns")
|
||||
.withIndex("by_work_and_createdAt", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(10);
|
||||
const events = await ctx.db
|
||||
.query("workEvents")
|
||||
.withIndex("by_work_and_createdAt", (q: any) =>
|
||||
q.eq("workId", work._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
const attachments = await ctx.db
|
||||
.query("signalWorkAttachments")
|
||||
.withIndex("by_work", (q: any) => q.eq("workId", work._id))
|
||||
.collect();
|
||||
const signals = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const signal = await ctx.db.get(attachment.signalId);
|
||||
if (!signal) return null;
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signalId_and_ordinal", (q: any) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
summary: signal.summary,
|
||||
title: signal.title,
|
||||
sources: sources
|
||||
.sort((a, b) => a.ordinal - b.ordinal)
|
||||
.map((source) => ({
|
||||
createdAt: source.sourceCreatedAt,
|
||||
messageId: String(source.messageId),
|
||||
rawText: source.rawTextSnapshot,
|
||||
submissionId: null,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
const currentDefinition = definitions.find(
|
||||
(definition) => definition.status === "current"
|
||||
);
|
||||
const currentDesign = designs.find(
|
||||
(design) => design.status === "current"
|
||||
);
|
||||
return {
|
||||
...work,
|
||||
definitions,
|
||||
designs,
|
||||
slices: slices.sort((a, b) => a.ordinal - b.ordinal),
|
||||
runs,
|
||||
events,
|
||||
signals: signals.filter((signal) => signal !== null),
|
||||
definition: currentDefinition
|
||||
? JSON.parse(currentDefinition.payloadJson)
|
||||
: null,
|
||||
design: currentDesign ? JSON.parse(currentDesign.payloadJson) : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user