diff --git a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml index 8fae27ec..90fc01b6 100644 --- a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml +++ b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml @@ -3,6 +3,15 @@ id: cli.golden.component-boundaries title: Golden operator component boundaries scope: local-component-tests workflow: + - id: restart-current-custody-unit + action: { verifier: cli.golden.restart-current-custody-unit } + expect: { status: passed } + - id: restart-refresh-denial-unit + action: { verifier: cli.golden.restart-refresh-denial-unit } + expect: { status: passed } + - id: restart-current-custody-native + action: { verifier: cli.golden.restart-current-custody-native } + expect: { status: passed } - id: execution-diagnostics-read-unit action: { verifier: cli.golden.execution-diagnostics-read-unit } expect: { status: passed } diff --git a/guarantees/verifiers/golden.verifiers.yaml b/guarantees/verifiers/golden.verifiers.yaml index babfc351..5f99ec9a 100644 --- a/guarantees/verifiers/golden.verifiers.yaml +++ b/guarantees/verifiers/golden.verifiers.yaml @@ -811,3 +811,18 @@ verifiers: ownerPackage: "@treeseed/cli" testFile: tests/integration/development-rebuild.test.ts testName: "native public rebuild and restart retain exact candidate output without transient released execution" + cli.golden.restart-current-custody-unit: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts + testName: restart refreshes current manager custody before handoff and omits direct host readiness for a managed endpoint + cli.golden.restart-refresh-denial-unit: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts + testName: refused restart refresh performs no build start release or route mutation + cli.golden.restart-current-custody-native: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/integration/development-rebuild.test.ts + testName: native restart refreshes changed caller recipe and retains one exact build and endpoint through retry diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index 7078a125..7b9d1ac1 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -309,8 +309,10 @@ async function restartConsumer(input: { state: LocalSessionState; runtime: Devel } async function restart(invocation: ParsedInvocation, context: CommandContext, state: LocalSessionState, sessionId: string) { const selection = parseSelection(`${invocation.arguments[0]}=candidate`); + const runtimes = (await loadDevelopmentRuntimes(state.manifest)).map(({ runtime }) => runtime); + if (invocation.options.plan !== true) await invoke(context, 'local.dev.session.refresh', { sessionId, runtimes }); const status = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord; - const record = { ...status, runtimes: (await loadDevelopmentRuntimes(state.manifest)).map(({ runtime }) => runtime) }; + const record = { ...status, runtimes }; const selected = record.session.targets.find((entry) => entry.projectId === selection.projectId && entry.targetId === selection.targetId); if (!selected || selected.mode === 'released') throw new Error(`${selection.projectId}.${selection.targetId} is not selected for local development.`); const { runtime, target } = selectedTarget(record, selection.projectId, selection.targetId); @@ -325,7 +327,7 @@ async function restart(invocation: ParsedInvocation, context: CommandContext, st } if (target.kind === 'package-watch') throw new Error('Package-watch targets rebuild atomically and do not support restart.'); await restartConsumer({ state, runtime, target, worktree: repository.worktree, mode: selected.mode as 'candidate' | 'live', context, recordGeneration: false }); - await invoke(context, 'local.dev.use', { sessionId, projectId: selection.projectId, targetId: selection.targetId, mode: selected.mode, ...(target.endpoints[0] ? { port: target.endpoints[0].port } : {}) }); + await invoke(context, 'local.dev.use', { sessionId, projectId: selection.projectId, targetId: selection.targetId, mode: selected.mode, ...(!usesManagerBuild(target) && target.endpoints[0] ? { port: target.endpoints[0].port } : {}) }); saveState(state, context.env); return { sessionId, target: `${selection.projectId}.${selection.targetId}`, restarted: true, record: await invoke(context, 'local.dev.status', { sessionId, all: false }) }; } diff --git a/tests/contract/package/thin-package.test.ts b/tests/contract/package/thin-package.test.ts index 4ed935a6..e4c759ad 100644 --- a/tests/contract/package/thin-package.test.ts +++ b/tests/contract/package/thin-package.test.ts @@ -153,6 +153,7 @@ test('guarantee metadata binds owner tests without adding a second CLI implement 'cli.golden.freeze-earlier-artifact': {testFile:'tests/integration/development-freeze.test.ts',testName:'native freeze rejects earlier artifact mutation by a later owner build'}, 'cli.golden.freeze-repeat': {testFile:'tests/integration/development-freeze.test.ts',testName:'native freeze preserves repeated declared outputs and dirty source without claiming a verification pass'}, 'cli.golden.freeze-failure': {testFile:'tests/integration/development-freeze.test.ts',testName:'native freeze failure retains partial outputs without persisting or registering a candidate'}, + 'cli.golden.restart-current-custody-native': {testFile:'tests/integration/development-rebuild.test.ts',testName:'native restart refreshes changed caller recipe and retains one exact build and endpoint through retry'}, 'cli.golden.freeze-wildcard': {testFile:'tests/integration/development-freeze.test.ts',testName:'native freeze admits new wildcard output directories without treating generated bytes as source'}, 'cli.golden.freeze-empty-target': {testFile:'tests/integration/development-freeze.test.ts',testName:'native freeze blocks a target with no declared outputs before executing a later build'}, 'cli.golden.source-parent': {testFile:'tests/unit/command-boundary/development/source-closure.test.ts',testName:'development closure rejects a selected directory borrowing its parent Git root'}, diff --git a/tests/integration/development-rebuild.test.ts b/tests/integration/development-rebuild.test.ts index 37834a18..b00fce25 100644 --- a/tests/integration/development-rebuild.test.ts +++ b/tests/integration/development-rebuild.test.ts @@ -5,6 +5,30 @@ import {resolve} from 'node:path'; import type {DevelopmentRuntime} from '@treeseed/sdk/development'; import {rebuildFixture} from '../support/development-rebuild.ts'; +test('native restart refreshes changed caller recipe and retains one exact build and endpoint through retry', async()=>{ + const f=await rebuildFixture();try{ + const file=resolve(f.root,'treeseed.package.yaml'); + const document=JSON.parse(readFileSync(file,'utf8')) as {development:DevelopmentRuntime}; + const target=document.development.targets[0]!; + target.endpoints=[{id:'http',protocol:'http',port:4321,visibility:'host',canonicalAlias:'specimen.treeseed.localhost',authentication:'application'}]; + target.operations.build!.environment={FAIL_AT:'1'}; + writeFileSync(file,JSON.stringify(document));const source=readFileSync(file); + const build=readFileSync(resolve(f.root,'scripts/build.ts')); + assert.equal(await f.invoke('restart'),1,f.output.join('')); + assert.deepEqual(f.managerRuntimes(),[document.development]); + assert.deepEqual(f.calls.slice(0,2),['local.dev.session.refresh','local.dev.status']); + assert.deepEqual(f.builds(),['1']);assert.deepEqual(f.selectedBytes,[]);assert.deepEqual([...f.uses],[]); + const failed=f.output.join('');assert.match(failed,/failed/i); + assert.equal(f.mode(),'candidate');assert.equal(f.calls.includes('local.dev.container:stop'),false); + assert.equal(await f.invoke('restart'),0,f.output.join('')); + assert.deepEqual(f.builds(),['1','2']);assert.deepEqual(f.selectedBytes,['built-2']); + assert.equal(f.uses.length,1);assert.equal(f.uses[0]!.port,4321); + assert.equal(readFileSync(resolve(f.root,'candidate.bin'),'utf8'),'built-2'); + assert.equal(f.output.join('').startsWith(failed),true); + assert.deepEqual(readFileSync(file),source);assert.deepEqual(readFileSync(resolve(f.root,'scripts/build.ts')),build); + }finally{f.close();} +}); + // Real public CLI, source/Git custody, native caller builds and selected file // readback. Manager replies remain controlled inputs: not physical Docker or // live provider claim-retention proof. diff --git a/tests/support/development-rebuild.ts b/tests/support/development-rebuild.ts index 478c7c3f..3655ef0d3 100644 --- a/tests/support/development-rebuild.ts +++ b/tests/support/development-rebuild.ts @@ -13,7 +13,7 @@ export function rebuildTarget(managerOwned=false):DevelopmentTarget { ready:{kind:'process',graceSeconds:0},outputs:[],endpoints:[],dependencies:[],statePolicy:'stateless',migrationPolicy:'none',secretRefs:{},shutdown:{graceSeconds:1,activeWorkPolicy:'block'},resources:{},logs:[],forbiddenOperations:[],promotion:{liveAdmissible:false,candidateRequiresVerification:true}}; } -export async function rebuildFixture(options:{managerOwned?:boolean;failAt?:number;rejectStop?:boolean;rejectStart?:boolean;missingBuild?:boolean}={}) { +export async function rebuildFixture(options:{managerOwned?:boolean;failAt?:number;rejectStop?:boolean;rejectStart?:boolean;missingBuild?:boolean;rejectRefresh?:boolean}={}) { const root=mkdtempSync(resolve(tmpdir(),'cli-rebuild-source-')),state=mkdtempSync(resolve(tmpdir(),'cli-rebuild-state-')); const target=rebuildTarget(options.managerOwned); if(options.missingBuild)delete target.operations.build; @@ -26,9 +26,14 @@ export async function rebuildFixture(options:{managerOwned?:boolean;failAt?:numb const git=(...args:string[])=>execFileSync('git',['-C',root,...args],{encoding:'utf8'}); git('init','-b','staging');git('add','.');git('-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','fixture'); let record:{session:DevelopmentSession;runtimes:DevelopmentRuntime[]};const calls:string[]=[],output:string[]=[],selectedBytes:string[]=[],selections:string[]=[]; + const uses:Array<{mode:string;port?:number}>=[]; const context:CommandContext={cwd:root,env:{...process.env,XDG_STATE_HOME:state},interactiveUi:false,outputFormat:'json',write:value=>output.push(value),hostInvoke:async input=>{ const payload=JSON.parse(String(input.options.payload));calls.push(input.handlerId+(payload.action?`:${payload.action}`:'')); - if(input.handlerId==='local.dev.session.start'){record={session:payload.session,runtimes:payload.runtimes};return record;} + if(input.handlerId==='local.dev.session.start'){record={session:payload.session,runtimes:structuredClone(payload.runtimes)};return record;} + if(input.handlerId==='local.dev.session.refresh'){ + if(options.rejectRefresh)throw new Error('Fixture refuses runtime refresh.'); + record.runtimes=structuredClone(payload.runtimes);return record; + } if(input.handlerId==='local.dev.environment')return {environment:{}}; if(input.handlerId==='local.dev.container'){ if(payload.action==='stop'&&options.rejectStop)throw new Error('Fixture active claim refuses stop.'); @@ -41,15 +46,15 @@ export async function rebuildFixture(options:{managerOwned?:boolean;failAt?:numb } return {}; } - if(input.handlerId==='local.dev.use'){selections.push(payload.mode);record.session.targets[0]!.mode=payload.mode;return record;} - if(input.handlerId==='local.dev.status'||input.handlerId==='local.dev.session.refresh'||input.handlerId==='local.dev.rebuild')return record; + if(input.handlerId==='local.dev.use'){uses.push(payload);selections.push(payload.mode);record.session.targets[0]!.mode=payload.mode;return record;} + if(input.handlerId==='local.dev.status'||input.handlerId==='local.dev.rebuild')return record; throw new Error(`Unexpected fixture boundary ${input.handlerId}`); }}; try { assert.equal(await runCommandLine(['dev','session','start',file,'--json'],context),0,output.join('')); record!.session.targets[0]!.mode='candidate';calls.length=0;output.length=0;selections.length=0; }catch(error){rmSync(root,{recursive:true,force:true});rmSync(state,{recursive:true,force:true});throw error;} - return {root,calls,output,selectedBytes,selections, + return {root,calls,output,selectedBytes,selections,uses,managerRuntimes:()=>structuredClone(record.runtimes), invoke:(command='rebuild',plan=false)=>runCommandLine(['dev',command,'specimen.worker',...(plan?['--plan']:[]),'--json'],context), builds:()=>existsSync(resolve(root,'build.log'))?readFileSync(resolve(root,'build.log'),'utf8').trim().split('\n'):[], mode:()=>record.session.targets[0]!.mode, diff --git a/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts b/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts index 77c8fcb3..04e7806a 100644 --- a/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts +++ b/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts @@ -12,6 +12,43 @@ import type { DevelopmentRuntime, DevelopmentTarget } from '@treeseed/sdk/develo import { startPackageSynchronizer, stopProcess, waitForPackageOverlay } from '../../../../src/cli/commands/development-support/overlays.ts'; import { rebuildFixture, rebuildTarget } from '../../../support/development-rebuild.ts'; +test('restart refreshes current manager custody before handoff and omits direct host readiness for a managed endpoint', async () => { + const f = await rebuildFixture({ managerOwned: true }); + try { + const file = resolve(f.root, 'treeseed.package.yaml'); + const document = JSON.parse(readFileSync(file, 'utf8')) as { development: DevelopmentRuntime }; + document.development.targets[0]!.endpoints = [{ id: 'http', protocol: 'http', port: 4000, + visibility: 'host', canonicalAlias: 'specimen.treeseed.localhost', authentication: 'application' }]; + writeFileSync(file, JSON.stringify(document)); + const before = readFileSync(file), stored = f.managerRuntimes(); + assert.equal(await f.invoke('restart', true), 0, f.output.join('')); + assert.deepEqual(f.calls, ['local.dev.status']); + assert.deepEqual(f.managerRuntimes(), stored); + f.calls.length = 0; + assert.equal(await f.invoke('restart'), 0, f.output.join('')); + assert.deepEqual(f.calls.slice(0, 2), ['local.dev.session.refresh', 'local.dev.status']); + assert.deepEqual(f.managerRuntimes(), [document.development]); + assert.equal(Object.hasOwn(f.uses[0]!, 'port'), false); + assert.equal(f.mode(), 'candidate'); + assert.deepEqual(f.selectedBytes, ['manager-owned']); + assert.deepEqual(f.builds(), []); + assert.deepEqual(readFileSync(file), before); + } finally { f.close(); } +}); + +test('refused restart refresh performs no build start release or route mutation', async () => { + const f = await rebuildFixture({ managerOwned: true, rejectRefresh: true }); + try { + const before = f.managerRuntimes(); + assert.equal(await f.invoke('restart'), 1, f.output.join('')); + assert.match(f.output.join(''), /refuses runtime refresh/); + assert.deepEqual(f.calls, ['local.dev.session.refresh']); + assert.deepEqual(f.managerRuntimes(), before); + assert.deepEqual(f.builds(), []); assert.deepEqual(f.selectedBytes, []); + assert.deepEqual(f.uses, []); assert.equal(f.mode(), 'candidate'); + } finally { f.close(); } +}); + test('managed rebuild and restart delegate one owning handoff without selecting or starting released code', async () => { for (const command of ['rebuild', 'restart']) { const f = await rebuildFixture({ managerOwned: true }); @@ -95,8 +132,8 @@ test('boot resume and manual use re-read state under the same lifecycle lock', { operations: { start: { command: 'manager-runtime' } }, dependencies: [], endpoints: [{ id: 'http', protocol: 'http', port: 4000 }], ready: { kind: 'process', graceSeconds: 0 } }] }], }; let started = false, starts = 0; - const context = { - cwd: root, env, + const context: CommandContext = { + cwd: root, env, interactiveUi: false, outputFormat: 'json', write() {}, hostInvoke: async (request: { handlerId: string; options: { payload?: unknown } }) => { const payload = request.options.payload ? JSON.parse(String(request.options.payload)) : {}; if (request.handlerId === 'local.dev.status') return payload.all ? { sessions: [record] } : record; @@ -120,8 +157,10 @@ test('boot resume and manual use re-read state under the same lifecycle lock', { if (request.handlerId === 'local.host.start') return { state: 'running', changed: true }; throw new Error(`Unexpected operation ${request.handlerId}`); }, - } as CommandContext; - const invocation = { command: { name: 'dev use' }, arguments: ['api.operations-runner=candidate'], options: { session: sessionId } } as ParsedInvocation; + }; + const selectedCommand = resolveCommand(['dev', 'use', 'api.operations-runner=candidate', '--session', sessionId]); + assert.ok(selectedCommand); + const invocation = parseInvocation(selectedCommand.command, selectedCommand.rest); try { await Promise.all([resumeDevelopmentSession(sessionId, context), runDevelopment(invocation, context)]); assert.equal(starts, 1);