intial files
This commit is contained in:
94
packages/backend/convex/artifactModel.ts
Normal file
94
packages/backend/convex/artifactModel.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const ARTIFACT_PATHS = [
|
||||
"project.md",
|
||||
"business.md",
|
||||
"design.md",
|
||||
"agent.md",
|
||||
"work.md",
|
||||
"steps.md",
|
||||
"artifacts.md",
|
||||
"signals.md",
|
||||
"agent-manager.md",
|
||||
"context.md",
|
||||
"card.md",
|
||||
] as const;
|
||||
|
||||
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
||||
|
||||
export const artifactPath = v.union(
|
||||
v.literal("project.md"),
|
||||
v.literal("business.md"),
|
||||
v.literal("design.md"),
|
||||
v.literal("agent.md"),
|
||||
v.literal("work.md"),
|
||||
v.literal("steps.md"),
|
||||
v.literal("artifacts.md"),
|
||||
v.literal("signals.md"),
|
||||
v.literal("agent-manager.md"),
|
||||
v.literal("context.md"),
|
||||
v.literal("card.md")
|
||||
);
|
||||
|
||||
type ProjectSeed = {
|
||||
readonly defaultBranch: string;
|
||||
readonly description?: string;
|
||||
readonly name: string;
|
||||
readonly repoName: string;
|
||||
readonly repoOwner: string;
|
||||
readonly repoUrl: string;
|
||||
};
|
||||
|
||||
export const createInitialArtifacts = (
|
||||
project: ProjectSeed
|
||||
): ReadonlyArray<{ path: ArtifactPath; content: string }> => {
|
||||
const repository = `${project.repoOwner}/${project.repoName}`;
|
||||
const purpose = project.description ?? `Maintain and improve ${repository}.`;
|
||||
|
||||
return [
|
||||
{
|
||||
path: "project.md",
|
||||
content: `# ${project.name}\n\nRepository: [${repository}](${project.repoUrl})\n\n## Project knowledge\n\n- [Business](business.md)\n- [Design](design.md)\n- [Agent](agent.md)\n\n## Delivery\n\n- [Work](work.md)\n- [Steps](steps.md)\n- [Artifacts](artifacts.md)\n`,
|
||||
},
|
||||
{
|
||||
path: "business.md",
|
||||
content: `# Business\n\n## Purpose\n\n${purpose}\n\n## Source of truth\n\nThe connected GitHub repository and its project issues define the current product work.\n`,
|
||||
},
|
||||
{
|
||||
path: "design.md",
|
||||
content: `# Design\n\n## Repository boundary\n\nWork targets \`${repository}\` on \`${project.defaultBranch}\`. Preserve its established architecture and conventions before introducing new patterns.\n\n## Decision record\n\nRecord issue-specific architecture decisions here when they affect later work.\n`,
|
||||
},
|
||||
{
|
||||
path: "agent.md",
|
||||
content: `# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n`,
|
||||
},
|
||||
{
|
||||
path: "work.md",
|
||||
content: "# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
|
||||
},
|
||||
{
|
||||
path: "steps.md",
|
||||
content: "# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
|
||||
},
|
||||
{
|
||||
path: "artifacts.md",
|
||||
content: "# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
|
||||
},
|
||||
{
|
||||
path: "signals.md",
|
||||
content: "# Signals\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. Signals are durable project events and drive the web status.\n",
|
||||
},
|
||||
{
|
||||
path: "agent-manager.md",
|
||||
content: "# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
|
||||
},
|
||||
{
|
||||
path: "context.md",
|
||||
content: `# Context\n\n- Provider: GitHub\n- Repository: ${repository}\n- URL: ${project.repoUrl}\n- Default branch: ${project.defaultBranch}\n\nIssue-specific facts belong below this repository context.\n`,
|
||||
},
|
||||
{
|
||||
path: "card.md",
|
||||
content: "# Project card\n\nThe web project card is the interface between UI and packages. It reads `projects.list`, `projectArtifacts.list`, and `projectIssues.list`; it writes through `projects.connectGitHub`, `projectIssues.create`, and `projectIssues.begin`. The issue-scoped Flue agent reads `agentWorkspace.get`, publishes with `agentWorkspace.updateArtifact`, and reports state with `agentWorkspace.setStatus`. Filesystem and shell operations are executed by the AgentOS sandbox adapter through durable daemon commands.\n",
|
||||
},
|
||||
];
|
||||
};
|
||||
17
packages/backend/convex/authz.ts
Normal file
17
packages/backend/convex/authz.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
export type AuthContext = {
|
||||
readonly auth: {
|
||||
readonly getUserIdentity: () => Promise<{
|
||||
readonly tokenIdentifier: string;
|
||||
} | null>;
|
||||
};
|
||||
};
|
||||
|
||||
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
}
|
||||
return identity.tokenIdentifier;
|
||||
};
|
||||
19
packages/backend/convex/projectArtifacts.ts
Normal file
19
packages/backend/convex/projectArtifacts.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
|
||||
.take(25);
|
||||
},
|
||||
});
|
||||
145
packages/backend/convex/projectIssues.ts
Normal file
145
packages/backend/convex/projectIssues.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
const requireProjectOwner = async (
|
||||
ctx: Parameters<Parameters<typeof mutation>[0]["handler"]>[0],
|
||||
projectId: Parameters<typeof ctx.db.get<"projects">>[1],
|
||||
ownerId: string
|
||||
) => {
|
||||
const project = await ctx.db.get("projects", projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
await requireProjectOwner(ctx, args.projectId, ownerId);
|
||||
const title = args.title.trim();
|
||||
const body = args.body.trim();
|
||||
if (title.length < 3 || title.length > 160) {
|
||||
throw new ConvexError("Issue title must be between 3 and 160 characters");
|
||||
}
|
||||
if (body.length < 10 || body.length > 10_000) {
|
||||
throw new ConvexError("Issue description must be between 10 and 10000 characters");
|
||||
}
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
projectId: args.projectId,
|
||||
number,
|
||||
title,
|
||||
body,
|
||||
status: "open",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const workArtifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", args.projectId).eq("path", "work.md")
|
||||
)
|
||||
.unique();
|
||||
if (workArtifact) {
|
||||
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
||||
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
|
||||
revision: workArtifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId: args.projectId,
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
data: { number, title },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
return issueId;
|
||||
},
|
||||
});
|
||||
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
||||
if (issue.status === "queued" || issue.status === "working") {
|
||||
return issue.status;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId: issue.projectId,
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
data: { number: issue.number },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
return "queued" as const;
|
||||
},
|
||||
});
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { issueId: v.id("projectIssues"), error: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId: issue.projectId,
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
184
packages/backend/convex/projects.ts
Normal file
184
packages/backend/convex/projects.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
type GitHubRepository = {
|
||||
readonly defaultBranch: string;
|
||||
readonly description?: string;
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly owner: string;
|
||||
readonly url: string;
|
||||
};
|
||||
|
||||
const parseRepository = (value: string): { owner: string; repo: string } => {
|
||||
const normalized = value.trim().replace(/\.git$/u, "");
|
||||
const match = normalized.match(
|
||||
/^(?:https?:\/\/github\.com\/)?([^/\s]+)\/([^/\s]+)$/iu
|
||||
);
|
||||
if (!match?.[1] || !match[2]) {
|
||||
throw new ConvexError("Use a GitHub repository in owner/name format");
|
||||
}
|
||||
return { owner: match[1], repo: match[2] };
|
||||
};
|
||||
|
||||
const readGitHubRepository = (value: unknown): GitHubRepository => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new ConvexError("GitHub returned an invalid repository response");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const owner = record.owner;
|
||||
if (
|
||||
typeof record.id !== "number" ||
|
||||
typeof record.name !== "string" ||
|
||||
typeof record.html_url !== "string" ||
|
||||
typeof record.default_branch !== "string" ||
|
||||
typeof owner !== "object" ||
|
||||
owner === null ||
|
||||
typeof (owner as Record<string, unknown>).login !== "string"
|
||||
) {
|
||||
throw new ConvexError("GitHub returned incomplete repository metadata");
|
||||
}
|
||||
return {
|
||||
defaultBranch: record.default_branch,
|
||||
...(typeof record.description === "string"
|
||||
? { description: record.description }
|
||||
: {}),
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
owner: (owner as { login: string }).login,
|
||||
url: record.html_url,
|
||||
};
|
||||
};
|
||||
|
||||
export const connectGitHub = action({
|
||||
args: { repository: v.string() },
|
||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const { owner, repo } = parseRepository(args.repository);
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "zopu-code",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new ConvexError(
|
||||
"Repository not found. This first flow supports public GitHub repositories."
|
||||
);
|
||||
}
|
||||
throw new ConvexError(`GitHub connection failed with ${response.status}`);
|
||||
}
|
||||
const repository = readGitHubRepository(await response.json());
|
||||
const projectId: Id<"projects"> = await ctx.runMutation(
|
||||
internal.projects.storeGitHubProject,
|
||||
{ ownerId, repository }
|
||||
);
|
||||
return projectId;
|
||||
},
|
||||
});
|
||||
|
||||
export const storeGitHubProject = internalMutation({
|
||||
args: {
|
||||
ownerId: v.string(),
|
||||
repository: v.object({
|
||||
defaultBranch: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
id: v.number(),
|
||||
name: v.string(),
|
||||
owner: v.string(),
|
||||
url: v.string(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_repository", (q) =>
|
||||
q
|
||||
.eq("ownerId", args.ownerId)
|
||||
.eq("provider", "github")
|
||||
.eq("repoOwner", args.repository.owner)
|
||||
.eq("repoName", args.repository.name)
|
||||
)
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("projects", existing._id, {
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
providerRepoId: args.repository.id,
|
||||
repoUrl: args.repository.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
ownerId: args.ownerId,
|
||||
provider: "github",
|
||||
providerRepoId: args.repository.id,
|
||||
name: args.repository.name,
|
||||
description: args.repository.description,
|
||||
repoOwner: args.repository.owner,
|
||||
repoName: args.repository.name,
|
||||
repoUrl: args.repository.url,
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const artifacts = createInitialArtifacts({
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
name: args.repository.name,
|
||||
repoName: args.repository.name,
|
||||
repoOwner: args.repository.owner,
|
||||
repoUrl: args.repository.url,
|
||||
});
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
projectId,
|
||||
path,
|
||||
content,
|
||||
revision: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId,
|
||||
kind: "project.connected",
|
||||
data: { provider: "github", repository: args.repository.url },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
return projectId;
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
return await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
||||
.order("desc")
|
||||
.take(50);
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
return project?.ownerId === ownerId ? project : null;
|
||||
},
|
||||
});
|
||||
@@ -62,6 +62,86 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||
projects: defineTable({
|
||||
ownerId: v.string(),
|
||||
provider: v.literal("github"),
|
||||
providerRepoId: v.number(),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
repoOwner: v.string(),
|
||||
repoName: v.string(),
|
||||
repoUrl: v.string(),
|
||||
defaultBranch: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_owner_and_createdAt", ["ownerId", "createdAt"])
|
||||
.index("by_owner_and_repository", [
|
||||
"ownerId",
|
||||
"provider",
|
||||
"repoOwner",
|
||||
"repoName",
|
||||
]),
|
||||
projectArtifacts: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectIssues: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
number: v.number(),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
status: v.union(
|
||||
v.literal("open"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_number", ["projectId", "number"])
|
||||
.index("by_project_and_status", ["projectId", "status"]),
|
||||
projectWorkRuns: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.id("projectIssues"),
|
||||
agentId: v.string(),
|
||||
daemonId: v.string(),
|
||||
actorKey: v.array(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
summary: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
projectSignals: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.optional(v.id("projectIssues")),
|
||||
runId: v.optional(v.id("projectWorkRuns")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_issue_and_createdAt", ["issueId", "createdAt"]),
|
||||
todos: defineTable({
|
||||
text: v.string(),
|
||||
completed: v.boolean(),
|
||||
|
||||
Reference in New Issue
Block a user