From c93bc6c589bfde13e63ea13a8adea1ae29cb5bef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:38:40 +0000 Subject: [PATCH 1/3] Add Cloudflare Flue SDK adapters to cloudflare-runtime Implements four new adapters in @agent-assistant/cloudflare-runtime that leverage Cloudflare's newly announced Flue SDK and existing platform primitives: - CfShellVfsProvider: VfsProvider backed by @cloudflare/shell, wrapping the SQLite-backed virtual filesystem for read/list/grep operations - CfKvSessionStoreAdapter: SessionStoreAdapter using KVNamespace, filling the previously missing Cloudflare adapter for the sessions package - CfWorkflowSchedulerBinding: SchedulerBinding for the proactive engine using runWorkflow(), giving watch rules durable delay and retry - CfFiberTurnExecutor: ContinuationHarnessAdapter wrapping runFiber()/stash() so resumed turns survive Worker process interruptions without re-running model or tool calls All three new package dependencies (@agent-assistant/sessions, proactive, vfs) are optional peerDependencies so the main bundle stays lean for consumers that only need a subset of adapters. 82 tests pass. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_012qgiUVUBzg9YtUAPKCVwke --- package-lock.json | 42 +++- packages/cloudflare-runtime/package.json | 13 ++ .../adapters/cf-fiber-turn-executor.test.ts | 128 +++++++++++++ .../src/adapters/cf-fiber-turn-executor.ts | 72 +++++++ .../cf-kv-session-store-adapter.test.ts | 125 ++++++++++++ .../adapters/cf-kv-session-store-adapter.ts | 181 ++++++++++++++++++ .../adapters/cf-shell-vfs-provider.test.ts | 97 ++++++++++ .../src/adapters/cf-shell-vfs-provider.ts | 83 ++++++++ .../cf-workflow-scheduler-binding.test.ts | 87 +++++++++ .../adapters/cf-workflow-scheduler-binding.ts | 55 ++++++ packages/cloudflare-runtime/src/index.ts | 29 +++ 11 files changed, 906 insertions(+), 6 deletions(-) create mode 100644 packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.test.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.test.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.test.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.test.ts create mode 100644 packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.ts diff --git a/package-lock.json b/package-lock.json index 5f78043..cbd834e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3680,10 +3680,29 @@ "@agent-assistant/webhook-runtime": "^0.2.5" }, "devDependencies": { + "@agent-assistant/proactive": "^0.4.35", + "@agent-assistant/sessions": "^0.4.35", + "@agent-assistant/vfs": "^0.4.35", "@cloudflare/workers-types": "^4.20260417.1", "@types/node": "^24.6.0", "typescript": "^5.9.3", "vitest": "^3.2.4" + }, + "peerDependencies": { + "@agent-assistant/proactive": "^0.4.0", + "@agent-assistant/sessions": "^0.4.0", + "@agent-assistant/vfs": "^0.4.0" + }, + "peerDependenciesMeta": { + "@agent-assistant/proactive": { + "optional": true + }, + "@agent-assistant/sessions": { + "optional": true + }, + "@agent-assistant/vfs": { + "optional": true + } } }, "packages/cloudflare-runtime/node_modules/@agent-assistant/connectivity": { @@ -3729,12 +3748,6 @@ "resolved": "https://registry.npmjs.org/@agent-assistant/surfaces/-/surfaces-0.3.21.tgz", "integrity": "sha512-zHAIYUrwnHnQkj89ImdVwJSN6GL3xroU6rQk5lb/SzReKH6Kod8uwpEOAA+s/nQF6RQW6ts9c7sJexFxHhuN9Q==" }, - "packages/cloudflare-runtime/node_modules/@agent-assistant/vfs": { - "version": "0.2.24", - "resolved": "https://registry.npmjs.org/@agent-assistant/vfs/-/vfs-0.2.24.tgz", - "integrity": "sha512-yRT0YMMwskDg5aEvwn6iUDQZxgYqD8AtbIL3dnb+cdHkyd5hoKDi9rQiHq7Mi+yBg/s9ja4cGks9kI1pNLemQA==", - "license": "MIT" - }, "packages/cloudflare-runtime/node_modules/@agent-assistant/webhook-runtime": { "version": "0.2.22", "resolved": "https://registry.npmjs.org/@agent-assistant/webhook-runtime/-/webhook-runtime-0.2.22.tgz", @@ -5521,6 +5534,12 @@ "@agent-relay/sdk": "^4.0.22" } }, + "packages/turn-context/node_modules/@agent-assistant/harness/node_modules/@agent-assistant/vfs": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/vfs/-/vfs-0.2.24.tgz", + "integrity": "sha512-yRT0YMMwskDg5aEvwn6iUDQZxgYqD8AtbIL3dnb+cdHkyd5hoKDi9rQiHq7Mi+yBg/s9ja4cGks9kI1pNLemQA==", + "license": "MIT" + }, "packages/turn-context/node_modules/@agent-assistant/memory": { "version": "0.2.24", "resolved": "https://registry.npmjs.org/@agent-assistant/memory/-/memory-0.2.24.tgz", @@ -5536,6 +5555,17 @@ "integrity": "sha512-oopf1b1qO3PS9Yp+XJZFH6r7mSdKMfsdqQBz+pSFI9pcJacL0j1Jo8ECmmzwOP+3kUcudmK9SerLMQIs64Qh0Q==", "license": "MIT" }, + "packages/turn-context/node_modules/@agent-assistant/turn-context": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@agent-assistant/turn-context/-/turn-context-0.3.21.tgz", + "integrity": "sha512-QBM/pgl2Z9L95nlnI8P5U3w4ivDG1IhV9UNle+cz0edEDcfITmzTuxqTwSkcjt3ODHCBssHI5XF6dN6f0g2ECQ==", + "license": "MIT", + "dependencies": { + "@agent-assistant/harness": "^0.4.0 || ^0.6.0", + "@agent-assistant/memory": "^0.2.0", + "@agent-assistant/traits": "^0.2.0" + } + }, "packages/turn-context/node_modules/@agent-relay/config": { "version": "4.0.40", "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-4.0.40.tgz", diff --git a/packages/cloudflare-runtime/package.json b/packages/cloudflare-runtime/package.json index 003f6ce..258cc83 100644 --- a/packages/cloudflare-runtime/package.json +++ b/packages/cloudflare-runtime/package.json @@ -22,7 +22,20 @@ "@agent-assistant/surfaces": "^0.3.0", "@agent-assistant/webhook-runtime": "^0.2.5" }, + "peerDependencies": { + "@agent-assistant/proactive": "^0.4.0", + "@agent-assistant/sessions": "^0.4.0", + "@agent-assistant/vfs": "^0.4.0" + }, + "peerDependenciesMeta": { + "@agent-assistant/proactive": { "optional": true }, + "@agent-assistant/sessions": { "optional": true }, + "@agent-assistant/vfs": { "optional": true } + }, "devDependencies": { + "@agent-assistant/proactive": "^0.4.35", + "@agent-assistant/sessions": "^0.4.35", + "@agent-assistant/vfs": "^0.4.35", "@cloudflare/workers-types": "^4.20260417.1", "@types/node": "^24.6.0", "typescript": "^5.9.3", diff --git a/packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.test.ts b/packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.test.ts new file mode 100644 index 0000000..33f13b9 --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ContinuationHarnessAdapter, ContinuationResumedTurnInput } from '@agent-assistant/continuation'; +import type { HarnessResult } from '@agent-assistant/harness'; +import { CfFiberTurnExecutor, type FiberContextLike, type RunFiberFn } from './cf-fiber-turn-executor.js'; +import type { DurableObjectStorageLike } from './cf-continuation-store.js'; + +function makeStorage(): DurableObjectStorageLike { + const map = new Map(); + return { + async get(key: string) { return map.get(key) as T | undefined; }, + async put(key: string, value: T) { map.set(key, value); }, + async delete(key: string) { map.delete(key); return true; }, + async list() { return new Map(); }, + }; +} + +function makeRunFiber(): RunFiberFn { + return async (_storage: DurableObjectStorageLike, fn: (ctx: FiberContextLike) => Promise): Promise => { + const stash = new Map(); + const ctx: FiberContextLike = { + stash(key: string, value: V) { stash.set(key, value); }, + stashed(key: string): V | undefined { return stash.get(key) as V | undefined; }, + }; + return fn(ctx); + }; +} + +const successResult: HarnessResult = { + outcome: 'complete', + stopReason: 'end_turn', + outputMessages: [], +} as unknown as HarnessResult; + +function makeInput(resumedTurnId = 'turn-abc'): ContinuationResumedTurnInput { + return { + resumedTurnId, + continuation: {} as never, + trigger: { type: 'user_reply', message: {} as never, receivedAt: new Date().toISOString() }, + }; +} + +describe('CfFiberTurnExecutor', () => { + it('delegates to the inner harness adapter', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockResolvedValue(successResult), + }; + const executor = new CfFiberTurnExecutor({ + inner, + storage: makeStorage(), + runFiber: makeRunFiber(), + }); + const result = await executor.runResumedTurn(makeInput()); + expect(result).toBe(successResult); + expect(inner.runResumedTurn).toHaveBeenCalledOnce(); + }); + + it('returns stashed result without calling inner on recovery', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockResolvedValue(successResult), + }; + + const stash = new Map(); + // Simulate a fiber that already has a stashed result (recovery scenario) + const recoveryRunFiber: RunFiberFn = async ( + _storage: DurableObjectStorageLike, + fn: (ctx: FiberContextLike) => Promise, + ): Promise => { + const ctx: FiberContextLike = { + stash(key: string, value: V) { stash.set(key, value); }, + stashed(key: string): V | undefined { return stash.get(key) as V | undefined; }, + }; + return fn(ctx); + }; + + // Pre-populate the stash with the result for this turn + stash.set('turn:turn-abc', successResult); + + const executor = new CfFiberTurnExecutor({ + inner, + storage: makeStorage(), + runFiber: recoveryRunFiber, + }); + + const result = await executor.runResumedTurn(makeInput('turn-abc')); + expect(result).toBe(successResult); + expect(inner.runResumedTurn).not.toHaveBeenCalled(); + }); + + it('stashes result after a successful turn', async () => { + const stash = new Map(); + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockResolvedValue(successResult), + }; + + const trackingRunFiber: RunFiberFn = async ( + _storage: DurableObjectStorageLike, + fn: (ctx: FiberContextLike) => Promise, + ): Promise => { + const ctx: FiberContextLike = { + stash(key: string, value: V) { stash.set(key, value); }, + stashed(key: string): V | undefined { return stash.get(key) as V | undefined; }, + }; + return fn(ctx); + }; + + const executor = new CfFiberTurnExecutor({ + inner, + storage: makeStorage(), + runFiber: trackingRunFiber, + }); + + await executor.runResumedTurn(makeInput('turn-xyz')); + expect(stash.has('turn:turn-xyz')).toBe(true); + expect(stash.get('turn:turn-xyz')).toBe(successResult); + }); + + it('propagates errors from the inner harness', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockRejectedValue(new Error('harness failed')), + }; + const executor = new CfFiberTurnExecutor({ + inner, + storage: makeStorage(), + runFiber: makeRunFiber(), + }); + await expect(executor.runResumedTurn(makeInput())).rejects.toThrow('harness failed'); + }); +}); diff --git a/packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.ts b/packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.ts new file mode 100644 index 0000000..a45d078 --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-fiber-turn-executor.ts @@ -0,0 +1,72 @@ +import type { + ContinuationHarnessAdapter, + ContinuationResumedTurnInput, +} from '@agent-assistant/continuation'; +import type { HarnessResult } from '@agent-assistant/harness'; + +import type { DurableObjectStorageLike } from './cf-continuation-store.js'; + +/** + * Minimal interface for the Cloudflare Flue SDK fiber context. + * Provided by `runFiber()` — each key maps to a SQLite-backed checkpoint slot. + */ +export interface FiberContextLike { + stash(key: string, value: T): void; + stashed(key: string): T | undefined; +} + +/** + * Signature for Cloudflare's `runFiber()` from the Flue SDK. + * Wraps an async function in a durable fiber that checkpoints to SQLite via + * the provided Durable Object storage. If the Worker process is interrupted, + * `runFiber` re-invokes `fn` with the same FiberContext — stashed values are + * restored from SQLite so completed work is not repeated. + */ +export type RunFiberFn = ( + storage: DurableObjectStorageLike, + fn: (ctx: FiberContextLike) => Promise, +) => Promise; + +export interface CfFiberTurnExecutorOptions { + /** The underlying harness adapter that actually executes resumed turns. */ + inner: ContinuationHarnessAdapter; + /** Durable Object storage used by runFiber for checkpointing. */ + storage: DurableObjectStorageLike; + /** + * The Cloudflare Flue `runFiber` function. + * Pass `runFiber` imported from `@cloudflare/agents` (or equivalent). + */ + runFiber: RunFiberFn; +} + +/** + * ContinuationHarnessAdapter that wraps turn execution in a Cloudflare fiber. + * + * Once a resumed turn completes its result is stashed to SQLite. If the + * Worker crashes after the turn finishes but before the result is persisted to + * the continuation store, the next invocation recovers the stashed result + * immediately — no model or tool calls are re-issued. + */ +export class CfFiberTurnExecutor implements ContinuationHarnessAdapter { + private readonly inner: ContinuationHarnessAdapter; + private readonly storage: DurableObjectStorageLike; + private readonly runFiber: RunFiberFn; + + constructor(options: CfFiberTurnExecutorOptions) { + this.inner = options.inner; + this.storage = options.storage; + this.runFiber = options.runFiber; + } + + async runResumedTurn(input: ContinuationResumedTurnInput): Promise { + return this.runFiber(this.storage, async (ctx) => { + const key = `turn:${input.resumedTurnId}`; + const stashed = ctx.stashed(key); + if (stashed !== undefined) return stashed; + + const result = await this.inner.runResumedTurn(input); + ctx.stash(key, result); + return result; + }); + } +} diff --git a/packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.test.ts b/packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.test.ts new file mode 100644 index 0000000..b5d1609 --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from '@agent-assistant/sessions'; +import { CfKvSessionStoreAdapter } from './cf-kv-session-store-adapter.js'; + +function makeKv(): { store: Map; ns: Parameters[0]['kv'] } { + const store = new Map(); + const ns = { + async get(key: string) { + return store.get(key) ?? null; + }, + async put(key: string, value: string) { + store.set(key, value); + }, + async delete(key: string) { + store.delete(key); + }, + async list(options?: { prefix?: string }) { + const prefix = options?.prefix ?? ''; + const keys = [...store.keys()] + .filter((k) => k.startsWith(prefix)) + .map((name) => ({ name })); + return { keys, list_complete: true, cursor: '' }; + }, + } as unknown as Parameters[0]['kv']; + return { store, ns }; +} + +function session(overrides: Partial = {}): Session { + return { + id: 'sess-1', + userId: 'user-1', + state: 'active', + createdAt: '2026-01-01T00:00:00Z', + lastActivityAt: '2026-01-01T00:00:00Z', + attachedSurfaces: [], + metadata: {}, + ...overrides, + }; +} + +describe('CfKvSessionStoreAdapter', () => { + it('inserts and fetches a session', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session()); + const fetched = await adapter.fetchById('sess-1'); + expect(fetched?.id).toBe('sess-1'); + }); + + it('throws SessionConflictError on duplicate insert', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session()); + await expect(adapter.insert(session())).rejects.toMatchObject({ name: 'SessionConflictError' }); + }); + + it('returns null for missing session', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + expect(await adapter.fetchById('nope')).toBeNull(); + }); + + it('updates a session', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session()); + const updated = await adapter.update('sess-1', { state: 'suspended' }); + expect(updated.state).toBe('suspended'); + expect((await adapter.fetchById('sess-1'))?.state).toBe('suspended'); + }); + + it('throws SessionNotFoundError when updating missing session', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await expect(adapter.update('nope', { state: 'expired' })).rejects.toMatchObject({ + name: 'SessionNotFoundError', + }); + }); + + it('deletes a session', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session()); + await adapter.delete('sess-1'); + expect(await adapter.fetchById('sess-1')).toBeNull(); + }); + + it('fetches many by userId', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session({ id: 'a', userId: 'u1' })); + await adapter.insert(session({ id: 'b', userId: 'u2' })); + const results = await adapter.fetchMany({ userId: 'u1' }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('a'); + }); + + it('filters fetchMany by state', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session({ id: 'a', userId: 'u1', state: 'active' })); + await adapter.insert(session({ id: 'b', userId: 'u1', state: 'suspended' })); + const results = await adapter.fetchMany({ userId: 'u1', state: 'active' }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('a'); + }); + + it('applies limit to fetchMany', async () => { + const { ns } = makeKv(); + const adapter = new CfKvSessionStoreAdapter({ kv: ns }); + await adapter.insert(session({ id: 'a', userId: 'u1' })); + await adapter.insert(session({ id: 'b', userId: 'u1' })); + const results = await adapter.fetchMany({ userId: 'u1', limit: 1 }); + expect(results).toHaveLength(1); + }); + + it('respects key prefix to isolate namespaces', async () => { + const { ns } = makeKv(); + const a = new CfKvSessionStoreAdapter({ kv: ns, prefix: 'tenant-a:' }); + const b = new CfKvSessionStoreAdapter({ kv: ns, prefix: 'tenant-b:' }); + await a.insert(session({ id: 'shared-id', userId: 'u' })); + expect(await b.fetchById('shared-id')).toBeNull(); + expect(await a.fetchById('shared-id')).not.toBeNull(); + }); +}); diff --git a/packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.ts b/packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.ts new file mode 100644 index 0000000..f79b331 --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-kv-session-store-adapter.ts @@ -0,0 +1,181 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import type { Session, SessionConflictError, SessionNotFoundError, SessionQuery, SessionStoreAdapter } from '@agent-assistant/sessions'; + +const SESSION_KEY_PREFIX = 'session:'; +const USER_INDEX_PREFIX = 'user:'; +const WORKSPACE_INDEX_PREFIX = 'workspace:'; + +export interface CfKvSessionStoreAdapterOptions { + kv: KVNamespace; + /** Key prefix for all entries. Useful when the namespace is shared. Default: ''. */ + prefix?: string; +} + +export class CfKvSessionStoreAdapter implements SessionStoreAdapter { + private readonly kv: KVNamespace; + private readonly prefix: string; + + constructor(options: CfKvSessionStoreAdapterOptions) { + this.kv = options.kv; + this.prefix = options.prefix ?? ''; + } + + async insert(session: Session): Promise { + const existing = await this.kv.get(this.sessionKey(session.id)); + if (existing !== null) { + // Throw without importing the error class — same message shape the store expects. + const err = new Error(`Session already exists: ${session.id}`) as Error & { name: string; sessionId: string }; + err.name = 'SessionConflictError'; + err.sessionId = session.id; + throw err; + } + + await Promise.all([ + this.kv.put(this.sessionKey(session.id), JSON.stringify(session)), + this.addToIndex(this.userIndexKey(session.userId), session.id), + session.workspaceId + ? this.addToIndex(this.workspaceIndexKey(session.workspaceId), session.id) + : Promise.resolve(), + ]); + } + + async fetchById(sessionId: string): Promise { + const raw = await this.kv.get(this.sessionKey(sessionId)); + if (raw === null) return null; + return JSON.parse(raw) as Session; + } + + async fetchMany(query: SessionQuery): Promise { + let sessions: Session[]; + + if (query.userId) { + sessions = await this.loadFromIndex(this.userIndexKey(query.userId)); + } else if (query.workspaceId) { + sessions = await this.loadFromIndex(this.workspaceIndexKey(query.workspaceId)); + } else { + sessions = await this.scanAll(); + } + + sessions = this.applyFilters(sessions, query); + + if (query.limit != null) { + sessions = sessions.slice(0, query.limit); + } + + return sessions; + } + + async update(sessionId: string, patch: Partial): Promise { + const existing = await this.fetchById(sessionId); + if (!existing) { + const err = new Error(`Session not found: ${sessionId}`) as Error & { name: string; sessionId: string }; + err.name = 'SessionNotFoundError'; + err.sessionId = sessionId; + throw err; + } + + const updated: Session = { ...existing, ...patch }; + + const writes: Promise[] = [ + this.kv.put(this.sessionKey(sessionId), JSON.stringify(updated)), + ]; + + if (patch.userId && patch.userId !== existing.userId) { + writes.push( + this.removeFromIndex(this.userIndexKey(existing.userId), sessionId), + this.addToIndex(this.userIndexKey(patch.userId), sessionId), + ); + } + + if ('workspaceId' in patch) { + if (existing.workspaceId && patch.workspaceId !== existing.workspaceId) { + writes.push(this.removeFromIndex(this.workspaceIndexKey(existing.workspaceId), sessionId)); + } + if (patch.workspaceId) { + writes.push(this.addToIndex(this.workspaceIndexKey(patch.workspaceId), sessionId)); + } + } + + await Promise.all(writes); + return updated; + } + + async delete(sessionId: string): Promise { + const session = await this.fetchById(sessionId); + if (!session) return; + + await Promise.all([ + this.kv.delete(this.sessionKey(sessionId)), + this.removeFromIndex(this.userIndexKey(session.userId), sessionId), + session.workspaceId + ? this.removeFromIndex(this.workspaceIndexKey(session.workspaceId), sessionId) + : Promise.resolve(), + ]); + } + + // ─── Index helpers ────────────────────────────────────────────────────────── + + private async loadFromIndex(indexKey: string): Promise { + const raw = await this.kv.get(indexKey); + if (!raw) return []; + const ids = JSON.parse(raw) as string[]; + const sessions = await Promise.all(ids.map((id) => this.fetchById(id))); + return sessions.filter((s): s is Session => s !== null); + } + + private async addToIndex(indexKey: string, sessionId: string): Promise { + const raw = await this.kv.get(indexKey); + const ids: string[] = raw ? (JSON.parse(raw) as string[]) : []; + if (!ids.includes(sessionId)) { + ids.push(sessionId); + await this.kv.put(indexKey, JSON.stringify(ids)); + } + } + + private async removeFromIndex(indexKey: string, sessionId: string): Promise { + const raw = await this.kv.get(indexKey); + if (!raw) return; + const ids = (JSON.parse(raw) as string[]).filter((id) => id !== sessionId); + if (ids.length === 0) { + await this.kv.delete(indexKey); + } else { + await this.kv.put(indexKey, JSON.stringify(ids)); + } + } + + private async scanAll(): Promise { + const listed = await this.kv.list({ prefix: `${this.prefix}${SESSION_KEY_PREFIX}` }); + const sessions = await Promise.all( + listed.keys.map((key) => this.kv.get(key.name).then((raw) => (raw ? (JSON.parse(raw) as Session) : null))), + ); + return sessions.filter((s): s is Session => s !== null); + } + + private applyFilters(sessions: Session[], query: SessionQuery): Session[] { + return sessions.filter((s) => { + if (query.workspaceId && s.workspaceId !== query.workspaceId) return false; + if (query.userId && s.userId !== query.userId) return false; + if (query.state) { + const states = Array.isArray(query.state) ? query.state : [query.state]; + if (!states.includes(s.state)) return false; + } + if (query.surfaceId && !s.attachedSurfaces.includes(query.surfaceId)) return false; + if (query.activeAfter && s.lastActivityAt <= query.activeAfter) return false; + return true; + }); + } + + // ─── Key builders ─────────────────────────────────────────────────────────── + + private sessionKey(id: string): string { + return `${this.prefix}${SESSION_KEY_PREFIX}${id}`; + } + + private userIndexKey(userId: string): string { + return `${this.prefix}${USER_INDEX_PREFIX}${userId}:sessions`; + } + + private workspaceIndexKey(workspaceId: string): string { + return `${this.prefix}${WORKSPACE_INDEX_PREFIX}${workspaceId}:sessions`; + } +} diff --git a/packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.test.ts b/packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.test.ts new file mode 100644 index 0000000..8c7355f --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { CfShellVfsProvider, type CloudflareShellLike } from './cf-shell-vfs-provider.js'; + +function makeShell(files: Record): CloudflareShellLike { + return { + async read(path) { + return files[path] ?? null; + }, + async write(path, content) { + files[path] = content; + }, + async delete(path) { + delete files[path]; + }, + async list(prefix) { + return Object.keys(files).filter((p) => p.startsWith(prefix)); + }, + async grep(pattern, options) { + const regex = new RegExp(pattern); + const searchPath = options?.path ?? ''; + const matches: Array<{ path: string; content: string; lineNumber?: number }> = []; + for (const [p, content] of Object.entries(files)) { + if (searchPath && !p.startsWith(searchPath)) continue; + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + matches.push({ path: p, content: lines[i], lineNumber: i + 1 }); + if (options?.maxResults && matches.length >= options.maxResults) return matches; + } + } + } + return matches; + }, + }; +} + +describe('CfShellVfsProvider', () => { + it('reads a file', async () => { + const provider = new CfShellVfsProvider({ + shell: makeShell({ 'src/index.ts': 'export {}' }), + }); + const result = await provider.read('src/index.ts'); + expect(result).not.toBeNull(); + expect(result?.content).toBe('export {}'); + expect(result?.provider).toBe('cf-shell'); + }); + + it('returns null for missing file', async () => { + const provider = new CfShellVfsProvider({ shell: makeShell({}) }); + expect(await provider.read('missing.ts')).toBeNull(); + }); + + it('lists files under a path', async () => { + const provider = new CfShellVfsProvider({ + shell: makeShell({ 'src/a.ts': '', 'src/b.ts': '', 'tests/c.ts': '' }), + }); + const entries = await provider.list('src'); + expect(entries.map((e) => e.path)).toEqual(expect.arrayContaining(['src/a.ts', 'src/b.ts'])); + expect(entries.find((e) => e.path === 'tests/c.ts')).toBeUndefined(); + }); + + it('respects list limit', async () => { + const provider = new CfShellVfsProvider({ + shell: makeShell({ 'a.ts': '', 'b.ts': '', 'c.ts': '' }), + }); + const entries = await provider.list('', { limit: 2 }); + expect(entries).toHaveLength(2); + }); + + it('searches with grep', async () => { + const provider = new CfShellVfsProvider({ + shell: makeShell({ 'a.ts': 'hello world\nfoo bar', 'b.ts': 'hello there' }), + }); + const results = await provider.search('hello'); + expect(results).toHaveLength(2); + expect(results.every((r) => r.provider === 'cf-shell')).toBe(true); + }); + + it('applies prefix to all operations', async () => { + const files: Record = { 'ws1/src/index.ts': 'content' }; + const provider = new CfShellVfsProvider({ + shell: makeShell(files), + prefix: 'ws1', + }); + const result = await provider.read('src/index.ts'); + expect(result?.content).toBe('content'); + expect(result?.path).toBe('src/index.ts'); + }); + + it('stat returns entry for existing file, null for missing', async () => { + const provider = new CfShellVfsProvider({ + shell: makeShell({ 'README.md': '# hi' }), + }); + expect(await provider.stat('README.md')).not.toBeNull(); + expect(await provider.stat('MISSING.md')).toBeNull(); + }); +}); diff --git a/packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.ts b/packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.ts new file mode 100644 index 0000000..c72765b --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-shell-vfs-provider.ts @@ -0,0 +1,83 @@ +import type { VfsEntry, VfsListOptions, VfsProvider, VfsReadResult, VfsSearchOptions, VfsSearchResult } from '@agent-assistant/vfs'; + +export interface CloudflareShellLike { + read(path: string): Promise; + write(path: string, content: string): Promise; + delete(path: string): Promise; + list(path: string): Promise; + grep( + pattern: string, + options?: { path?: string; maxResults?: number }, + ): Promise>; +} + +export interface CfShellVfsProviderOptions { + shell: CloudflareShellLike; + /** Path prefix prepended to every shell operation. Useful for namespacing by workspace. */ + prefix?: string; +} + +export class CfShellVfsProvider implements VfsProvider { + private readonly shell: CloudflareShellLike; + private readonly prefix: string; + + constructor(options: CfShellVfsProviderOptions) { + this.shell = options.shell; + this.prefix = options.prefix ?? ''; + } + + async list(path: string, options?: VfsListOptions): Promise { + const fullPath = this.resolve(path); + const paths = await this.shell.list(fullPath); + const entries: VfsEntry[] = paths.map((p) => ({ + path: this.strip(p), + type: p.endsWith('/') ? ('dir' as const) : ('file' as const), + provider: 'cf-shell', + })); + + const limit = options?.limit; + return limit != null ? entries.slice(0, limit) : entries; + } + + async read(path: string): Promise { + const content = await this.shell.read(this.resolve(path)); + if (content === null) return null; + return { + path, + content, + contentType: 'text/plain', + encoding: 'utf-8', + provider: 'cf-shell', + }; + } + + async search(query: string, options?: VfsSearchOptions): Promise { + const results = await this.shell.grep(query, { + path: this.prefix || undefined, + maxResults: options?.limit, + }); + return results.map((r) => ({ + path: this.strip(r.path), + type: 'file' as const, + snippet: r.content, + provider: 'cf-shell', + })); + } + + async stat(path: string): Promise { + const content = await this.shell.read(this.resolve(path)); + if (content === null) return null; + return { path, type: 'file', provider: 'cf-shell' }; + } + + private resolve(path: string): string { + if (!this.prefix) return path; + return `${this.prefix}/${path}`.replace(/\/+/g, '/'); + } + + private strip(path: string): string { + if (!this.prefix) return path; + const stripped = path.startsWith(this.prefix) ? path.slice(this.prefix.length) : path; + return stripped.startsWith('/') ? stripped.slice(1) : stripped; + } +} diff --git a/packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.test.ts b/packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.test.ts new file mode 100644 index 0000000..1fed99b --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { WakeUpContext } from '@agent-assistant/proactive'; +import { + CfWorkflowSchedulerBinding, + type CloudflareWorkflowBindingLike, + type WorkflowInstanceLike, +} from './cf-workflow-scheduler-binding.js'; + +function makeBinding(): { + instances: Map; + binding: CloudflareWorkflowBindingLike; +} { + const instances = new Map(); + + const binding: CloudflareWorkflowBindingLike = { + async create(options) { + const id = options.id ?? crypto.randomUUID(); + const instance: WorkflowInstanceLike = { id, terminate: vi.fn().mockResolvedValue(undefined) }; + instances.set(id, instance); + return instance; + }, + async get(id) { + const instance = instances.get(id); + if (!instance) throw new Error(`Not found: ${id}`); + return instance; + }, + }; + + return { instances, binding }; +} + +const baseContext: WakeUpContext = { + sessionId: 'sess-1', + scheduledAt: '2026-01-01T00:00:00Z', +}; + +describe('CfWorkflowSchedulerBinding', () => { + it('creates a workflow instance and returns its id', async () => { + const { instances, binding } = makeBinding(); + const scheduler = new CfWorkflowSchedulerBinding({ binding }); + + const at = new Date('2026-01-02T12:00:00Z'); + const id = await scheduler.requestWakeUp(at, baseContext); + + expect(id).toBeTruthy(); + expect(instances.has(id)).toBe(true); + }); + + it('encodes sessionId and ruleId in the instance id', async () => { + const { binding } = makeBinding(); + const scheduler = new CfWorkflowSchedulerBinding({ binding }); + + const ctx: WakeUpContext = { ...baseContext, ruleId: 'my-rule' }; + const id = await scheduler.requestWakeUp(new Date('2026-06-01T00:00:00Z'), ctx); + + expect(id).toContain('sess-1'); + expect(id).toContain('my-rule'); + }); + + it('terminates the workflow instance on cancelWakeUp', async () => { + const { instances, binding } = makeBinding(); + const scheduler = new CfWorkflowSchedulerBinding({ binding }); + + const id = await scheduler.requestWakeUp(new Date(), baseContext); + await scheduler.cancelWakeUp(id); + + expect(instances.get(id)?.terminate).toHaveBeenCalledOnce(); + }); + + it('is a no-op when cancelling a non-existent bindingId', async () => { + const { binding } = makeBinding(); + const scheduler = new CfWorkflowSchedulerBinding({ binding }); + + // Should not throw even though the instance doesn't exist + await expect(scheduler.cancelWakeUp('nonexistent-id')).resolves.toBeUndefined(); + }); + + it('generates unique ids for different wakeUp times', async () => { + const { binding } = makeBinding(); + const scheduler = new CfWorkflowSchedulerBinding({ binding }); + + const id1 = await scheduler.requestWakeUp(new Date('2026-01-01T00:00:00Z'), baseContext); + const id2 = await scheduler.requestWakeUp(new Date('2026-01-02T00:00:00Z'), baseContext); + + expect(id1).not.toBe(id2); + }); +}); diff --git a/packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.ts b/packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.ts new file mode 100644 index 0000000..407a33c --- /dev/null +++ b/packages/cloudflare-runtime/src/adapters/cf-workflow-scheduler-binding.ts @@ -0,0 +1,55 @@ +import type { SchedulerBinding, WakeUpContext } from '@agent-assistant/proactive'; + +export interface WorkflowInstanceLike { + readonly id: string; + terminate(): Promise; +} + +export interface CloudflareWorkflowBindingLike { + create(options: { id?: string; params?: Record }): Promise; + get(id: string): Promise; +} + +export interface CfWorkflowSchedulerBindingOptions { + binding: CloudflareWorkflowBindingLike; +} + +/** + * SchedulerBinding for @agent-assistant/proactive backed by Cloudflare Workflows. + * + * Each wake-up becomes a Workflow instance. The Workflow itself must be + * authored by the consumer — it receives { wakeAt, context } as params, + * sleeps until wakeAt, then invokes the appropriate handler. This adapter + * only manages instance lifecycle (create / terminate). + */ +export class CfWorkflowSchedulerBinding implements SchedulerBinding { + private readonly binding: CloudflareWorkflowBindingLike; + + constructor(options: CfWorkflowSchedulerBindingOptions) { + this.binding = options.binding; + } + + async requestWakeUp(at: Date, context: WakeUpContext): Promise { + const instanceId = buildInstanceId(context, at); + await this.binding.create({ + id: instanceId, + params: { wakeAt: at.toISOString(), context }, + }); + return instanceId; + } + + async cancelWakeUp(bindingId: string): Promise { + try { + const instance = await this.binding.get(bindingId); + await instance.terminate(); + } catch { + // Already terminated, not found, or the Workflow completed before we cancelled. + // All treated as no-op — the SchedulerBinding contract allows this. + } + } +} + +function buildInstanceId(context: WakeUpContext, at: Date): string { + const ruleSegment = context.ruleId ? `:${context.ruleId}` : ''; + return `wakeup:${context.sessionId}${ruleSegment}:${at.getTime()}`; +} diff --git a/packages/cloudflare-runtime/src/index.ts b/packages/cloudflare-runtime/src/index.ts index 266d4ab..b3c0e0e 100644 --- a/packages/cloudflare-runtime/src/index.ts +++ b/packages/cloudflare-runtime/src/index.ts @@ -71,6 +71,35 @@ export type { SpecialistCallInput, SpecialistResultInput, } from './adapters/cf-specialist-client.js'; +export { + CfShellVfsProvider, +} from './adapters/cf-shell-vfs-provider.js'; +export type { + CloudflareShellLike, + CfShellVfsProviderOptions, +} from './adapters/cf-shell-vfs-provider.js'; +export { + CfKvSessionStoreAdapter, +} from './adapters/cf-kv-session-store-adapter.js'; +export type { + CfKvSessionStoreAdapterOptions, +} from './adapters/cf-kv-session-store-adapter.js'; +export { + CfWorkflowSchedulerBinding, +} from './adapters/cf-workflow-scheduler-binding.js'; +export type { + CloudflareWorkflowBindingLike, + CfWorkflowSchedulerBindingOptions, + WorkflowInstanceLike, +} from './adapters/cf-workflow-scheduler-binding.js'; +export { + CfFiberTurnExecutor, +} from './adapters/cf-fiber-turn-executor.js'; +export type { + FiberContextLike, + RunFiberFn, + CfFiberTurnExecutorOptions, +} from './adapters/cf-fiber-turn-executor.js'; export type { CfBindingsShape, SpecialistCallQueueMessage, From a5beefb810e950ec1068fa88e6b7a9793d1814b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:45:15 +0000 Subject: [PATCH 2/3] Add @agent-assistant/gcp-runtime package with GCP adapter implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds a new package mirroring the structure of cloudflare-runtime, giving agent-assistant deployments on Google Cloud the same adapter coverage as the Cloudflare path. Six adapters, all tested against in-memory fakes of the GCP SDK interfaces: - GcpFirestoreSessionStoreAdapter: SessionStoreAdapter backed by Firestore — uses create() for conflict-safe inserts, where() for efficient userId/workspaceId queries, in-memory filtering for the rest - GcpFirestoreContinuationStore: ContinuationStore backed by Firestore including findByTrigger() via field path queries on waitFor.approvalId, waitFor.operationId, and waitFor.wakeUpId - GcpCloudTasksSchedulerBinding: SchedulerBinding for @agent-assistant/proactive using Cloud Tasks HTTP push tasks with scheduleTime for deferred delivery; supports OIDC auth via serviceAccountEmail - GcpCloudTasksContinuationScheduler: ContinuationSchedulerAdapter using Cloud Tasks — encodes continuationId + trigger in the task body for the consumer's resume endpoint to parse - GcpStorageVfsProvider: VfsProvider backed by Google Cloud Storage with in-memory grep for search (consumers should front with a search index for high-cardinality buckets) - GcpIdempotentTurnExecutor: ContinuationHarnessAdapter equivalent to CfFiberTurnExecutor — stashes HarnessResult to Firestore after first completion so recovered Workers never re-run model/tool calls All GCP SDK dependencies are typed via minimal local interfaces so the package ships zero GCP SDK bytes; consumers pass real SDK objects that satisfy those interfaces. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_012qgiUVUBzg9YtUAPKCVwke --- package-lock.json | 46 +++++ package.json | 1 + packages/gcp-runtime/package.json | 51 ++++++ ...cloud-tasks-continuation-scheduler.test.ts | 81 +++++++++ .../gcp-cloud-tasks-continuation-scheduler.ts | 106 ++++++++++++ .../gcp-cloud-tasks-scheduler-binding.test.ts | 106 ++++++++++++ .../gcp-cloud-tasks-scheduler-binding.ts | 118 +++++++++++++ .../gcp-firestore-continuation-store.test.ts | 137 +++++++++++++++ .../gcp-firestore-continuation-store.ts | 74 ++++++++ ...cp-firestore-session-store-adapter.test.ts | 158 ++++++++++++++++++ .../gcp-firestore-session-store-adapter.ts | 133 +++++++++++++++ .../gcp-idempotent-turn-executor.test.ts | 108 ++++++++++++ .../adapters/gcp-idempotent-turn-executor.ts | 51 ++++++ .../adapters/gcp-storage-vfs-provider.test.ts | 93 +++++++++++ .../src/adapters/gcp-storage-vfs-provider.ts | 120 +++++++++++++ packages/gcp-runtime/src/index.ts | 46 +++++ packages/gcp-runtime/tsconfig.json | 21 +++ packages/gcp-runtime/vitest.config.ts | 7 + 18 files changed, 1457 insertions(+) create mode 100644 packages/gcp-runtime/package.json create mode 100644 packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.test.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.test.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.test.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.test.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.test.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.test.ts create mode 100644 packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.ts create mode 100644 packages/gcp-runtime/src/index.ts create mode 100644 packages/gcp-runtime/tsconfig.json create mode 100644 packages/gcp-runtime/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index cbd834e..1210683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "packages/telemetry", "packages/examples", "packages/cloudflare-runtime", + "packages/gcp-runtime", "packages/webhook-runtime" ], "dependencies": { @@ -68,6 +69,10 @@ "resolved": "packages/examples", "link": true }, + "node_modules/@agent-assistant/gcp-runtime": { + "resolved": "packages/gcp-runtime", + "link": true + }, "node_modules/@agent-assistant/harness": { "resolved": "packages/harness", "link": true @@ -423,6 +428,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -4098,6 +4104,46 @@ "typescript": "^5.9.3" } }, + "packages/gcp-runtime": { + "name": "@agent-assistant/gcp-runtime", + "version": "0.4.35", + "dependencies": { + "@agent-assistant/continuation": "^0.3.4" + }, + "devDependencies": { + "@agent-assistant/harness": "^0.4.35", + "@agent-assistant/proactive": "^0.4.35", + "@agent-assistant/sessions": "^0.4.35", + "@agent-assistant/vfs": "^0.4.35", + "@types/node": "^24.6.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "@agent-assistant/proactive": "^0.4.0", + "@agent-assistant/sessions": "^0.4.0", + "@agent-assistant/vfs": "^0.4.0" + }, + "peerDependenciesMeta": { + "@agent-assistant/proactive": { + "optional": true + }, + "@agent-assistant/sessions": { + "optional": true + }, + "@agent-assistant/vfs": { + "optional": true + } + } + }, + "packages/gcp-runtime/node_modules/@agent-assistant/continuation": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@agent-assistant/continuation/-/continuation-0.3.21.tgz", + "integrity": "sha512-Jh1L7OuKo5Ep5Ss5ZfL3NyBYDrH1Uyp97GfF+Mjk2oW4J3Wr+kYL6jg1TgUt6+iJT8peJd7wcYgPhmjzrftv8w==", + "dependencies": { + "@agent-assistant/harness": "^0.4.0 || ^0.6.0" + } + }, "packages/github-vfs": { "name": "@agent-assistant/github-vfs", "version": "0.1.0", diff --git a/package.json b/package.json index 6ffb5eb..0027b9c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "packages/telemetry", "packages/examples", "packages/cloudflare-runtime", + "packages/gcp-runtime", "packages/webhook-runtime" ], "scripts": { diff --git a/packages/gcp-runtime/package.json b/packages/gcp-runtime/package.json new file mode 100644 index 0000000..8f31888 --- /dev/null +++ b/packages/gcp-runtime/package.json @@ -0,0 +1,51 @@ +{ + "name": "@agent-assistant/gcp-runtime", + "version": "0.4.35", + "description": "Google Cloud Platform adapters for Agent Assistant SDK (Firestore, Cloud Tasks, Cloud Storage)", + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-assistant/continuation": "^0.3.4" + }, + "peerDependencies": { + "@agent-assistant/proactive": "^0.4.0", + "@agent-assistant/sessions": "^0.4.0", + "@agent-assistant/vfs": "^0.4.0" + }, + "peerDependenciesMeta": { + "@agent-assistant/proactive": { "optional": true }, + "@agent-assistant/sessions": { "optional": true }, + "@agent-assistant/vfs": { "optional": true } + }, + "devDependencies": { + "@agent-assistant/harness": "^0.4.35", + "@agent-assistant/proactive": "^0.4.35", + "@agent-assistant/sessions": "^0.4.35", + "@agent-assistant/vfs": "^0.4.35", + "@types/node": "^24.6.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/agent-assistant" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.test.ts b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.test.ts new file mode 100644 index 0000000..ed04f14 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + GcpCloudTasksContinuationScheduler, + type GcpCloudTasksContinuationSchedulerOptions, +} from './gcp-cloud-tasks-continuation-scheduler.js'; +import type { CloudTasksClientLike } from './gcp-cloud-tasks-scheduler-binding.js'; + +function makeClient(): { tasks: Map; client: CloudTasksClientLike } { + const tasks = new Map(); + const client: CloudTasksClientLike = { + queuePath(p, l, q) { return `projects/${p}/locations/${l}/queues/${q}`; }, + async createTask(req) { + const name = req.task.name ?? `${req.parent}/tasks/auto`; + tasks.set(name, req.task); + return [{ name }]; + }, + async deleteTask(req) { tasks.delete(req.name); }, + }; + return { tasks, client }; +} + +const baseOpts: Omit = { + project: 'proj', + location: 'us-central1', + queue: 'continuations', + handlerUrl: 'https://example.com/resume', +}; + +describe('GcpCloudTasksContinuationScheduler', () => { + it('scheduleWake creates a task and returns a wakeUpId', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksContinuationScheduler({ ...baseOpts, client }); + + const { wakeUpId } = await scheduler.scheduleWake({ + continuationId: 'cont-abc', + wakeAtMs: Date.now() + 60_000, + }); + + expect(wakeUpId).toContain('cont-abc'); + expect(tasks.size).toBe(1); + }); + + it('task body contains continuationId and trigger', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksContinuationScheduler({ ...baseOpts, client }); + + const { wakeUpId } = await scheduler.scheduleWake({ continuationId: 'cont-xyz', wakeAtMs: Date.now() + 5000 }); + + const task = [...tasks.values()][0] as { httpRequest: { body: string } }; + const body = JSON.parse(Buffer.from(task.httpRequest.body, 'base64').toString()); + expect(body.continuationId).toBe('cont-xyz'); + expect(body.trigger.type).toBe('scheduled_wake'); + expect(body.trigger.wakeUpId).toBe(wakeUpId); + }); + + it('cancelWake deletes the task', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksContinuationScheduler({ ...baseOpts, client }); + + const { wakeUpId } = await scheduler.scheduleWake({ continuationId: 'cont-1', wakeAtMs: Date.now() + 5000 }); + expect(tasks.size).toBe(1); + await scheduler.cancelWake(wakeUpId); + expect(tasks.size).toBe(0); + }); + + it('cancelWake is a no-op for unknown wakeUpId', async () => { + const { client } = makeClient(); + const scheduler = new GcpCloudTasksContinuationScheduler({ ...baseOpts, client }); + await expect(scheduler.cancelWake('unknown-wake-id')).resolves.toBeUndefined(); + }); + + it('wakeUpId is stable and unique per continuationId and wakeAtMs', async () => { + const { client } = makeClient(); + const scheduler = new GcpCloudTasksContinuationScheduler({ ...baseOpts, client }); + + const t = Date.now() + 10_000; + const { wakeUpId: id1 } = await scheduler.scheduleWake({ continuationId: 'cont-a', wakeAtMs: t }); + const { wakeUpId: id2 } = await scheduler.scheduleWake({ continuationId: 'cont-b', wakeAtMs: t }); + expect(id1).not.toBe(id2); + }); +}); diff --git a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts new file mode 100644 index 0000000..1348767 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts @@ -0,0 +1,106 @@ +import type { ContinuationSchedulerAdapter } from '@agent-assistant/continuation'; + +import type { CloudTasksClientLike, CloudTasksHttpRequest } from './gcp-cloud-tasks-scheduler-binding.js'; + +export interface GcpCloudTasksContinuationSchedulerOptions { + client: CloudTasksClientLike; + /** GCP project ID. */ + project: string; + /** Cloud Tasks queue location (e.g. 'us-central1'). */ + location: string; + /** Cloud Tasks queue name. */ + queue: string; + /** + * URL of the HTTP endpoint that handles continuation resume delivery. + * Receives POST with JSON body: { continuationId: string, trigger: ContinuationResumeTrigger }. + */ + handlerUrl: string; + /** Optional OIDC service account email for authenticated push targets. */ + serviceAccountEmail?: string; + now?: () => number; +} + +/** + * ContinuationSchedulerAdapter backed by Google Cloud Tasks. + * + * Each scheduled wake-up becomes a Cloud Tasks HTTP task. The consumer's + * HTTP handler must parse the body and call continuationRuntime.resume(). + */ +export class GcpCloudTasksContinuationScheduler implements ContinuationSchedulerAdapter { + private readonly client: CloudTasksClientLike; + private readonly project: string; + private readonly location: string; + private readonly queue: string; + private readonly handlerUrl: string; + private readonly serviceAccountEmail?: string; + private readonly now: () => number; + + constructor(options: GcpCloudTasksContinuationSchedulerOptions) { + this.client = options.client; + this.project = options.project; + this.location = options.location; + this.queue = options.queue; + this.handlerUrl = options.handlerUrl; + this.serviceAccountEmail = options.serviceAccountEmail; + this.now = options.now ?? Date.now; + } + + async scheduleWake(input: { + continuationId: string; + wakeAtMs: number; + }): Promise<{ wakeUpId: string }> { + const parent = this.client.queuePath(this.project, this.location, this.queue); + const wakeUpId = `wake-${sanitize(input.continuationId)}-${input.wakeAtMs}`; + const taskName = `${parent}/tasks/${wakeUpId}`; + + const trigger = { + type: 'scheduled_wake', + wakeUpId, + firedAt: new Date(input.wakeAtMs).toISOString(), + }; + + const body = Buffer.from( + JSON.stringify({ continuationId: input.continuationId, trigger }), + ).toString('base64'); + + const httpRequest: CloudTasksHttpRequest = { + url: this.handlerUrl, + httpMethod: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }; + + if (this.serviceAccountEmail) { + (httpRequest as Record)['oidcToken'] = { + serviceAccountEmail: this.serviceAccountEmail, + audience: this.handlerUrl, + }; + } + + const delayMs = Math.max(0, input.wakeAtMs - this.now()); + + await this.client.createTask({ + parent, + task: { + name: taskName, + scheduleTime: { seconds: Math.floor((Date.now() + delayMs) / 1000) }, + httpRequest, + }, + }); + + return { wakeUpId }; + } + + async cancelWake(wakeUpId: string): Promise { + const parent = this.client.queuePath(this.project, this.location, this.queue); + try { + await this.client.deleteTask({ name: `${parent}/tasks/${wakeUpId}` }); + } catch { + // Task already fired or not found — cancellation is a no-op. + } + } +} + +function sanitize(s: string): string { + return s.replace(/[^a-zA-Z0-9_-]/g, '_'); +} diff --git a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.test.ts b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.test.ts new file mode 100644 index 0000000..f842fa0 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { WakeUpContext } from '@agent-assistant/proactive'; +import { + GcpCloudTasksSchedulerBinding, + type CloudTasksClientLike, +} from './gcp-cloud-tasks-scheduler-binding.js'; + +function makeClient(): { + tasks: Map; + client: CloudTasksClientLike; +} { + const tasks = new Map(); + const client: CloudTasksClientLike = { + queuePath(project, location, queue) { + return `projects/${project}/locations/${location}/queues/${queue}`; + }, + async createTask(req) { + const name = req.task.name ?? `${req.parent}/tasks/auto`; + tasks.set(name, req.task); + return [{ name }]; + }, + async deleteTask(req) { + tasks.delete(req.name); + }, + }; + return { tasks, client }; +} + +const baseContext: WakeUpContext = { + sessionId: 'sess-1', + scheduledAt: '2026-01-01T00:00:00Z', +}; + +const opts = { + project: 'my-project', + location: 'us-central1', + queue: 'proactive-wakeups', + handlerUrl: 'https://example.com/wakeup', +}; + +describe('GcpCloudTasksSchedulerBinding', () => { + it('creates a task and returns its name', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksSchedulerBinding({ ...opts, client }); + + const at = new Date('2026-06-01T12:00:00Z'); + const id = await scheduler.requestWakeUp(at, baseContext); + + expect(id).toBeTruthy(); + expect(tasks.size).toBe(1); + }); + + it('encodes wakeAt and context in task body', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksSchedulerBinding({ ...opts, client }); + + const at = new Date('2026-06-01T12:00:00Z'); + await scheduler.requestWakeUp(at, { ...baseContext, ruleId: 'my-rule' }); + + const task = [...tasks.values()][0] as { httpRequest: { body: string } }; + const body = JSON.parse(Buffer.from(task.httpRequest.body, 'base64').toString()); + expect(body.wakeAt).toBe(at.toISOString()); + expect(body.context.sessionId).toBe('sess-1'); + expect(body.context.ruleId).toBe('my-rule'); + }); + + it('uses scheduleTime based on the requested at date', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksSchedulerBinding({ ...opts, client }); + + const at = new Date('2030-01-01T00:00:00Z'); + await scheduler.requestWakeUp(at, baseContext); + + const task = [...tasks.values()][0] as { scheduleTime: { seconds: number } }; + expect(task.scheduleTime.seconds).toBe(Math.floor(at.getTime() / 1000)); + }); + + it('deletes the task on cancelWakeUp', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksSchedulerBinding({ ...opts, client }); + + const id = await scheduler.requestWakeUp(new Date(), baseContext); + expect(tasks.size).toBe(1); + await scheduler.cancelWakeUp(id); + expect(tasks.size).toBe(0); + }); + + it('is a no-op when cancelling a non-existent task', async () => { + const { client } = makeClient(); + const scheduler = new GcpCloudTasksSchedulerBinding({ ...opts, client }); + await expect(scheduler.cancelWakeUp('nonexistent-task-name')).resolves.toBeUndefined(); + }); + + it('includes OIDC token when serviceAccountEmail is set', async () => { + const { tasks, client } = makeClient(); + const scheduler = new GcpCloudTasksSchedulerBinding({ + ...opts, + client, + serviceAccountEmail: 'svc@project.iam.gserviceaccount.com', + }); + + await scheduler.requestWakeUp(new Date(), baseContext); + const task = [...tasks.values()][0] as { httpRequest: { oidcToken?: unknown } }; + expect(task.httpRequest.oidcToken).toBeDefined(); + }); +}); diff --git a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts new file mode 100644 index 0000000..91c7e04 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts @@ -0,0 +1,118 @@ +import type { SchedulerBinding, WakeUpContext } from '@agent-assistant/proactive'; + +// ─── Minimal Cloud Tasks interface ──────────────────────────────────────────── +// Accepts a real @google-cloud/tasks CloudTasksClient without importing the package. + +export interface CloudTasksHttpRequest { + url: string; + httpMethod: 'POST' | 'GET'; + headers?: Record; + /** Base64-encoded request body. */ + body?: string; +} + +export interface CloudTasksTask { + name?: string; + scheduleTime?: { seconds: number }; + httpRequest: CloudTasksHttpRequest; +} + +export interface CloudTasksClientLike { + createTask(request: { parent: string; task: CloudTasksTask }): Promise<[{ name?: string | null }]>; + deleteTask(request: { name: string }): Promise; + /** + * Builds the full queue resource path. + * On the real client: client.queuePath(project, location, queue) + */ + queuePath(project: string, location: string, queue: string): string; +} + +export interface GcpCloudTasksSchedulerBindingOptions { + client: CloudTasksClientLike; + /** GCP project ID. */ + project: string; + /** Cloud Tasks queue location (e.g. 'us-central1'). */ + location: string; + /** Cloud Tasks queue name. */ + queue: string; + /** + * URL of the HTTP endpoint that handles wake-up delivery. + * Receives POST with JSON body: { wakeAt: string, context: WakeUpContext }. + */ + handlerUrl: string; + /** Optional OIDC token audience for authenticated push targets. */ + serviceAccountEmail?: string; +} + +/** + * SchedulerBinding for @agent-assistant/proactive backed by Google Cloud Tasks. + * + * Each requested wake-up becomes a Cloud Tasks HTTP task with a scheduled + * delivery time. The task POSTs to `handlerUrl` with the wake-up context. + * The consumer's HTTP handler is responsible for invoking the proactive engine. + */ +export class GcpCloudTasksSchedulerBinding implements SchedulerBinding { + private readonly client: CloudTasksClientLike; + private readonly project: string; + private readonly location: string; + private readonly queue: string; + private readonly handlerUrl: string; + private readonly serviceAccountEmail?: string; + + constructor(options: GcpCloudTasksSchedulerBindingOptions) { + this.client = options.client; + this.project = options.project; + this.location = options.location; + this.queue = options.queue; + this.handlerUrl = options.handlerUrl; + this.serviceAccountEmail = options.serviceAccountEmail; + } + + async requestWakeUp(at: Date, context: WakeUpContext): Promise { + const parent = this.client.queuePath(this.project, this.location, this.queue); + const taskId = buildTaskId(context, at); + const taskName = `${parent}/tasks/${taskId}`; + + const body = Buffer.from(JSON.stringify({ wakeAt: at.toISOString(), context })).toString('base64'); + + const httpRequest: CloudTasksHttpRequest = { + url: this.handlerUrl, + httpMethod: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }; + + if (this.serviceAccountEmail) { + (httpRequest as Record)['oidcToken'] = { + serviceAccountEmail: this.serviceAccountEmail, + audience: this.handlerUrl, + }; + } + + const [task] = await this.client.createTask({ + parent, + task: { + name: taskName, + scheduleTime: { seconds: Math.floor(at.getTime() / 1000) }, + httpRequest, + }, + }); + + return task.name ?? taskName; + } + + async cancelWakeUp(bindingId: string): Promise { + try { + await this.client.deleteTask({ name: bindingId }); + } catch { + // Task already fired or does not exist — cancellation is a no-op. + } + } +} + +function buildTaskId(context: WakeUpContext, at: Date): string { + const ruleSegment = context.ruleId ? `-${context.ruleId}` : ''; + // Cloud Tasks names: [a-zA-Z0-9_-] only; sanitize sessionId and ruleId + const safe = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, '_'); + return `wakeup-${safe(context.sessionId)}${safe(ruleSegment)}-${at.getTime()}`; +} diff --git a/packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.test.ts b/packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.test.ts new file mode 100644 index 0000000..a232c27 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import type { ContinuationRecord } from '@agent-assistant/continuation'; +import { GcpFirestoreContinuationStore } from './gcp-firestore-continuation-store.js'; +import type { + FirestoreCollectionLike, + FirestoreDocRef, + FirestoreDocSnapshot, + FirestoreQuery, + FirestoreQuerySnapshot, +} from './gcp-firestore-session-store-adapter.js'; + +function makeCollection>(): FirestoreCollectionLike { + const docs = new Map(); + + function snap(id: string): FirestoreDocSnapshot { + const data = docs.get(id); + return { exists: data !== undefined, id, data: () => data }; + } + + function snapMany(items: T[]): FirestoreQuerySnapshot { + return { docs: items.map((d) => ({ exists: true, id: '', data: () => d })) }; + } + + function buildQuery(filters: Array<(d: T) => boolean>): FirestoreQuery { + let cap: number | undefined; + const q: FirestoreQuery = { + where(field, _op, value) { + return buildQuery([ + ...filters, + (d) => { + const keys = field.split('.'); + let v: unknown = d; + for (const k of keys) v = (v as Record)[k]; + return v === value; + }, + ]); + }, + limit(n) { cap = n; return q; }, + async get() { + let r = [...docs.values()].filter((d) => filters.every((f) => f(d))); + if (cap !== undefined) r = r.slice(0, cap); + return snapMany(r); + }, + }; + return q; + } + + const col: FirestoreCollectionLike = { + doc(id: string): FirestoreDocRef { + return { + get: async () => snap(id), + set: async (data) => { docs.set(id, data); }, + update: async (patch) => { docs.set(id, { ...docs.get(id)!, ...patch } as T); }, + delete: async () => { docs.delete(id); }, + create: async (data) => { docs.set(id, data); }, + }; + }, + where(field, _op, value) { + return buildQuery([(d) => { + const keys = field.split('.'); + let v: unknown = d; + for (const k of keys) v = (v as Record)[k]; + return v === value; + }]); + }, + limit(n) { return buildQuery([]).limit(n); }, + async get() { return snapMany([...docs.values()]); }, + }; + + return col; +} + +function record(overrides: Partial = {}): ContinuationRecord { + return { + id: 'cont-1', + assistantId: 'asst-1', + sessionId: 'sess-1', + origin: { turnId: 't1', outcome: 'awaiting_approval', stopReason: 'needs_approval', createdAt: '2026-01-01T00:00:00Z' }, + status: 'pending', + waitFor: { type: 'approval_resolution', approvalId: 'appr-1' }, + continuation: {} as never, + delivery: { status: 'pending_delivery' }, + bounds: { expiresAt: '2026-12-31T00:00:00Z', maxResumeAttempts: 3, resumeAttempts: 0 }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('GcpFirestoreContinuationStore', () => { + it('puts and gets a record', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + await store.put(record()); + expect(await store.get('cont-1')).toMatchObject({ id: 'cont-1' }); + }); + + it('returns null for missing record', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + expect(await store.get('nope')).toBeNull(); + }); + + it('deletes a record', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + await store.put(record()); + await store.delete('cont-1'); + expect(await store.get('cont-1')).toBeNull(); + }); + + it('listBySession returns records for the session', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + await store.put(record({ id: 'a', sessionId: 'sess-1' })); + await store.put(record({ id: 'b', sessionId: 'sess-2' })); + const results = await store.listBySession('sess-1'); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe('a'); + }); + + it('findByTrigger resolves approval_resolution trigger', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + await store.put(record({ id: 'cont-appr', waitFor: { type: 'approval_resolution', approvalId: 'appr-xyz' } })); + const found = await store.findByTrigger({ type: 'approval_resolution', approvalId: 'appr-xyz', decision: 'approved', resolvedAt: '' }); + expect(found?.id).toBe('cont-appr'); + }); + + it('findByTrigger returns null for user_reply (no symmetric field)', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + const result = await store.findByTrigger({ type: 'user_reply', message: {} as never, receivedAt: '' }); + expect(result).toBeNull(); + }); + + it('overwrites an existing record on put', async () => { + const store = new GcpFirestoreContinuationStore({ collection: makeCollection() }); + await store.put(record({ status: 'pending' })); + await store.put(record({ status: 'resuming' })); + expect((await store.get('cont-1'))?.status).toBe('resuming'); + }); +}); diff --git a/packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.ts b/packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.ts new file mode 100644 index 0000000..374f6fe --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-firestore-continuation-store.ts @@ -0,0 +1,74 @@ +import type { + ContinuationRecord, + ContinuationResumeTrigger, + ContinuationStore, +} from '@agent-assistant/continuation'; + +import type { FirestoreCollectionLike, FirestoreQuery } from './gcp-firestore-session-store-adapter.js'; + +export interface GcpFirestoreContinuationStoreOptions { + collection: FirestoreCollectionLike; +} + +export class GcpFirestoreContinuationStore implements ContinuationStore { + private readonly col: FirestoreCollectionLike; + + constructor(options: GcpFirestoreContinuationStoreOptions) { + this.col = options.collection; + } + + async put(record: ContinuationRecord): Promise { + await this.col.doc(record.id).set(record); + } + + async get(continuationId: string): Promise { + const snap = await this.col.doc(continuationId).get(); + return snap.exists ? (snap.data() as ContinuationRecord) : null; + } + + async delete(continuationId: string): Promise { + await this.col.doc(continuationId).delete(); + } + + async listBySession(sessionId: string): Promise { + const snap = await (this.col as FirestoreQuery) + .where('sessionId', '==', sessionId) + .get(); + return snap.docs.map((d) => d.data() as ContinuationRecord); + } + + /** + * Find a pending continuation by its resume trigger. + * Firestore queries by the trigger-specific correlation field. + * Returns null for user_reply triggers without a correlationKey (no symmetric field to query). + */ + async findByTrigger(trigger: ContinuationResumeTrigger): Promise { + const constraint = triggerConstraint(trigger); + if (!constraint) return null; + + const snap = await (this.col as FirestoreQuery) + .where(constraint.field, '==', constraint.value) + .limit(1) + .get(); + + return snap.docs[0] ? (snap.docs[0].data() as ContinuationRecord) : null; + } +} + +interface TriggerConstraint { + field: string; + value: string; +} + +function triggerConstraint(trigger: ContinuationResumeTrigger): TriggerConstraint | null { + switch (trigger.type) { + case 'approval_resolution': + return { field: 'waitFor.approvalId', value: trigger.approvalId }; + case 'external_result': + return { field: 'waitFor.operationId', value: trigger.operationId }; + case 'scheduled_wake': + return trigger.wakeUpId ? { field: 'waitFor.wakeUpId', value: trigger.wakeUpId } : null; + case 'user_reply': + return null; + } +} diff --git a/packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.test.ts b/packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.test.ts new file mode 100644 index 0000000..9838f16 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from '@agent-assistant/sessions'; +import { + GcpFirestoreSessionStoreAdapter, + type FirestoreCollectionLike, + type FirestoreDocRef, + type FirestoreDocSnapshot, + type FirestoreQuery, + type FirestoreQuerySnapshot, +} from './gcp-firestore-session-store-adapter.js'; + +function makeCollection(): FirestoreCollectionLike { + const docs = new Map(); + + function snap(id: string): FirestoreDocSnapshot { + const data = docs.get(id); + return { exists: data !== undefined, id, data: () => data }; + } + + function querySnap(items: Session[]): FirestoreQuerySnapshot { + return { docs: items.map((s) => snap(s.id)) }; + } + + function buildQuery(filters: Array<(s: Session) => boolean>): FirestoreQuery { + let limited: number | undefined; + const q: FirestoreQuery = { + where(field, _op, value) { + return buildQuery([ + ...filters, + (s) => { + const keys = field.split('.'); + let val: unknown = s; + for (const k of keys) val = (val as Record)[k]; + return val === value; + }, + ]); + }, + limit(n) { + limited = n; + return q; + }, + async get() { + let result = [...docs.values()].filter((s) => filters.every((f) => f(s))); + if (limited !== undefined) result = result.slice(0, limited); + return querySnap(result); + }, + }; + return q; + } + + const col: FirestoreCollectionLike = { + doc(id: string): FirestoreDocRef { + return { + get: async () => snap(id), + set: async (data) => { docs.set(id, data); }, + update: async (patch) => { docs.set(id, { ...docs.get(id)!, ...patch }); }, + delete: async () => { docs.delete(id); }, + create: async (data) => { + if (docs.has(id)) { + const err = Object.assign(new Error('already exists'), { code: 6 }); + throw err; + } + docs.set(id, data); + }, + }; + }, + where(field, op, value) { return buildQuery([(s) => { + const keys = field.split('.'); + let val: unknown = s; + for (const k of keys) val = (val as Record)[k]; + return val === value; + }]); }, + limit(n) { return buildQuery([]).limit(n); }, + async get() { return querySnap([...docs.values()]); }, + }; + + return col; +} + +function session(overrides: Partial = {}): Session { + return { + id: 'sess-1', + userId: 'user-1', + state: 'active', + createdAt: '2026-01-01T00:00:00Z', + lastActivityAt: '2026-01-01T00:00:00Z', + attachedSurfaces: [], + metadata: {}, + ...overrides, + }; +} + +describe('GcpFirestoreSessionStoreAdapter', () => { + it('inserts and fetches a session', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session()); + expect(await adapter.fetchById('sess-1')).toMatchObject({ id: 'sess-1' }); + }); + + it('throws SessionConflictError on duplicate insert', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session()); + await expect(adapter.insert(session())).rejects.toMatchObject({ name: 'SessionConflictError' }); + }); + + it('returns null for missing session', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + expect(await adapter.fetchById('nope')).toBeNull(); + }); + + it('updates a session', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session()); + const updated = await adapter.update('sess-1', { state: 'suspended' }); + expect(updated.state).toBe('suspended'); + expect((await adapter.fetchById('sess-1'))?.state).toBe('suspended'); + }); + + it('throws SessionNotFoundError when updating missing session', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await expect(adapter.update('nope', { state: 'expired' })).rejects.toMatchObject({ + name: 'SessionNotFoundError', + }); + }); + + it('deletes a session', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session()); + await adapter.delete('sess-1'); + expect(await adapter.fetchById('sess-1')).toBeNull(); + }); + + it('fetchMany filters by userId', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session({ id: 'a', userId: 'u1' })); + await adapter.insert(session({ id: 'b', userId: 'u2' })); + const results = await adapter.fetchMany({ userId: 'u1' }); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe('a'); + }); + + it('fetchMany filters by state', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session({ id: 'a', userId: 'u1', state: 'active' })); + await adapter.insert(session({ id: 'b', userId: 'u1', state: 'suspended' })); + const results = await adapter.fetchMany({ userId: 'u1', state: 'active' }); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe('a'); + }); + + it('fetchMany applies limit', async () => { + const adapter = new GcpFirestoreSessionStoreAdapter({ collection: makeCollection() }); + await adapter.insert(session({ id: 'a', userId: 'u1' })); + await adapter.insert(session({ id: 'b', userId: 'u1' })); + const results = await adapter.fetchMany({ userId: 'u1', limit: 1 }); + expect(results).toHaveLength(1); + }); +}); diff --git a/packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.ts b/packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.ts new file mode 100644 index 0000000..9320b8d --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-firestore-session-store-adapter.ts @@ -0,0 +1,133 @@ +import type { Session, SessionQuery, SessionStoreAdapter } from '@agent-assistant/sessions'; + +// ─── Minimal Firestore interface types ──────────────────────────────────────── +// Accepts real @google-cloud/firestore objects without importing the package. + +export interface FirestoreDocSnapshot { + readonly exists: boolean; + readonly id: string; + data(): T | undefined; +} + +export interface FirestoreQuerySnapshot { + readonly docs: ReadonlyArray>; +} + +export interface FirestoreDocRef { + get(): Promise>; + set(data: T): Promise; + update(data: Record): Promise; + delete(): Promise; + create(data: T): Promise; +} + +export interface FirestoreQuery { + where(field: string, op: '==' | 'in' | 'array-contains', value: unknown): FirestoreQuery; + limit(n: number): FirestoreQuery; + get(): Promise>; +} + +export interface FirestoreCollectionLike extends FirestoreQuery { + doc(id: string): FirestoreDocRef; +} + +export interface GcpFirestoreSessionStoreAdapterOptions { + collection: FirestoreCollectionLike; +} + +export class GcpFirestoreSessionStoreAdapter implements SessionStoreAdapter { + private readonly col: FirestoreCollectionLike; + + constructor(options: GcpFirestoreSessionStoreAdapterOptions) { + this.col = options.collection; + } + + async insert(session: Session): Promise { + try { + await this.col.doc(session.id).create(session); + } catch (err) { + if (isAlreadyExistsError(err)) { + const conflict = new Error(`Session already exists: ${session.id}`) as Error & { + name: string; + sessionId: string; + }; + conflict.name = 'SessionConflictError'; + conflict.sessionId = session.id; + throw conflict; + } + throw err; + } + } + + async fetchById(sessionId: string): Promise { + const snap = await this.col.doc(sessionId).get(); + return snap.exists ? (snap.data() as Session) : null; + } + + async fetchMany(query: SessionQuery): Promise { + // Anchor on the most selective indexed field then filter the rest in memory. + // Firestore compound-index requirements mean we avoid stacking arbitrary + // where() chains without knowing which indexes the consumer has created. + let q: FirestoreQuery = this.col; + + if (query.userId) { + q = q.where('userId', '==', query.userId); + } else if (query.workspaceId) { + q = q.where('workspaceId', '==', query.workspaceId); + } else if (query.state && !Array.isArray(query.state)) { + q = q.where('state', '==', query.state); + } + + const snap = await q.get(); + let sessions = snap.docs.map((d) => d.data() as Session); + sessions = this.applyFilters(sessions, query); + if (query.limit != null) sessions = sessions.slice(0, query.limit); + return sessions; + } + + async update(sessionId: string, patch: Partial): Promise { + const ref = this.col.doc(sessionId); + const snap = await ref.get(); + if (!snap.exists) throw notFoundError(sessionId); + await ref.update(patch as Record); + const updated = await ref.get(); + return updated.data() as Session; + } + + async delete(sessionId: string): Promise { + await this.col.doc(sessionId).delete(); + } + + private applyFilters(sessions: Session[], query: SessionQuery): Session[] { + return sessions.filter((s) => { + if (query.userId && s.userId !== query.userId) return false; + if (query.workspaceId && s.workspaceId !== query.workspaceId) return false; + if (query.state) { + const states = Array.isArray(query.state) ? query.state : [query.state]; + if (!states.includes(s.state)) return false; + } + if (query.surfaceId && !s.attachedSurfaces.includes(query.surfaceId)) return false; + if (query.activeAfter && s.lastActivityAt <= query.activeAfter) return false; + return true; + }); + } +} + +function notFoundError(sessionId: string): Error & { name: string; sessionId: string } { + const err = new Error(`Session not found: ${sessionId}`) as Error & { + name: string; + sessionId: string; + }; + err.name = 'SessionNotFoundError'; + err.sessionId = sessionId; + return err; +} + +function isAlreadyExistsError(err: unknown): boolean { + if (err instanceof Error) { + // @google-cloud/firestore throws gRPC status codes + const code = (err as { code?: number }).code; + return code === 6; // ALREADY_EXISTS + } + return false; +} diff --git a/packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.test.ts b/packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.test.ts new file mode 100644 index 0000000..0bcfd6e --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ContinuationHarnessAdapter, ContinuationResumedTurnInput } from '@agent-assistant/continuation'; +import type { HarnessResult } from '@agent-assistant/harness'; +import { GcpIdempotentTurnExecutor } from './gcp-idempotent-turn-executor.js'; +import type { + FirestoreCollectionLike, + FirestoreDocRef, + FirestoreDocSnapshot, + FirestoreQuery, + FirestoreQuerySnapshot, +} from './gcp-firestore-session-store-adapter.js'; + +type StashDoc = { result: HarnessResult }; + +function makeStashCollection(): FirestoreCollectionLike { + const docs = new Map(); + + function snap(id: string): FirestoreDocSnapshot { + const data = docs.get(id); + return { exists: data !== undefined, id, data: () => data }; + } + + const col: FirestoreCollectionLike = { + doc(id: string): FirestoreDocRef { + return { + get: async () => snap(id), + set: async (data) => { docs.set(id, data); }, + update: async (patch) => { docs.set(id, { ...docs.get(id)!, ...patch } as StashDoc); }, + delete: async () => { docs.delete(id); }, + create: async (data) => { docs.set(id, data); }, + }; + }, + where(_f, _op, _v): FirestoreQuery { return col as unknown as FirestoreQuery; }, + limit(_n): FirestoreQuery { return col as unknown as FirestoreQuery; }, + async get(): Promise> { return { docs: [] }; }, + }; + + return col; +} + +const successResult: HarnessResult = { + outcome: 'complete', + stopReason: 'end_turn', + outputMessages: [], +} as unknown as HarnessResult; + +function makeInput(resumedTurnId = 'turn-abc'): ContinuationResumedTurnInput { + return { + resumedTurnId, + continuation: {} as never, + trigger: { type: 'user_reply', message: {} as never, receivedAt: new Date().toISOString() }, + }; +} + +describe('GcpIdempotentTurnExecutor', () => { + it('delegates to the inner harness on first run', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockResolvedValue(successResult), + }; + const executor = new GcpIdempotentTurnExecutor({ + inner, + stashCollection: makeStashCollection(), + }); + const result = await executor.runResumedTurn(makeInput()); + expect(result).toBe(successResult); + expect(inner.runResumedTurn).toHaveBeenCalledOnce(); + }); + + it('returns stashed result without calling inner on recovery', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockResolvedValue(successResult), + }; + const stash = makeStashCollection(); + + // Simulate a previous run that stashed the result + await stash.doc('turn-abc').set({ result: successResult }); + + const executor = new GcpIdempotentTurnExecutor({ inner, stashCollection: stash }); + const result = await executor.runResumedTurn(makeInput('turn-abc')); + expect(result).toBe(successResult); + expect(inner.runResumedTurn).not.toHaveBeenCalled(); + }); + + it('stashes the result after the first successful run', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockResolvedValue(successResult), + }; + const stash = makeStashCollection(); + const executor = new GcpIdempotentTurnExecutor({ inner, stashCollection: stash }); + + await executor.runResumedTurn(makeInput('turn-xyz')); + + const stored = await stash.doc('turn-xyz').get(); + expect(stored.exists).toBe(true); + expect(stored.data()?.result).toBe(successResult); + }); + + it('propagates errors from the inner harness', async () => { + const inner: ContinuationHarnessAdapter = { + runResumedTurn: vi.fn().mockRejectedValue(new Error('harness error')), + }; + const executor = new GcpIdempotentTurnExecutor({ + inner, + stashCollection: makeStashCollection(), + }); + await expect(executor.runResumedTurn(makeInput())).rejects.toThrow('harness error'); + }); +}); diff --git a/packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.ts b/packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.ts new file mode 100644 index 0000000..f3cd921 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-idempotent-turn-executor.ts @@ -0,0 +1,51 @@ +import type { + ContinuationHarnessAdapter, + ContinuationResumedTurnInput, +} from '@agent-assistant/continuation'; +import type { HarnessResult } from '@agent-assistant/harness'; + +import type { FirestoreCollectionLike } from './gcp-firestore-session-store-adapter.js'; + +export interface GcpIdempotentTurnExecutorOptions { + /** The underlying harness adapter that executes resumed turns. */ + inner: ContinuationHarnessAdapter; + /** + * Firestore collection used to stash completed turn results. + * Documents are keyed by resumedTurnId and contain { result: HarnessResult }. + * TTL policy on this collection is recommended (e.g. 24 hours). + */ + stashCollection: FirestoreCollectionLike<{ result: HarnessResult }>; +} + +/** + * ContinuationHarnessAdapter that makes resumed turns idempotent via Firestore. + * + * Equivalent to CfFiberTurnExecutor from the Cloudflare adapter — same safety + * guarantee, different storage backend. If the process dies after a turn + * completes but before the result is written to the continuation store, the + * next attempt finds the stashed result in Firestore and returns it immediately + * without re-running model or tool calls. + */ +export class GcpIdempotentTurnExecutor implements ContinuationHarnessAdapter { + private readonly inner: ContinuationHarnessAdapter; + private readonly stash: FirestoreCollectionLike<{ result: HarnessResult }>; + + constructor(options: GcpIdempotentTurnExecutorOptions) { + this.inner = options.inner; + this.stash = options.stashCollection; + } + + async runResumedTurn(input: ContinuationResumedTurnInput): Promise { + const ref = this.stash.doc(input.resumedTurnId); + const snap = await ref.get(); + + if (snap.exists) { + const stashed = snap.data(); + if (stashed) return stashed.result; + } + + const result = await this.inner.runResumedTurn(input); + await ref.set({ result }); + return result; + } +} diff --git a/packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.test.ts b/packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.test.ts new file mode 100644 index 0000000..eb04500 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { GcpStorageVfsProvider, type GcsBucketLike, type GcsFileLike } from './gcp-storage-vfs-provider.js'; + +function makeBucket(files: Record): GcsBucketLike { + function makeFile(name: string): GcsFileLike { + return { + name, + async exists() { return [name in files]; }, + async download() { return [Buffer.from(files[name] ?? '')]; }, + async save(content) { files[name] = typeof content === 'string' ? content : content.toString(); }, + async delete() { delete files[name]; }, + }; + } + + return { + file(path) { return makeFile(path); }, + async getFiles(opts) { + const prefix = opts?.prefix ?? ''; + const max = opts?.maxResults ?? Infinity; + const matching = Object.keys(files) + .filter((k) => k.startsWith(prefix)) + .slice(0, max) + .map(makeFile); + return [matching]; + }, + }; +} + +describe('GcpStorageVfsProvider', () => { + it('reads an existing file', async () => { + const provider = new GcpStorageVfsProvider({ + bucket: makeBucket({ 'src/index.ts': 'export {}' }), + }); + const result = await provider.read('src/index.ts'); + expect(result?.content).toBe('export {}'); + expect(result?.provider).toBe('gcp-storage'); + }); + + it('returns null for missing file', async () => { + const provider = new GcpStorageVfsProvider({ bucket: makeBucket({}) }); + expect(await provider.read('missing.ts')).toBeNull(); + }); + + it('lists files under a path', async () => { + const provider = new GcpStorageVfsProvider({ + bucket: makeBucket({ 'src/a.ts': '', 'src/b.ts': '', 'tests/c.ts': '' }), + }); + const entries = await provider.list('src'); + const paths = entries.map((e) => e.path); + expect(paths).toContain('src/a.ts'); + expect(paths).toContain('src/b.ts'); + expect(paths).not.toContain('tests/c.ts'); + }); + + it('stat returns entry for existing file', async () => { + const provider = new GcpStorageVfsProvider({ + bucket: makeBucket({ 'README.md': '# hello' }), + }); + expect(await provider.stat('README.md')).toMatchObject({ path: 'README.md', type: 'file' }); + }); + + it('stat returns null for missing file', async () => { + const provider = new GcpStorageVfsProvider({ bucket: makeBucket({}) }); + expect(await provider.stat('nope.md')).toBeNull(); + }); + + it('search finds matching lines', async () => { + const provider = new GcpStorageVfsProvider({ + bucket: makeBucket({ 'a.ts': 'hello world\nfoo bar', 'b.ts': 'hello again' }), + }); + const results = await provider.search('hello'); + expect(results).toHaveLength(2); + expect(results.every((r) => r.provider === 'gcp-storage')).toBe(true); + }); + + it('applies search limit', async () => { + const provider = new GcpStorageVfsProvider({ + bucket: makeBucket({ 'a.ts': 'match', 'b.ts': 'match', 'c.ts': 'match' }), + }); + const results = await provider.search('match', { limit: 2 }); + expect(results.length).toBeLessThanOrEqual(2); + }); + + it('resolves paths relative to prefix', async () => { + const provider = new GcpStorageVfsProvider({ + bucket: makeBucket({ 'ws1/src/index.ts': 'content' }), + prefix: 'ws1', + }); + const result = await provider.read('src/index.ts'); + expect(result?.content).toBe('content'); + expect(result?.path).toBe('src/index.ts'); + }); +}); diff --git a/packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.ts b/packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.ts new file mode 100644 index 0000000..e1b4235 --- /dev/null +++ b/packages/gcp-runtime/src/adapters/gcp-storage-vfs-provider.ts @@ -0,0 +1,120 @@ +import type { VfsEntry, VfsListOptions, VfsProvider, VfsReadResult, VfsSearchOptions, VfsSearchResult } from '@agent-assistant/vfs'; + +// ─── Minimal GCS interface ──────────────────────────────────────────────────── +// Accepts real @google-cloud/storage Bucket/File objects without importing the SDK. + +export interface GcsFileLike { + readonly name: string; + exists(): Promise<[boolean]>; + download(): Promise<[Buffer]>; + save(content: string | Buffer, options?: { contentType?: string }): Promise; + delete(): Promise; +} + +export interface GcsBucketLike { + file(path: string): GcsFileLike; + getFiles(options?: { + prefix?: string; + delimiter?: string; + maxResults?: number; + }): Promise<[GcsFileLike[]]>; +} + +export interface GcpStorageVfsProviderOptions { + bucket: GcsBucketLike; + /** Object key prefix for all operations — namespaces the VFS within the bucket. */ + prefix?: string; +} + +/** + * VfsProvider backed by Google Cloud Storage. + * + * Each GCS object is a VFS file entry. Directory entries are inferred from + * object key prefixes. The `search` method performs an in-memory grep across + * matched objects — for high-cardinality buckets, wire a dedicated search + * index (e.g. Vertex AI Search) instead. + */ +export class GcpStorageVfsProvider implements VfsProvider { + private readonly bucket: GcsBucketLike; + private readonly prefix: string; + + constructor(options: GcpStorageVfsProviderOptions) { + this.bucket = options.bucket; + this.prefix = options.prefix ? `${options.prefix.replace(/\/$/, '')}/` : ''; + } + + async list(path: string, options?: VfsListOptions): Promise { + const gcsPrefix = this.resolve(path ? `${path}/` : ''); + const [files] = await this.bucket.getFiles({ + prefix: gcsPrefix, + maxResults: options?.limit, + }); + + return files.map((f) => ({ + path: this.strip(f.name), + type: f.name.endsWith('/') ? ('dir' as const) : ('file' as const), + provider: 'gcp-storage', + })); + } + + async read(path: string): Promise { + const file = this.bucket.file(this.resolve(path)); + const [exists] = await file.exists(); + if (!exists) return null; + + const [buf] = await file.download(); + return { + path, + content: buf.toString('utf-8'), + contentType: 'text/plain', + encoding: 'utf-8', + provider: 'gcp-storage', + }; + } + + async search(query: string, options?: VfsSearchOptions): Promise { + const [files] = await this.bucket.getFiles({ prefix: this.prefix }); + const regex = new RegExp(query); + const results: VfsSearchResult[] = []; + const limit = options?.limit ?? Infinity; + + for (const file of files) { + if (results.length >= limit) break; + try { + const [buf] = await file.download(); + const text = buf.toString('utf-8'); + const matchLine = text.split('\n').find((l) => regex.test(l)); + if (matchLine !== undefined) { + results.push({ + path: this.strip(file.name), + type: 'file', + snippet: matchLine, + provider: 'gcp-storage', + }); + } + } catch { + // Skip unreadable objects (binary, oversized, permissions). + } + } + + return results; + } + + async stat(path: string): Promise { + const file = this.bucket.file(this.resolve(path)); + const [exists] = await file.exists(); + if (!exists) return null; + return { path, type: 'file', provider: 'gcp-storage' }; + } + + private resolve(path: string): string { + return `${this.prefix}${path}`.replace(/\/+/g, '/').replace(/^\//, ''); + } + + private strip(gcspath: string): string { + const stripped = gcspath.startsWith(this.prefix) + ? gcspath.slice(this.prefix.length) + : gcspath; + return stripped.startsWith('/') ? stripped.slice(1) : stripped; + } +} diff --git a/packages/gcp-runtime/src/index.ts b/packages/gcp-runtime/src/index.ts new file mode 100644 index 0000000..dbd4ac1 --- /dev/null +++ b/packages/gcp-runtime/src/index.ts @@ -0,0 +1,46 @@ +export { + GcpFirestoreSessionStoreAdapter, +} from './adapters/gcp-firestore-session-store-adapter.js'; +export type { + FirestoreCollectionLike, + FirestoreDocRef, + FirestoreDocSnapshot, + FirestoreQuery, + FirestoreQuerySnapshot, + GcpFirestoreSessionStoreAdapterOptions, +} from './adapters/gcp-firestore-session-store-adapter.js'; +export { + GcpFirestoreContinuationStore, +} from './adapters/gcp-firestore-continuation-store.js'; +export type { + GcpFirestoreContinuationStoreOptions, +} from './adapters/gcp-firestore-continuation-store.js'; +export { + GcpCloudTasksSchedulerBinding, +} from './adapters/gcp-cloud-tasks-scheduler-binding.js'; +export type { + CloudTasksClientLike, + CloudTasksHttpRequest, + CloudTasksTask, + GcpCloudTasksSchedulerBindingOptions, +} from './adapters/gcp-cloud-tasks-scheduler-binding.js'; +export { + GcpCloudTasksContinuationScheduler, +} from './adapters/gcp-cloud-tasks-continuation-scheduler.js'; +export type { + GcpCloudTasksContinuationSchedulerOptions, +} from './adapters/gcp-cloud-tasks-continuation-scheduler.js'; +export { + GcpStorageVfsProvider, +} from './adapters/gcp-storage-vfs-provider.js'; +export type { + GcsBucketLike, + GcsFileLike, + GcpStorageVfsProviderOptions, +} from './adapters/gcp-storage-vfs-provider.js'; +export { + GcpIdempotentTurnExecutor, +} from './adapters/gcp-idempotent-turn-executor.js'; +export type { + GcpIdempotentTurnExecutorOptions, +} from './adapters/gcp-idempotent-turn-executor.js'; diff --git a/packages/gcp-runtime/tsconfig.json b/packages/gcp-runtime/tsconfig.json new file mode 100644 index 0000000..b2cda12 --- /dev/null +++ b/packages/gcp-runtime/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/packages/gcp-runtime/vitest.config.ts b/packages/gcp-runtime/vitest.config.ts new file mode 100644 index 0000000..4ac6027 --- /dev/null +++ b/packages/gcp-runtime/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); From 4455e028a099765ef67a03931fb9c25508ec5277 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 18 Jun 2026 21:52:40 +0000 Subject: [PATCH 3/3] chore: apply pr-reviewer fixes for #100 --- .../src/adapters/gcp-cloud-tasks-continuation-scheduler.ts | 2 +- .../src/adapters/gcp-cloud-tasks-scheduler-binding.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts index 1348767..86b0b1f 100644 --- a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts +++ b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-continuation-scheduler.ts @@ -71,7 +71,7 @@ export class GcpCloudTasksContinuationScheduler implements ContinuationScheduler }; if (this.serviceAccountEmail) { - (httpRequest as Record)['oidcToken'] = { + (httpRequest as unknown as Record)['oidcToken'] = { serviceAccountEmail: this.serviceAccountEmail, audience: this.handlerUrl, }; diff --git a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts index 91c7e04..585d2d8 100644 --- a/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts +++ b/packages/gcp-runtime/src/adapters/gcp-cloud-tasks-scheduler-binding.ts @@ -83,7 +83,7 @@ export class GcpCloudTasksSchedulerBinding implements SchedulerBinding { }; if (this.serviceAccountEmail) { - (httpRequest as Record)['oidcToken'] = { + (httpRequest as unknown as Record)['oidcToken'] = { serviceAccountEmail: this.serviceAccountEmail, audience: this.handlerUrl, };