mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
Third PR: depends on #15728 and #15727. The user has reviewed and authorized merging this experimental feature stack. ## Thinking Path > - Paperclip manages agent work through tasks and governed connections. > - Chat connections already bind external conversations to those tasks. > - Speko handles voice and telephony, while the selected Paperclip agent does the work. > - A call needs durable session authority, ordered follow-ups, and safe result delivery. > - This PR adds repeatable qualification for the Speko voice task integration. > - This PR depends on #15728, which depends on #15727. Speko stays experimental. ## Linked Issues or Issue Description **Problem or motivation** Phone and browser voice conversations cannot use the chat connection task lifecycle. A separate voice runtime is needed to preserve task authority, retries, and reply history. **Proposed solution** Add company-scoped voice sessions, signed callbacks, durable replies, inbound intake, and an agent-facing outbound-call tool. Reuse existing tasks, queues, secrets, and publications. **Roadmap alignment** This extends the experimental chat connector entry points under Agent Chat and Connected Apps. The project owner requested this feature. Refs #11418 for the original provider catalog proposal. This implementation uses the newer chat connection infrastructure rather than an MCP catalog-only integration. Refs #15721 for remote OpenCode startup. ## What Changed - Add a real Paperclip server/database E2E fixture that stubs only external Speko transport and the execution provider. - Add authenticated multi-user and cross-company qualification journeys. - Add Storybook interaction, accessibility, responsive, and visual-suite integration. - Add an E01–E16 runbook, component-to-story inventory, and current redacted qualification report. - Keep historical private call traces out of the PR. Provider proof tools are opt-in. ## Verification - All 21 standalone proof tests and browser client/server syntax checks pass. They cover signatures, redaction, cleanup retry, final audio saves, bounded report transport and late server write ordering. No live calls were placed. - All 27 focused UI/controller regressions, UI typecheck, token gates, and 28 map/catalog structural tests pass. The shared connection cache test covers pause/resume invalidation during the 30-second fresh period. - Storybook builds 157 Speko production-component stories. The complete prior 156-story set passed all 624 checks on UI source249cbc616a. After the final cache repair, all 152 affected page/launcher checks passed across both themes, 390/1200 widths and reduced motion. Real microphone access and external provider requests are blocked. - Hosted Storybook origin isolation and representative narrow-screen stories pass. Provider requests remain blocked. - Source annotations follow the current generated-catalog contract. No generated catalog or lockfile is included. Three inherited GitHub-heading tests passed after master reconciliation. - Commit905bc5da5dhas 55 passing checks, including required verify/E2E gates, and a fresh Greptile 5/5 review with zero unresolved findings. Local fixture success does not establish provider or spoken-playback acceptance. - The separate integrated browser run passed two journeys and failed browser answer retrieval. Real browser/telephone playback and timing acceptance remain outstanding. See the dated qualification report and E01–E16 runbook. Speko stays experimental. ## Risks - Voice sessions add migrations and provider-dependent lifecycle behavior. Migrations preserve existing chat and AI transports. - Callback origins must be reachable HTTPS. Provider keys and signing secrets remain vaulted. - Unverified callers receive task-scoped low-trust authority. Caller ID does not grant private task access. - Outbound telephony incurs provider charges. Provider acceptance is not proof that the caller heard an answer. - Keep the feature behind the existing experimental chat setting until the real browser, inbound, outbound, and delayed-result acceptance matrix passes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
165 lines
14 KiB
TypeScript
165 lines
14 KiB
TypeScript
/** Production app with a synthetic execution provider; launched by server.ts. */
|
|
import { appendFile, writeFile, rename } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { randomInt } from "node:crypto";
|
|
import { once } from "node:events";
|
|
import { createRequire } from "node:module";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { createDb, authUsers, instanceUserRoles, issueComments, issues, issueThreadInteractions, issueQuestionResponseDeliveries, chatVoiceInboundCalls, chatVoiceReports, chatVoiceSessions, chatVoiceReplies, chatVoiceToolCalls, chatActions, chatDeliveries, chatPublications, heartbeatRuns, agentWakeupRequests } from "../../packages/db/src/index.js";
|
|
const home = process.env.PAPERCLIP_HOME!;
|
|
const port = Number(process.env.SPEKO_NATIVE_PORT ?? 3449);
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
const callbackOrigin = process.env.SPEKO_NATIVE_CALLBACK_ORIGIN!;
|
|
const requireServer = createRequire(new URL("../../server/package.json", import.meta.url));
|
|
const { eq } = requireServer("drizzle-orm");
|
|
const db = createDb(process.env.DATABASE_URL!);
|
|
const authenticated = process.env.SPEKO_NATIVE_AUTH === "1";
|
|
const deploymentMode = authenticated ? "authenticated" : "local_trusted";
|
|
// Match index.ts's local-trusted bootstrap: agent JWTs retain this real
|
|
// responsible user and the memberships created by the normal company route.
|
|
if (!authenticated) await db.insert(authUsers).values({ id: "local-board", name: "Board", email: "local@paperclip.local", emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
|
if (!authenticated) await db.insert(instanceUserRoles).values({ userId: "local-board", role: "instance_admin" }).onConflictDoNothing();
|
|
const { registerServerAdapter, waitForExternalAdapters } = await import("../../server/src/adapters/registry.js");
|
|
await waitForExternalAdapters();
|
|
const { instanceSettingsService } = await import("../../server/src/services/instance-settings.js");
|
|
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
|
const firstRun = new Map<string, { began: number; number: number }>();
|
|
registerServerAdapter({
|
|
type: "process", supportsLocalAgentJwt: true,
|
|
async testEnvironment() { return { adapterType: "process", status: "pass", checks: [], testedAt: new Date().toISOString() }; },
|
|
async execute(ctx) {
|
|
const issueId = String(ctx.context.issueId ?? "");
|
|
if (!issueId || !ctx.authToken) throw new Error("Expected real task and run-scoped agent identity");
|
|
ctx.onDispatch?.();
|
|
await ctx.onCancellationReady?.();
|
|
if (ctx.config.command === "fixture-phone") {
|
|
// Only execution is synthetic. Discover and invoke through the real
|
|
// run-scoped MCP transport; do not reach into the voice service.
|
|
const server = ctx.runtimeMcp?.getServers().find(server => server.name === "paperclip-assigned");
|
|
if (!server) throw new Error("The assigned phone tool was not delivered to the runtime");
|
|
const { Client } = requireServer("@modelcontextprotocol/sdk/client/index.js");
|
|
const { StreamableHTTPClientTransport } = requireServer("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
const client = new Client({ name: "speko-phone-fixture", version: "1.0.0" });
|
|
try {
|
|
await client.connect(new StreamableHTTPClientTransport(new URL(server.url), { requestInit: { headers: { authorization: `Bearer ${server.token}` } } }));
|
|
const listed = await client.listTools();
|
|
const tool = listed.tools.find((tool: { name: string }) => tool.name.endsWith(":call_my_phone"));
|
|
if (!tool) throw new Error("The callback tool was not discoverable");
|
|
const result = await client.callTool({ name: tool.name, arguments: {} });
|
|
if (result.isError) throw new Error("The fixture phone call failed");
|
|
// A second click must not dial again while this exact task is on a call.
|
|
let secondRejected = false;
|
|
try { const repeated = await client.callTool({ name: tool.name, arguments: {} }); secondRejected = !!repeated.isError; } catch { secondRejected = true; }
|
|
if (!secondRejected) throw new Error("A simultaneous duplicate phone request was not rejected");
|
|
await appendFile(resolve(home, "execution.jsonl"), JSON.stringify({ event: "phone_called", issueId, runId: ctx.runId, result, secondRejected }) + "\n", { mode: 0o600 });
|
|
return { exitCode: 0, signal: null, timedOut: false, summary: "The assigned phone tool placed one call; the duplicate request was rejected." };
|
|
} finally { await client.close(); }
|
|
}
|
|
let run = firstRun.get(issueId);
|
|
const followupRun = !!run;
|
|
if (!run) { run = { began: Date.now(), number: randomInt(1000, 10000) }; firstRun.set(issueId, run); }
|
|
await appendFile(resolve(home, "execution.jsonl"), JSON.stringify({ event: "started", issueId, runId: ctx.runId, at: new Date().toISOString() }) + "\n", { mode: 0o600 });
|
|
await ctx.onLog("stdout", "Synthetic execution provider is processing the voice request.\n");
|
|
const remaining = run.began + 61_000 - Date.now();
|
|
if (remaining > 0) await delay(remaining, undefined, { signal: ctx.signal });
|
|
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
|
const requests = comments.filter((comment) => !comment.authorAgentId);
|
|
if (requests.some((comment) => comment.body.includes("Ask the structured acceptance question"))) {
|
|
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
|
|
const question = interactions.find((item) => item.idempotencyKey === `fixture-question:${issueId}`);
|
|
if (!question) {
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
const response = await fetch(`${origin}/api/issues/${issueId}/interactions`, {
|
|
method: "POST", headers: { authorization: `Bearer ${ctx.authToken}`, "content-type": "application/json", "x-paperclip-run-id": ctx.runId },
|
|
body: JSON.stringify({ kind: "ask_user_questions", idempotencyKey: `fixture-question:${issueId}`, addresseeUserId: task.responsibleUserId,
|
|
payload: { version: 1, questions: [{ id: "color", prompt: "Which color should I use?", selectionMode: "single", required: true, allowOther: false, options: [{ id: "cobalt", label: "Cobalt" }, { id: "amber", label: "Amber" }] }] } }), signal: ctx.signal,
|
|
});
|
|
if (!response.ok) throw new Error(`Fixture question API rejected: ${response.status}`);
|
|
return { exitCode: 0, signal: null, timedOut: false, summary: "" };
|
|
}
|
|
if (question.status !== "answered") return { exitCode: 0, signal: null, timedOut: false, summary: "" };
|
|
}
|
|
const answeredQuestion = (await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).find((item) => item.idempotencyKey === `fixture-question:${issueId}` && item.status === "answered");
|
|
const summary = answeredQuestion ? `Your structured answer was received: ${JSON.stringify(answeredQuestion.result)}.` : followupRun ? "Your follow-up was included in the completed result." : `The delayed work is complete. Verification number ${run.number}. I received ${Math.max(0, requests.length - 1)} follow-up instructions on this same task.`;
|
|
const response = await fetch(`${origin}/api/issues/${issueId}/comments`, {
|
|
method: "POST", headers: { authorization: `Bearer ${ctx.authToken}`, "content-type": "application/json", "x-paperclip-run-id": ctx.runId },
|
|
body: JSON.stringify({ body: summary }), signal: ctx.signal,
|
|
});
|
|
if (!response.ok) throw new Error(`Normal agent comment API rejected fixture: ${response.status} ${(await response.json() as { error: string }).error}`);
|
|
const comment = await response.json() as { id: string };
|
|
await appendFile(resolve(home, "execution.jsonl"), JSON.stringify({ event: "completed", issueId, runId: ctx.runId, commentId: comment.id, number: run.number, requests: requests.length, elapsedMs: Date.now() - run.began, at: new Date().toISOString() }) + "\n", { mode: 0o600 });
|
|
return { exitCode: 0, signal: null, timedOut: false, summary, resultJson: { summary } };
|
|
},
|
|
});
|
|
const closeStub = process.env.SPEKO_NATIVE_STUB === "1"
|
|
? await (await import("./stub-provider.js")).installSpekoTestProvider(origin, Number(process.env.SPEKO_NATIVE_STUB_PORT ?? port + 1))
|
|
: undefined;
|
|
const { createApp } = await import("../../server/src/app.js");
|
|
const { createStorageService } = await import("../../server/src/storage/service.js");
|
|
const { createLocalDiskStorageProvider } = await import("../../server/src/storage/local-disk-provider.js");
|
|
const authModule = await import("../../server/src/auth/better-auth.js");
|
|
const { loadConfig } = await import("../../server/src/config.js");
|
|
const auth = authenticated ? authModule.createBetterAuthInstance(db, loadConfig(), [origin]) : undefined;
|
|
const app = await createApp(db, {
|
|
uiMode: "static", serverPort: port, bindHost: "127.0.0.1", allowedHostnames: ["127.0.0.1", "localhost", new URL(callbackOrigin).hostname],
|
|
deploymentMode,
|
|
...(auth ? { betterAuthHandler: authModule.createBetterAuthHandler(auth), resolveSession: (req: import("express").Request) => authModule.resolveBetterAuthSession(auth, req) } : {}),
|
|
deploymentExposure: "private", authReady: true, companyDeletionEnabled: true,
|
|
chatWebhookPublicBaseUrl: callbackOrigin, authPublicBaseUrl: origin,
|
|
storageService: createStorageService(createLocalDiskStorageProvider(resolve(home, "storage"))),
|
|
decisionServiceOptions: { wakeOriginAgent: async () => {} }, managedPluginAutoInstall: [],
|
|
});
|
|
let evidenceWriting = false;
|
|
async function writeEvidence() {
|
|
if (evidenceWriting) return;
|
|
evidenceWriting = true;
|
|
try {
|
|
const [reports, incoming, sessions, replies, tools, deliveries, publications, runs, wakeups, comments, interactions, questionDeliveries, pushActions] = await Promise.all([
|
|
db.select({sessionId: chatVoiceReports.sessionId, companyId: chatVoiceReports.companyId, status: chatVoiceReports.status, costMicroUsd: chatVoiceReports.costMicroUsd, transcript: chatVoiceReports.transcript}).from(chatVoiceReports),
|
|
db.select({id: chatVoiceInboundCalls.id, companyId: chatVoiceInboundCalls.companyId, endpointId: chatVoiceInboundCalls.endpointId, state: chatVoiceInboundCalls.state, sessionId: chatVoiceInboundCalls.sessionId}).from(chatVoiceInboundCalls),
|
|
db.select({ id: chatVoiceSessions.id, issueId: chatVoiceSessions.issueId, providerSessionId: chatVoiceSessions.providerSessionId, mode: chatVoiceSessions.mode, state: chatVoiceSessions.state }).from(chatVoiceSessions),
|
|
db.select().from(chatVoiceReplies),
|
|
db.select({ id: chatVoiceToolCalls.id, sessionId: chatVoiceToolCalls.sessionId, tool: chatVoiceToolCalls.tool, response: chatVoiceToolCalls.response }).from(chatVoiceToolCalls),
|
|
db.select({ id: chatDeliveries.id, state: chatDeliveries.state, conversationId: chatDeliveries.conversationId, redactedError: chatDeliveries.redactedError }).from(chatDeliveries),
|
|
db.select({ id: chatPublications.id, issueId: chatPublications.issueId, state: chatPublications.state, payload: chatPublications.payload, redactedError: chatPublications.redactedError }).from(chatPublications),
|
|
db.select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, error: heartbeatRuns.error, errorCode: heartbeatRuns.errorCode, runtimeMode: heartbeatRuns.runtimeMode, nativeIssueId: heartbeatRuns.nativeIssueId, contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns),
|
|
db.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status, runId: agentWakeupRequests.runId }).from(agentWakeupRequests),
|
|
db.select({ id: issueComments.id, issueId: issueComments.issueId, body: issueComments.body, authorAgentId: issueComments.authorAgentId, createdByRunId: issueComments.createdByRunId }).from(issueComments),
|
|
db.select().from(issueThreadInteractions),
|
|
db.select().from(issueQuestionResponseDeliveries),
|
|
db.select().from(chatActions).where(eq(chatActions.kind, "speko_voice_reply_push")),
|
|
]);
|
|
const runEvidence = runs.map(({ contextSnapshot, ...row }) => {
|
|
const context = (contextSnapshot ?? {}) as Record<string, unknown>;
|
|
const wake = (context.paperclipWake ?? {}) as Record<string, unknown>;
|
|
return { ...row, context: { source: context.source, issueId: context.issueId, wakeCommentIds: context.wakeCommentIds,
|
|
paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance, paperclipHarnessCheckedOut: context.paperclipHarnessCheckedOut, paperclipExternalChatExecutionBound: context.paperclipExternalChatExecutionBound,
|
|
wake: { externalChatProvider: wake.externalChatProvider, checkedOutByHarness: wake.checkedOutByHarness, externalChatExecutionBound: wake.externalChatExecutionBound } } };
|
|
});
|
|
await writeFile(resolve(home, "evidence.pending.json"), JSON.stringify({ appPid: process.pid, at: new Date().toISOString(), reports, incoming, sessions, replies, tools, deliveries, publications, runs: runEvidence, wakeups, comments, interactions, questionDeliveries, pushActions }), { mode: 0o600 });
|
|
await rename(resolve(home, "evidence.pending.json"), resolve(home, "evidence.json"));
|
|
} finally { evidenceWriting = false; }
|
|
}
|
|
await writeEvidence();
|
|
const evidenceTimer = setInterval(() => { void writeEvidence().catch((error) => console.error("Evidence snapshot failed:", error.message)); }, 2000);
|
|
evidenceTimer.unref();
|
|
const { createPaperclipHttpServer } = await import("../../server/src/http/server.js");
|
|
const server = createPaperclipHttpServer(app, { apiUrl: origin });
|
|
server.listen(port, "127.0.0.1");
|
|
const { setupLiveEventsWebSocketServer } = await import("../../server/src/realtime/live-events-ws.js");
|
|
const liveEvents = setupLiveEventsWebSocketServer(server, db, { deploymentMode, ...(auth ? { resolveSessionFromHeaders: (headers: Headers) => authModule.resolveBetterAuthSessionFromHeaders(auth, headers) } : {}) });
|
|
await once(server, "listening");
|
|
console.log(JSON.stringify({ ready: true, origin, home }));
|
|
let stopping = false;
|
|
async function stop() {
|
|
if (stopping) return; stopping = true;
|
|
clearInterval(evidenceTimer);
|
|
for (const client of liveEvents.clients) client.terminate();
|
|
server.close(); server.closeAllConnections();
|
|
await app.locals.paperclipShutdown?.();
|
|
await closeStub?.();
|
|
process.exit(0);
|
|
}
|
|
process.once("SIGINT", () => void stop());
|
|
process.once("SIGTERM", () => void stop());
|