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
29 changes: 29 additions & 0 deletions src/cli/commands/development-support/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { resolve } from 'node:path';
import { withOsCustodyLock } from '../../support/server-custody.js';
import { developmentStateRoot } from '../development-cli-selection.js';

/** Shared builds and current.json cross session boundaries. Lock the Linux
* operator's lifecycle, not a time-limited development selection. Deployment
* owns the kernel-lock implementation; process death releases its descriptor.
*/
export function withDevelopmentLifecycle<T>(env: NodeJS.ProcessEnv, action: () => Promise<T>): Promise<T> {
if (process.platform !== 'linux') return action();
return withOsCustodyLock(resolve(developmentStateRoot(env), 'lifecycle'), action, 60);
}

/** Reusing an immutable snapshot is not permission to overwrite its contents.
* Source changes enter through explicit restart/rebuild, not another use/resume.
*/
export function managedContainerAlreadyReady(value: unknown, sessionId: string, targetId: string): boolean {
const result = value as { registered?: unknown; state?: unknown };
if (result?.registered === false) return false;
if (result?.registered !== true || typeof result.state !== 'string') throw new Error('Managed runtime status is invalid.');
let rows: Array<{ Name?: string; State?: string; Health?: string }>;
try { rows = result.state.trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); }
catch { throw new Error('Managed runtime status is invalid.'); }
if (rows.length !== 1 || rows[0]?.Name !== `treeseed-${sessionId}-api-${targetId}`)
throw new Error('Managed runtime identity is inconsistent; inspect the session before restarting.');
if (rows[0].State !== 'running' || rows[0].Health !== 'healthy')
throw new Error('Managed runtime is not healthy; use dev restart to drain and recover it.');
return true;
}
16 changes: 15 additions & 1 deletion src/cli/commands/development.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { runHostDevelopment } from './development-support/host-runtime.js';
import { applyDevelopmentRecovery, planDevelopmentRecovery } from './development-support/recovery.js';
import { ownsDevelopmentProcess, processIdentity } from './development-support/process-identity.js';
import { developmentBootOrder } from './development-support/boot-order.js';
import { managedContainerAlreadyReady, withDevelopmentLifecycle } from './development-support/lifecycle.js';
export { relativeOverlayTarget, startPackageSynchronizer, stopProcess, waitForNewPackageOverlay } from './development-support/overlays.js';

export { developmentCliEntrypointPath, selectDevelopmentCli } from './development-cli-selection.js';
Expand Down Expand Up @@ -127,7 +128,7 @@ export function usesManagedContainer(target: DevelopmentTarget) {
return target.operations.start?.command === 'docker';
}

