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
120 lines
4.0 KiB
TypeScript
120 lines
4.0 KiB
TypeScript
"use node";
|
|
|
|
import { env } from "@code/env/convex";
|
|
import {
|
|
WorkAttemptExecutionError,
|
|
decodeWorkAttemptExecutionFailure,
|
|
decodeWorkAttemptExecutionResult,
|
|
} from "@code/primitives/execution-runtime";
|
|
import { makeFunctionReference } from "convex/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Effect } from "effect";
|
|
|
|
import { internal } from "./_generated/api";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { internalAction } from "./_generated/server";
|
|
import { decryptCredential } from "./gitConnections";
|
|
|
|
const getRepositoryRef = makeFunctionReference<
|
|
"query",
|
|
{ repositoryId: Id<"gitRepositories"> },
|
|
{
|
|
readonly cloneUrl: string;
|
|
readonly defaultBranch: string;
|
|
} | null
|
|
>("gitProvisioning:getRepository");
|
|
|
|
const backendUrl = () => env.AGENT_BACKEND_URL ?? env.FLUE_URL;
|
|
|
|
export const executeAttempt = internalAction({
|
|
args: { attemptId: v.id("workAttempts") },
|
|
handler: async (ctx, args) => {
|
|
const running = await ctx.runMutation(
|
|
internal.workExecutionWorkflow.markAttemptRunning,
|
|
args
|
|
);
|
|
if (!running) {
|
|
throw new ConvexError("Attempt is no longer runnable");
|
|
}
|
|
const context = await ctx.runQuery(
|
|
internal.workExecutionWorkflow.executionContext,
|
|
args
|
|
);
|
|
const credential = await decryptCredential(
|
|
context.connection.credentialCiphertext,
|
|
context.connection.credentialIv
|
|
);
|
|
// Resolve the project's normalized repository if available; fall back to
|
|
// legacy sourceUrl for backward compatibility with pre-backfill projects.
|
|
const repository = context.project.gitRepositoryId
|
|
? await ctx.runQuery(getRepositoryRef, {
|
|
repositoryId: context.project.gitRepositoryId,
|
|
})
|
|
: null;
|
|
const repositoryUrl = repository?.cloneUrl ?? context.project.sourceUrl;
|
|
const baseBranch =
|
|
repository?.defaultBranch ?? context.project.defaultBranch ?? "main";
|
|
|
|
const response = await fetch(
|
|
`${backendUrl()}/internal/work-attempts/execute`,
|
|
{
|
|
body: JSON.stringify({
|
|
attemptId: String(context.attempt._id),
|
|
auth: {
|
|
credential,
|
|
provider: context.connection.provider,
|
|
serverUrl: context.connection.serverUrl,
|
|
username: context.connection.username,
|
|
},
|
|
baseBranch,
|
|
prompt: context.prompt,
|
|
repositoryUrl,
|
|
runId: String(context.run._id),
|
|
workId: String(context.work._id),
|
|
workspaceKey: context.attempt.workspaceKey,
|
|
}),
|
|
headers: {
|
|
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
}
|
|
);
|
|
const payload = (await response.json()) as unknown;
|
|
if (!response.ok) {
|
|
// Decode the agent's classified failure envelope and re-throw as the
|
|
// typed runtime error so the workflow handler maps reason/retryable to
|
|
// a durable attempt classification. A malformed envelope falls back to
|
|
// an InvalidInput failure (non-retryable).
|
|
const failure = await Effect.runPromise(
|
|
decodeWorkAttemptExecutionFailure(payload)
|
|
);
|
|
throw new WorkAttemptExecutionError(failure.error);
|
|
}
|
|
return await Effect.runPromise(decodeWorkAttemptExecutionResult(payload));
|
|
},
|
|
});
|
|
|
|
export const cancelAttempt = internalAction({
|
|
args: {
|
|
attemptId: v.string(),
|
|
workspaceKey: v.string(),
|
|
},
|
|
handler: async (_ctx, args) => {
|
|
// The workspace key remains the URL path segment (workspace identity),
|
|
// while the body carries the attemptId so the runtime can target the
|
|
// matching Pi ACP session for cancellation.
|
|
await fetch(
|
|
`${backendUrl()}/internal/work-attempts/${encodeURIComponent(args.workspaceKey)}/cancel`,
|
|
{
|
|
body: JSON.stringify({ attemptId: args.attemptId }),
|
|
headers: {
|
|
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
}
|
|
);
|
|
},
|
|
});
|