The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| ColineScheduleContextV2, |
| 6 | ColineToolResult, |
| 7 | } from "../define/types"; |
| 8 | import type { ColineUiNode } from "../tree/tree"; |
| 9 | import { validateChatCardTree } from "../tree/chat-card"; |
| 10 | import { |
| 11 | TestWorkspaceState, |
| 12 | createFakeCapabilities, |
| 13 | type TestJobRun, |
| 14 | type TestWorkspaceOptions, |
| 15 | } from "./fake-capabilities"; |
| 16 | import { eventPatternMatches } from "../manifest/triggers"; |
| 17 | import { toEndpointResponse, toJobRunResult } from "../server/kinds"; |
| 18 | import { parseEndpointJson } from "../server/runtime-helpers"; |
| 19 | import { guardTestCapabilities } from "./guarded-capabilities"; |
| 20 | import { siteToolEffect, type ToolEffect } from "../manifest/effect"; |
| 21 | import type { AppPermissionV2 } from "../manifest/permissions"; |
| 22 | import type { |
| 23 | ColineCapabilities, |
| 24 | ColineCollection, |
| 25 | ColineMemberSummary, |
| 26 | ColineVisitorRef, |
| 27 | } from "../capabilities/types"; |
| 28 | import { createFakeCollection } from "./fake-collection"; |
| 29 |
| 30 | /** |
| 31 | * `createTestWorkspace` (plan §14.2): a fake workspace + runtime for app |
| 32 | * tests. Invoke tools, render surfaces, and fire schedules exactly the |
| 33 | * way the platform does — input parsed through the tool's schema, cards |
| 34 | * validated against the chat subset, render contexts read-shaped — then |
| 35 | * assert on the inspectable state. |
| 36 | * |
| 37 | * ```ts |
| 38 | * const ws = createTestWorkspace(app); |
| 39 | * const result = await ws.invokeTool("crm.create_contact", { name: "Sarah" }); |
| 40 | * expect(ws.files.byType("crm.contact")).toHaveLength(1); |
| 41 | * ``` |
| 42 | */ |
| 43 |
| 44 | export interface InvokeToolResult { |
| 45 | output: unknown; |
| 46 | card: ColineUiNode | null; |
| 47 | } |
| 48 |
| 49 | export interface TestWorkspace { |
| 50 | state: TestWorkspaceState; |
| 51 | client(surface?: "home" | "editor" | "preview"): ColineCapabilities; |
| 52 | clientAs(userId: string, surface?: "home" | "editor" | "preview"): ColineCapabilities; |
| 53 | files: { |
| 54 | byType( |
| 55 | typeKey: string, |
| 56 | ): Array<{ fileId: string; name: string; document: Record<string, unknown> }>; |
| 57 | all(): Array<{ fileId: string; name: string; typeKey: string }>; |
| 58 | }; |
| 59 | invokeTool(name: string, input: unknown, userId?: string): Promise<InvokeToolResult>; |
| 60 | renderHome(): Promise<ColineUiNode>; |
| 61 | renderFileSurface( |
| 62 | surface: "editor" | "preview" | "inline", |
| 63 | file: { fileId: string; name: string; typeKey: string; version?: number }, |
| 64 | document?: Record<string, unknown>, |
| 65 | ): Promise<ColineUiNode>; |
| 66 | fireSchedule(scheduleId: string, at?: Date): Promise<void>; |
| 67 | /** |
| 68 | * Deliver an event to `onEvent` the way the platform does — only when |
| 69 | * the manifest subscribes to it. Returns false when unsubscribed. |
| 70 | */ |
| 71 | fireEvent( |
| 72 | type: string, |
| 73 | data?: Record<string, unknown>, |
| 74 | options?: { actorUserId?: string; sourceAppKey?: string }, |
| 75 | ): Promise<boolean>; |
| 76 | /** Send a request to a declared endpoint, as if verification passed. */ |
| 77 | request( |
| 78 | endpointId: string, |
| 79 | request?: { |
| 80 | method?: string; |
| 81 | path?: string; |
| 82 | query?: Record<string, string>; |
| 83 | headers?: Record<string, string>; |
| 84 | body?: string | Record<string, unknown>; |
| 85 | /** A site endpoint called by the site's page, as this visitor; otherwise the app. */ |
| 86 | visitor?: TestVisitor; |
| 87 | }, |
| 88 | ): Promise<{ status: number; headers: Record<string, string>; body: unknown }>; |
| 89 | /** |
| 90 | * Run queued background jobs until none are due (bounded by `maxRuns`). |
| 91 | * Failed attempts retry immediately in tests; returns the runs executed. |
| 92 | */ |
| 93 | runJobs(options?: { maxRuns?: number; now?: Date }): Promise<TestJobRun[]>; |
| 94 | /** |
| 95 | * Run a tool the site lists, as a visitor would: the tool's calls run as |
| 96 | * the app at most at the `write` effect, and records it creates belong to |
| 97 | * the visitor. |
| 98 | */ |
| 99 | invokeSiteTool(name: string, input: unknown, visitor?: TestVisitor): Promise<InvokeToolResult>; |
| 100 | /** |
| 101 | * What a site page sees calling collections directly: the collection's |
| 102 | * declared visitor rules, and nothing a page may not call. |
| 103 | */ |
| 104 | asVisitor(visitor?: TestVisitor): { collection<T>(name: string): ColineCollection<T> }; |
| 105 | } |
| 106 |
| 107 | export interface TestVisitor { |
| 108 | visitorId?: string; |
| 109 | signedIn?: boolean; |
| 110 | name?: string | null; |
| 111 | email?: string | null; |
| 112 | } |
| 113 |
| 114 | function visitorRef(visitor: TestVisitor = {}): ColineVisitorRef { |
| 115 | const signedIn = visitor.signedIn ?? false; |
| 116 | return { |
| 117 | kind: "visitor", |
| 118 | visitorId: visitor.visitorId ?? "vis_test", |
| 119 | signedIn, |
| 120 | name: signedIn ? (visitor.name ?? "Test Visitor") : null, |
| 121 | email: signedIn ? (visitor.email ?? "visitor@example.com") : null, |
| 122 | }; |
| 123 | } |
| 124 |
| 125 | export function createTestWorkspace( |
| 126 | app: ColineAppV2, |
| 127 | options: TestWorkspaceOptions = {}, |
| 128 | ): TestWorkspace { |
| 129 | const state = new TestWorkspaceState(); |
| 130 | const resolved = { |
| 131 | workspaceId: options.workspaceId ?? "ws_test", |
| 132 | workspaceSlug: options.workspaceSlug ?? "test-workspace", |
| 133 | workspaceName: options.workspaceName ?? "Test Workspace", |
| 134 | appKey: options.appKey ?? app.manifest.key, |
| 135 | installId: options.installId ?? "inst_test", |
| 136 | }; |
| 137 | const members: ColineMemberSummary[] = (options.members ?? [{ userId: "user_test" }]).map( |
| 138 | (member) => ({ |
| 139 | userId: member.userId, |
| 140 | email: member.email ?? `${member.userId}@example.com`, |
| 141 | handle: member.handle ?? member.userId, |
| 142 | firstName: member.firstName ?? "Test", |
| 143 | lastName: member.lastName ?? "User", |
| 144 | avatarUrl: member.avatarUrl ?? null, |
| 145 | }), |
| 146 | ); |
| 147 | const actorUserId = members[0]?.userId ?? "user_test"; |
| 148 | for (const [key, connection] of Object.entries(options.connections ?? {})) { |
| 149 | state.connections.set(key, { |
| 150 | accountLabel: connection.accountLabel ?? null, |
| 151 | scopes: connection.scopes ?? [], |
| 152 | connectedAt: new Date().toISOString(), |
| 153 | }); |
| 154 | } |
| 155 |
| 156 | function capabilities( |
| 157 | effect: ToolEffect, |
| 158 | interactive = false, |
| 159 | appPrincipal = false, |
| 160 | userId = actorUserId, |
| 161 | readAsUserId: string | null = null, |
| 162 | ): ReturnType<typeof guardTestCapabilities> { |
| 163 | if (!appPrincipal && !members.some((member) => member.userId === userId)) |
| 164 | throw Object.assign(new Error("Actor is not a member."), { code: "actor_not_member" }); |
| 165 | const raw = createFakeCapabilities(state, { |
| 166 | ...resolved, |
| 167 | members, |
| 168 | actor: appPrincipal ? { kind: "app" } : { kind: "user", userId }, |
| 169 | onNetFetch: options.onNetFetch, |
| 170 | onAiGenerate: options.onAiGenerate, |
| 171 | aiModels: options.aiModels, |
| 172 | onAiJob: options.onAiJob, |
| 173 | pickers: options.pickers ?? {}, |
| 174 | manifest: app.manifest, |
| 175 | destructiveAllowed: effect === "destructive", |
| 176 | effect, |
| 177 | }); |
| 178 | return guardTestCapabilities( |
| 179 | raw, |
| 180 | { |
| 181 | manifestPermissions: app.manifest.permissions, |
| 182 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 183 | effectCeiling: effect, |
| 184 | }, |
| 185 | { |
| 186 | interactive, |
| 187 | appPrincipal, |
| 188 | // Like production, an event's reads are evaluated as its actor |
| 189 | // while they are still a member. |
| 190 | ...(appPrincipal && readAsUserId && members.some((member) => member.userId === readAsUserId) |
| 191 | ? { readAs: capabilities("read", false, false, readAsUserId) } |
| 192 | : {}), |
| 193 | }, |
| 194 | ); |
| 195 | } |
| 196 | const coline = capabilities("read"); |
| 197 |
| 198 | const base = { |
| 199 | coline, |
| 200 | workspace: { |
| 201 | workspaceId: resolved.workspaceId, |
| 202 | workspaceSlug: resolved.workspaceSlug, |
| 203 | workspaceName: resolved.workspaceName, |
| 204 | }, |
| 205 | app: { appKey: resolved.appKey, installId: resolved.installId }, |
| 206 | }; |
| 207 |
| 208 | // Site calls pass the same permission gate as on Coline: declared and granted. |
| 209 | const assertSiteGrant = (permission: AppPermissionV2) => { |
| 210 | const granted = options.grantedPermissions ?? app.manifest.permissions; |
| 211 | if (!app.manifest.permissions.includes(permission) || !granted.includes(permission)) { |
| 212 | throw Object.assign(new Error(`Permission "${permission}" is not granted to this app.`), { |
| 213 | code: "permission_not_granted", |
| 214 | }); |
| 215 | } |
| 216 | }; |
| 217 |
| 218 | const workspace: TestWorkspace = { |
| 219 | state, |
| 220 | client: (surface = "home") => workspace.clientAs(actorUserId, surface), |
| 221 | clientAs: (userId, surface = "home") => { |
| 222 | const client = capabilities( |
| 223 | surface === "preview" ? "read" : "destructive", |
| 224 | true, |
| 225 | false, |
| 226 | userId, |
| 227 | ); |
| 228 | return { |
| 229 | ...client, |
| 230 | tools: { |
| 231 | invoke: async (name, input = {}) => { |
| 232 | const { createCapabilityGate } = await import("../capabilities/gate"); |
| 233 | createCapabilityGate({ |
| 234 | manifestPermissions: app.manifest.permissions, |
| 235 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 236 | effectCeiling: surface === "preview" ? "read" : "destructive", |
| 237 | }).assert("tools.invoke"); |
| 238 | const result = await workspace.invokeTool(name, input, userId); |
| 239 | return { output: result.output, ...(result.card ? { card: result.card } : {}) }; |
| 240 | }, |
| 241 | }, |
| 242 | }; |
| 243 | }, |
| 244 | files: { |
| 245 | byType: (typeKey) => |
| 246 | state |
| 247 | .filesByType(typeKey) |
| 248 | .map((file) => ({ fileId: file.fileId, name: file.name, document: file.document })), |
| 249 | all: () => |
| 250 | [...state.files.values()] |
| 251 | .filter((file) => !file.trashed) |
| 252 | .map((file) => ({ fileId: file.fileId, name: file.name, typeKey: file.typeKey })), |
| 253 | }, |
| 254 |
| 255 | async invokeTool(name, input, userId = actorUserId) { |
| 256 | const tool = app.runtime.tools.get(name); |
| 257 | if (!tool) { |
| 258 | throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); |
| 259 | } |
| 260 | const parsed = tool.input.safeParse(input); |
| 261 | if (!parsed.success) { |
| 262 | throw new Error( |
| 263 | `Tool input is invalid: ${parsed.error.issues |
| 264 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 265 | .join("; ")}`, |
| 266 | ); |
| 267 | } |
| 268 | const result: ColineToolResult = await tool.execute(parsed.data as never, { |
| 269 | ...base, |
| 270 | coline: capabilities(tool.effect, false, false, userId), |
| 271 | actor: { kind: "user", userId }, |
| 272 | }); |
| 273 | let card: ColineUiNode | null = null; |
| 274 | if (result.card) { |
| 275 | const validation = validateChatCardTree(result.card); |
| 276 | if (!validation.ok) { |
| 277 | throw new Error(`Tool card rejected: ${validation.reason}`); |
| 278 | } |
| 279 | card = result.card; |
| 280 | } |
| 281 | return { output: result.output, card }; |
| 282 | }, |
| 283 |
| 284 | async renderHome() { |
| 285 | if (!app.runtime.renderHome) { |
| 286 | throw new Error(`App "${app.manifest.key}" has no tree home handler.`); |
| 287 | } |
| 288 | const context: ColineHomeRenderContextV2 = { |
| 289 | ...base, |
| 290 | actor: { kind: "user", userId: actorUserId }, |
| 291 | }; |
| 292 | return app.runtime.renderHome(context); |
| 293 | }, |
| 294 |
| 295 | async renderFileSurface(surface, file, document = {}) { |
| 296 | const runtime = app.runtime.fileTypes.get(file.typeKey); |
| 297 | const handler = |
| 298 | surface === "editor" |
| 299 | ? runtime?.renderEditor |
| 300 | : surface === "preview" |
| 301 | ? runtime?.renderPreview |
| 302 | : runtime?.renderInline; |
| 303 | if (!handler) { |
| 304 | throw new Error(`File type "${file.typeKey}" has no ${surface} handler.`); |
| 305 | } |
| 306 | const context: ColineFileRenderContextV2 = { |
| 307 | ...base, |
| 308 | actor: { kind: "user", userId: actorUserId }, |
| 309 | file: { version: 1, ...file }, |
| 310 | document, |
| 311 | }; |
| 312 | const tree = await handler(context); |
| 313 | if (surface === "inline") { |
| 314 | const validation = validateChatCardTree(tree); |
| 315 | if (!validation.ok) { |
| 316 | throw new Error(`Inline surface rejected: ${validation.reason}`); |
| 317 | } |
| 318 | } |
| 319 | return tree; |
| 320 | }, |
| 321 |
| 322 | async fireSchedule(scheduleId, at = new Date()) { |
| 323 | if (!app.runtime.onSchedule) { |
| 324 | throw new Error(`App "${app.manifest.key}" has no schedule handler.`); |
| 325 | } |
| 326 | const declared = app.manifest.triggers.schedules.find( |
| 327 | (schedule) => schedule.id === scheduleId, |
| 328 | ); |
| 329 | if (!declared) { |
| 330 | throw new Error(`Schedule "${scheduleId}" is not declared in the manifest.`); |
| 331 | } |
| 332 | const context: ColineScheduleContextV2 = { |
| 333 | coline: capabilities("write", false, true), |
| 334 | workspace: base.workspace, |
| 335 | app: base.app, |
| 336 | scheduleId, |
| 337 | firedAt: at.toISOString(), |
| 338 | }; |
| 339 | await app.runtime.onSchedule(context); |
| 340 | }, |
| 341 |
| 342 | async fireEvent(type, data = {}, eventOptions = {}) { |
| 343 | const subscribed = app.manifest.triggers.webhooks.some((subscription) => |
| 344 | eventPatternMatches(subscription.event, type), |
| 345 | ); |
| 346 | if (!subscribed) return false; |
| 347 | if (!app.runtime.onEvent) { |
| 348 | throw new Error(`App "${app.manifest.key}" has no onEvent handler.`); |
| 349 | } |
| 350 | eventCounter += 1; |
| 351 | await app.runtime.onEvent({ |
| 352 | coline: capabilities("write", false, true, actorUserId, eventOptions.actorUserId ?? null), |
| 353 | workspace: base.workspace, |
| 354 | app: base.app, |
| 355 | attempt: 1, |
| 356 | event: { |
| 357 | id: `evt_test_${eventCounter}`, |
| 358 | type, |
| 359 | occurredAt: new Date().toISOString(), |
| 360 | actor: eventOptions.actorUserId ? { kind: "user", userId: eventOptions.actorUserId } : null, |
| 361 | source: eventOptions.sourceAppKey |
| 362 | ? { kind: "app", appKey: eventOptions.sourceAppKey } |
| 363 | : { kind: "platform" }, |
| 364 | data: structuredClone(data), |
| 365 | }, |
| 366 | }); |
| 367 | return true; |
| 368 | }, |
| 369 |
| 370 | async request(endpointId, request = {}) { |
| 371 | const declared = app.manifest.endpoints.find((endpoint) => endpoint.id === endpointId); |
| 372 | const handler = app.runtime.endpoints.get(endpointId); |
| 373 | if (!declared || !handler) { |
| 374 | throw new Error(`App "${app.manifest.key}" does not define endpoint "${endpointId}".`); |
| 375 | } |
| 376 | const method = (request.method ?? declared.methods[0] ?? "POST").toUpperCase(); |
| 377 | if (!declared.methods.includes(method as never)) { |
| 378 | return { status: 405, headers: {}, body: { error: "method_not_allowed" } }; |
| 379 | } |
| 380 | const body = |
| 381 | typeof request.body === "string" |
| 382 | ? request.body |
| 383 | : request.body === undefined |
| 384 | ? "" |
| 385 | : JSON.stringify(request.body); |
| 386 | const response = toEndpointResponse( |
| 387 | await handler({ |
| 388 | coline: capabilities("write", false, true), |
| 389 | workspace: base.workspace, |
| 390 | app: base.app, |
| 391 | actor: request.visitor ? visitorRef(request.visitor) : { kind: "app" }, |
| 392 | request: { |
| 393 | endpointId, |
| 394 | method, |
| 395 | path: request.path ?? "/", |
| 396 | query: request.query ?? {}, |
| 397 | headers: Object.fromEntries( |
| 398 | Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), |
| 399 | ), |
| 400 | body, |
| 401 | bodyBase64: null, |
| 402 | receivedAt: new Date().toISOString(), |
| 403 | verifiedBy: declared.verify.kind, |
| 404 | }, |
| 405 | json: <T>() => parseEndpointJson<T>(body), |
| 406 | }), |
| 407 | ); |
| 408 | return { status: response.status, headers: response.headers, body: response.body ?? null }; |
| 409 | }, |
| 410 |
| 411 | async runJobs(runOptions = {}) { |
| 412 | const executed: TestJobRun[] = []; |
| 413 | const maxRuns = runOptions.maxRuns ?? 100; |
| 414 | while (executed.length < maxRuns) { |
| 415 | const nowMs = (runOptions.now ?? new Date()).getTime(); |
| 416 | const run = [...state.jobs.values()].find( |
| 417 | (candidate) => candidate.status === "queued" && Date.parse(candidate.runAt) <= nowMs, |
| 418 | ); |
| 419 | if (!run) break; |
| 420 | const definition = app.runtime.jobs.get(run.name); |
| 421 | const declared = app.manifest.jobs.find((job) => job.name === run.name); |
| 422 | if (!definition || !declared) { |
| 423 | run.status = "dead"; |
| 424 | run.error = { code: "unknown_job", message: `Job "${run.name}" is not defined.` }; |
| 425 | executed.push(run); |
| 426 | continue; |
| 427 | } |
| 428 | run.status = "running"; |
| 429 | run.attempt += 1; |
| 430 | run.startedAt = new Date().toISOString(); |
| 431 | const appPrincipal = run.actor.kind === "app"; |
| 432 | const coline = capabilities( |
| 433 | declared.effect, |
| 434 | false, |
| 435 | appPrincipal, |
| 436 | run.actor.kind === "user" ? run.actor.userId : actorUserId, |
| 437 | ); |
| 438 | try { |
| 439 | let payload: unknown = run.payload; |
| 440 | if (definition.payload) payload = definition.payload.parse(payload); |
| 441 | const outcome = toJobRunResult( |
| 442 | await definition.run(payload as never, { |
| 443 | coline, |
| 444 | workspace: base.workspace, |
| 445 | app: base.app, |
| 446 | actor: run.actor, |
| 447 | job: { |
| 448 | id: run.id, |
| 449 | name: run.name, |
| 450 | attempt: run.attempt, |
| 451 | maxAttempts: run.maxAttempts, |
| 452 | enqueuedAt: run.createdAt, |
| 453 | checkpoint: run.checkpoint, |
| 454 | saveCheckpoint: async (value) => { |
| 455 | run.checkpoint = structuredClone(value); |
| 456 | }, |
| 457 | progress: async (value, message) => { |
| 458 | run.progress = { value: Math.max(0, Math.min(1, value)), message: message ?? null }; |
| 459 | }, |
| 460 | }, |
| 461 | }), |
| 462 | ); |
| 463 | if (outcome.kind === "continue") { |
| 464 | run.payload = outcome.payload; |
| 465 | run.status = "queued"; |
| 466 | run.attempt = 0; |
| 467 | run.runAt = new Date(nowMs + (outcome.delaySeconds ?? 0) * 1000).toISOString(); |
| 468 | } else { |
| 469 | run.status = "succeeded"; |
| 470 | run.result = outcome.result ?? null; |
| 471 | run.finishedAt = new Date().toISOString(); |
| 472 | } |
| 473 | } catch (error) { |
| 474 | run.error = { |
| 475 | code: |
| 476 | error && typeof error === "object" && "code" in error ? String(error.code) : "job_failed", |
| 477 | message: error instanceof Error ? error.message : String(error), |
| 478 | }; |
| 479 | run.status = run.attempt >= run.maxAttempts ? "dead" : "queued"; |
| 480 | if (run.status === "dead") run.finishedAt = new Date().toISOString(); |
| 481 | } |
| 482 | executed.push(structuredClone(run)); |
| 483 | } |
| 484 | return executed; |
| 485 | }, |
| 486 |
| 487 | async invokeSiteTool(name, input, visitor) { |
| 488 | if (!app.manifest.surfaces.site?.tools.includes(name)) { |
| 489 | throw Object.assign(new Error(`Tool "${name}" is not listed in surfaces.site.tools.`), { |
| 490 | code: "site_tool_not_allowed", |
| 491 | }); |
| 492 | } |
| 493 | const tool = app.runtime.tools.get(name); |
| 494 | if (!tool) throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); |
| 495 | // Like Coline: running a tool from a site needs ai.tools granted. |
| 496 | assertSiteGrant("ai.tools"); |
| 497 | const parsed = tool.input.parse(input); |
| 498 | const actor = visitorRef(visitor); |
| 499 | const effect = siteToolEffect(tool.effect); |
| 500 | const raw = createFakeCapabilities(state, { |
| 501 | ...resolved, |
| 502 | members, |
| 503 | actor: { kind: "app" }, |
| 504 | createdBy: actor, |
| 505 | onNetFetch: options.onNetFetch, |
| 506 | onAiGenerate: options.onAiGenerate, |
| 507 | aiModels: options.aiModels, |
| 508 | onAiJob: options.onAiJob, |
| 509 | pickers: options.pickers ?? {}, |
| 510 | manifest: app.manifest, |
| 511 | destructiveAllowed: false, |
| 512 | effect, |
| 513 | }); |
| 514 | const result = await tool.execute(parsed as never, { |
| 515 | ...base, |
| 516 | coline: guardTestCapabilities( |
| 517 | raw, |
| 518 | { |
| 519 | manifestPermissions: app.manifest.permissions, |
| 520 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 521 | effectCeiling: effect, |
| 522 | }, |
| 523 | { interactive: false, appPrincipal: true }, |
| 524 | ), |
| 525 | actor, |
| 526 | }); |
| 527 | if (result.card) { |
| 528 | const validation = validateChatCardTree(result.card); |
| 529 | if (!validation.ok) throw new Error(`Tool card rejected: ${validation.reason}`); |
| 530 | } |
| 531 | return { output: result.output, card: result.card ?? null }; |
| 532 | }, |
| 533 |
| 534 | asVisitor(visitor) { |
| 535 | if (!app.manifest.surfaces.site) { |
| 536 | throw new Error(`App "${app.manifest.key}" has no site surface.`); |
| 537 | } |
| 538 | const actor = visitorRef(visitor); |
| 539 | return { |
| 540 | collection: <T>(name: string) => { |
| 541 | assertSiteGrant("storage.app"); |
| 542 | const access = app.manifest.collections[name]?.access; |
| 543 | const schema = app.runtime.collectionSchemas?.get(name); |
| 544 | return createFakeCollection<T>(state, name, actor, resolved.installId, { |
| 545 | destructiveAllowed: true, |
| 546 | ...(access ? { rules: access } : {}), |
| 547 | ...(schema ? { schema } : {}), |
| 548 | }); |
| 549 | }, |
| 550 | }; |
| 551 | }, |
| 552 | }; |
| 553 | let eventCounter = 0; |
| 554 | return workspace; |
| 555 | } |
| 556 |