async function containerOperation(context: CommandContext, sessionId: string, runtime: DevelopmentRuntime, target: DevelopmentTarget, action: 'start' | 'stop') {
async function containerOperation(context: CommandContext, sessionId: string, runtime: DevelopmentRuntime, target: DevelopmentTarget, action: 'start' | 'stop' | 'status') {
return invoke(context, 'local.dev.container', {sessionId,projectId:runtime.project.id,targetId:target.id,action});
}

Expand Down Expand Up @@ -222,6 +223,10 @@ async function useTargets(invocation: Pick<ParsedInvocation, 'arguments' | 'opti
restoreOverlays(state, selection.projectId);
if (selection.projectId === 'cli' && selection.targetId === 'package') selectDevelopmentCli(context.env, null);
} else {
if (usesManagedContainer(target) && managedContainerAlreadyReady(await containerOperation(context, sessionId, runtime, target, 'status'), sessionId, target.id)) {
await invoke(context, 'local.dev.use', { sessionId, ...selection, ...(target.endpoints[0] ? { port: target.endpoints[0].port } : {}) });
continue;
}
const resolved = await invoke(context, 'local.dev.environment', { sessionId, projectId: selection.projectId, targetId: selection.targetId }) as { environment?: NodeJS.ProcessEnv };
if (target.operations.setup) runOneShotOperation(state, target.operations.setup, repository.worktree, selection.mode, context.env, resolved.environment ?? {});
const running = operationIsRunning(state, `${runtime.project.id}.${target.id}`);
Expand Down Expand Up @@ -401,6 +406,10 @@ async function rebuild(invocation: ParsedInvocation, context: CommandContext, st

/** Resume only current manager selections; never reconstruct desired state from stale PIDs. */
export async function resumeDevelopmentSession(sessionId: string, context: CommandContext) {
return withDevelopmentLifecycle(context.env, () => resumeDevelopmentUnlocked(sessionId, context));
}

async function resumeDevelopmentUnlocked(sessionId: string, context: CommandContext) {
if (!/^dev-[a-z0-9-]{1,64}$/.test(sessionId)) throw new Error('An exact development session is required.');
loadState(context.env, sessionId);
const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord & { session: { status: string } };
Expand All @@ -415,6 +424,11 @@ export async function resumeDevelopmentSession(sessionId: string, context: Comma
}

export async function runDevelopment(invocation: ParsedInvocation, context: CommandContext) {
if (invocation.options.plan === true || ['dev status', 'dev logs', 'dev plan', 'dev host status'].includes(invocation.command.name)) return runDevelopmentUnlocked(invocation, context);
return withDevelopmentLifecycle(context.env, () => runDevelopmentUnlocked(invocation, context));
}

async function runDevelopmentUnlocked(invocation: ParsedInvocation, context: CommandContext) {
if (invocation.command.name === 'dev session recover') {
const sessionId = String(invocation.options.session ?? '');
if (!/^dev-[a-z0-9-]{1,64}$/.test(sessionId)) throw new Error('An exact development session is required.');
Expand Down
2 changes: 2 additions & 0 deletions src/cli/support/server-custody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
import { homedir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { OsSecretCustody, withOsCustodyLock } from '@treeseed/deployment/security/custody';
// Keep shared OS locking in this already-bundled Deployment custody boundary.
export { withOsCustodyLock };
import { defaultLocalControlPlaneServer, normalizeControlPlaneServerRegistry,
type ControlPlaneServerProfile, type ControlPlaneServerRegistry, type ControlPlaneServerSession } from '@treeseed/sdk/control-plane-client';
interface SessionState { version: 1; sessions: ControlPlaneServerSession[]; custodyVersion: number }
Expand Down
2 changes: 1 addition & 1 deletion tests/contract/package/custody-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ test('extracted CLI custody imports without a manager or runtime Deployment pack
cpSync('dist',join(root,'dist'),{recursive:true});writeFileSync(join(root,'package.json'),'{"type":"module"}');
mkdirSync(join(root,'node_modules','@treeseed'),{recursive:true});
symlinkSync(resolve('node_modules/@treeseed/sdk'),join(root,'node_modules/@treeseed/sdk'),'dir');
const result=spawnSync(process.execPath,['--input-type=module','-e',"import {inspectServerCustody} from './dist/cli/support/server-custody.js'; const result=inspectServerCustody({TREESEED_CONFIG_HOME:process.cwd()+'/config'}); if(result.custody!=='os'||result.encrypted)process.exit(1);"],{cwd:root,encoding:'utf8',env:{...process.env,NODE_OPTIONS:''}});
const result=spawnSync(process.execPath,['--input-type=module','-e',"import {inspectServerCustody} from './dist/cli/support/server-custody.js'; import {withDevelopmentLifecycle} from './dist/cli/commands/development-support/lifecycle.js'; await withDevelopmentLifecycle({XDG_STATE_HOME:process.cwd()},async()=>{}); const result=inspectServerCustody({TREESEED_CONFIG_HOME:process.cwd()+'/config'}); if(result.custody!=='os'||result.encrypted)process.exit(1);"],{cwd:root,encoding:'utf8',env:{...process.env,NODE_OPTIONS:''}});
assert.equal(result.status,0,result.stderr);
}finally{rmSync(root,{recursive:true,force:true});}
});
7 changes: 7 additions & 0 deletions tests/support/lifecycle-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { withDevelopmentLifecycle } from '../../src/cli/commands/development-support/lifecycle.ts';

await withDevelopmentLifecycle({ ...process.env, XDG_STATE_HOME: process.argv[2] }, async () => {
process.send?.('entered');
await new Promise<void>(resolve => process.once('message', () => resolve()));
});
process.disconnect?.();
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { resumeDevelopmentSession, runDevelopment } from '../../../../src/cli/commands/development.ts';
import type { CommandContext, ParsedInvocation } from '../../../../src/cli/types.ts';

test('boot resume and manual use re-read state under the same lifecycle lock', { skip: process.platform !== 'linux' }, async () => {
const root = mkdtempSync(resolve(tmpdir(), 'lifecycle-entry-'));
const sessionId = 'dev-test', env = { ...process.env, XDG_STATE_HOME: root };
const directory = resolve(root, 'treeseed/development');
mkdirSync(directory, { recursive: true, mode: 0o700 });
writeFileSync(resolve(directory, 'current.json'), JSON.stringify({ sessionId, manifest: resolve(root, 'session.yaml'), processes: {}, overlays: [], candidates: [] }));
const record = {
session: { sessionId, status: 'active', repositories: [{ projectId: 'api', worktree: root }],
targets: [{ projectId: 'api', targetId: 'operations-runner', mode: 'candidate', generation: 0, health: 'pending' }] },
runtimes: [{ project: { id: 'api' }, targets: [{ id: 'operations-runner', kind: 'rebuild-restart',
operations: { start: { command: 'docker' } }, dependencies: [], endpoints: [], ready: { kind: 'process', graceSeconds: 0 } }] }],
};
let started = false, starts = 0;
const context = {
cwd: root, env,
hostInvoke: async (request: { handlerId: string; options: { payload?: unknown } }) => {
const payload = JSON.parse(String(request.options.payload));
if (request.handlerId === 'local.dev.status' || request.handlerId === 'local.dev.use') return record;
if (request.handlerId === 'local.dev.environment') return { environment: {} };
if (request.handlerId === 'local.dev.container' && payload.action === 'status') return started
? { registered: true, state: JSON.stringify({ Name: 'treeseed-dev-test-api-operations-runner', State: 'running', Health: 'healthy' }) }
: { registered: false, state: null };
if (request.handlerId === 'local.dev.container' && payload.action === 'start') {
starts++; await new Promise(resolve => setTimeout(resolve, 30)); started = true; return { started: 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;
try {
await Promise.all([resumeDevelopmentSession(sessionId, context), runDevelopment(invocation, context)]);
assert.equal(starts, 1);
await resumeDevelopmentSession(sessionId, context);
assert.equal(starts, 1);
} finally { rmSync(root, { recursive: true, force: true }); }
});
57 changes: 57 additions & 0 deletions tests/unit/command-boundary/development/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fork, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { managedContainerAlreadyReady, withDevelopmentLifecycle } from '../../../../src/cli/commands/development-support/lifecycle.ts';

const worker = resolve(import.meta.dirname, '../../../support/lifecycle-worker.ts');
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
function child(root: string) {
return fork(worker, [root], { execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'inherit', 'ipc'] });
}
async function stop(process: ChildProcess) {
if (process.exitCode !== null || process.signalCode !== null) return;
const exited = once(process, 'exit'); process.kill('SIGKILL'); await exited;
}

test('Linux manual/resume processes serialize; killing owner releases the lock', { skip: process.platform !== 'linux', timeout: 15000 }, async () => {
const root = mkdtempSync(resolve(tmpdir(), 'lifecycle-'));
const first = child(root); let second: ChildProcess | undefined;
try {
assert.equal((await once(first, 'message'))[0], 'entered');
second = child(root);
let entered = false;
const ready = once(second, 'message').then(value => { entered = true; return value; });
await delay(500); assert.equal(entered, false);
await stop(first);
assert.equal((await ready)[0], 'entered');
const exited = once(second, 'exit'); second.send('release');
assert.deepEqual(await exited, [0, null]);
await withDevelopmentLifecycle({ XDG_STATE_HOME: root }, async () => {});
} finally { await stop(first); if (second) await stop(second); rmSync(root, { recursive: true, force: true }); }
});

test('lifecycle lock releases after a failed operation', { skip: process.platform !== 'linux' }, async () => {
const root = mkdtempSync(resolve(tmpdir(), 'lifecycle-'));
try {
await assert.rejects(withDevelopmentLifecycle({ XDG_STATE_HOME: root }, async () => { throw new Error('test failure'); }), /test failure/);
assert.equal(await withDevelopmentLifecycle({ XDG_STATE_HOME: root }, async () => 'ready'), 'ready');
} finally { rmSync(root, { recursive: true, force: true }); }
});

test('healthy exact managed snapshot is reusable; missing runtime requires startup', () => {
const state = JSON.stringify({ Name: 'treeseed-dev-test-api-operations-runner', State: 'running', Health: 'healthy' });
assert.equal(managedContainerAlreadyReady({ registered: true, state }, 'dev-test', 'operations-runner'), true);
assert.equal(managedContainerAlreadyReady({ registered: false, state: null }, 'dev-test', 'operations-runner'), false);
});

test('registered unhealthy, malformed, or wrong-instance state never authorizes overwrite', () => {
const value = { Name: 'treeseed-dev-test-api-operations-runner', State: 'running', Health: 'unhealthy' };
assert.throws(() => managedContainerAlreadyReady({ registered: true, state: JSON.stringify(value) }, 'dev-test', 'operations-runner'), /dev restart/);
value.Health = 'healthy'; value.Name = 'unrelated';
assert.throws(() => managedContainerAlreadyReady({ registered: true, state: JSON.stringify(value) }, 'dev-test', 'operations-runner'), /identity/);
for (const state of ['', 'not json', '{}\n{}']) assert.throws(() => managedContainerAlreadyReady({ registered: true, state }, 'dev-test', 'service'));
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ test('container startup and cleanup only invoke the protected manager, including
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 {};}
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.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;
Expand All @@ -42,6 +42,6 @@ test('container startup and cleanup only invoke the protected manager, including
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,['start','stop','start','stop','stop']);
assert.deepEqual(actions,['status','start','stop','start','stop','stop']);
} finally {rmSync(root,{recursive:true,force:true});}
});
Loading