Integrate mobile chat workspace and OpenRouter agent flow
This commit is contained in:
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -26,8 +26,10 @@ import type * as projectIssues from "../projectIssues.js";
|
||||
import type * as projectStore from "../projectStore.js";
|
||||
import type * as projects from "../projects.js";
|
||||
import type * as publicGit from "../publicGit.js";
|
||||
import type * as signalRouting from "../signalRouting.js";
|
||||
import type * as signals from "../signals.js";
|
||||
import type * as todos from "../todos.js";
|
||||
import type * as workflows from "../workflows.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -54,8 +56,10 @@ declare const fullApi: ApiFromModules<{
|
||||
projectStore: typeof projectStore;
|
||||
projects: typeof projects;
|
||||
publicGit: typeof publicGit;
|
||||
signalRouting: typeof signalRouting;
|
||||
signals: typeof signals;
|
||||
todos: typeof todos;
|
||||
workflows: typeof workflows;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { DataModel } from "./dataModel.js";
|
||||
*/
|
||||
type Env = {
|
||||
readonly FLUE_DB_TOKEN: string;
|
||||
readonly GITEA_TOKEN: string | undefined;
|
||||
readonly GITEA_URL: string | undefined;
|
||||
readonly NATIVE_APP_URL: string | undefined;
|
||||
readonly SITE_URL: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
decodePublicGitImportResult,
|
||||
preparePublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
@@ -8,15 +9,17 @@ import {
|
||||
import { v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireCurrentOrganization } from "./authz";
|
||||
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
|
||||
import {
|
||||
makeProjectMutationStore,
|
||||
makeProjectQueryStore,
|
||||
persistPublicGitImportTransaction,
|
||||
} from "./projectStore";
|
||||
import { inspectPublicGit } from "./publicGit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: persist a public Git import in one transaction
|
||||
@@ -182,18 +185,31 @@ export const get = query({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public action: import a public Git repository
|
||||
// (daemon wiring added in the Daemon phase)
|
||||
// Public action: import a supported public Git repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const importPublicGit = action({
|
||||
args: { repositoryUrl: v.string() },
|
||||
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
|
||||
// Normalize through the domain layer to validate the URL before the
|
||||
// daemon adapter is wired.
|
||||
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
|
||||
// The daemon PublicGit adapter is wired in the Daemon phase.
|
||||
throw new Error("PublicGit import is not yet wired to the daemon adapter");
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
const userId = await requireAuthUserId(ctx);
|
||||
const source = await Effect.runPromise(
|
||||
preparePublicGitSource(args.repositoryUrl)
|
||||
);
|
||||
const remote = await inspectPublicGit(source);
|
||||
const validatedRemote = await Effect.runPromise(
|
||||
decodePublicGitImportResult(remote)
|
||||
);
|
||||
return await ctx.runMutation(internal.projects.persistPublicGitImport, {
|
||||
remote: {
|
||||
defaultBranch: validatedRemote.defaultBranch,
|
||||
documents: validatedRemote.documents.map((document) => ({
|
||||
...document,
|
||||
})),
|
||||
warnings: validatedRemote.warnings.map((warning) => ({ ...warning })),
|
||||
},
|
||||
source,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
58
packages/backend/convex/publicGit.test.ts
Normal file
58
packages/backend/convex/publicGit.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { inspectPublicGit } from "./publicGit";
|
||||
|
||||
const source = {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/sai-karthik/hello-world",
|
||||
projectName: "hello-world",
|
||||
repositoryPath: "Sai-karthik/hello-world",
|
||||
url: "https://git.openputer.com/Sai-karthik/hello-world.git",
|
||||
};
|
||||
|
||||
describe("public Git repository inspection", () => {
|
||||
test("imports metadata for an empty public Gitea repository", async () => {
|
||||
const requests: string[] = [];
|
||||
const result = await inspectPublicGit(source, {
|
||||
fetch(input) {
|
||||
const url = String(input);
|
||||
requests.push(url);
|
||||
if (url.endsWith("/api/v1/repos/Sai-karthik/hello-world")) {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
default_branch: "main",
|
||||
empty: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response("not found", { status: 404 }));
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
defaultBranch: "main",
|
||||
documents: [],
|
||||
});
|
||||
expect(result.warnings).toHaveLength(6);
|
||||
expect(requests).toContain(
|
||||
"https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world"
|
||||
);
|
||||
expect(requests).toContain(
|
||||
"https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world/raw/README.md?ref=main"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hosts outside the supported provider allowlist", async () => {
|
||||
await expect(
|
||||
inspectPublicGit({
|
||||
...source,
|
||||
host: "example.com",
|
||||
normalizedUrl: "https://example.com/example/repository",
|
||||
repositoryPath: "example/repository",
|
||||
url: "https://example.com/example/repository.git",
|
||||
})
|
||||
).rejects.toThrow(
|
||||
"Repository import supports public GitHub and git.openputer.com URLs"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,31 @@ import {
|
||||
} from "@code/primitives/project";
|
||||
|
||||
const GITHUB_HOST = "github.com";
|
||||
const REQUEST_HEADERS = {
|
||||
const GITEA_HOST = "git.openputer.com";
|
||||
const GITHUB_REQUEST_HEADERS = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "zopu-project-importer",
|
||||
} as const;
|
||||
const GITEA_REQUEST_HEADERS = {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "zopu-project-importer",
|
||||
} as const;
|
||||
|
||||
type Fetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => Promise<Response>;
|
||||
|
||||
interface PublicGitProvider {
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly metadataUrl: (source: PreparedPublicGitSource) => string;
|
||||
readonly rawFileUrl: (
|
||||
source: PreparedPublicGitSource,
|
||||
branch: string,
|
||||
path: string
|
||||
) => string;
|
||||
readonly repositoryNotFoundMessage: string;
|
||||
}
|
||||
|
||||
const candidatePaths = {
|
||||
agents: ["AGENTS.md"],
|
||||
@@ -28,18 +49,51 @@ const encodePath = (path: string) =>
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
|
||||
const providerForSource = (
|
||||
source: PreparedPublicGitSource
|
||||
): PublicGitProvider => {
|
||||
if (source.host === GITHUB_HOST) {
|
||||
return {
|
||||
headers: GITHUB_REQUEST_HEADERS,
|
||||
metadataUrl: (candidate) =>
|
||||
`https://api.github.com/repos/${encodePath(candidate.repositoryPath)}`,
|
||||
rawFileUrl: (candidate, branch, path) =>
|
||||
`https://raw.githubusercontent.com/${encodePath(candidate.repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`,
|
||||
repositoryNotFoundMessage: "Public GitHub repository not found",
|
||||
};
|
||||
}
|
||||
if (source.host === GITEA_HOST) {
|
||||
return {
|
||||
headers: GITEA_REQUEST_HEADERS,
|
||||
metadataUrl: (candidate) =>
|
||||
`https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}`,
|
||||
rawFileUrl: (candidate, branch, path) =>
|
||||
`https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}/raw/${encodePath(path)}?ref=${encodeURIComponent(branch)}`,
|
||||
repositoryNotFoundMessage: "Public Gitea repository not found",
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
"Repository import supports public GitHub and git.openputer.com URLs"
|
||||
);
|
||||
};
|
||||
|
||||
const fetchTextCandidate = async (
|
||||
repositoryPath: string,
|
||||
fetcher: Fetch,
|
||||
provider: PublicGitProvider,
|
||||
source: PreparedPublicGitSource,
|
||||
branch: string,
|
||||
path: string
|
||||
): Promise<string | null> => {
|
||||
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`;
|
||||
const response = await fetch(url, { headers: REQUEST_HEADERS });
|
||||
const response = await fetcher(provider.rawFileUrl(source, branch, path), {
|
||||
headers: provider.headers,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub returned ${response.status} for ${path}`);
|
||||
throw new Error(
|
||||
`${source.host} returned ${response.status} while reading ${path}`
|
||||
);
|
||||
}
|
||||
const content = await response.text();
|
||||
if (content.trim().length === 0) {
|
||||
@@ -52,13 +106,17 @@ const fetchTextCandidate = async (
|
||||
};
|
||||
|
||||
const fetchContextDocument = async (
|
||||
fetcher: Fetch,
|
||||
provider: PublicGitProvider,
|
||||
source: PreparedPublicGitSource,
|
||||
branch: string,
|
||||
kind: (typeof CONTEXT_KINDS)[number]
|
||||
): Promise<RepositoryContextDocument | null> => {
|
||||
for (const candidate of candidatePaths[kind]) {
|
||||
const content = await fetchTextCandidate(
|
||||
source.repositoryPath,
|
||||
fetcher,
|
||||
provider,
|
||||
source,
|
||||
branch,
|
||||
candidate
|
||||
);
|
||||
@@ -74,20 +132,18 @@ const fetchContextDocument = async (
|
||||
};
|
||||
|
||||
const readDefaultBranch = async (
|
||||
fetcher: Fetch,
|
||||
provider: PublicGitProvider,
|
||||
source: PreparedPublicGitSource
|
||||
): Promise<string> => {
|
||||
if (source.host !== GITHUB_HOST) {
|
||||
throw new Error("Repository import currently supports public GitHub URLs");
|
||||
}
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
|
||||
{ headers: REQUEST_HEADERS }
|
||||
);
|
||||
const response = await fetcher(provider.metadataUrl(source), {
|
||||
headers: provider.headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? "Public GitHub repository not found"
|
||||
: `GitHub returned ${response.status} while reading the repository`
|
||||
? provider.repositoryNotFoundMessage
|
||||
: `${source.host} returned ${response.status} while reading the repository`
|
||||
);
|
||||
}
|
||||
const metadata = (await response.json()) as { default_branch?: unknown };
|
||||
@@ -95,18 +151,21 @@ const readDefaultBranch = async (
|
||||
typeof metadata.default_branch !== "string" ||
|
||||
metadata.default_branch.length === 0
|
||||
) {
|
||||
throw new Error("GitHub did not return a default branch");
|
||||
throw new Error(`${source.host} did not return a default branch`);
|
||||
}
|
||||
return metadata.default_branch;
|
||||
};
|
||||
|
||||
export const inspectPublicGit = async (
|
||||
source: PreparedPublicGitSource
|
||||
source: PreparedPublicGitSource,
|
||||
options: { readonly fetch?: Fetch } = {}
|
||||
): Promise<PublicGitImportResult> => {
|
||||
const defaultBranch = await readDefaultBranch(source);
|
||||
const fetcher = options.fetch ?? globalThis.fetch;
|
||||
const provider = providerForSource(source);
|
||||
const defaultBranch = await readDefaultBranch(fetcher, provider, source);
|
||||
const candidates = await Promise.all(
|
||||
CONTEXT_KINDS.map((kind) =>
|
||||
fetchContextDocument(source, defaultBranch, kind)
|
||||
fetchContextDocument(fetcher, provider, source, defaultBranch, kind)
|
||||
)
|
||||
);
|
||||
const documents = candidates.filter(
|
||||
|
||||
@@ -491,7 +491,22 @@ describe("signalRouting begin issue", () => {
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(status).toBe("queued");
|
||||
expect(status).toEqual({
|
||||
dispatchRequired: true,
|
||||
projectId,
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
const repeated = await t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(repeated).toEqual({
|
||||
dispatchRequired: false,
|
||||
projectId,
|
||||
status: "queued",
|
||||
});
|
||||
});
|
||||
|
||||
test("begin rejects invalid token", async () => {
|
||||
|
||||
@@ -613,7 +613,14 @@ export const beginIssue = mutation({
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<"queued" | "working"> => {
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
dispatchRequired: boolean;
|
||||
projectId: Id<"projects">;
|
||||
status: "queued" | "working";
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
@@ -629,7 +636,11 @@ export const beginIssue = mutation({
|
||||
);
|
||||
});
|
||||
if (status === issue.status) {
|
||||
return status;
|
||||
return {
|
||||
dispatchRequired: false,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
@@ -644,12 +655,50 @@ export const beginIssue = mutation({
|
||||
kind: "issue.queued",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return status;
|
||||
return {
|
||||
dispatchRequired: true,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Get project summary + context documents for routing decisions.
|
||||
// 8. Mark a failed Flue dispatch so retrying can safely queue it again.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: {
|
||||
error: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch(issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Get project summary + context documents for routing decisions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getProjectContext = query({
|
||||
|
||||
Reference in New Issue
Block a user