diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53592b35..9066e8b6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -89,7 +89,7 @@ jobs: # persona-kit is a leaf dep consumed by workload-router and cli, so it # must publish first. The top-level `agentworkforce` wrapper depends on # `@agentworkforce/cli`, so it must publish last. - echo "packages=persona-kit workload-router cli agentworkforce" >> "$GITHUB_OUTPUT" + echo "packages=persona-kit workload-router cli daytona-runner agentworkforce" >> "$GITHUB_OUTPUT" # Lockstep baseline heal. The workspace publishes every package at the # same version, so if any package's local version lags either its own @@ -689,7 +689,7 @@ jobs: // didn't publish an umbrella stamp (e.g. version: none re-runs). const releaseVersion = process.env.RELEASE_VERSION || canonicalVersion; - const packageOrder = ['persona-kit', 'workload-router', 'cli', 'agentworkforce']; + const packageOrder = ['persona-kit', 'workload-router', 'cli', 'daytona-runner', 'agentworkforce']; const entries = versionsRaw.trim().split(/\s+/).filter(Boolean).map((entry) => { const idx = entry.indexOf(':'); return { pkg: entry.slice(0, idx), ver: entry.slice(idx + 1) }; diff --git a/packages/daytona-runner/.gitignore b/packages/daytona-runner/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/daytona-runner/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/daytona-runner/README.md b/packages/daytona-runner/README.md new file mode 100644 index 00000000..a1dd9a9c --- /dev/null +++ b/packages/daytona-runner/README.md @@ -0,0 +1,40 @@ +# @agentworkforce/daytona-runner + +Daytona-backed `WorkflowRuntime` adapter for AgentWorkforce deploy workflows. This package owns the Daytona runtime implementation, auth helpers, and runtime contract types. + +## Install + +```sh +npm install @agentworkforce/daytona-runner @daytonaio/sdk +``` + +`@daytonaio/sdk` is a peer dependency — consumers bring their own version (^0.148.0). + +## Usage + +```ts +import { Daytona } from '@daytonaio/sdk'; +import { + DaytonaRuntime, + resolveDaytonaAuthCredentials, +} from '@agentworkforce/daytona-runner'; + +const auth = resolveDaytonaAuthCredentials({ + apiKey: process.env.DAYTONA_API_KEY, + jwtToken: process.env.DAYTONA_JWT_TOKEN, + organizationId: process.env.DAYTONA_ORGANIZATION_ID, +}); + +const daytona = new Daytona(auth); +const runtime = new DaytonaRuntime({ daytona }); + +const handle = await runtime.launch({ label: 'my-workflow' }); +const result = await runtime.exec(handle, 'node -e "console.log(\\"ok\\")"'); +await runtime.destroy(handle); +``` + +## Exports + +- `DaytonaRuntime` — the `WorkflowRuntime` implementation. +- `resolveDaytonaAuthCredentials` / `applyDaytonaAuthEnv` — Daytona auth helpers (apiKey vs JWT+org). +- `WorkflowRuntime`, `RuntimeHandle`, `LaunchOptions`, `ExecOptions`, `ExecResult`, `RuntimeCapabilities` — runtime contract types. diff --git a/packages/daytona-runner/package.json b/packages/daytona-runner/package.json new file mode 100644 index 00000000..7c5078a8 --- /dev/null +++ b/packages/daytona-runner/package.json @@ -0,0 +1,42 @@ +{ + "name": "@agentworkforce/daytona-runner", + "version": "0.1.0", + "description": "Daytona-backed WorkflowRuntime adapter for AgentWorkforce deploy workflows.", + "private": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/daytona-runner" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p tsconfig.json && node --test dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": {}, + "peerDependencies": { + "@daytonaio/sdk": "^0.148.0" + }, + "devDependencies": { + "@daytonaio/sdk": "^0.148.0" + } +} diff --git a/packages/daytona-runner/src/auth.ts b/packages/daytona-runner/src/auth.ts new file mode 100644 index 00000000..94620916 --- /dev/null +++ b/packages/daytona-runner/src/auth.ts @@ -0,0 +1,53 @@ +export interface DaytonaAuthCredentials { + apiKey?: string; + jwtToken?: string; + organizationId?: string; +} + +export type ResolvedDaytonaAuthCredentials = + | { apiKey: string } + | { jwtToken: string; organizationId: string }; + +export function resolveDaytonaAuthCredentials( + credentials: DaytonaAuthCredentials, +): ResolvedDaytonaAuthCredentials { + const apiKey = credentials.apiKey?.trim(); + if (apiKey) { + return { apiKey }; + } + + const jwtToken = credentials.jwtToken?.trim(); + if (!jwtToken) { + throw new Error('Daytona auth is required in credential bundle'); + } + + const organizationId = credentials.organizationId?.trim(); + if (!organizationId) { + throw new Error('DAYTONA_ORGANIZATION_ID is required when using Daytona JWT auth'); + } + + return { jwtToken, organizationId }; +} + +export function applyDaytonaAuthEnv( + env: Record, + credentials: DaytonaAuthCredentials, +): void { + const resolved = resolveDaytonaAuthCredentials(credentials); + if ('apiKey' in resolved) { + env.DAYTONA_API_KEY = resolved.apiKey; + // Clear the JWT-mode env keys so a caller flipping from JWT to apiKey + // auth doesn't leave stale credentials in the bag that downstream + // consumers might read and prefer over the apiKey path. + delete env.DAYTONA_JWT_TOKEN; + delete env.DAYTONA_ORGANIZATION_ID; + return; + } + + env.DAYTONA_JWT_TOKEN = resolved.jwtToken; + env.DAYTONA_ORGANIZATION_ID = resolved.organizationId; + // Same logic in reverse: a JWT-auth caller should not see a lingering + // DAYTONA_API_KEY from an earlier mode that would silently take + // priority over the freshly-applied JWT. + delete env.DAYTONA_API_KEY; +} diff --git a/packages/daytona-runner/src/index.ts b/packages/daytona-runner/src/index.ts new file mode 100644 index 00000000..9b0fca0d --- /dev/null +++ b/packages/daytona-runner/src/index.ts @@ -0,0 +1,21 @@ +export { + DaytonaRuntime, + type DaytonaAttachedSandboxOptions, + type DaytonaRuntimeOptions, +} from './runtime.js'; + +export { + applyDaytonaAuthEnv, + resolveDaytonaAuthCredentials, + type DaytonaAuthCredentials, + type ResolvedDaytonaAuthCredentials, +} from './auth.js'; + +export type { + ExecOptions, + ExecResult, + LaunchOptions, + RuntimeCapabilities, + RuntimeHandle, + WorkflowRuntime, +} from './types.js'; diff --git a/packages/daytona-runner/src/runtime.test.ts b/packages/daytona-runner/src/runtime.test.ts new file mode 100644 index 00000000..47716ca1 --- /dev/null +++ b/packages/daytona-runner/src/runtime.test.ts @@ -0,0 +1,87 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Daytona } from '@daytonaio/sdk'; + +import * as pkg from './index.js'; +import { DaytonaRuntime, applyDaytonaAuthEnv, resolveDaytonaAuthCredentials } from './index.js'; +import type { RuntimeHandle } from './index.js'; + +describe('public barrel', () => { + it('exports DaytonaRuntime as a class', () => { + assert.equal(typeof pkg.DaytonaRuntime, 'function'); + assert.equal(typeof DaytonaRuntime, 'function'); + }); + + it('exports resolveDaytonaAuthCredentials and applyDaytonaAuthEnv', () => { + assert.equal(typeof pkg.resolveDaytonaAuthCredentials, 'function'); + assert.equal(typeof resolveDaytonaAuthCredentials, 'function'); + assert.equal(typeof pkg.applyDaytonaAuthEnv, 'function'); + assert.equal(typeof applyDaytonaAuthEnv, 'function'); + }); + + it('resolveDaytonaAuthCredentials normalises an apiKey-only input', () => { + const resolved = resolveDaytonaAuthCredentials({ apiKey: 'sk-test' }); + assert.ok('apiKey' in resolved, 'expected apiKey-mode result'); + assert.equal(resolved.apiKey, 'sk-test'); + }); + + it('resolveDaytonaAuthCredentials rejects empty input', () => { + assert.throws(() => resolveDaytonaAuthCredentials({})); + }); + + it('applyDaytonaAuthEnv writes DAYTONA_API_KEY into the supplied env bag', () => { + const env: Record = {}; + applyDaytonaAuthEnv(env, { apiKey: 'sk-test' }); + assert.equal(env.DAYTONA_API_KEY, 'sk-test'); + }); +}); + +const daytonaApiKey = process.env.DAYTONA_API_KEY?.trim(); +const HAS_DAYTONA = Boolean(daytonaApiKey); +const SMOKE_LABEL = 'daytona-runner-smoke'; + +describe('DaytonaRuntime smoke', { concurrency: false }, () => { + let runtime: DaytonaRuntime | undefined; + let handle: RuntimeHandle | undefined; + + before(() => { + if (!HAS_DAYTONA) return; + const auth = resolveDaytonaAuthCredentials({ + apiKey: daytonaApiKey, + jwtToken: process.env.DAYTONA_JWT_TOKEN, + organizationId: process.env.DAYTONA_ORGANIZATION_ID, + }); + const daytona = new Daytona(auth); + runtime = new DaytonaRuntime({ daytona }); + }); + + after(async () => { + if (runtime && handle) { + try { + await runtime.destroy(handle); + } catch { + // best-effort cleanup; sandbox leaks surface via Daytona dashboard + } + } + }); + + it( + 'launches a sandbox, runs node -e, and destroys it', + { skip: HAS_DAYTONA ? false : 'DAYTONA_API_KEY is not set', timeout: 120_000 }, + async () => { + assert.ok(runtime, 'runtime should be initialised when DAYTONA_API_KEY is set'); + handle = await runtime.launch({ label: SMOKE_LABEL }); + const result = await runtime.exec(handle, "node -e 'console.log(\"ok\")'"); + assert.equal( + result.exitCode, + 0, + `expected exitCode 0, got ${result.exitCode}: ${result.output}`, + ); + assert.match( + result.output, + /\bok\b/, + `expected output to contain "ok", got: ${result.output}`, + ); + }, + ); +}); diff --git a/packages/daytona-runner/src/runtime.ts b/packages/daytona-runner/src/runtime.ts new file mode 100644 index 00000000..407a7547 --- /dev/null +++ b/packages/daytona-runner/src/runtime.ts @@ -0,0 +1,246 @@ +import type { Daytona, Sandbox } from '@daytonaio/sdk'; +import type { + ExecOptions, + ExecResult, + LaunchOptions, + RuntimeCapabilities, + RuntimeHandle, + WorkflowRuntime, +} from './types.js'; + +export interface DaytonaRuntimeOptions { + daytona: Daytona; + snapshot?: string; + defaultHomeDir?: string; +} + +export interface DaytonaAttachedSandboxOptions { + homeDir?: string; + workdir?: string; + owned?: boolean; +} + +interface RegisteredSandbox { + sandbox: Sandbox; + owned: boolean; +} + +export class DaytonaRuntime implements WorkflowRuntime { + readonly id = 'daytona'; + readonly capabilities: RuntimeCapabilities = { + pty: false, + snapshots: true, + isolation: 'strong', + persistentHandle: true, + streamingLogs: true, + }; + + private readonly sandboxes = new Map(); + private readonly daytona: Daytona; + private readonly snapshot?: string; + private readonly defaultHomeDir: string; + + constructor(options: DaytonaRuntimeOptions) { + this.daytona = options.daytona; + this.snapshot = options.snapshot; + this.defaultHomeDir = options.defaultHomeDir ?? '/home/daytona'; + } + + async launch(options: LaunchOptions = {}): Promise { + const sandbox = await this.createSandbox(options); + const homeDir = await this.resolveHomeDir(sandbox); + return this.registerSandbox(sandbox, { + owned: true, + homeDir, + workdir: options.workdir, + }); + } + + attachSandbox(sandbox: Sandbox, options: DaytonaAttachedSandboxOptions = {}): RuntimeHandle { + return this.registerSandbox(sandbox, { + owned: options.owned ?? false, + homeDir: options.homeDir, + workdir: options.workdir, + }); + } + + async exec(handle: RuntimeHandle, command: string, options: ExecOptions = {}): Promise { + const sandbox = this.requireSandbox(handle); + const result = await sandbox.process.executeCommand( + command, + options.cwd, + options.env, + this.msToSeconds(options.timeoutMs), + ); + + return { + output: result.result ?? '', + exitCode: result.exitCode ?? 0, + }; + } + + async uploadFile(handle: RuntimeHandle, source: string | Buffer, destination: string): Promise { + const sandbox = this.requireSandbox(handle); + if (typeof source === 'string') { + await sandbox.fs.uploadFile(source, destination); + return; + } + await sandbox.fs.uploadFile(source, destination); + } + + async downloadFile(handle: RuntimeHandle, source: string, destination?: string): Promise { + const sandbox = this.requireSandbox(handle); + if (destination) { + await sandbox.fs.downloadFile(source, destination); + return; + } + return sandbox.fs.downloadFile(source); + } + + async getHomeDir(handle: RuntimeHandle): Promise { + if (handle.homeDir) { + return handle.homeDir; + } + + const sandbox = this.requireSandbox(handle); + const homeDir = await this.resolveHomeDir(sandbox); + handle.homeDir = homeDir; + return homeDir; + } + + async destroy(handle: RuntimeHandle): Promise { + const entry = this.sandboxes.get(handle.id); + if (!entry) { + return; + } + + if (!entry.owned) { + // For attached (non-owned) sandboxes we never call the remote + // delete; just drop the local registration so the caller-managed + // resource isn't tracked here any more. + this.sandboxes.delete(handle.id); + return; + } + + const client = this.daytona as unknown as { + remove?: (sandbox: Sandbox) => Promise; + delete: (sandbox: Sandbox) => Promise; + }; + const remove = client.remove ?? client.delete; + // Order matters: do the remote delete *first*, and only drop the + // local map entry after it succeeds. If we dropped the entry first + // and the remote delete then failed, the handle id would be lost + // and the caller could not retry cleanup safely. + await remove.call(client, entry.sandbox); + this.sandboxes.delete(handle.id); + } + + private async createSandbox(options: LaunchOptions): Promise { + const params = this.buildCreateParams(options); + + if (this.snapshot) { + try { + return await this.daytona.create({ + snapshot: this.snapshot, + ...params, + }); + } catch (err) { + // Only fall back to a fresh sandbox when the snapshot itself is + // missing. Auth/network/quota errors should bubble — otherwise + // we silently mask real failures (a 401 ends up creating an + // unsnapshotted sandbox under whichever credentials worked). + if (!isSnapshotNotFoundError(err)) { + throw err; + } + } + } + + return this.daytona.create({ + language: 'typescript', + ...params, + }); + } + + private buildCreateParams(options: LaunchOptions): { + envVars?: Record; + name?: string; + } { + const envVars = options.env && Object.keys(options.env).length > 0 ? options.env : undefined; + const name = options.label?.trim() ? options.label.trim() : undefined; + + return { + ...(envVars ? { envVars } : {}), + ...(name ? { name } : {}), + }; + } + + private registerSandbox( + sandbox: Sandbox, + options: DaytonaAttachedSandboxOptions & { owned: boolean }, + ): RuntimeHandle { + const handle: RuntimeHandle = { + id: sandbox.id, + ...(options.homeDir ? { homeDir: options.homeDir } : {}), + ...(options.workdir ? { workdir: options.workdir } : {}), + }; + + this.sandboxes.set(handle.id, { + sandbox, + owned: options.owned, + }); + return handle; + } + + private requireSandbox(handle: RuntimeHandle): Sandbox { + const entry = this.sandboxes.get(handle.id); + if (!entry) { + throw new Error(`Runtime handle "${handle.id}" is no longer active`); + } + return entry.sandbox; + } + + private async resolveHomeDir(sandbox: Sandbox): Promise { + try { + const home = await sandbox.getUserHomeDir(); + if (home) { + return home; + } + } catch { + // fall through to default + } + + return this.defaultHomeDir; + } + + private msToSeconds(timeoutMs?: number): number | undefined { + if (!timeoutMs || timeoutMs <= 0) { + return undefined; + } + + return Math.max(1, Math.ceil(timeoutMs / 1000)); + } +} + +/** + * Heuristic: identify Daytona errors that indicate the snapshot we asked + * for doesn't exist (so falling back to a fresh sandbox is safe). We look + * at the HTTP status when the SDK surfaces one, plus a few well-known + * error-message shapes Daytona emits. Anything else propagates so the + * caller sees the original error (auth/network/quota/etc.). + */ +function isSnapshotNotFoundError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const candidate = err as { status?: unknown; statusCode?: unknown; message?: unknown; code?: unknown }; + const status = typeof candidate.status === 'number' + ? candidate.status + : typeof candidate.statusCode === 'number' + ? candidate.statusCode + : undefined; + if (status === 404) return true; + const message = typeof candidate.message === 'string' ? candidate.message.toLowerCase() : ''; + if (!message) return false; + return ( + message.includes('snapshot') && + (message.includes('not found') || message.includes('does not exist') || message.includes('no such')) + ); +} diff --git a/packages/daytona-runner/src/types.ts b/packages/daytona-runner/src/types.ts new file mode 100644 index 00000000..303afcc6 --- /dev/null +++ b/packages/daytona-runner/src/types.ts @@ -0,0 +1,44 @@ +export type IsolationLevel = 'none' | 'process' | 'strong'; + +export interface RuntimeCapabilities { + pty: boolean; + snapshots: boolean; + isolation: IsolationLevel; + persistentHandle: boolean; + streamingLogs: boolean; +} + +export interface LaunchOptions { + env?: Record; + label?: string; + workdir?: string; +} + +export interface RuntimeHandle { + id: string; + homeDir?: string; + workdir?: string; +} + +export interface ExecOptions { + cwd?: string; + env?: Record; + timeoutMs?: number; +} + +export interface ExecResult { + output: string; + exitCode: number; +} + +export interface WorkflowRuntime { + readonly id: string; + readonly capabilities: RuntimeCapabilities; + + launch(options?: LaunchOptions): Promise; + exec(handle: RuntimeHandle, command: string, options?: ExecOptions): Promise; + uploadFile(handle: RuntimeHandle, source: string | Buffer, destination: string): Promise; + downloadFile(handle: RuntimeHandle, source: string, destination?: string): Promise; + getHomeDir(handle: RuntimeHandle): Promise; + destroy(handle: RuntimeHandle): Promise; +} diff --git a/packages/daytona-runner/tsconfig.json b/packages/daytona-runner/tsconfig.json new file mode 100644 index 00000000..df59da57 --- /dev/null +++ b/packages/daytona-runner/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f7a133a..f8c3cbf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: specifier: ^9.4.0 version: 9.4.0 + packages/daytona-runner: + devDependencies: + '@daytonaio/sdk': + specifier: ^0.148.0 + version: 0.148.0(ws@8.20.0) + packages/deploy: dependencies: '@agentworkforce/persona-kit':