diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5c68519..4509df8f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -89,6 +89,7 @@ jobs: # events (leaf — consumed by runtime and Cloud) # persona-kit (leaf — consumed by everyone) # runtime (→ persona-kit; consumed by deploy + mcp-workforce) + # compose (leaf — TeamSpec parsing and preview planning) # delivery (→ runtime; consumed by external agents) # workload-router (→ persona-kit; consumed by cli) # deploy (→ persona-kit + runtime; consumed by cli) @@ -97,7 +98,7 @@ jobs: # cli (→ persona-kit + workload-router + deploy) # agentworkforce (→ cli — umbrella wrapper, must publish last) # personas-core publishes via the separate publish-personas.yml workflow. - echo "packages=events persona-kit runtime delivery workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT" + echo "packages=events persona-kit runtime compose delivery workload-router deploy mcp-workforce daytona-runner cli 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 diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 314113f8..f01e282e 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -15,6 +15,7 @@ on: type: choice options: - '@agentworkforce/events' + - '@agentworkforce/compose' - '@agentworkforce/cli' - 'agentworkforce' - '@agentworkforce/workload-router' diff --git a/packages/compose/README.md b/packages/compose/README.md new file mode 100644 index 00000000..92ded7b7 --- /dev/null +++ b/packages/compose/README.md @@ -0,0 +1,5 @@ +# @agentworkforce/compose + +Portable TeamSpec validation and stable Compose references/plans for +AgentWorkforce. This package is the source of truth consumed by authoring, +Cloud, and agent repositories; Relayflows remains the workflow-schema owner. diff --git a/packages/compose/package.json b/packages/compose/package.json new file mode 100644 index 00000000..c7655a2a --- /dev/null +++ b/packages/compose/package.json @@ -0,0 +1,33 @@ +{ + "name": "@agentworkforce/compose", + "version": "0.1.0", + "private": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "default": "./dist/schema.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md", "package.json"], + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/compose" + }, + "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 dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/compose/src/index.ts b/packages/compose/src/index.ts new file mode 100644 index 00000000..411fc13d --- /dev/null +++ b/packages/compose/src/index.ts @@ -0,0 +1,25 @@ +export { TEAM_SPEC_JSON_SCHEMA } from './schema.js'; +export { + findTeamSpecFromPersonaDir, + loadTeamSpec, + loadTeamSpecFile, + loadTeamSpecFromPersonaDir, + parseTeamSpecFile, + TEAM_SPEC_FILENAME, + TeamSpecError, + validateTeamSpec +} from './team-spec.js'; +export { createComposePreviewResult, planTeamSpec } from './plan.js'; +export type { + ComposePlanEdgeV1, + ComposePlanNodeV1, + ComposePlanV1, + ComposeRef, + ComposeResultV1, + DelegationRule, + PersonaRef, + TeamMember, + TeamRef, + TeamSpec, + TriggerSelector +} from './types.js'; diff --git a/packages/compose/src/plan.ts b/packages/compose/src/plan.ts new file mode 100644 index 00000000..2d7cad6d --- /dev/null +++ b/packages/compose/src/plan.ts @@ -0,0 +1,53 @@ +import type { ComposePlanV1, ComposeRef, ComposeResultV1, TeamSpec } from './types.js'; +import { loadTeamSpec } from './team-spec.js'; + +/** Pure TeamSpec planner used by preview mode; it never launches child Runs. */ +export function planTeamSpec(input: unknown, ref?: ComposeRef): ComposePlanV1 { + const spec = loadTeamSpec(input); + const composeRef: ComposeRef = ref ?? { kind: 'team', ref: spec.id }; + if (composeRef.kind !== 'team') throw new Error('planTeamSpec requires a team ComposeRef'); + const nodes: ComposePlanV1['nodes'] = []; + if (!spec.members.some((member) => member.name === spec.lead)) { + nodes.push({ id: spec.lead, kind: 'lead' }); + } + for (const member of spec.members) { + nodes.push({ + id: member.name, + kind: member.name === spec.lead ? 'lead' : 'member', + persona: member.persona, + ...(member.role ? { role: member.role } : {}), + ...(member.owns ? { owns: member.owns } : {}) + }); + } + return { + schemaVersion: 1, + composeId: spec.id, + kind: 'team', + ref: composeRef, + status: 'valid', + nodes, + edges: spec.delegation === undefined + ? spec.members + .filter((member) => member.name !== spec.lead) + .map((member) => ({ from: spec.lead, to: member.name, kind: 'delegates' as const })) + : [], + ...(spec.delegation !== undefined ? { delegation: spec.delegation.map((rule) => ({ ...rule })) } : {}), + ...((spec.tokenBudget !== undefined || spec.timeBudgetSeconds !== undefined) ? { + budget: { + ...(spec.tokenBudget !== undefined ? { tokenBudget: spec.tokenBudget } : {}), + ...(spec.timeBudgetSeconds !== undefined ? { timeBudgetSeconds: spec.timeBudgetSeconds } : {}) + } + } : {}), + errors: [] + }; +} + +export function createComposePreviewResult(plan: ComposePlanV1): ComposeResultV1 { + return { + schemaVersion: 1 as const, + composeId: plan.composeId, + status: 'previewed' as const, + plan, + childRunIds: [] + }; +} diff --git a/packages/compose/src/schema.ts b/packages/compose/src/schema.ts new file mode 100644 index 00000000..1eae0572 --- /dev/null +++ b/packages/compose/src/schema.ts @@ -0,0 +1,44 @@ +const nonBlankString = { type: 'string', minLength: 1, pattern: '\\S' } as const; + +export const TEAM_SPEC_JSON_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://agentworkforce.dev/schemas/compose/team-spec.json', + title: 'TeamSpec', + type: 'object', + required: ['id', 'lead', 'members'], + additionalProperties: false, + properties: { + id: nonBlankString, + lead: nonBlankString, + members: { + type: 'array', minItems: 1, + items: { + type: 'object', required: ['name', 'persona'], additionalProperties: false, + properties: { + name: nonBlankString, + persona: { + oneOf: [ + nonBlankString, + { + type: 'object', + additionalProperties: false, + properties: { + slug: nonBlankString, + version: { oneOf: [nonBlankString, { type: 'integer', minimum: 1 }] }, + path: nonBlankString, + inline: { type: 'object' } + }, + anyOf: [{ required: ['slug'] }, { required: ['path'] }, { required: ['inline'] }] + } + ] + }, + role: nonBlankString, + owns: { type: 'array', items: { type: 'object' } } + } + } + }, + delegation: { type: 'array', items: { type: 'object' } }, + tokenBudget: { type: 'integer', minimum: 1, maximum: 2147483647 }, + timeBudgetSeconds: { type: 'integer', minimum: 1, maximum: 2147483647 } + } +} as const; diff --git a/packages/compose/src/team-spec.test.ts b/packages/compose/src/team-spec.test.ts new file mode 100644 index 00000000..1ccf9e22 --- /dev/null +++ b/packages/compose/src/team-spec.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import test from 'node:test'; +import { + createComposePreviewResult, + loadTeamSpec, + parseTeamSpecFile, + planTeamSpec, + TEAM_SPEC_JSON_SCHEMA, + TeamSpecError, + validateTeamSpec +} from './index.js'; + +const SPEC = { + id: 'cloud-team-issue', + lead: 'cloud-team-issue', + members: [ + { name: 'implementer', persona: { slug: 'cloud-team-implementer' }, role: 'implementer' }, + { name: 'reviewer', persona: { slug: 'cloud-team-reviewer' }, role: 'reviewer' } + ], + tokenBudget: 400000, + timeBudgetSeconds: 1800 +}; + +test('loads the canonical team.json shape from spec section 12.3', () => { + assert.deepEqual(loadTeamSpec(SPEC), SPEC); + assert.deepEqual(validateTeamSpec(SPEC, { expectedId: 'cloud-team-issue' }), []); + assert.equal(TEAM_SPEC_JSON_SCHEMA.properties.members.minItems, 1); +}); + +test('matches Cloud validation for persona refs, budgets, names, and ownership', () => { + assert.throws(() => loadTeamSpec({ ...SPEC, tokenBudget: 0 }), /positive 32-bit integer/); + assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: { version: 1 } }] }), /must include slug, path, or inline/); + assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: 'a' }, { name: 'x', persona: 'b' }] }), /duplicate member name/); + assert.throws(() => loadTeamSpec({ ...SPEC, members: [ + { name: 'a', persona: 'a', owns: [{ provider: 'linear', on: 'issue.created' }] }, + { name: 'b', persona: 'b', owns: [{ on: 'issue.created', provider: 'linear' }] } + ] }), /claimed by both/); + assert.throws(() => loadTeamSpec(null), TeamSpecError); + assert.throws(() => loadTeamSpec({ ...SPEC, tokenBudegt: 10 }), /TeamSpec\.tokenBudegt is not a known field/); + assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: 'x', typo: true }] }), /members\[0\]\.typo is not a known field/); + assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: { slug: 'x', typo: true } }] }), /persona\.typo is not a known field/); + assert.doesNotThrow(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: 'x', owns: [{ optional: undefined }] }] })); + assert.equal(TEAM_SPEC_JSON_SCHEMA.properties.id.pattern, '\\S'); +}); + +test('Compose preview resolves the roster and launches zero children', () => { + const plan = planTeamSpec(SPEC); + assert.equal(plan.status, 'valid'); + assert.deepEqual(plan.nodes.map((node) => node.id), ['cloud-team-issue', 'implementer', 'reviewer']); + assert.deepEqual(plan.budget, { tokenBudget: 400000, timeBudgetSeconds: 1800 }); + const result = createComposePreviewResult(plan); + assert.equal(result.status, 'previewed'); + assert.deepEqual(result.childRunIds, []); + + const delegation = [{ from: 'implementer', to: 'reviewer', when: 'ready' }]; + const delegated = planTeamSpec({ ...SPEC, delegation }); + assert.deepEqual(delegated.delegation, delegation); + assert.deepEqual(delegated.edges, []); +}); + +test('file parser requires team id to match its directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'compose-team-')); + try { + const dir = join(root, 'expected'); + await mkdir(dir); + const file = join(dir, 'team.json'); + await writeFile(file, JSON.stringify({ ...SPEC, id: 'wrong' })); + await assert.rejects(() => parseTeamSpecFile(file), /must match team directory "expected"/); + await writeFile(file, JSON.stringify({ ...SPEC, id: 'expected' })); + assert.equal((await parseTeamSpecFile(relative(process.cwd(), file))).id, 'expected'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/compose/src/team-spec.ts b/packages/compose/src/team-spec.ts new file mode 100644 index 00000000..41cb3ab5 --- /dev/null +++ b/packages/compose/src/team-spec.ts @@ -0,0 +1,215 @@ +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import type { PersonaRef, TeamMember, TeamSpec } from './types.js'; + +export const TEAM_SPEC_FILENAME = 'team.json'; +const POSTGRES_INTEGER_MAX = 2_147_483_647; + +export class TeamSpecError extends Error { + constructor(message: string) { + super(message); + this.name = 'TeamSpecError'; + } +} + +/** Throwing parser compatible with Cloud's original TeamSpec contract. */ +export function loadTeamSpec(value: unknown): TeamSpec { + if (!isRecord(value)) throw new TeamSpecError('TeamSpec must be an object'); + assertKnownKeys(value, new Set(['id', 'lead', 'members', 'delegation', 'tokenBudget', 'timeBudgetSeconds']), 'TeamSpec'); + const id = requireNonEmptyString(value.id, 'id'); + const lead = requireNonEmptyString(value.lead, 'lead'); + if (!Array.isArray(value.members) || value.members.length === 0) { + throw new TeamSpecError('members must be a non-empty array'); + } + + const members: TeamMember[] = []; + for (let index = 0; index < value.members.length; index += 1) { + members.push(parseMember(value.members[index], index)); + } + const names = new Set(); + const ownedSelectors = new Map(); + for (const member of members) { + if (names.has(member.name)) throw new TeamSpecError(`duplicate member name "${member.name}"`); + names.add(member.name); + for (const selector of member.owns ?? []) { + const key = stableJson(selector); + const existingOwner = ownedSelectors.get(key); + if (existingOwner && existingOwner !== member.name) { + throw new TeamSpecError(`owns selector ${key} is claimed by both "${existingOwner}" and "${member.name}"`); + } + ownedSelectors.set(key, member.name); + } + } + + const spec: TeamSpec = { id, lead, members }; + const delegation = parseRecordArray(value.delegation, 'delegation'); + const tokenBudget = optionalPositiveInteger(value.tokenBudget, 'tokenBudget'); + const timeBudgetSeconds = optionalPositiveInteger(value.timeBudgetSeconds, 'timeBudgetSeconds'); + if (delegation !== undefined) spec.delegation = delegation; + if (tokenBudget !== undefined) spec.tokenBudget = tokenBudget; + if (timeBudgetSeconds !== undefined) spec.timeBudgetSeconds = timeBudgetSeconds; + return spec; +} + +export function validateTeamSpec(value: unknown, options: { expectedId?: string } = {}): string[] { + try { + const spec = loadTeamSpec(value); + if (options.expectedId !== undefined && spec.id !== options.expectedId) { + return [`id "${spec.id}" must match team directory "${options.expectedId}"`]; + } + return []; + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } +} + +export async function loadTeamSpecFile(filePath: string): Promise { + let contents: string; + try { + contents = await readFile(filePath, 'utf8'); + } catch (error) { + throw new TeamSpecError(`Unable to read team spec at ${filePath}: ${errorMessage(error)}`); + } + return parseTeamSpecJson(contents, filePath); +} + +export async function parseTeamSpecFile(filePath: string): Promise { + const spec = await loadTeamSpecFile(filePath); + const expectedId = basename(dirname(resolve(filePath))); + if (spec.id !== expectedId) { + throw new TeamSpecError(`TeamSpec id "${spec.id}" must match team directory "${expectedId}"`); + } + return spec; +} + +export async function findTeamSpecFromPersonaDir(personaDir: string): Promise { + const filePath = join(personaDir, TEAM_SPEC_FILENAME); + let contents: string; + try { + contents = await readFile(filePath, 'utf8'); + } catch (error) { + if (errorCode(error) === 'ENOENT') return null; + throw new TeamSpecError(`Unable to read team spec at ${filePath}: ${errorMessage(error)}`); + } + return parseTeamSpecJson(contents, filePath); +} + +export async function loadTeamSpecFromPersonaDir(personaDir: string): Promise { + const spec = await findTeamSpecFromPersonaDir(personaDir); + if (!spec) throw new TeamSpecError(`No ${TEAM_SPEC_FILENAME} found in persona directory ${personaDir}`); + return spec; +} + +function parseTeamSpecJson(contents: string, sourcePath: string): TeamSpec { + let parsed: unknown; + try { + parsed = JSON.parse(contents) as unknown; + } catch (error) { + throw new TeamSpecError(`Invalid JSON in ${sourcePath}: ${errorMessage(error)}`); + } + try { + return loadTeamSpec(parsed); + } catch (error) { + if (error instanceof TeamSpecError) throw new TeamSpecError(`Invalid team spec in ${sourcePath}: ${error.message}`); + throw error; + } +} + +function parseMember(value: unknown, index: number): TeamMember { + if (!isRecord(value)) throw new TeamSpecError(`members[${index}] must be an object`); + assertKnownKeys(value, new Set(['name', 'persona', 'role', 'owns']), `members[${index}]`); + const member: TeamMember = { + name: requireNonEmptyString(value.name, `members[${index}].name`), + persona: parsePersonaRef(value.persona, `members[${index}].persona`) + }; + const role = optionalNonEmptyString(value.role, `members[${index}].role`); + const owns = parseRecordArray(value.owns, `members[${index}].owns`); + if (role !== undefined) member.role = role; + if (owns !== undefined) member.owns = owns; + return member; +} + +function parsePersonaRef(value: unknown, path: string): PersonaRef { + if (typeof value === 'string') return requireNonEmptyString(value, path); + if (!isRecord(value)) throw new TeamSpecError(`${path} must be a string or object`); + assertKnownKeys(value, new Set(['slug', 'version', 'path', 'inline']), path); + const slug = optionalNonEmptyString(value.slug, `${path}.slug`); + const pathRef = optionalNonEmptyString(value.path, `${path}.path`); + let version: string | number | undefined; + if (value.version !== undefined) { + if (typeof value.version === 'string') { + version = value.version.trim(); + if (!version) throw new TeamSpecError(`${path}.version must be a non-empty string or positive integer`); + } else if (typeof value.version !== 'number' || !Number.isInteger(value.version) || value.version <= 0) { + throw new TeamSpecError(`${path}.version must be a non-empty string or positive integer`); + } else version = value.version; + } + let inline: Record | undefined; + if (value.inline !== undefined) { + if (!isRecord(value.inline)) throw new TeamSpecError(`${path}.inline must be an object`); + inline = value.inline; + } + const optional = { + ...(version !== undefined ? { version } : {}), + ...(pathRef !== undefined ? { path: pathRef } : {}), + ...(inline !== undefined ? { inline } : {}) + }; + if (slug !== undefined) return { slug, ...optional }; + if (pathRef !== undefined) return { path: pathRef, ...(version !== undefined ? { version } : {}), ...(inline !== undefined ? { inline } : {}) }; + if (inline !== undefined) return { inline, ...(version !== undefined ? { version } : {}) }; + throw new TeamSpecError(`${path} must include slug, path, or inline`); +} + +function parseRecordArray(value: unknown, path: string): Record[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new TeamSpecError(`${path} must be an array`); + const entries: Record[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!isRecord(entry)) throw new TeamSpecError(`${path}[${index}] must be an object`); + entries.push(entry); + } + return entries; +} + +function optionalPositiveInteger(value: unknown, path: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > POSTGRES_INTEGER_MAX) { + throw new TeamSpecError(`${path} must be a positive 32-bit integer`); + } + return value; +} + +function optionalNonEmptyString(value: unknown, path: string): string | undefined { + return value === undefined ? undefined : requireNonEmptyString(value, path); +} + +function requireNonEmptyString(value: unknown, path: string): string { + if (typeof value !== 'string' || !value.trim()) throw new TeamSpecError(`${path} must be a non-empty string`); + return value.trim(); +} + +function stableJson(value: unknown): string { + if (value === undefined) return 'null'; + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; + return JSON.stringify(value) ?? 'null'; +} + +function assertKnownKeys(value: Record, allowed: ReadonlySet, path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new TeamSpecError(`${path}.${key} is not a known field`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorCode(error: unknown): string | undefined { + return isRecord(error) && typeof error.code === 'string' ? error.code : undefined; +} diff --git a/packages/compose/src/types.ts b/packages/compose/src/types.ts new file mode 100644 index 00000000..9e83261e --- /dev/null +++ b/packages/compose/src/types.ts @@ -0,0 +1,91 @@ +interface PersonaRefFields { + slug?: string; + version?: number | string; + path?: string; + inline?: Record; +} + +/** Object refs require at least one resolvable selector, matching the parser/schema. */ +export type PersonaRef = string | (PersonaRefFields & ( + | { slug: string } + | { path: string } + | { inline: Record } +)); + +export type TriggerSelector = Record; +export type DelegationRule = Record; + +export interface TeamMember { + name: string; + persona: PersonaRef; + role?: string; + owns?: TriggerSelector[]; +} + +export interface TeamSpec { + id: string; + lead: string; + members: TeamMember[]; + delegation?: DelegationRule[]; + tokenBudget?: number; + timeBudgetSeconds?: number; +} + +export type TeamRef = + | string + | { + id: string; + version?: string | number; + path?: string; + }; + +/** Relayflows is referenced, never copied into this package. */ +export type ComposeRef = + | { kind: 'team'; ref: TeamRef } + | { kind: 'workflow'; ref: string; version?: string | number }; + +export interface ComposePlanNodeV1 { + id: string; + kind: 'lead' | 'member' | 'workflow-step'; + persona?: PersonaRef; + role?: string; + owns?: TriggerSelector[]; + extensions?: Record; +} + +export interface ComposePlanEdgeV1 { + from: string; + to: string; + kind: 'delegates' | 'depends-on'; + reason?: string; +} + +interface ComposePlanBaseV1 { + schemaVersion: 1; + composeId: string; + status: 'valid' | 'invalid'; + nodes: ComposePlanNodeV1[]; + edges: ComposePlanEdgeV1[]; + delegation?: DelegationRule[]; + budget?: { tokenBudget?: number; timeBudgetSeconds?: number }; + errors: string[]; + extensions?: Record; +} + +/** Plan kind and reference stay correlated across serialization boundaries. */ +export type ComposePlanV1 = ComposePlanBaseV1 & ( + | { kind: 'team'; ref: Extract } + | { kind: 'workflow'; ref: Extract } +); + +export interface ComposeResultV1 { + schemaVersion: 1; + composeId: string; + status: 'previewed' | 'succeeded' | 'failed' | 'cancelled'; + plan: ComposePlanV1; + childRunIds: string[]; + tokenBudgetConsumed?: number; + timeConsumedSeconds?: number; + errors?: string[]; + extensions?: Record; +} diff --git a/packages/compose/tsconfig.json b/packages/compose/tsconfig.json new file mode 100644 index 00000000..5ee9c865 --- /dev/null +++ b/packages/compose/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist" }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 439cc12c..19863ab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,8 @@ importers: specifier: ^9.4.0 version: 9.4.0 + packages/compose: {} + packages/daytona-runner: devDependencies: '@daytonaio/sdk':