Integrate mobile chat workspace and OpenRouter agent flow

This commit is contained in:
sai karthik
2026-07-25 17:49:11 +05:30
parent a17aa5c283
commit 48200a11df
24 changed files with 1142 additions and 211 deletions

View File

@@ -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;
}>;
/**

View File

@@ -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;
};

View File

@@ -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,
});
},
});

View 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"
);
});
});

View File

@@ -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(

View File

@@ -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 () => {

View File

@@ -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({