Files
zopu-code/packages/backend/convex/projects.ts
-Puter c644ec8d01 feat(git): thin project onboarding, provider integration, and AgentOS repository access
Effect primitives:
- git-provider: GitProvider, connection states, normalized errors, URL
  normalization, host compatibility, credential freshness window
- git-provisioning: validated Puter commands, migration states, idempotency
  keys, safe replacement rules, owner-safe guards
- git-webhook: supported events, signature verification, delivery states
- host-repository: provider-neutral credential-safe clone via GIT_ASKPASS

Normalized Convex schema:
- gitProviderAccounts, refined gitConnections, gitProviderOrganizations,
  gitRepositories, gitMigrations, gitWebhookDeliveries
- projects: gitRepositoryId + instructions fields
- Schema fields optional for backward compatibility, with backfill cron

Backend:
- Connection health: verify action, hourly reconciliation (covers stale
  active + reauth-required + undefined-state legacy connections)
- Puter provisioning: createPuterUser/Organization/Repository with owner
  binding, startGithubMigration (durable via scheduler), getMigration
- Org ownership: explicit member add with admin role + verification
- Webhook HTTP actions: HMAC verification, delivery persistence with
  idempotency, repository resolution, byte-length payload limit
- Automatic Puter webhook creation after repo creation/migration with
  fail-loud state tracking
- Repository sync after connection (Gitea + GitHub)
- AgentOS execution resolves gitRepositoryId for real clone URL
- Credential gating: state + freshness checks before execution and
  project creation
- listForOrganization for cross-project Work filtering

Frontend:
- /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT
- Zero-project redirect, repository selection, context editor
- Provider-aware settings panel (no serverUrl for Puter)
- Project selection via ?project= query param
- GitHub scopes: repo + read:org

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
2026-07-31 14:36:56 +05:30

236 lines
7.1 KiB
TypeScript

import { validateProjectInstructions } from "@code/primitives/git-provisioning";
import {
decodePublicGitImportResult,
preparePublicGitSource,
} from "@code/primitives/project";
import type {
ProjectImportOutcome,
ProjectView,
} from "@code/primitives/project";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { action, internalMutation, mutation, query } from "./_generated/server";
import {
requireAuthUserId,
requireCurrentOrganization,
requireProjectMember,
} from "./authz";
import { inspectPublicGit } from "./publicGit";
const toProjectView = async (
ctx: Parameters<typeof requireCurrentOrganization>[0],
project: Doc<"projects">
): Promise<ProjectView> => {
const documents = await ctx.db
.query("projectContextDocuments")
.withIndex("by_projectId_and_path", (q) => q.eq("projectId", project._id))
.collect();
const view = {
contextDocuments: documents.map((document) => ({
content: document.content,
kind: document.kind,
origin: document.origin,
path: document.path,
revision: 1,
sourceUrl: document.sourceUrl,
})),
createdAt: project.createdAt,
id: String(project._id),
name: project.name,
organizationId: String(project.organizationId),
sources: [
{
createdAt: project.createdAt,
defaultBranch: project.defaultBranch,
host: project.sourceHost,
kind: "git",
normalizedUrl: project.normalizedSourceUrl,
projectId: String(project._id),
repositoryPath: project.repositoryPath,
updatedAt: project.updatedAt,
url: project.sourceUrl,
},
],
updatedAt: project.updatedAt,
};
return view as unknown as ProjectView;
};
export const persistPublicGitImport = internalMutation({
args: {
remote: v.object({
defaultBranch: v.optional(v.string()),
documents: v.array(
v.object({
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
path: v.string(),
})
),
warnings: v.array(v.object({ message: v.string(), path: v.string() })),
}),
source: v.object({
host: v.string(),
normalizedUrl: v.string(),
projectName: v.string(),
repositoryPath: v.string(),
url: v.string(),
}),
userId: v.string(),
},
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
const organization = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", args.userId).eq("kind", "personal")
)
.unique();
if (!organization) {
throw new ConvexError("Organization not found");
}
const timestamp = Date.now();
const existing = await ctx.db
.query("projects")
.withIndex("by_organizationId_and_normalizedSourceUrl", (q) =>
q
.eq("organizationId", organization._id)
.eq("normalizedSourceUrl", args.source.normalizedUrl)
)
.unique();
let projectId: Id<"projects">;
if (existing) {
projectId = existing._id;
await ctx.db.patch(projectId, {
defaultBranch: args.remote.defaultBranch,
name: args.source.projectName,
repositoryPath: args.source.repositoryPath,
sourceHost: args.source.host,
sourceUrl: args.source.url,
updatedAt: timestamp,
});
const oldDocuments = await ctx.db
.query("projectContextDocuments")
.withIndex("by_projectId_and_path", (q) => q.eq("projectId", projectId))
.collect();
for (const document of oldDocuments) {
await ctx.db.delete(document._id);
}
} else {
projectId = await ctx.db.insert("projects", {
createdAt: timestamp,
defaultBranch: args.remote.defaultBranch,
name: args.source.projectName,
normalizedSourceUrl: args.source.normalizedUrl,
organizationId: organization._id,
repositoryPath: args.source.repositoryPath,
sourceHost: args.source.host,
sourceUrl: args.source.url,
updatedAt: timestamp,
});
}
for (const document of args.remote.documents) {
await ctx.db.insert("projectContextDocuments", {
...document,
createdAt: timestamp,
origin: "repository",
projectId,
sourceUrl: args.source.url,
updatedAt: timestamp,
});
}
const project = await ctx.db.get(projectId);
if (!project) {
throw new Error("Project could not be read after import");
}
const view = await toProjectView(ctx, project);
return { ...view, source: view.sources[0]! };
},
});
export const list = query({
args: {},
handler: async (ctx): Promise<ProjectView[]> => {
const { organizationId } = await requireCurrentOrganization(ctx);
const projects = await ctx.db
.query("projects")
.withIndex("by_organizationId_and_createdAt", (q) =>
q.eq("organizationId", organizationId)
)
.order("desc")
.take(50);
return await Promise.all(
projects.map((project) => toProjectView(ctx, project))
);
},
});
export const get = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args): Promise<ProjectView | null> => {
const { organizationId } = await requireCurrentOrganization(ctx);
const project = await ctx.db.get(args.projectId);
return project?.organizationId === organizationId
? await toProjectView(ctx, project)
: null;
},
});
export const importPublicGit = action({
args: { repositoryUrl: v.string() },
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
const userId = await requireAuthUserId(ctx);
const source = await Effect.runPromise(
preparePublicGitSource(args.repositoryUrl)
);
const remote = await Effect.runPromise(
decodePublicGitImportResult(await inspectPublicGit(source))
);
return await ctx.runMutation(internal.projects.persistPublicGitImport, {
remote: {
defaultBranch: remote.defaultBranch,
documents: remote.documents.map((document) => ({ ...document })),
warnings: remote.warnings.map((warning) => ({ ...warning })),
},
source,
userId,
});
},
});
export const updateInstructions = mutation({
args: {
instructions: v.string(),
projectId: v.id("projects"),
},
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const validated = await Effect.runPromise(
validateProjectInstructions(args.instructions)
);
await ctx.db.patch(args.projectId, {
instructions: validated,
updatedAt: Date.now(),
});
return { updated: true };
},
});
export const getInstructions = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const project = await ctx.db.get(args.projectId);
return project?.instructions ?? "";
},
});