Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/agent-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
agentDeploySourceAssetName,
agentSlugFromSourceAssetName,
buildAgentDefinitionJson,
buildScheduledRunBody,
} from "./agent-deploy";

describe("buildAgentDefinitionJson", () => {
Expand Down Expand Up @@ -34,6 +35,20 @@ describe("buildAgentDefinitionJson", () => {
});
});

describe("buildScheduledRunBody", () => {
test("asks the agent to mail the deploying person's address", () => {
const body = buildScheduledRunBody("alice@example.test");
expect(body).toContain("alice@example.test");
expect(body).toContain("`to` list");
});

test("falls back to a bare reply instruction when no address is known", () => {
const body = buildScheduledRunBody(undefined);
expect(body).not.toContain("@");
expect(body).toContain("Reply with the result.");
});
});

describe("agentSlugFromSourceAssetName", () => {
test("recovers the slug agentDeploySourceAssetName wrapped", () => {
expect(agentSlugFromSourceAssetName(agentDeploySourceAssetName("echo-bot"))).toBe("echo-bot");
Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { renderBundledWorkflowSourceTree } from "@corbits/workflows/client";
import { type } from "arktype";
import { WorkflowDeploymentResponse } from "@intx/types";
import { reportError } from "@corbits/error-sink";

import { resolveExistingOffering } from "./onboarding/provider-connect-step";
import { isValidSlug, slugify } from "@/lib/slug";
Expand All @@ -17,6 +18,10 @@ const AssetCreatedShape = type({ id: "string" });
const AssetListShape = type({ id: "string", name: "string" }).array();
const GitTokenMintShape = type({ id: "string", secret: "string" });
const TenantDomainShape = type({ domain: "string" });
// Same shape `session.ts`'s `fetchSession` parses; a person's refId is
// their better-auth user id, exactly what `threads-api.ts` builds a
// principal's mailbox address from.
const SessionUserShape = type({ user: { id: "string" } });

const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000;

Expand Down Expand Up @@ -241,23 +246,67 @@ function cronPath(tenantId: string): string {
return `/api/tenants/${encodeURIComponent(tenantId)}/cron`;
}

/** The scheduled-run mail body: names the person to report to (when known)
* so the agent's reply has somewhere routable to go, since the cron
* sender itself has no mailbox. */
export function buildScheduledRunBody(deployerAddress?: string): string {
const task = "Do the work your definition describes.";
if (deployerAddress === undefined) {
return `This is your scheduled run. ${task} Reply with the result.`;
}
return `This is your scheduled run. ${task} Mail the result to ${deployerAddress} (pass it as a single-item \`to\` list) with a short, descriptive subject.`;
}

/** The deploying person's mailbox address — same source `session.ts`'s
* `fetchSession` reads, same shape `threads-api.ts` builds a person
* participant's address from (`<refId>@<tenantDomain>`). Best-effort: a
* session probe that fails or comes back signed-out just means the
* scheduled run's body falls back to naming nobody, never a failed
* deploy over it. */
async function resolveDeployerAddress(
tenantDomain: string,
fetchImpl: typeof fetch,
): Promise<string | undefined> {
let response: Response;
try {
response = await fetchImpl("/api/auth/get-session", {
headers: { accept: "application/json" },
});
} catch (cause) {
reportError(cause, { operation: "agent_deploy_resolve_deployer_address" });
return undefined;
}
if (!response.ok) return undefined;
let body: unknown;
try {
body = await response.json();
} catch (cause) {
reportError(cause, { operation: "agent_deploy_resolve_deployer_address" });
return undefined;
}
const parsed = SessionUserShape(body);
return parsed instanceof type.errors ? undefined : `${parsed.user.id}@${tenantDomain}`;
}

/** Creates a `@corbits/cron` schedule row addressed at a deployed agent's
* run — the only way an agent fires on a cadence, since Interchange's
* `schedule` trigger is reserved but unimplemented. */
async function scheduleAgentRun(
tenantId: string,
expression: string,
runAddress: string,
tenantDomain: string,
fetchImpl: typeof fetch,
): Promise<void> {
const deployerAddress = await resolveDeployerAddress(tenantDomain, fetchImpl);
const created = await fetchImpl(cronPath(tenantId), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expression,
toAddress: runAddress,
subject: "Scheduled run",
body: "This is your scheduled run. Do the work your definition describes and reply with the result.",
body: buildScheduledRunBody(deployerAddress),
}),
});
if (!created.ok) {
Expand Down Expand Up @@ -346,6 +395,7 @@ export async function deployAgentSource(
// The deployment id is the top-level run id (already `run_…`), and
// the run address is that id at the tenant domain.
`${parsed.id}@${tenant.domain}`,
tenant.domain,
fetchImpl,
);
}
Expand Down
Loading