Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,12 @@ workflow:
- id: operator-unresolved-recovery-native
action: { verifier: cli.golden.operator-unresolved-recovery-native }
expect: { status: passed }
- id: provider-handoff-selection-unit
action: { verifier: cli.golden.provider-handoff-selection-unit }
expect: { status: passed }
- id: provider-handoff-denial-unit
action: { verifier: cli.golden.provider-handoff-denial-unit }
expect: { status: passed }
- id: provider-handoff-selection-native
action: { verifier: cli.golden.provider-handoff-selection-native }
expect: { status: passed }
15 changes: 15 additions & 0 deletions guarantees/verifiers/golden.verifiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -796,3 +796,18 @@ verifiers:
ownerPackage: "@treeseed/cli"
testFile: tests/unit/command-boundary/assignments/recovery.test.ts
testName: "packaged native recovery retains exact confirmation denial version and unresolved response through immutable retry without usage writes"
cli.golden.provider-handoff-selection-unit:
kind: nodeTestCase
ownerPackage: "@treeseed/cli"
testFile: tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts
testName: "managed rebuild and restart delegate one owning handoff without selecting or starting released code"
cli.golden.provider-handoff-denial-unit:
kind: nodeTestCase
ownerPackage: "@treeseed/cli"
testFile: tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts
testName: "managed handoff denial preserves the original selection without release fallback or a passing retry"
cli.golden.provider-handoff-selection-native:
kind: nodeTestCase
ownerPackage: "@treeseed/cli"
testFile: tests/integration/development-rebuild.test.ts
testName: "native public rebuild and restart retain exact candidate output without transient released execution"
10 changes: 4 additions & 6 deletions src/cli/commands/development.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,10 @@ async function restartConsumer(input: { state: LocalSessionState; runtime: Devel
const resolved = await invoke(context, 'local.dev.environment', { sessionId: state.sessionId, projectId: runtime.project.id, targetId: target.id }) as { environment?: NodeJS.ProcessEnv };
if (usesManagedContainer(target) && !usesManagerBuild(target) && target.kind === 'rebuild-restart' && target.operations.build) runOneShotOperation(state, target.operations.build, worktree, mode, context.env, resolved.environment ?? {},{runtime,target});
await stopProcess(state, key);
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' });
// Managed start owns preparation, active-work refusal and replacement.
// Explicit stop restores released code and is only for a deliberate release,
// never an intermediate step of rebuilding the selected candidate.
if (!usesManagedContainer(target) && target.operations.cleanup) runOneShotOperation(state, target.operations.cleanup, worktree, mode, context.env, { TREESEED_DEVELOPMENT_CLEANUP_SCOPE: 'runtime' });
if (target.operations.setup) runOneShotOperation(state, target.operations.setup, worktree, mode, context.env, resolved.environment ?? {});
if (!target.operations.start && target.kind === 'rebuild-restart' && target.operations.build) {
runOneShotOperation(state, target.operations.build, worktree, mode, context.env, resolved.environment ?? {},{runtime,target});
Expand Down
1 change: 1 addition & 0 deletions tests/contract/package/thin-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ test('guarantee metadata binds owner tests without adding a second CLI implement
'cli.golden.build-docker-unit': {testFile:"tests/unit/command-boundary/development/build-custody.test.ts",testName:"development build custody recognizes a caller-built Docker runtime"},
'cli.golden.build-direct-unit': {testFile:"tests/unit/command-boundary/development/build-custody.test.ts",testName:"development build custody leaves a direct native runtime in caller custody"},
'cli.golden.build-once-native': {testFile:"tests/integration/development-rebuild.test.ts",testName:"native development rebuild executes its caller build once before selecting exact output"},
'cli.golden.provider-handoff-selection-native': {testFile:'tests/integration/development-rebuild.test.ts',testName:'native public rebuild and restart retain exact candidate output without transient released execution'},
'cli.golden.build-non-idempotent-native': {testFile:"tests/integration/development-rebuild.test.ts",testName:"native development rebuild does not repeat a non-idempotent successful build"},
'cli.golden.build-failure-native': {testFile:"tests/integration/development-rebuild.test.ts",testName:"native development rebuild preserves selection and avoids stop after a failed build"},
'cli.golden.build-refused-native': {testFile:"tests/integration/development-rebuild.test.ts",testName:"native development rebuild runs once and preserves selection when stop is refused"},
Expand Down
23 changes: 22 additions & 1 deletion tests/integration/development-rebuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import {resolve} from 'node:path';
import type {DevelopmentRuntime} from '@treeseed/sdk/development';
import {rebuildFixture} from '../support/development-rebuild.ts';

// 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.
test('native public rebuild and restart retain exact candidate output without transient released execution',async()=>{
const f=await rebuildFixture();try{
const source=readFileSync(resolve(f.root,'treeseed.package.yaml'));
const build=readFileSync(resolve(f.root,'scripts/build.ts'));
for(const [index,command] of ['rebuild','restart','rebuild'].entries()){
assert.equal(await f.invoke(command),0,f.output.join(''));
assert.deepEqual(f.builds(),Array.from({length:index+1},(_,n)=>String(n+1)));
assert.deepEqual(f.selectedBytes,Array.from({length:index+1},(_,n)=>`built-${n+1}`));
assert.equal(readFileSync(resolve(f.root,'candidate.bin'),'utf8'),`built-${index+1}`);
assert.equal(f.mode(),'candidate');assert.deepEqual(f.selections,Array(index+1).fill('candidate'));
assert.equal(f.calls.includes('local.dev.container:stop'),false);
assert.equal(f.calls.filter(call=>call==='local.dev.container:start').length,index+1);
assert.deepEqual(readFileSync(resolve(f.root,'treeseed.package.yaml')),source);
assert.deepEqual(readFileSync(resolve(f.root,'scripts/build.ts')),build);
}
}finally{f.close();}
});

test('native development rebuild executes its caller build once before selecting exact output',async()=>{
const f=await rebuildFixture();try{assert.equal(await f.invoke(),0,f.output.join(''));assert.deepEqual(f.builds(),['1']);assert.deepEqual(f.selectedBytes,['built-1']);assert.equal(f.mode(),'candidate');}finally{f.close();}
});
Expand All @@ -15,7 +36,7 @@ test('native development rebuild preserves selection and avoids stop after a fai
const f=await rebuildFixture({failAt:1});try{assert.equal(await f.invoke(),1);assert.deepEqual(f.builds(),['1']);assert.equal(f.mode(),'candidate');assert.equal(f.calls.includes('local.dev.container:stop'),false);assert.deepEqual(f.selectedBytes,[]);}finally{f.close();}
});
test('native development rebuild runs once and preserves selection when stop is refused',async()=>{
const f=await rebuildFixture({rejectStop:true});try{assert.equal(await f.invoke(),1);assert.deepEqual(f.builds(),['1']);assert.equal(f.mode(),'candidate');assert.deepEqual(f.selectedBytes,[]);assert.equal(f.calls.filter(x=>x==='local.dev.container:stop').length,1);}finally{f.close();}
const f=await rebuildFixture({rejectStop:true});try{assert.equal(await f.invoke(),1);assert.deepEqual(f.builds(),['1']);assert.equal(f.mode(),'candidate');assert.deepEqual(f.selectedBytes,[]);assert.equal(f.calls.filter(x=>x==='local.dev.container:start').length,1);assert.equal(f.calls.includes('local.dev.container:stop'),false);assert.deepEqual(f.selections,[]);}finally{f.close();}
});
test('native development rebuild plan neither builds nor refreshes manager state',async()=>{
const f=await rebuildFixture();try{assert.equal(await f.invoke('rebuild',true),0,f.output.join(''));assert.deepEqual(f.builds(),[]);assert.deepEqual(f.calls,['local.dev.status']);assert.equal(f.mode(),'candidate');assert.deepEqual(f.selectedBytes,[]);}finally{f.close();}
Expand Down
18 changes: 12 additions & 6 deletions tests/support/development-rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;missingBuild?:boolean}={}) {
export async function rebuildFixture(options:{managerOwned?:boolean;failAt?:number;rejectStop?:boolean;rejectStart?:boolean;missingBuild?: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;
Expand All @@ -25,25 +25,31 @@ export async function rebuildFixture(options:{managerOwned?:boolean;failAt?:numb
writeFileSync(resolve(root,'.gitignore'),'build.log\ncandidate.bin\n');
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[]=[];
let record:{session:DevelopmentSession;runtimes:DevelopmentRuntime[]};const calls:string[]=[],output:string[]=[],selectedBytes:string[]=[],selections:string[]=[];
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.environment')return {environment:{}};
if(input.handlerId==='local.dev.container'){
if(payload.action==='stop'&&options.rejectStop)throw new Error('Fixture active claim refuses stop.');
if(payload.action==='start')selectedBytes.push(existsSync(resolve(root,'candidate.bin'))?readFileSync(resolve(root,'candidate.bin'),'utf8'):'manager-owned');
if(payload.action==='start'){
// The owning start boundary performs its drain/active-claim check before
// replacing a selection; refusal must not require an explicit release.
if(options.rejectStop)throw new Error('Fixture active claim refuses stop.');
if(options.rejectStart)throw new Error('Fixture owning handoff refuses active custody.');
selectedBytes.push(existsSync(resolve(root,'candidate.bin'))?readFileSync(resolve(root,'candidate.bin'),'utf8'):'manager-owned');
}
return {};
}
if(input.handlerId==='local.dev.use'){record.session.targets[0]!.mode=payload.mode;return record;}
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;
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;
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,
return {root,calls,output,selectedBytes,selections,
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,38 @@ import { parseInvocation } from '../../../../src/cli/parser.ts';
import { resolveCommand } from '../../../../src/cli/registry.ts';
import type { DevelopmentRuntime, DevelopmentTarget } from '@treeseed/sdk/development';
import { startPackageSynchronizer, stopProcess, waitForPackageOverlay } from '../../../../src/cli/commands/development-support/overlays.ts';
import { rebuildTarget } from '../../../support/development-rebuild.ts';
import { rebuildFixture, rebuildTarget } from '../../../support/development-rebuild.ts';

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 });
try {
assert.equal(await f.invoke(command), 0, f.output.join(''));
assert.equal(f.mode(), 'candidate');
assert.deepEqual(f.selections, ['candidate']);
assert.deepEqual(f.selectedBytes, ['manager-owned']);
assert.deepEqual(f.builds(), []);
assert.equal(f.calls.filter(call => call === 'local.dev.container:start').length, 1);
assert.equal(f.calls.includes('local.dev.container:stop'), false);
} finally { f.close(); }
}
});

test('managed handoff denial preserves the original selection without release fallback or a passing retry', async () => {
for (const command of ['rebuild', 'restart']) {
const f = await rebuildFixture({ managerOwned: true, rejectStart: true });
try {
assert.equal(await f.invoke(command), 1);
assert.equal(f.mode(), 'candidate');
assert.deepEqual(f.selections, []);
assert.deepEqual(f.selectedBytes, []);
assert.deepEqual(f.builds(), []);
assert.equal(f.calls.filter(call => call === 'local.dev.container:start').length, 1);
assert.equal(f.calls.includes('local.dev.container:stop'), false);
assert.match(f.output.join(''), /owning handoff refuses active custody/);
} finally { f.close(); }
}
});

test('development freeze and verify planning returns exact nonmutating actions without manager dispatch', async () => {
const root = mkdtempSync(resolve(tmpdir(), 'provider-plan-unit-'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test('container startup and cleanup only invoke the protected manager, including
if(request.handlerId==='local.dev.environment')return {environment:{}};
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.');
if((payload.action==='start' || 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'){
Expand Down Expand Up @@ -57,12 +57,12 @@ test('container startup and cleanup only invoke the protected manager, including
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');
assert.equal(actions.at(-1),'start','Replacement must drain through the owning start boundary without a released-code 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','stop','stop','start','stop','status']);
assert.deepEqual(actions,['status','start','start','start','start','stop','status']);
} finally {rmSync(root,{recursive:true,force:true});}
});
Loading