From 005b26fa32566884ae815ebe68c1ffa7271add62 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:52:14 +0530 Subject: [PATCH] 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. --- .../components/slice-one/slice-one-page.tsx | 225 +++++- apps/web/src/hooks/slice-one/use-slice-one.ts | 136 +++- packages/agents/package.json | 3 +- packages/agents/src/agents/work-planner.ts | 30 + packages/agents/src/agents/zopu.ts | 5 +- packages/agents/src/tools/work-planner.ts | 69 ++ packages/backend/convex/schema.ts | 173 ++++- packages/backend/convex/workExecution.ts | 419 ++++++++++++ packages/backend/convex/workPlanning.test.ts | 151 ++++ packages/backend/convex/workPlanning.ts | 645 ++++++++++++++++++ packages/primitives/package.json | 7 +- packages/primitives/src/harness-runtime.ts | 167 +++++ packages/primitives/src/index.ts | 5 + packages/primitives/src/resolver.ts | 71 ++ packages/primitives/src/work-definition.ts | 91 +++ packages/primitives/src/work-design.ts | 98 +++ packages/primitives/src/work-lifecycle.ts | 69 ++ .../primitives/src/work-resolution.test.ts | 105 +++ packages/primitives/src/work.ts | 24 +- 19 files changed, 2480 insertions(+), 13 deletions(-) create mode 100644 packages/agents/src/agents/work-planner.ts create mode 100644 packages/agents/src/tools/work-planner.ts create mode 100644 packages/backend/convex/workExecution.ts create mode 100644 packages/backend/convex/workPlanning.test.ts create mode 100644 packages/backend/convex/workPlanning.ts create mode 100644 packages/primitives/src/harness-runtime.ts create mode 100644 packages/primitives/src/resolver.ts create mode 100644 packages/primitives/src/work-definition.ts create mode 100644 packages/primitives/src/work-design.ts create mode 100644 packages/primitives/src/work-lifecycle.ts create mode 100644 packages/primitives/src/work-resolution.test.ts diff --git a/apps/web/src/components/slice-one/slice-one-page.tsx b/apps/web/src/components/slice-one/slice-one-page.tsx index 5ddedf4..f7f007f 100644 --- a/apps/web/src/components/slice-one/slice-one-page.tsx +++ b/apps/web/src/components/slice-one/slice-one-page.tsx @@ -6,11 +6,15 @@ import { import { Button } from "@code/ui/components/button"; import { ChevronRight, + Check, FolderGit2, + Hammer, LoaderCircle, Menu, MessageSquareText, ImagePlus, + Play, + RotateCcw, Send, Sparkles, X, @@ -34,11 +38,67 @@ const EMPTY_WORKS: readonly SliceWork[] = []; interface WorkCardProps { readonly onSourceSelect: (rawText: string) => void; readonly work: SliceWork; + readonly slice: SliceOneState; } -const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => { +const starterDefinition = (work: SliceWork) => ({ + acceptanceCriteria: ["The requested outcome is observable and documented"], + affectedUsers: ["Project users"], + assumptions: [], + constraints: [], + desiredOutcome: work.objective, + inScope: [work.objective], + outOfScope: ["Unrelated product changes"], + problem: work.objective, + questions: [], + requiredArtifacts: ["Simulation activity and terminal outcome"], + risk: "medium", +}); + +const starterDesign = (work: SliceWork) => ({ + architectureSummary: + "Validate the approved Definition, then exercise one deterministic fake slice.", + callFlowDelta: [ + "Work -> Run -> Attempt -> normalized events -> terminal outcome", + ], + concerns: [], + evidenceRequirements: ["Terminal Run classification"], + fileTreeDelta: [], + impactMap: { + files: [], + modules: [], + risks: [], + summary: "Compact vertical-slice simulation", + }, + invariants: [ + "Simulation never claims implementation", + "Every Attempt reaches a terminal classification", + ], + keyInterfaces: ["HarnessRuntime", "AttemptOutcome"], + slices: [ + { + codeBoundaries: ["workExecution"], + dependsOn: [], + evidenceRequirements: ["Normalized activity events"], + id: "slice-1", + objective: work.objective, + observableBehavior: "A terminal fake Run is visible", + reviewRequired: false, + title: "Deterministic simulation", + verification: ["Run completes with a terminal classification"], + }, + ], + tradeoffs: ["Fake runtime proves contract before sandbox integration"], +}); + +// oxlint-disable-next-line complexity -- the expanded card intentionally keeps the three review sections together. +const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => { const [sourcesOpen, setSourcesOpen] = useState(false); + const [expanded, setExpanded] = useState(false); const sources = work.signals.flatMap((signal) => signal.sources); + const { definition } = work; + const { design } = work; + const [latestRun] = work.runs; return (
@@ -70,6 +130,16 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => { className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`} /> + {sourcesOpen ? (
{sources.map((source) => ( @@ -85,6 +155,156 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => { ))}
) : null} + {expanded ? ( +
+
+

+ Outcome +

+

{work.objective}

+

+ Risk: {definition?.risk ?? "not defined"} +

+ {definition?.questions?.length ? ( +

+ { + definition.questions.filter( + (question) => question.status === "open" + ).length + }{" "} + open question(s) +

+ ) : null} +
+ {work.status === "proposed" ? ( + + ) : null} + {work.status === "defining" ? ( + + ) : null} + {work.status === "awaiting-definition-approval" && + work.definitionVersion ? ( + + ) : null} +
+
+
+

+ Design +

+

+ {design?.architectureSummary ?? "No Design Packet yet."} +

+ {design?.slices?.map((item) => ( +

+ {item.title}: {item.observableBehavior} +

+ ))} +
+ {work.status === "designing" ? ( + + ) : null} + {work.status === "awaiting-design-approval" && + work.definitionApprovalVersion && + work.designVersion ? ( + + ) : null} +
+
+
+

+ Build +

+ {latestRun ? ( +

+ Run {latestRun.status}:{" "} + {latestRun.terminalSummary ?? + latestRun.terminalClassification ?? + "activity is still arriving"} +

+ ) : ( +

No simulation Run yet.

+ )} +
+ {work.status === "ready" ? ( + + ) : null} + {latestRun?.status === "running" ? ( + + ) : null} + {latestRun?.status === "terminal" && + latestRun.terminalClassification === "RetryableFailure" ? ( + + ) : null} +
+
+
+ ) : null}
); }; @@ -278,6 +498,7 @@ export const SliceOnePage = () => {

@@ -387,6 +608,7 @@ export const SliceOnePage = () => { ))} @@ -422,6 +644,7 @@ export const SliceOnePage = () => { ))} diff --git a/apps/web/src/hooks/slice-one/use-slice-one.ts b/apps/web/src/hooks/slice-one/use-slice-one.ts index ddb022f..ab4e0be 100644 --- a/apps/web/src/hooks/slice-one/use-slice-one.ts +++ b/apps/web/src/hooks/slice-one/use-slice-one.ts @@ -1,6 +1,7 @@ import { api } from "@code/backend/convex/_generated/api"; import type { Id } from "@code/backend/convex/_generated/dataModel"; -import { useAction, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "convex/react"; +import { makeFunctionReference } from "convex/server"; import { useMemo, useState } from "react"; import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent"; @@ -9,6 +10,93 @@ import { usePersonalOrganization } from "@/hooks/use-personal-organization"; const toError = (error: unknown) => error instanceof Error ? error : new Error(String(error)); +interface WorkRecord { + readonly _id: Id<"works">; + readonly title: string; + readonly objective: string; + readonly status: string; + readonly definitionVersion?: number; + readonly definitionApprovalVersion?: number; + readonly designVersion?: number; + readonly designApprovalVersion?: number; + readonly signals: readonly { + sources: readonly { messageId: string; rawText: string }[]; + }[]; + readonly events: readonly { _id: string; createdAt: number; kind: string }[]; + readonly definitions: readonly unknown[]; + readonly designs: readonly unknown[]; + readonly slices: readonly unknown[]; + readonly runs: readonly { + _id: Id<"workRuns">; + status: string; + terminalClassification?: string; + terminalSummary?: string; + }[]; + readonly definition: { + risk?: string; + questions?: { status: string }[]; + } | null; + readonly design: { + architectureSummary?: string; + slices?: { id: string; title: string; observableBehavior: string }[]; + } | null; +} + +const workListRef = makeFunctionReference< + "query", + { projectId: Id<"projects"> }, + WorkRecord[] +>("workPlanning:listForProject"); +const requestDefinitionRef = makeFunctionReference< + "mutation", + { workId: Id<"works"> }, + unknown +>("workPlanning:requestDefinition"); +const saveDefinitionRef = makeFunctionReference< + "mutation", + { workId: Id<"works">; payloadJson: string }, + unknown +>("workPlanning:saveDefinitionProposal"); +const approveDefinitionRef = makeFunctionReference< + "mutation", + { workId: Id<"works">; version: number }, + unknown +>("workPlanning:approveDefinition"); +const saveDesignRef = makeFunctionReference< + "mutation", + { workId: Id<"works">; payloadJson: string }, + unknown +>("workPlanning:saveDesignProposal"); +const approveDesignRef = makeFunctionReference< + "mutation", + { workId: Id<"works">; definitionVersion: number; designVersion: number }, + unknown +>("workPlanning:approveDesign"); +const startSimulationRef = makeFunctionReference< + "mutation", + { + workId: Id<"works">; + scenario: + | "success" + | "transient-failure-then-success" + | "needs-input" + | "permanent-failure" + | "cancelled"; + sliceId?: string; + }, + unknown +>("workExecution:startSimulatedExecution"); +const cancelSimulationRef = makeFunctionReference< + "mutation", + { runId: Id<"workRuns"> }, + unknown +>("workExecution:cancelSimulatedExecution"); +const retrySimulationRef = makeFunctionReference< + "mutation", + { runId: Id<"workRuns"> }, + unknown +>("workExecution:retrySimulatedExecution"); + export const useSliceOne = () => { const organization = usePersonalOrganization(); const projects = useQuery( @@ -29,9 +117,17 @@ export const useSliceOne = () => { ? selectedProjectId : ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null); const works = useQuery( - api.works.listForProject, + workListRef, activeProjectId ? { projectId: activeProjectId } : "skip" ); + const requestDefinitionMutation = useMutation(requestDefinitionRef); + const saveDefinitionMutation = useMutation(saveDefinitionRef); + const approveDefinitionMutation = useMutation(approveDefinitionRef); + const saveDesignMutation = useMutation(saveDesignRef); + const approveDesignMutation = useMutation(approveDesignRef); + const startSimulationMutation = useMutation(startSimulationRef); + const cancelSimulationMutation = useMutation(cancelSimulationRef); + const retrySimulationMutation = useMutation(retrySimulationRef); const selectedProject = useMemo( () => projects?.find( @@ -62,16 +158,52 @@ export const useSliceOne = () => { setSelectedProjectId(projectId as unknown as Id<"projects">); }; + const requestDefinition = (workId: Id<"works">) => + requestDefinitionMutation({ workId }); + const saveDefinition = (workId: Id<"works">, payload: unknown) => + saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }); + const approveDefinition = (workId: Id<"works">, version: number) => + approveDefinitionMutation({ version, workId }); + const saveDesign = (workId: Id<"works">, payload: unknown) => + saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }); + const approveDesign = ( + workId: Id<"works">, + definitionVersion: number, + designVersion: number + ) => approveDesignMutation({ definitionVersion, designVersion, workId }); + const startSimulation = ( + workId: Id<"works">, + scenario: + | "success" + | "transient-failure-then-success" + | "needs-input" + | "permanent-failure" + | "cancelled", + sliceId?: string + ) => startSimulationMutation({ scenario, sliceId, workId }); + const cancelSimulation = (runId: Id<"workRuns">) => + cancelSimulationMutation({ runId }); + const retrySimulation = (runId: Id<"workRuns">) => + retrySimulationMutation({ runId }); + return { agent, + approveDefinition, + approveDesign, + cancelSimulation, connectRepository, error, pending, projects, repository, + requestDefinition, + retrySimulation, + saveDefinition, + saveDesign, selectProject, selectedProject, setRepository, + startSimulation, works, } as const; }; diff --git a/packages/agents/package.json b/packages/agents/package.json index 82db077..d8d37fb 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -9,7 +9,8 @@ "dev": "bun --env-file=../../.env flue dev", "dev:tailscale": "bun --env-file=../../.env flue dev", "run": "bun --env-file=../../.env flue run", - "run:zopu": "bun --env-file=../../.env flue run zopu" + "run:zopu": "bun --env-file=../../.env flue run zopu", + "run:work-planner": "bun --env-file=../../.env flue run work-planner" }, "dependencies": { "@code/backend": "workspace:*", diff --git a/packages/agents/src/agents/work-planner.ts b/packages/agents/src/agents/work-planner.ts new file mode 100644 index 0000000..0ea8183 --- /dev/null +++ b/packages/agents/src/agents/work-planner.ts @@ -0,0 +1,30 @@ +import { parseAgentEnv } from "@code/env/agent"; +import { defineAgent } from "@flue/runtime"; + +import { createWorkPlannerTools } from "../tools/work-planner"; + +const INSTRUCTIONS = `You are Zopu's private Work Planner. + +You may only submit typed proposals through tools: +- a Work Definition proposal; +- a Design Packet proposal containing 1-4 observable, independently verifiable slices; +- structured questions. + +You must never approve a Definition or Design, change Work status, start execution, claim implementation, create Git changes, or claim that simulation implemented the Work. Convex validates and stores every proposal. Ask a focused structured question when a high-impact decision is unresolved.`; + +export { + convexAgentRoute as attachments, + convexAgentRoute as route, +} from "../auth"; + +export default defineAgent(({ env }) => { + const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); + return { + description: + "Proposes Work Definitions, Design Packets, and structured questions.", + instructions: INSTRUCTIONS, + model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`, + thinkingLevel: "medium", + tools: createWorkPlannerTools(env), + }; +}); diff --git a/packages/agents/src/agents/zopu.ts b/packages/agents/src/agents/zopu.ts index b85b67d..d664606 100644 --- a/packages/agents/src/agents/zopu.ts +++ b/packages/agents/src/agents/zopu.ts @@ -3,7 +3,7 @@ import { defineAgent } from "@flue/runtime"; import { createSliceOneTools } from "../tools/slice-one"; -const INSTRUCTIONS = `You are Zopu for product Slice 1: Conversation to Signal to proposed Work. +const INSTRUCTIONS = `You are Zopu for product Slice 1 and the Work planning handoff. ## Your role @@ -46,7 +46,8 @@ When a user sends a message, follow this decision flow: - Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent. - Never claim you created a Signal until the tool call returns successfully. - Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1. -- Proposed Work is the only Work status in this slice.`; + - After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it. + - Proposed Work is the only Work status you directly create.`; export { convexAgentRoute as attachments, diff --git a/packages/agents/src/tools/work-planner.ts b/packages/agents/src/tools/work-planner.ts new file mode 100644 index 0000000..8c12a68 --- /dev/null +++ b/packages/agents/src/tools/work-planner.ts @@ -0,0 +1,69 @@ +import { parseAgentEnv } from "@code/env/agent"; +import { defineTool } from "@flue/runtime"; +import { ConvexHttpClient } from "convex/browser"; +import { makeFunctionReference } from "convex/server"; +import * as v from "valibot"; + +const submitDefinitionRef = makeFunctionReference< + "mutation", + { workId: string; payloadJson: string; token: string }, + { version: number } +>("workPlanning:submitDefinitionProposal"); +const submitDesignRef = makeFunctionReference< + "mutation", + { workId: string; payloadJson: string; token: string }, + { version: number } +>("workPlanning:submitDesignProposal"); +const submitQuestionRef = makeFunctionReference< + "mutation", + { workId: string; questionJson: string; token: string }, + { accepted: boolean } +>("workPlanning:submitQuestion"); + +export const createWorkPlannerTools = ( + runtimeEnv: Record +) => { + const env = parseAgentEnv(runtimeEnv); + const client = new ConvexHttpClient(env.CONVEX_URL); + const token = env.FLUE_DB_TOKEN; + return [ + defineTool({ + description: + "Submit a versioned Work Definition proposal. This never approves the Definition.", + input: v.object({ definitionJson: v.string(), workId: v.string() }), + name: "submit_definition_proposal", + async run({ input }) { + return await client.mutation(submitDefinitionRef, { + payloadJson: input.definitionJson, + token, + workId: input.workId, + }); + }, + }), + defineTool({ + description: + "Submit a Design Packet with observable independently verifiable slices. This never approves the Design.", + input: v.object({ designJson: v.string(), workId: v.string() }), + name: "submit_design_proposal", + async run({ input }) { + return await client.mutation(submitDesignRef, { + payloadJson: input.designJson, + token, + workId: input.workId, + }); + }, + }), + defineTool({ + description: "Submit one structured question for human attention.", + input: v.object({ questionJson: v.string(), workId: v.string() }), + name: "submit_question", + async run({ input }) { + return await client.mutation(submitQuestionRef, { + questionJson: input.questionJson, + token, + workId: input.workId, + }); + }, + }), + ]; +}; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 5588812..ed489d8 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -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). // ----------------------------------------------------------------- diff --git a/packages/backend/convex/workExecution.ts b/packages/backend/convex/workExecution.ts new file mode 100644 index 0000000..4d68c25 --- /dev/null +++ b/packages/backend/convex/workExecution.ts @@ -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(); + }, +}); diff --git a/packages/backend/convex/workPlanning.test.ts b/packages/backend/convex/workPlanning.test.ts new file mode 100644 index 0000000..c824ac9 --- /dev/null +++ b/packages/backend/convex/workPlanning.test.ts @@ -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 Promise>; + } +} + +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); + }); +}); diff --git a/packages/backend/convex/workPlanning.ts b/packages/backend/convex/workPlanning.ts new file mode 100644 index 0000000..f29590b --- /dev/null +++ b/packages/backend/convex/workPlanning.ts @@ -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 => { + 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; +}; + +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, + }; + }) + ); + }, +}); diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 678830c..b2e2cba 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -15,7 +15,12 @@ "./project-workspace": "./src/project-workspace.ts", "./signal": "./src/signal.ts", "./smoke": "./src/smoke.ts", - "./work": "./src/work.ts" + "./work": "./src/work.ts", + "./work-definition": "./src/work-definition.ts", + "./work-design": "./src/work-design.ts", + "./work-lifecycle": "./src/work-lifecycle.ts", + "./resolver": "./src/resolver.ts", + "./harness-runtime": "./src/harness-runtime.ts" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/primitives/src/harness-runtime.ts b/packages/primitives/src/harness-runtime.ts new file mode 100644 index 0000000..e5ce5a4 --- /dev/null +++ b/packages/primitives/src/harness-runtime.ts @@ -0,0 +1,167 @@ +import { Effect, Schema } from "effect"; + +import type { AttemptOutcome } from "./resolver"; + +const Text = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +export const HarnessEventKind = Schema.Literals([ + "started", + "progress", + "question", + "log", + "completed", + "failed", + "cancelled", +]); +export type HarnessEventKind = typeof HarnessEventKind.Type; + +export const HarnessEvent = Schema.Struct({ + kind: HarnessEventKind, + message: Text, + metadata: Schema.Record(Schema.String, Schema.String), + occurredAt: Schema.Number, + sequence: Schema.Int, +}); +export type HarnessEvent = typeof HarnessEvent.Type; + +export const FakeScenario = Schema.Literals([ + "success", + "transient-failure-then-success", + "needs-input", + "permanent-failure", + "cancelled", +]); +export type FakeScenario = typeof FakeScenario.Type; + +export interface HarnessRuntime { + readonly name: string; + readonly run: (input: { + readonly attemptNumber?: number; + readonly scenario: FakeScenario; + readonly signal?: AbortSignal; + }) => Effect.Effect; +} + +const event = ( + sequence: number, + kind: HarnessEventKind, + message: string, + occurredAt: number +): HarnessEvent => ({ kind, message, metadata: {}, occurredAt, sequence }); + +export const FakeHarnessLive = ( + now: () => number = Date.now +): HarnessRuntime => ({ + name: "fake", + run: ({ attemptNumber = 1, scenario, signal }) => + Effect.sync(() => { + const started = now(); + const events: HarnessEvent[] = [ + event(0, "started", `fake harness started: ${scenario}`, started), + ]; + if (signal?.aborted || scenario === "cancelled") { + events.push(event(1, "cancelled", "simulation cancelled", now())); + return [ + events, + { + classification: "Cancelled", + retryable: false, + summary: "Simulation cancelled", + }, + ] as const; + } + events.push( + event(1, "progress", "validated approved Definition and Design", now()) + ); + switch (scenario) { + case "success": { + events.push( + event( + 2, + "completed", + "fake implementation evidence recorded", + now() + ) + ); + return [ + events, + { + classification: "Succeeded", + retryable: false, + summary: "Simulation succeeded; no implementation was claimed", + }, + ] as const; + } + case "transient-failure-then-success": { + if (attemptNumber > 1) { + events.push( + event(2, "completed", "transient failure recovered", now()) + ); + return [ + events, + { + classification: "Succeeded", + retryable: false, + summary: + "Simulation recovered after a transient failure; no implementation was claimed", + }, + ] as const; + } + events.push(event(2, "failed", "transient provider failure", now())); + return [ + events, + { + classification: "RetryableFailure", + retryable: true, + summary: "Transient simulation failure", + }, + ] as const; + } + case "needs-input": { + events.push( + event(2, "question", "simulation needs a human decision", now()) + ); + return [ + events, + { + classification: "NeedsInput", + retryable: false, + summary: "Simulation needs input", + }, + ] as const; + } + case "permanent-failure": { + events.push( + event( + 2, + "failed", + "deterministic permanent simulation failure", + now() + ) + ); + return [ + events, + { + classification: "PermanentFailure", + retryable: false, + summary: "Permanent simulation failure", + }, + ] as const; + } + default: { + return [ + events, + { + classification: "PermanentFailure", + retryable: false, + summary: "Unknown simulation scenario", + }, + ] as const; + } + } + }), +}); diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 635fe29..ab5915f 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -10,3 +10,8 @@ export * from "./project-work"; export * from "./signal"; export * from "./smoke"; export * from "./work"; +export * from "./work-definition"; +export * from "./work-design"; +export * from "./work-lifecycle"; +export * from "./resolver"; +export * from "./harness-runtime"; diff --git a/packages/primitives/src/resolver.ts b/packages/primitives/src/resolver.ts new file mode 100644 index 0000000..0454d89 --- /dev/null +++ b/packages/primitives/src/resolver.ts @@ -0,0 +1,71 @@ +import { Schema } from "effect"; + +const Text = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +export const RunStatus = Schema.Literals(["ready", "running", "terminal"]); +export type RunStatus = typeof RunStatus.Type; +export const AttemptStatus = Schema.Literals([ + "queued", + "claimed", + "running", + "terminal", +]); +export type AttemptStatus = typeof AttemptStatus.Type; +export const AttemptClassification = Schema.Literals([ + "Succeeded", + "RetryableFailure", + "NeedsInput", + "Blocked", + "VerificationFailed", + "BudgetExhausted", + "Cancelled", + "PermanentFailure", +]); +export type AttemptClassification = typeof AttemptClassification.Type; + +export const AttemptOutcome = Schema.Struct({ + classification: AttemptClassification, + retryable: Schema.Boolean, + summary: Text, +}); +export type AttemptOutcome = typeof AttemptOutcome.Type; + +export const RetryPolicy = Schema.Struct({ + maxAttempts: Schema.Int, + retryableClassifications: Schema.Array(AttemptClassification), +}); +export type RetryPolicy = typeof RetryPolicy.Type; + +export const CodingKitV0 = Schema.Struct({ + harness: Schema.Literal("fake"), + id: Schema.Literal("coding-kit-v0"), + retryPolicy: RetryPolicy, + version: Schema.Literal("0.1.0"), +}); +export type CodingKitV0 = typeof CodingKitV0.Type; + +export const isTerminalClassification = (_: AttemptClassification): true => + true; + +export const shouldRetry = ( + outcome: AttemptOutcome, + attemptNumber: number, + policy: RetryPolicy +): boolean => + outcome.retryable && + attemptNumber < policy.maxAttempts && + policy.retryableClassifications.includes(outcome.classification); + +export const defaultCodingKitV0: CodingKitV0 = { + harness: "fake", + id: "coding-kit-v0", + retryPolicy: { + maxAttempts: 3, + retryableClassifications: ["RetryableFailure"], + }, + version: "0.1.0", +}; diff --git a/packages/primitives/src/work-definition.ts b/packages/primitives/src/work-definition.ts new file mode 100644 index 0000000..a2abe8d --- /dev/null +++ b/packages/primitives/src/work-definition.ts @@ -0,0 +1,91 @@ +import { Effect, Schema } from "effect"; + +const Text = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +export const DefinitionRisk = Schema.Literals(["low", "medium", "high"]); +export type DefinitionRisk = typeof DefinitionRisk.Type; + +export const QuestionImpact = Schema.Literals(["low", "medium", "high"]); +export type QuestionImpact = typeof QuestionImpact.Type; + +export const WorkQuestion = Schema.Struct({ + alternatives: Schema.Array(Text), + answer: Schema.optional(Text), + id: Text, + impact: QuestionImpact, + prompt: Text, + recommendation: Schema.optional(Text), + status: Schema.Literals(["open", "answered", "withdrawn"]), +}); +export type WorkQuestion = typeof WorkQuestion.Type; + +export const WorkDefinition = Schema.Struct({ + acceptanceCriteria: Schema.NonEmptyArray(Text), + affectedUsers: Schema.Array(Text), + assumptions: Schema.Array(Text), + constraints: Schema.Array(Text), + desiredOutcome: Text, + inScope: Schema.Array(Text), + outOfScope: Schema.Array(Text), + problem: Text, + questions: Schema.Array(WorkQuestion), + requiredArtifacts: Schema.Array(Text), + risk: DefinitionRisk, + rollback: Schema.optional(Text), + rollout: Schema.optional(Text), + version: Schema.Int, +}); +export type WorkDefinition = typeof WorkDefinition.Type; + +export const DefinitionApproval = Schema.Struct({ + approvedAt: Schema.Number, + approvedBy: Text, + definitionVersion: Schema.Int, +}); +export type DefinitionApproval = typeof DefinitionApproval.Type; + +export const DefinitionProposal = WorkDefinition; +export type DefinitionProposal = typeof DefinitionProposal.Type; + +export class DefinitionError extends Schema.TaggedErrorClass()( + "DefinitionError", + { + message: Schema.String, + reason: Schema.Literals(["Invalid", "OpenHighImpactQuestion"]), + } +) {} + +export const validateDefinition = Effect.fn("Work.validateDefinition")( + function* validateDefinition(input: unknown) { + const definition = yield* Schema.decodeUnknownEffect(WorkDefinition)( + input + ).pipe( + Effect.mapError( + (cause) => + new DefinitionError({ message: cause.message, reason: "Invalid" }) + ) + ); + const blocked = definition.questions.some( + (question) => question.status === "open" && question.impact === "high" + ); + if (blocked) { + return yield* Effect.fail( + new DefinitionError({ + message: + "High-impact open questions must be resolved before approval", + reason: "OpenHighImpactQuestion", + }) + ); + } + return definition; + } +); + +export const canApproveDefinition = (definition: WorkDefinition): boolean => + definition.questions.every( + (question) => question.status !== "open" || question.impact !== "high" + ); diff --git a/packages/primitives/src/work-design.ts b/packages/primitives/src/work-design.ts new file mode 100644 index 0000000..cbd6b8a --- /dev/null +++ b/packages/primitives/src/work-design.ts @@ -0,0 +1,98 @@ +import { Effect, Schema } from "effect"; + +const Text = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +export const ImpactMap = Schema.Struct({ + files: Schema.Array(Text), + modules: Schema.Array(Text), + risks: Schema.Array(Text), + summary: Text, +}); +export type ImpactMap = typeof ImpactMap.Type; + +export const VerticalSlice = Schema.Struct({ + codeBoundaries: Schema.Array(Text), + dependsOn: Schema.Array(Text), + evidenceRequirements: Schema.NonEmptyArray(Text), + id: Text, + objective: Text, + observableBehavior: Text, + reviewRequired: Schema.Boolean, + title: Text, + verification: Schema.NonEmptyArray(Text), +}); +export type VerticalSlice = typeof VerticalSlice.Type; + +export const DesignPacket = Schema.Struct({ + architectureSummary: Text, + callFlowDelta: Schema.Array(Text), + concerns: Schema.Array(Text), + definitionVersion: Schema.Int, + evidenceRequirements: Schema.NonEmptyArray(Text), + fileTreeDelta: Schema.Array(Text), + impactMap: ImpactMap, + invariants: Schema.NonEmptyArray(Text), + keyInterfaces: Schema.Array(Text), + slices: Schema.Array(VerticalSlice), + tradeoffs: Schema.Array(Text), + version: Schema.Int, +}); +export type DesignPacket = typeof DesignPacket.Type; + +export const DesignApproval = Schema.Struct({ + approvedAt: Schema.Number, + approvedBy: Text, + definitionVersion: Schema.Int, + designVersion: Schema.Int, +}); +export type DesignApproval = typeof DesignApproval.Type; + +export class DesignError extends Schema.TaggedErrorClass()( + "DesignError", + { + message: Schema.String, + reason: Schema.Literals([ + "Invalid", + "NoObservableSlices", + "DefinitionMismatch", + ]), + } +) {} + +export const validateDesignPacket = Effect.fn("Work.validateDesignPacket")( + function* validateDesignPacket(input: unknown) { + const design = yield* Schema.decodeUnknownEffect(DesignPacket)(input).pipe( + Effect.mapError( + (cause) => + new DesignError({ message: cause.message, reason: "Invalid" }) + ) + ); + if ( + design.slices.length === 0 || + design.slices.some( + (slice) => + slice.observableBehavior.trim().length === 0 || + slice.evidenceRequirements.length === 0 || + slice.verification.length === 0 + ) + ) { + return yield* Effect.fail( + new DesignError({ + message: + "Every Design must contain observable, independently verifiable slices", + reason: "NoObservableSlices", + }) + ); + } + return design; + } +); + +export const isObservableSlice = (slice: VerticalSlice): boolean => + slice.observableBehavior.trim().length > 0 && + slice.evidenceRequirements.length > 0 && + slice.verification.length > 0; diff --git a/packages/primitives/src/work-lifecycle.ts b/packages/primitives/src/work-lifecycle.ts new file mode 100644 index 0000000..d20a63c --- /dev/null +++ b/packages/primitives/src/work-lifecycle.ts @@ -0,0 +1,69 @@ +import { Schema } from "effect"; + +export const WorkStatus = Schema.Literals([ + "proposed", + "defining", + "awaiting-definition-approval", + "designing", + "awaiting-design-approval", + "ready", + "executing", + "needs-input", + "blocked", + "completed", + "failed", + "cancelled", +]); +export type WorkStatus = typeof WorkStatus.Type; + +export const WORK_TRANSITIONS: Readonly< + Record +> = { + "awaiting-definition-approval": ["designing", "defining"], + "awaiting-design-approval": [ + "ready", + "designing", + "awaiting-definition-approval", + ], + blocked: ["ready", "executing", "cancelled"], + cancelled: ["ready", "proposed"], + completed: ["proposed", "defining"], + defining: ["awaiting-definition-approval", "proposed"], + designing: ["awaiting-design-approval", "awaiting-definition-approval"], + executing: [ + "ready", + "needs-input", + "blocked", + "completed", + "failed", + "cancelled", + ], + failed: ["ready", "executing", "cancelled"], + "needs-input": ["ready", "executing", "cancelled"], + proposed: ["defining"], + ready: ["executing", "designing", "cancelled"], +}; + +export const canTransitionWork = (from: WorkStatus, to: WorkStatus): boolean => + WORK_TRANSITIONS[from].includes(to); + +export const assertWorkTransition = ( + from: WorkStatus, + to: WorkStatus +): void => { + if (!canTransitionWork(from, to)) { + throw new Error(`Invalid Work transition: ${from} -> ${to}`); + } +}; + +export const definitionRevisionInvalidation = { + definitionApproval: "invalidate" as const, + dependentDesign: "invalidate" as const, + designApproval: "invalidate" as const, + workStatus: "defining" as const, +}; + +export const designRevisionInvalidation = { + designApproval: "invalidate" as const, + workStatus: "designing" as const, +}; diff --git a/packages/primitives/src/work-resolution.test.ts b/packages/primitives/src/work-resolution.test.ts new file mode 100644 index 0000000..4648b09 --- /dev/null +++ b/packages/primitives/src/work-resolution.test.ts @@ -0,0 +1,105 @@ +import { Effect } from "effect"; +import { describe, expect, test } from "vitest"; + +import { FakeHarnessLive } from "./harness-runtime"; +import { defaultCodingKitV0, shouldRetry } from "./resolver"; +import { validateDefinition } from "./work-definition"; +import { validateDesignPacket } from "./work-design"; +import { canTransitionWork } from "./work-lifecycle"; + +const definition = { + acceptanceCriteria: ["terminal outcome"], + affectedUsers: ["founders"], + assumptions: [], + constraints: [], + desiredOutcome: "A visible terminal simulation exists", + inScope: ["fake execution"], + outOfScope: ["real sandboxing"], + problem: "A deterministic build path is needed", + questions: [ + { + alternatives: [], + id: "q1", + impact: "high", + prompt: "Which runtime?", + status: "open", + }, + ], + requiredArtifacts: ["events"], + risk: "medium", + version: 1, +}; + +describe("work resolution contracts", () => { + test("high-impact open questions block definition approval", async () => { + await expect( + Effect.runPromise(validateDefinition(definition)) + ).rejects.toThrow(/High-impact/u); + }); + + test("observable slices and terminal fake outcomes are enforced", async () => { + const approved = await Effect.runPromise( + validateDefinition({ ...definition, questions: [] }) + ); + const design = await Effect.runPromise( + validateDesignPacket({ + architectureSummary: "small", + callFlowDelta: [], + concerns: [], + definitionVersion: approved.version, + evidenceRequirements: ["event"], + fileTreeDelta: [], + impactMap: { files: [], modules: [], risks: [], summary: "small" }, + invariants: ["terminal"], + keyInterfaces: [], + slices: [ + { + codeBoundaries: ["runtime"], + dependsOn: [], + evidenceRequirements: ["event"], + id: "s1", + objective: "one", + observableBehavior: "visible", + reviewRequired: false, + title: "one", + verification: ["assert"], + }, + ], + tradeoffs: [], + version: 1, + }) + ); + expect(design.slices).toHaveLength(1); + const [, outcome] = await Effect.runPromise( + FakeHarnessLive(() => 1).run({ scenario: "success" }) + ); + expect(outcome.classification).toBe("Succeeded"); + expect(outcome.summary).toMatch(/no implementation/u); + const [, firstTransient] = await Effect.runPromise( + FakeHarnessLive(() => 1).run({ + attemptNumber: 1, + scenario: "transient-failure-then-success", + }) + ); + const [, recoveredTransient] = await Effect.runPromise( + FakeHarnessLive(() => 1).run({ + attemptNumber: 2, + scenario: "transient-failure-then-success", + }) + ); + expect(firstTransient.classification).toBe("RetryableFailure"); + expect(recoveredTransient.classification).toBe("Succeeded"); + expect( + shouldRetry( + { + classification: "RetryableFailure", + retryable: true, + summary: "retry", + }, + 1, + defaultCodingKitV0.retryPolicy + ) + ).toBe(true); + expect(canTransitionWork("ready", "executing")).toBe(true); + }); +}); diff --git a/packages/primitives/src/work.ts b/packages/primitives/src/work.ts index a53436d..1133000 100644 --- a/packages/primitives/src/work.ts +++ b/packages/primitives/src/work.ts @@ -1,14 +1,14 @@ import { Array as EffectArray, Effect, Order, Schema } from "effect"; +export { WorkStatus } from "./work-lifecycle"; +export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle"; + const MeaningfulString = Schema.String.check( Schema.makeFilter((value) => value.trim().length > 0, { expected: "a non-empty string", }) ); -export const WorkStatus = Schema.Literal("proposed"); -export type WorkStatus = typeof WorkStatus.Type; - export const WorkDraft = Schema.Struct({ objective: MeaningfulString, title: MeaningfulString, @@ -91,6 +91,24 @@ const WorkAttachmentInput = Schema.Struct({ export const WorkEventKind = Schema.Literals([ "work.proposed", "signal.attached", + "definition.requested", + "definition.saved", + "definition.revised", + "definition.approved", + "definition.invalidated", + "question.answered", + "question.withdrawn", + "design.requested", + "design.saved", + "design.revised", + "design.approved", + "design.invalidated", + "run.started", + "run.cancelled", + "attempt.claimed", + "attempt.event", + "attempt.completed", + "attempt.reconciled", ]); export type WorkEventKind = typeof WorkEventKind.Type;