diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ef69b302..9e120c93 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -31,7 +31,7 @@ jobs: run: npm ci --no-audit --no-fund - name: Install exact integrated SDK - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@0bc542a8c2919bd58ac16f5a2b12eace961ef755 + uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@affa0623b8af92cde577f3b8ab685c1b61f2c8cb with: github-token: ${{ github.token }} diff --git a/completions/trsd.bash b/completions/trsd.bash index f9fd4643..9ffdd9d5 100644 --- a/completions/trsd.bash +++ b/completions/trsd.bash @@ -1,6 +1,6 @@ # Generated from treeseed.command-tree/v1. _trsd_complete() { - local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nproposals list\nproposals show\nproposals create\nproposals update\nproposals open\nproposals feedback resolve\nproposals voting start\nproposals vote\nproposals evaluate\ndecisions list\ndecisions show\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nservices credentials show\nservices credentials put\nservices credentials delete\nservices credentials validate\nplatform verify\nplatform workset\nplatform project create\nplatform topology plan\nplatform topology apply\nplatform topology status\nplatform topology rollback\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev session recover\ndev use\ndev rebuild\ndev migrate\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost initialize\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost start\nhost stop\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config stage\nhost config adopt\nhost postgres transfer prepare\nhost postgres transfer status\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost provider environment list\nhost provider environment show\nhost provider environment status\nhost provider environment set\nhost provider environment import\nhost provider environment unset\nhost provider environment rotate\nhost provider environment verify\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nhost uninstall\nagents list\nagents show\nagents team clone plan\nagents team clone apply\nagents handlers list\nagents handlers show\nagents profiles show\nagents profiles validate\nagents classes list\nagents classes show\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders registration code status\nproviders registration code reveal\nproviders registration code rotate\nproviders environments list\nproviders environments show\nproviders environments grant\nproviders environments revoke\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nworkdays profiles list\nworkdays profiles show\nworkdays profiles update\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays stop\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nexecution graph show\nexecution graph watch\nexecution node show\nexecution node explain\nexecution reconcile\nexecution assignments list\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai storage show\nai storage connect\nai storage disconnect\nai storage verify\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" + local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nproposals list\nproposals show\nproposals create\nproposals update\nproposals open\nproposals feedback resolve\nproposals voting start\nproposals vote\nproposals evaluate\nproposals withdraw\nproposals supersede\ndecisions list\ndecisions show\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nservices credentials show\nservices credentials put\nservices credentials delete\nservices credentials validate\nplatform verify\nplatform workset\nplatform project create\nplatform topology plan\nplatform topology apply\nplatform topology status\nplatform topology rollback\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev session recover\ndev use\ndev rebuild\ndev migrate\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost initialize\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost start\nhost stop\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config stage\nhost config adopt\nhost postgres transfer prepare\nhost postgres transfer status\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost provider environment list\nhost provider environment show\nhost provider environment status\nhost provider environment set\nhost provider environment import\nhost provider environment unset\nhost provider environment rotate\nhost provider environment verify\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nhost uninstall\nagents list\nagents show\nagents team clone plan\nagents team clone apply\nagents handlers list\nagents handlers show\nagents profiles show\nagents profiles validate\nagents classes list\nagents classes show\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders registration code status\nproviders registration code reveal\nproviders registration code rotate\nproviders environments list\nproviders environments show\nproviders environments grant\nproviders environments revoke\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nworkdays profiles list\nworkdays profiles show\nworkdays profiles update\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays stop\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nexecution graph show\nexecution graph watch\nexecution node show\nexecution node explain\nexecution reconcile\nexecution assignments list\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai storage show\nai storage connect\nai storage disconnect\nai storage verify\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" COMPREPLY=( $(compgen -W "$paths" -- "${COMP_WORDS[*]:1}") ) } complete -F _trsd_complete trsd diff --git a/docs/command-reference.md b/docs/command-reference.md index 437a1af5..9840155f 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -353,6 +353,36 @@ Control-plane operation: `governance.proposals.evaluate`. - `--plan`: Return the exact proposed outcome without mutation. - `--input `: Optional YAML or JSON evaluation decision. +### trsd proposals withdraw + +Withdraw the selected resource. + +Operation: mutation. Result schema: `treeseed.command.withdraw/v1`. +Control-plane operation: `governance.proposals.withdraw`. + +- `--server `: Control-plane server profile or URL. +- `--project `: Project id or slug. +- `--json`: Emit the stable JSON envelope. +- `--if-match `: Exact current resource version, or new when unconfigured. +- `--idempotency-key `: Reuse the same request identity when retrying this mutation. +- `--plan`: Return the exact proposed outcome without mutation. +- `--input `: Optional YAML or JSON withdrawal reason and evidence. + +### trsd proposals supersede + +Supersede the selected resource. + +Operation: mutation. Result schema: `treeseed.command.supersede/v1`. +Control-plane operation: `governance.proposals.supersede`. + +- `--server `: Control-plane server profile or URL. +- `--project `: Project id or slug. +- `--json`: Emit the stable JSON envelope. +- `--if-match `: Exact current resource version, or new when unconfigured. +- `--idempotency-key `: Reuse the same request identity when retrying this mutation. +- `--plan`: Return the exact proposed outcome without mutation. +- `--input `: Optional YAML or JSON successor, reason, and evidence. + ## trsd decisions Decisions operations. @@ -2188,6 +2218,7 @@ Control-plane operation: `workdays.profiles.update`. - `--if-match `: Exact current resource version, or new when unconfigured. - `--idempotency-key `: Reuse the same request identity when retrying this mutation. - `--plan`: Return the exact proposed outcome without mutation. +- `--input `: YAML or JSON workday policy document. ### trsd workdays plan diff --git a/guarantees/agent/golden/component-boundaries.guarantee.yaml b/guarantees/agent/golden/component-boundaries.guarantee.yaml new file mode 100644 index 00000000..1e933785 --- /dev/null +++ b/guarantees/agent/golden/component-boundaries.guarantee.yaml @@ -0,0 +1,12 @@ +schemaVersion: treeseed.guarantee/v1 +id: guarantee.cli.golden.component-boundaries +journey: Verify golden operator component boundaries +ownerPackage: "@treeseed/cli" +type: agent +subtype: operator +status: planned +gates: [core] +summary: Coded operator component scenarios; not proof of a live SDK golden workday. +scene: + required: true + manifest: guarantees/agent/golden/scenes/component-boundaries.scene.yaml diff --git a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml new file mode 100644 index 00000000..70dd996a --- /dev/null +++ b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml @@ -0,0 +1,32 @@ +schemaVersion: treeseed.scene/v1 +id: cli.golden.component-boundaries +title: Golden operator component boundaries +scope: local-component-tests +workflow: + - id: refused-rebuild + action: { verifier: cli.golden.refused-rebuild } + expect: { status: passed } + - id: allocations + action: { verifier: cli.golden.allocations } + expect: { status: passed } + - id: selection + action: { verifier: cli.golden.selection } + expect: { status: passed } + - id: decisions + action: { verifier: cli.golden.decisions } + expect: { status: passed } + - id: decision-denial + action: { verifier: cli.golden.decision-denial } + expect: { status: passed } + - id: input-isolation + action: { verifier: cli.golden.input-isolation } + expect: { status: passed } + - id: diagnostics + action: { verifier: cli.golden.diagnostics } + expect: { status: passed } + - id: runtime-readback + action: { verifier: cli.golden.runtime-readback } + expect: { status: passed } + - id: lifecycle-lock + action: { verifier: cli.golden.lifecycle-lock } + expect: { status: passed } diff --git a/guarantees/verifiers/golden.verifiers.yaml b/guarantees/verifiers/golden.verifiers.yaml new file mode 100644 index 00000000..99fd1845 --- /dev/null +++ b/guarantees/verifiers/golden.verifiers.yaml @@ -0,0 +1,48 @@ +schemaVersion: treeseed.guarantee-verifiers/v1 +ownerPackage: "@treeseed/cli" +verifiers: + cli.golden.refused-rebuild: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/host/managed-development-container.test.ts + testName: container startup and cleanup only invoke the protected manager, including failed-start cleanup + cli.golden.allocations: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: high-level allocation options use one nested policy input + cli.golden.selection: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: repeated and CSV selectors become a normalized intersecting nested intent + cli.golden.decisions: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: repeated and CSV accepted decisions become one normalized selection + cli.golden.decision-denial: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: empty decision selection never invokes the API + cli.golden.input-isolation: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: nested bindings reject prototype traversal, collisions, and excessive depth + cli.golden.diagnostics: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/communications/send-outcome.test.ts + testName: streamed human sends retain failure reason and IDs without duplicating prior responses + cli.golden.runtime-readback: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/development/lifecycle.test.ts + testName: managed Agent sandbox status recognizes exact active guest-image custody + cli.golden.lifecycle-lock: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/development/lifecycle.test.ts + testName: lifecycle lock releases after a failed operation diff --git a/schemas/command-tree.json b/schemas/command-tree.json index 53005180..495471e5 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -1235,6 +1235,126 @@ } ] } + }, + { + "nodeType": "leaf", + "segment": "withdraw", + "description": "Withdraw the selected resource.", + "kind": "mutation", + "arguments": [ + { + "name": "proposal", + "description": "proposal identity or path.", + "required": true + } + ], + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--input", + "description": "Optional YAML or JSON withdrawal reason and evidence.", + "type": "string" + } + ], + "authorization": { + "capability": "command.withdraw", + "confirmation": "never" + }, + "resultSchemaId": "treeseed.command.withdraw/v1", + "execution": { + "kind": "operation", + "operationId": "governance.proposals.withdraw", + "input": [ + { + "target": "path", + "field": "projectId", + "source": "context", + "name": "project", + "required": true, + "transform": "identity" + }, + { + "target": "path", + "field": "proposalId", + "source": "argument", + "name": "proposal", + "required": true, + "transform": "identity" + }, + { + "target": "body", + "field": "file", + "source": "option", + "name": "input", + "required": false, + "transform": "identity" + } + ] + } + }, + { + "nodeType": "leaf", + "segment": "supersede", + "description": "Supersede the selected resource.", + "kind": "mutation", + "arguments": [ + { + "name": "proposal", + "description": "proposal identity or path.", + "required": true + } + ], + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--input", + "description": "Optional YAML or JSON successor, reason, and evidence.", + "type": "string" + } + ], + "authorization": { + "capability": "command.supersede", + "confirmation": "never" + }, + "resultSchemaId": "treeseed.command.supersede/v1", + "execution": { + "kind": "operation", + "operationId": "governance.proposals.supersede", + "input": [ + { + "target": "path", + "field": "projectId", + "source": "context", + "name": "project", + "required": true, + "transform": "identity" + }, + { + "target": "path", + "field": "proposalId", + "source": "argument", + "name": "proposal", + "required": true, + "transform": "identity" + }, + { + "target": "body", + "field": "file", + "source": "option", + "name": "input", + "required": false, + "transform": "identity" + } + ] + } } ] }, @@ -5710,6 +5830,12 @@ "name": "--plan", "description": "Return the exact proposed outcome without mutation.", "type": "boolean" + }, + { + "name": "--input", + "description": "YAML or JSON workday policy document.", + "type": "string", + "required": true } ], "authorization": { diff --git a/src/cli/application/data.ts b/src/cli/application/data.ts index 6c22c662..6a30cd97 100644 --- a/src/cli/application/data.ts +++ b/src/cli/application/data.ts @@ -1,6 +1,4 @@ import type { InkRow as Row, InkSurfaceCollection as SurfaceCollection, InkSurfaceItem as SurfaceItem, InkWorkspaceDataSource } from '@treeseed/ui/ink'; -import { validateCapacityAllocationSetV2, type CapacityAllocationSetV2 } from '@treeseed/sdk/agent-capacity'; -import { parse as parseYaml } from 'yaml'; export type { InkRow as Row, InkSurfaceCollection as SurfaceCollection, InkSurfaceItem as SurfaceItem } from '@treeseed/ui/ink'; export type Invoke = (operationId: string, input: { path: Row; query: Row; body: unknown }, options?: Row) => Promise; @@ -87,7 +85,7 @@ export async function loadSurfaceCollection(invoke: Invoke, teamId: string, surf } export function canExecuteSurfaceAction(actionId: string, selected?: SurfaceItem) { - if (['project.create', 'service.connect', 'capacity.configure', 'allocation.save', 'agent.create'].includes(actionId)) return true; + if (['project.create', 'service.connect', 'capacity.configure', 'agent.create'].includes(actionId)) return true; if (['service.configure', 'service.remove', 'capacity.revoke', 'agent.save', 'content.edit', 'release.promote-production'].includes(actionId)) return Boolean(selected); if (actionId === 'release.cut') return !selected || ['approved', 'ready', 'staging'].includes(text(selected.raw.status)); if (actionId === 'question.answer') return selected?.raw.kind === 'question' && selected.raw.status === 'outstanding'; @@ -132,7 +130,7 @@ export async function executeSurfaceAction(invoke: Invoke, teamId: string, actio if (!selected) throw new Error('Revoke capacity requires a selected provider connection.'); return invoke('providers.disconnect', { path: { teamId, connectionId: text(selected.raw.connectionId) || selected.id }, query: {}, body: undefined }, { idempotencyKey: globalThis.crypto.randomUUID() }); } - if (['content.edit', 'agent.create', 'agent.save', 'allocation.save'].includes(actionId)) return executeTreeDxAuthoring(invoke, actionId, values, selected); + if (['content.edit', 'agent.create', 'agent.save'].includes(actionId)) return executeTreeDxAuthoring(invoke, actionId, values, selected); if (actionId === 'release.cut' || actionId === 'release.promote-production') { const reviewId = text(values.reviewId) || selected?.id; if (!reviewId) throw new Error(`${actionId} requires a review.`); @@ -146,13 +144,6 @@ export async function executeSurfaceAction(invoke: Invoke, teamId: string, actio async function executeTreeDxAuthoring(invoke: Invoke, actionId: string, values: Row, selected?: SurfaceItem) { const projectId = text(values.projectId || selected?.raw.projectId); if (!projectId) throw new Error(`${actionId} requires a project ID.`); - if (actionId === 'allocation.save') { - let allocation: unknown; - try { allocation = parseYaml(text(values.content)); } - catch (error) { throw new Error(`Allocation profile is not valid JSON or YAML: ${error instanceof Error ? error.message : String(error)}`); } - const validation = validateCapacityAllocationSetV2(allocation as CapacityAllocationSetV2); - if (!validation.ok) throw new Error(`Allocation profile is invalid: ${validation.diagnostics.map((entry) => `${entry.path}: ${entry.message}`).join(' ')}`); - } let workspaceId = text(values.workspaceId), version = Number(values.version || 0); if (!workspaceId) { const created = payload(await invoke('knowledge.workspaces.create', { path: { projectId }, query: {}, body: { requestId: globalThis.crypto.randomUUID() } }, { idempotencyKey: globalThis.crypto.randomUUID() })); @@ -161,7 +152,7 @@ async function executeTreeDxAuthoring(invoke: Invoke, actionId: string, values: if (!workspaceId || version < 1) throw new Error('A valid TreeDX workspace and version are required.'); const sourcePath = text(values.sourcePath || selected?.raw.path); const body = actionId === 'content.edit' ? { kind: 'page', version, sourcePath: sourcePath || undefined, bookId: text(values.bookId), slug: text(values.slug), title: text(values.title), summary: text(values.summary), body: text(values.body) } - : { kind: actionId === 'agent.save' || actionId === 'agent.create' ? 'agent-profile' : 'operational-content', version, sourcePath, expectedSha: text(selected?.raw.sha ?? values.expectedSha) || undefined, content: text(values.content), ...(actionId === 'agent.create' || !sourcePath ? { create: true } : {}) }; + : { kind: 'agent-profile', version, sourcePath, expectedSha: text(selected?.raw.sha ?? values.expectedSha) || undefined, content: text(values.content), ...(actionId === 'agent.create' || !sourcePath ? { create: true } : {}) }; const updated = payload(await invoke('knowledge.workspaces.content.update', { path: { workspaceId }, query: {}, body }, {})); const nextWorkspace = record(updated.workspace), nextVersion = Number(nextWorkspace.version ?? version + 1); return invoke('knowledge.workspaces.submit', { path: { workspaceId }, query: {}, body: { version: nextVersion, message: text(values.message) } }, { idempotencyKey: globalThis.crypto.randomUUID() }); diff --git a/src/cli/commands/development-support/host-runtime.ts b/src/cli/commands/development-support/host-runtime.ts index 2760638c..f03f7408 100644 --- a/src/cli/commands/development-support/host-runtime.ts +++ b/src/cli/commands/development-support/host-runtime.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { basename, relative, resolve } from 'node:path'; import type { CommandContext, ParsedInvocation } from '../../types.js'; import { invokeLocalHostManager } from '../../support/host-client.js'; @@ -83,18 +83,15 @@ export async function runHostDevelopment(invocation: ParsedInvocation, context: if (invocation.options.plan === true) return { action: 'guest-image-import', image, mutation: false }; const stateBase = context.env.XDG_STATE_HOME ?? (context.env.HOME ? resolve(context.env.HOME, '.local', 'state') : null); if (!stateBase) throw new Error('HOME or XDG_STATE_HOME is required for development guest-image custody.'); - const directory = resolve(stateBase, 'treeseed', 'development', 'images'), archivePath = resolve(directory, `sandbox-${randomUUID()}.tar`); + if (!json) context.write(`Importing ${image} through the local manager…`, 'stdout'); + const result = await invoke(context, 'local.dev.host.guest-image.import', { image }) as { digest?: unknown; architecture?: unknown }; + if (typeof result.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(result.digest)) throw new Error('Host guest-image import omitted its immutable digest.'); + const directory = resolve(stateBase, 'treeseed', 'development'); mkdirSync(directory, { recursive: true, mode: 0o700 }); - try { - if (!json) context.write(`Exporting ${image} for the local Kata runtime…`, 'stdout'); - execFileSync('docker', ['image', 'save', '--output', archivePath, image], { cwd: context.cwd, env: context.env, stdio: json ? 'pipe' : 'inherit' }); - const result = await invoke(context, 'local.dev.host.guest-image.import', { archivePath, image }) as { digest?: unknown; architecture?: unknown }; - if (typeof result.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(result.digest)) throw new Error('Host guest-image import omitted its immutable digest.'); - const receipt = resolve(stateBase, 'treeseed', 'development', 'sandbox-guest.json'), temporary = `${receipt}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 'treeseed.development-sandbox-guest/v1', image, digest: result.digest, architecture: result.architecture, importedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); - renameSync(temporary, receipt); - return result; - } finally { rmSync(archivePath, { force: true }); } + const receipt = resolve(directory, 'sandbox-guest.json'), temporary = `${receipt}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 'treeseed.development-sandbox-guest/v1', image, digest: result.digest, architecture: result.architecture, importedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, receipt); + return result; } if (invocation.command.name !== 'dev host activate') throw new Error(`Unsupported host development command ${invocation.command.name}.`); const worktree = resolve(String(invocation.arguments[0] ?? defaultWorktree(context.cwd))); diff --git a/src/cli/commands/development-support/overlays.ts b/src/cli/commands/development-support/overlays.ts index 02feea31..40e70cc2 100644 --- a/src/cli/commands/development-support/overlays.ts +++ b/src/cli/commands/development-support/overlays.ts @@ -171,7 +171,7 @@ export async function stopProcess(state: OverlaySessionState, key: string) { } export function dependentReactions(runtimes: DevelopmentRuntime[], projectId: string, targetId: string) { - const result: Array<{ runtime: DevelopmentRuntime; target: DevelopmentTarget; reaction: string }> = [], queued = [`${projectId}.${targetId}`], seen = new Set(queued); + const result: Array<{ runtime: DevelopmentRuntime; target: DevelopmentTarget; reaction: DevelopmentTarget['dependencies'][number]['reaction'] }> = [], queued = [`${projectId}.${targetId}`], seen = new Set(queued); while (queued.length) { const selected = queued.shift()!; for (const runtime of runtimes) for (const target of runtime.targets) for (const dependency of target.dependencies) { diff --git a/src/cli/commands/development-support/selection.ts b/src/cli/commands/development-support/selection.ts index 6ac45e85..2e3ae1d9 100644 --- a/src/cli/commands/development-support/selection.ts +++ b/src/cli/commands/development-support/selection.ts @@ -13,7 +13,7 @@ export function selectedDevelopmentTarget(record: unknown, projectId: string, ta return { runtime, target }; } -export function dependentDevelopmentAction(reaction: 'none' | 'restart' | 'rebuild' | 'manual', target: Pick) { +export function dependentDevelopmentAction(reaction: DevelopmentTarget['dependencies'][number]['reaction'], target: Pick) { if (reaction === 'manual') return 'manual' as const; if (reaction !== 'rebuild') return 'restart' as const; if (target.kind === 'package-watch') return 'package-rebuild' as const; diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index c5c5ef08..83ee398c 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -118,7 +118,6 @@ function operationIsRunning(state: LocalSessionState, key: string) { if (ownsDevelopmentProcess(existing, state.sessionId)) return true; delete state.processes[key]; return false; } - async function waitForDirectReadiness(target: DevelopmentTarget, timeoutSeconds: number, state?: LocalSessionState, key?: string) { if (target.ready.kind === 'process') { if (!state || !key) throw new Error(`Process readiness for ${target.id} requires tracked process state.`); @@ -141,7 +140,6 @@ async function waitForDirectReadiness(target: DevelopmentTarget, timeoutSeconds: } throw new Error(`Readiness timed out for ${target.id}.`); } - async function startSession(invocation: ParsedInvocation, context: CommandContext) { const manifest = resolve(context.cwd, invocation.arguments[0]!); if (invocation.options.plan !== true) assertNoSelectedDevelopmentCustody(context.env); @@ -290,12 +288,14 @@ async function rebuildPackage(input: { state: LocalSessionState; record: { sessi installPackageOverlay(state, record, runtime, target, worktree, overlayRoot); await markRebuilt(context, state.sessionId, runtime.project.id, target.id, mode, target); } - async function restartConsumer(input: { state: LocalSessionState; runtime: DevelopmentRuntime; target: DevelopmentTarget; worktree: string; mode: 'candidate' | 'live'; context: CommandContext; recordGeneration?: boolean }) { const { state, runtime, target, worktree, mode, context } = input, key = `${runtime.project.id}.${target.id}`; - if (usesManagedContainer(target)) await invoke(context, 'local.dev.use', {sessionId:state.sessionId,projectId:runtime.project.id,targetId:target.id,mode:'released'}); await stopProcess(state, key); - if (usesManagedContainer(target)) await containerOperation(context, state.sessionId, runtime, target, 'stop'); + if (usesManagedContainer(target)) { + // Preserve the live selection if manager custody refuses an active claim. + await containerOperation(context, state.sessionId, runtime, target, 'stop'); + await invoke(context, 'local.dev.use', {sessionId:state.sessionId,projectId:runtime.project.id,targetId:target.id,mode:'released'}); + } else if (target.operations.cleanup) runOneShotOperation(state, target.operations.cleanup, worktree, mode, context.env, { TREESEED_DEVELOPMENT_CLEANUP_SCOPE: 'runtime' }); const resolved = await invoke(context, 'local.dev.environment', { sessionId: state.sessionId, projectId: runtime.project.id, targetId: target.id }) as { environment?: NodeJS.ProcessEnv }; if (target.operations.setup) runOneShotOperation(state, target.operations.setup, worktree, mode, context.env, resolved.environment ?? {}); @@ -312,7 +312,6 @@ async function restartConsumer(input: { state: LocalSessionState; runtime: Devel } if (input.recordGeneration !== false) await markRebuilt(context, state.sessionId, runtime.project.id, target.id, mode, target); } - async function restart(invocation: ParsedInvocation, context: CommandContext, state: LocalSessionState, sessionId: string) { const selection = parseSelection(`${invocation.arguments[0]}=candidate`); const status = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord; @@ -335,7 +334,6 @@ async function restart(invocation: ParsedInvocation, context: CommandContext, st saveState(state, context.env); return { sessionId, target: `${selection.projectId}.${selection.targetId}`, restarted: true, record: await invoke(context, 'local.dev.status', { sessionId, all: false }) }; } - async function rebuild(invocation: ParsedInvocation, context: CommandContext, state: LocalSessionState, sessionId: string) { const selection = parseSelection(`${invocation.arguments[0]}=candidate`); const runtimes = (await loadDevelopmentRuntimes(state.manifest)).map(({ runtime }) => runtime); @@ -384,7 +382,9 @@ async function rebuild(invocation: ParsedInvocation, context: CommandContext, st if (action === 'package-rebuild') await rebuildPackage(dependentInput); else if (action === 'rebuild-restart') await restartConsumer(dependentInput); else if (action === 'build-only') { - runOneShotOperation(state, dependent.target.operations.build, dependentRepository.worktree, dependentSelection.mode, context.env); + const build = dependent.target.operations.build; + if (!build) throw new Error('Build-only dependent has no build operation.'); + runOneShotOperation(state, build, dependentRepository.worktree, dependentSelection.mode, context.env); await markRebuilt(context, sessionId, dependent.runtime.project.id, dependent.target.id, dependentSelection.mode, dependent.target); } else await restartConsumer(dependentInput); } diff --git a/src/cli/commands/operator.ts b/src/cli/commands/operator.ts index d0115d60..14cd885a 100644 --- a/src/cli/commands/operator.ts +++ b/src/cli/commands/operator.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { controlPlaneOperation, encodeConfirmationState, parseCommunicationAddresses, validateWorkdayIntentSelection, normalizeWorkdayAgentSelection, type CommandInputBinding } from '@treeseed/sdk/operator-contracts'; import { ControlPlaneClientError, resolveControlPlaneServer } from '@treeseed/sdk/control-plane-client'; -import { workdayAllocationOverridesSchema } from '@treeseed/sdk/agent-capacity'; +import { workdayAllocationOverridesSchema, workdayPolicySchema } from '@treeseed/sdk/agent-capacity'; import type { CommandContext, ParsedInvocation } from '../types.js'; import { launchApplication } from '../application/launch.js'; import { runInteractiveChat } from '../communication/interactive-chat.js'; @@ -121,7 +121,12 @@ async function operationInput(invocation: ParsedInvocation, context: CommandCont const parsed = parseYaml(await inputDocument(input.body.file, context)); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Object.assign(new Error('Input file must contain one YAML or JSON object.'), { category: 'invalid_input', code: 'command_input_file_invalid' }); delete input.body.file; - Object.assign(input.body, parsed); + if (operation.descriptor.operationId === 'workdays.profiles.update') { + const policy = workdayPolicySchema.safeParse(parsed); + if (!policy.success) throw Object.assign(new Error(policy.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')), + { category: 'invalid_input', code: 'workday_policy_file_invalid' }); + input.body.policy = policy.data; + } else Object.assign(input.body, parsed); } for (const binding of deferred) if (getOperationInputField(input[binding.target], binding.field) === undefined) { throw Object.assign(new Error(`Missing required ${binding.source}: ${binding.name}`), { category: 'ambiguous_context', code: `${binding.name}_required` }); diff --git a/tests/contract/package/thin-package.test.ts b/tests/contract/package/thin-package.test.ts index c2c9160a..e22fd147 100644 --- a/tests/contract/package/thin-package.test.ts +++ b/tests/contract/package/thin-package.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import test from 'node:test'; +import { parse } from 'yaml'; test('package has one executable and only its declared CLI runtime dependencies', () => { const pkg = JSON.parse(readFileSync('package.json', 'utf8')); @@ -67,5 +68,21 @@ test('source contains no legacy implementation residue', () => { for (const forbidden of ['MarketClient', 'marketId', '--market', 'operator/commands', 'workflow-support']) assert.equal(source.includes(forbidden), false, forbidden); const nonHostTransport = sourceFiles.filter((file) => !file.endsWith('/host-client.ts')).map((file) => readFileSync(file, 'utf8')).join('\n'); assert.equal(nonHostTransport.includes('/v1/'), false, '/v1/ outside the fixed host-manager transport'); - for (const removed of ['docs/src', 'guarantees', '.gitmodules']) assert.equal(existsSync(removed), false, removed); + for (const removed of ['docs/src', '.gitmodules']) assert.equal(existsSync(removed), false, removed); +}); + +test('guarantee metadata binds owner tests without adding a second CLI implementation', () => { + const files = readdirSync('guarantees', { recursive: true, withFileTypes: true }).filter(entry => entry.isFile()); + assert.equal(files.length, 3); + for (const entry of files) { + assert.match(entry.name, /\.yaml$/u); + const document = parse(readFileSync(`${entry.parentPath}/${entry.name}`, 'utf8')); + assert.match(document.schemaVersion, /^treeseed\.(guarantee|scene|guarantee-verifiers)\/v1$/u); + if (document.verifiers) for (const verifier of Object.values(document.verifiers) as Array<{kind: string; ownerPackage: string; testFile: string}>) { + assert.equal(verifier.kind, 'nodeTestCase'); + assert.equal(verifier.ownerPackage, '@treeseed/cli'); + assert.match(verifier.testFile, /^tests\/unit\/command-boundary\//u); + assert.equal(existsSync(verifier.testFile), true); + } + } }); diff --git a/tests/unit/application/workspaces.test.ts b/tests/unit/application/workspaces.test.ts index 5cd908a4..c351d9ce 100644 --- a/tests/unit/application/workspaces.test.ts +++ b/tests/unit/application/workspaces.test.ts @@ -134,18 +134,9 @@ test('TreeDX authoring creates, writes, and submits one recoverable workspace', assert.deepEqual(calls[2]?.input.body, { version: 2, message: 'Add welcome page' }); }); -test('allocation authoring validates normalized hierarchy percentages before writing TreeDX content', async () => { - const calls: string[] = []; - const invoke = async (operationId: string) => { - calls.push(operationId); - if (operationId === 'knowledge.workspaces.create') return { data: { id: 'workspace-a', version: 1 } }; - if (operationId === 'knowledge.workspaces.content.update') return { data: { workspace: { version: 2 } } }; - return { data: { review: { id: 'review-a' } } }; - }; - const allocation = { schemaVersion: 2, id: 'allocation-a', teamId: 'team-a', version: 1, status: 'draft', effectiveFrom: '2026-09-02T12:00:00.000Z', reservePolicy: { percent: 0, overflow: 'deny' }, slices: [{ id: 'project-a', scope: 'project', targetId: 'project-a', policy: { minPercent: 0, targetPercent: 100, maxPercent: 100, hardCapPercent: 100 } }], borrowingRules: [] }; - await executeSurfaceAction(invoke, 'team-a', 'allocation.save', { projectId: 'project-a', sourcePath: 'capacity/allocations/team.yaml', content: JSON.stringify(allocation), message: 'Allocate project capacity' }); - assert.deepEqual(calls, ['knowledge.workspaces.create', 'knowledge.workspaces.content.update', 'knowledge.workspaces.submit']); - await assert.rejects(() => executeSurfaceAction(invoke, 'team-a', 'allocation.save', { projectId: 'project-a', sourcePath: 'capacity/allocations/team.yaml', content: JSON.stringify({ ...allocation, slices: [{ ...allocation.slices[0], policy: { ...allocation.slices[0].policy, targetPercent: 90 } }] }), message: 'Invalid allocation' }), /Sibling target percentages must total 100/u); +test('retired allocation-set authoring cannot create a TreeDX workspace', async () => { + const invoke = async () => { throw new Error('unexpected operation'); }; + await assert.rejects(() => executeSurfaceAction(invoke, 'team-a', 'allocation.save', {}, undefined), /not implemented/u); }); test('release workflows publish staging and leave production fail-closed to control-plane authority', async () => { diff --git a/tests/unit/command-boundary/canonical-client.test.ts b/tests/unit/command-boundary/canonical-client.test.ts index de43324e..68fbbd86 100644 --- a/tests/unit/command-boundary/canonical-client.test.ts +++ b/tests/unit/command-boundary/canonical-client.test.ts @@ -54,6 +54,7 @@ test('leaf commands expose only catalog-derived high-level options', () => { assert.equal(commandSpecs.some((command) => command.options.some((option) => option.flag === '--execute' || option.flag === '--market')), false); }); + test('host commands preserve the SDK handler boundary and stable envelope', async () => { const calls: unknown[] = []; const output: string[] = []; const exit = await runCommandLine(['host', 'component', 'status', 'agent', '--server', 'lab', '--json'], { @@ -64,6 +65,7 @@ test('host commands preserve the SDK handler boundary and stable envelope', asyn assert.deepEqual(JSON.parse(output[0]!).result, { componentId: 'agent', healthy: true }); }); + test('host stop suspends a selected development session before stopping released workloads', async () => { const root = mkdtempSync(resolve(tmpdir(), 'treeseed-cli-host-stop-')); const env = { XDG_STATE_HOME: root }; diff --git a/tests/unit/command-boundary/host/lifecycle-envelopes.test.ts b/tests/unit/command-boundary/host/lifecycle-envelopes.test.ts new file mode 100644 index 00000000..9466d43f --- /dev/null +++ b/tests/unit/command-boundary/host/lifecycle-envelopes.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; + +test('host lifecycle commands preserve plan/noop/result envelopes and never need a remote server', async () => { + const calls: unknown[] = []; const output: string[] = []; + const invoke = async (input: unknown) => { calls.push(input); return (input as { handlerId: string }).handlerId === 'local.dev.status' + ? { sessions: [] } : { state: 'stopped', changed: false }; }; + for (const action of ['stop', 'start'] as const) { + for (const plan of [true, false]) { + const args = ['host', action, ...(plan ? ['--plan'] : ['--yes']), '--json']; + assert.equal(await runCommandLine(args, { interactiveUi: false, hostInvoke: invoke, write: (value) => output.push(value) }), 0); + assert.deepEqual(calls.at(-1), { handlerId: `local.host.${action}`, arguments: [], options: plan ? { plan: true } : {} }); + if (!plan) assert.deepEqual(calls.at(-2), { handlerId: 'local.dev.status', arguments: [], options: { payload: '{"all":true}' } }); + assert.deepEqual(JSON.parse(output.at(-1)!).result, { state: 'stopped', changed: false }); + } + } +}); diff --git a/tests/unit/command-boundary/host/managed-development-container.test.ts b/tests/unit/command-boundary/host/managed-development-container.test.ts index 639e8a5b..d92305e9 100644 --- a/tests/unit/command-boundary/host/managed-development-container.test.ts +++ b/tests/unit/command-boundary/host/managed-development-container.test.ts @@ -16,13 +16,17 @@ test('container startup and cleanup only invoke the protected manager, including const file=resolve(root,'treeseed.package.yaml');writeFileSync(file,JSON.stringify({development:runtime})); execFileSync('git',['init','-b','staging'],{cwd:root});execFileSync('git',['add','.'],{cwd:root}); execFileSync('git',['-c','user.name=Test','-c','user.email=test@example.invalid','commit','-m','fixture'],{cwd:root}); - let record:any;const actions:string[]=[],records=new Map();let selected=''; + let record:any;const actions:string[]=[],records=new Map();let selected='', rejectStop=false; const context={cwd:root,env:{XDG_STATE_HOME:resolve(root,'state'),USER:'tester'},interactiveUi:false,write:()=>{},hostInvoke:async(request:any)=>{ const payload=JSON.parse(request.options.payload); if(request.handlerId==='local.dev.session.start'){record={session:payload.session,runtimes:payload.runtimes};records.set(payload.session.sessionId,record);return record;} record=records.get(payload.sessionId)??record; if(request.handlerId==='local.dev.environment')return {environment:{}}; - if(request.handlerId==='local.dev.container'){assert.equal(payload.sessionId,selected);actions.push(payload.action);return payload.action==='status'?{registered:false,state:null}:{};} + if(request.handlerId==='local.dev.container'){ + assert.equal(payload.sessionId,selected);actions.push(payload.action); + if(payload.action==='stop' && rejectStop)throw new Error('Active assignment prevents guest trust replacement.'); + return payload.action==='status'?{registered:false,state:null}:{}; + } if(request.handlerId==='local.dev.use'){ if(payload.mode!=='released')assert.equal(payload.port,3000,'Every activation, including restart, must reattach the canonical route'); record.session.targets[0].mode=payload.mode; @@ -39,9 +43,16 @@ test('container startup and cleanup only invoke the protected manager, including assert.deepEqual(actions,beforePlan,'Plan must not stop or start a container'); assert.equal(record.session.targets[0].mode,'live','Plan must not switch routes'); assert.equal(JSON.parse(planned[0]!).result.mutation,false); + rejectStop=true; + for(const command of ['restart','rebuild']) { + assert.equal(await runCommandLine(['dev',command,'api.service','--session',selected,'--json'],context),1); + assert.equal(record.session.targets[0].mode,'live','A refused stop must not switch the selected runtime to released'); + assert.equal(actions.at(-1),'stop'); + } + rejectStop=false; assert.equal(await runCommandLine(['dev','restart','api.service','--session',selected,'--json'],context),0); assert.equal(await runCommandLine(['dev','use','api.service=released','--session',selected,'--json'],context),0); assert.equal(await runCommandLine(['dev','session','stop','--session',selected,'--json'],context),0); - assert.deepEqual(actions,['status','start','stop','start','stop','status']); + assert.deepEqual(actions,['status','start','stop','stop','stop','start','stop','status']); } finally {rmSync(root,{recursive:true,force:true});} }); diff --git a/tests/unit/command-boundary/workdays/allocation-policy.test.ts b/tests/unit/command-boundary/workdays/allocation-policy.test.ts new file mode 100644 index 00000000..239f83b5 --- /dev/null +++ b/tests/unit/command-boundary/workdays/allocation-policy.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; + +test('assignment explain preserves allocator evidence without inventing a CLI budget', async () => { + const calls: Array<{ operationId: string; input: any }> = []; + const output: string[] = []; + const allocation = { allocatedSeconds: 60, limitingConstraint: 'task-duration', + calibration: { multiplier: 2, measurementIds: [] }, opportunity: { shareSeconds: 600 } }; + const exit = await runCommandLine(['assignments', 'explain', 'assignment-1', + '--team', '11111111-1111-4111-8111-111111111111', '--json'], { + interactiveUi: false, write: (value) => output.push(value), + operationInvoke: async (operationId, input) => { + calls.push({ operationId, input }); return { data: { metadata: { allocation } } }; + }, + }); + assert.equal(exit, 0, output.join('')); + assert.equal(calls[0]?.operationId, 'assignments.explain'); + assert.deepEqual(calls[0]?.input.path, { teamId: '11111111-1111-4111-8111-111111111111', assignmentId: 'assignment-1' }); + assert.deepEqual(JSON.parse(output.at(-1)!).result.metadata.allocation, allocation); +}); + +test('workday profile update sends a validated policy document under the API policy field', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-')); + const file = resolve(root, 'policy.json'); + const policy = { durationSeconds: 28_800, maximumConcurrency: 1, communicationConcurrency: 1, + planningPercent: 20, projectPercentages: { sdk: 100 }, agentClassPercentages: { sdk: { engineer: 100 } } }; + const calls: Array<{ operationId: string; input: any }> = []; + const output: string[] = []; + try { + writeFileSync(file, JSON.stringify(policy)); + const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', '--input', file, + '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), + operationInvoke: async (operationId, input) => { calls.push({ operationId, input }); return { data: { id: 'default', teamId: '11111111-1111-4111-8111-111111111111', revision: 2, policy } }; } }); + assert.equal(exit, 0, output.join('')); + assert.equal(calls[0]?.operationId, 'workdays.profiles.update'); + assert.deepEqual(calls[0]?.input.body.policy, { ...policy, allocationWeight: 1, planningTurnMaximumSeconds: 180 }); + assert.equal(calls[0]?.input.body.file, undefined); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('workday profile update rejects an invalid policy file before mutation', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-invalid-')); + const file = resolve(root, 'policy.json'); + const calls: string[] = []; + const output: string[] = []; + try { + writeFileSync(file, JSON.stringify({ durationSeconds: -1, maximumConcurrency: 1, communicationConcurrency: 1 })); + const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', + '--input', file, '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), + operationInvoke: async (operationId) => { calls.push(operationId); return { data: {} }; } }); + assert.equal(exit, 1); + assert.deepEqual(calls, []); + assert.equal(JSON.parse(output.at(-1)!).error.code, 'workday_policy_file_invalid'); + } finally { rmSync(root, { recursive: true, force: true }); } +});