feat(migration): keep the compose.yaml the plan was made against
ci / check (push) Failing after 1m31s
ci / publish (push) Skipped

migration_plan reads the project's compose.yaml through its observe session
(masked like the model sees it) and keeps it as composeBefore, so the owner's
decision can show the plan as a diff. Plans made before this show no diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Opus 5.5 committed 2026-09-24 11:22:38 +02:00
1 parent 1946c187a7
commit cba8532656
10 files changed
+65 -11

No files matched your search

+18 -3
View File
@@ -246,6 +246,7 @@ describe('component operation executor: migration_plan', () => {
};
const observer = () => new FakeExecutorClient()
.on('openSession', { outcome: 'done', result: { token: 'observe-token', expires_at: NOW, services: ['influxdb'] } })
.on('sessionCompose', { outcome: 'done', result: { text: 'services:\n influxdb:\n image: influxdb:1.8\n' } })
.on('closeSession', { outcome: 'done', result: { project: 'grafana', service: 'influxdb', manifest_changed: false, manifest_sha256: 'a'.repeat(64), log: [{ at: NOW, op: 'compose', ok: true, detail: 'read' }] } });
it('has opus look at the app in an observe session and hands over the plan with its digest', async () => {
@@ -253,12 +254,16 @@ describe('component operation executor: migration_plan', () => {
const executor = observer();
const done = await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills, model, executor }).execute('migration_plan', 1, context);
expect(done).toMatchObject({ event: 'migration_needed', result: { plan: { needed: true, inputs: [{ name: 'ADMIN_PASSWORD' }] }, model: 'opus', costUsd: 0.4, actions: ['compose: read'] } });
expect(done).toMatchObject({
event: 'migration_needed',
result: { plan: { needed: true, inputs: [{ name: 'ADMIN_PASSWORD' }] }, model: 'opus', costUsd: 0.4, actions: ['compose: read'], composeBefore: 'services:\n influxdb:\n image: influxdb:1.8\n' },
});
expect((done.result as { planDigest: string }).planDigest).toMatch(/^[0-9a-f]{64}$/);
expect(model.requests[0]).toMatchObject({ executor: 'opus', profile: 'observe', sessionToken: 'observe-token' });
expect(model.requests[0]?.prompt).toContain('Before update: create an admin user');
expect(executor.calls.map((call) => call.op)).toEqual(['openSession', 'closeSession']);
expect(executor.calls.map((call) => call.op)).toEqual(['openSession', 'sessionCompose', 'closeSession']);
expect(executor.calls[0]?.request).toEqual({ project: 'grafana', service: 'influxdb', mode: 'observe' });
expect(executor.calls[1]?.request).toEqual({ token: 'observe-token' });
});
it('says no migration when the model finds none', async () => {
@@ -268,6 +273,16 @@ describe('component operation executor: migration_plan', () => {
expect(done).toMatchObject({ event: 'no_migration', result: { plan: { needed: false } } });
});
it('plans without the current compose.yaml when the session cannot read it', async () => {
const model = new FakeModelExecutor().reply({ output: plan, costUsd: 0.4 });
const executor = new FakeExecutorClient()
.on('openSession', { outcome: 'done', result: { token: 'observe-token', expires_at: NOW, services: ['influxdb'] } })
.on('sessionCompose', { outcome: 'failed', detail: 'no compose.yaml', manifest_replaced: false })
.on('closeSession', { outcome: 'done', result: { project: 'grafana', service: 'influxdb', manifest_changed: false, manifest_sha256: 'a'.repeat(64), log: [] } });
const done = await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills, model, executor }).execute('migration_plan', 1, context);
expect(done).toMatchObject({ event: 'migration_needed', result: { composeBefore: null } });
});
it('fails the plan, never passes it, on an invalid answer, a model error or without model and executor', async () => {
const invalid = new FakeModelExecutor().reply({ output: { ...plan, inputs: [] , composeYaml: 'x: <input:MISSING>' }, costUsd: 0.2 });
const bad = await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills, model: invalid, executor: observer() }).execute('migration_plan', 1, context);
@@ -278,7 +293,7 @@ describe('component operation executor: migration_plan', () => {
const executor = observer();
const failed = await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills, model: failing, executor }).execute('migration_plan', 1, context);
expect(failed).toMatchObject({ event: 'plan_failed', result: { costUsd: 0.01 } });
expect(executor.calls.map((call) => call.op)).toEqual(['openSession', 'closeSession']);
expect(executor.calls.map((call) => call.op)).toEqual(['openSession', 'sessionCompose', 'closeSession']);
expect(await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills }).execute('migration_plan', 1, context))
.toMatchObject({ event: 'plan_failed' });
+12 -3
View File
@@ -33,7 +33,7 @@ export interface ComponentOperationExecutorDeps {
readonly groupMembers?: (app: string, signal: AbortSignal) => Promise<readonly GroupMember[]>;
readonly fetch?: typeof fetch;
/** verify's live container check (unset: every verify attempt reports unhealthy) and migration_plan's observe session. */
readonly executor?: Pick<ExecutorClient, 'inspect'> & Partial<Pick<ExecutorClient, 'openSession' | 'closeSession'>>;
readonly executor?: Pick<ExecutorClient, 'inspect'> & Partial<Pick<ExecutorClient, 'openSession' | 'closeSession' | 'sessionCompose'>>;
}
export interface ComponentOperationExecutionOutcome {
@@ -330,6 +330,8 @@ async function migrationPlan(deps: ComponentOperationExecutorDeps, record: Recor
const opened = await executor.openSession({ project, service, mode: 'observe' }, signal);
if (opened.outcome !== 'done') return failed(`The observe session could not be opened: ${opened.detail}.`);
const { token } = opened.result;
// The owner's decision shows the plan's compose.yaml as a diff against this one (masked the same way, `<secret:NAME>`).
const composeBefore = await readCompose(executor, token, signal);
let answer: Awaited<ReturnType<typeof model.execute>>;
try {
answer = await model.execute({ executor: executorName, profile: 'observe', prompt, outputSchema: toJson(MIGRATION_PLAN_SCHEMA), sessionToken: token, signal });
@@ -348,16 +350,23 @@ async function migrationPlan(deps: ComponentOperationExecutorDeps, record: Recor
const plan = parsed.data;
return {
event: plan.needed ? 'migration_needed' : 'no_migration',
result: toJson({ plan, planDigest: migrationPlanDigest(plan), summary: plan.summary, model: executorName, costUsd, actions }),
result: toJson({ plan, planDigest: migrationPlanDigest(plan), summary: plan.summary, model: executorName, costUsd, actions, composeBefore }),
};
}
type ObservingExecutor = Pick<ExecutorClient, 'openSession' | 'closeSession'>;
type ObservingExecutor = Pick<ExecutorClient, 'openSession' | 'closeSession'> & Partial<Pick<ExecutorClient, 'sessionCompose'>>;
function canObserve(executor: ComponentOperationExecutorDeps['executor']): executor is NonNullable<ComponentOperationExecutorDeps['executor']> & ObservingExecutor {
return executor?.openSession !== undefined && executor.closeSession !== undefined;
}
/** The project's compose.yaml as the session reads it, or null when it cannot (a plan does not need it). */
async function readCompose(executor: ObservingExecutor, token: string, signal: AbortSignal): Promise<string | null> {
if (executor.sessionCompose === undefined) return null;
const read = await executor.sessionCompose({ token }, signal).catch(() => null);
return read?.outcome === 'done' ? read.result.text : null;
}
/** Closes the observe session and gives what the model did in it, as `op: detail` lines. */
async function closeObserve(executor: ObservingExecutor, token: string): Promise<string[]> {
const closed = await executor.closeSession({ token }).catch(() => null);
+1
View File
@@ -273,4 +273,5 @@ export type AgentExecResult = z.infer<typeof AgentExecResult>;
export const SessionOpenResponse = executorResponse(SessionOpenResult);
export const SessionCloseResponse = executorResponse(SessionSummary);
export const AgentTextResponse = executorResponse(AgentTextResult);
export const SessionLogResponse = executorResponse(SessionLogResult);
+3
View File
@@ -18,6 +18,7 @@ import type {
SessionOpenRequest,
SessionOpenResult,
SessionLogResult,
AgentTextResult,
SessionSummary,
} from './executor-api.ts';
import type { PreflightReport } from './preflight.ts';
@@ -377,6 +378,8 @@ export interface ExecutorClient {
closeSession(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<SessionSummary>>;
/** The session's log so far, without closing it. */
sessionLog(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<SessionLogResult>>;
/** The project's compose.yaml as the session sees it (secrets masked as `<secret:NAME>`). */
sessionCompose(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<AgentTextResult>>;
}
export interface ModelRequest {
+3 -2
View File
@@ -205,11 +205,12 @@ describe('Workflow Timeline Domain', () => {
],
stepsBefore: [], stepsAfter: ['hl ps'], rollback: 'Restore the backup.',
};
const context = decisionContext({ migrationPlan: { plan, planDigest: 'd'.repeat(64), model: 'opus' } }, ['ADMIN_PASSWORD', 'UNRELATED']);
const context = decisionContext({ migrationPlan: { plan, planDigest: 'd'.repeat(64), model: 'opus', composeBefore: 'services:\n a: {}\n' } }, ['ADMIN_PASSWORD', 'UNRELATED']);
expect(context?.migration).toEqual({
summary: 'Upgrade to 2.x.', breakingChanges: ['Auth is always on'], composeYaml: 'services: {}\n', inputs: plan.inputs,
summary: 'Upgrade to 2.x.', breakingChanges: ['Auth is always on'], composeYaml: 'services: {}\n', composeBefore: 'services:\n a: {}\n', inputs: plan.inputs,
stepsBefore: [], stepsAfter: ['hl ps'], rollback: 'Restore the backup.', planDigest: 'd'.repeat(64), model: 'opus', provided: ['ADMIN_PASSWORD'],
});
expect(decisionContext({ migrationPlan: { plan } })?.migration?.composeBefore).toBeNull();
expect(decisionContext({ migrationPlan: { plan: { ...plan, needed: false } } })).toBeNull();
});
+3
View File
@@ -50,6 +50,8 @@ export interface MigrationDecisionContext {
readonly summary: string;
readonly breakingChanges: readonly string[];
readonly composeYaml: string | null;
/** The project's compose.yaml when the plan was made, for a diff; null in plans made before it was kept. */
readonly composeBefore: string | null;
readonly inputs: readonly MigrationInput[];
readonly stepsBefore: readonly string[];
readonly stepsAfter: readonly string[];
@@ -364,6 +366,7 @@ function migrationContext(result: Record<string, unknown>, providedInputs: reado
summary: plan.summary,
breakingChanges: plan.breakingChanges,
composeYaml: plan.composeYaml,
composeBefore: text(result['composeBefore']),
inputs: plan.inputs,
stepsBefore: plan.stepsBefore,
stepsAfter: plan.stepsAfter,
+1
View File
@@ -219,6 +219,7 @@ export const DecisionContextResponse = z.object({
summary: z.string(),
breakingChanges: z.array(z.string()),
composeYaml: z.string().nullable(),
composeBefore: z.string().nullable().default(null),
inputs: z.array(MigrationInput),
stepsBefore: z.array(z.string()),
stepsAfter: z.array(z.string()),
+13
View File
@@ -82,6 +82,19 @@ describe('HTTP executor client against the real executor server', () => {
describe('HTTP executor client failures', () => {
const answering = (body: unknown, status = 200) => () => Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }));
it('reads a session’s compose.yaml with the session token, like the model’s hl', async () => {
const seen: { url: string; authorization: string | null }[] = [];
const client = createHttpExecutorClient({
baseUrl: 'http://executor:8081',
fetch: (input, init) => {
seen.push({ url: input instanceof Request ? input.url : input.toString(), authorization: new Headers(init?.headers).get('authorization') });
return answering({ outcome: 'done', result: { text: 'services: {}\n' } })();
},
});
expect(await client.sessionCompose({ token: 'a'.repeat(32) })).toEqual({ outcome: 'done', result: { text: 'services: {}\n' } });
expect(seen).toEqual([{ url: 'http://executor:8081/agent/compose', authorization: `Bearer ${'a'.repeat(32)}` }]);
});
it('an unreachable executor is unavailable, naming where it looked', async () => {
const client = createHttpExecutorClient({ baseUrl: 'http://executor:8081', fetch: () => Promise.reject(new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } })) });
expect(await client.inspect({ project: 'jackett', service: 'jackett' })).toEqual({ outcome: 'unavailable', detail: 'executor unreachable at http://executor:8081/inspect: fetch failed (ECONNREFUSED)' });
+5 -3
View File
@@ -1,5 +1,5 @@
import type { z } from 'zod';
import { ApplyResponse, BackupResponse, DiskUsageResponse, ImagePruneResponse, InspectResponse, MigrateResponse, PreflightResponse, RollbackResponse, SessionCloseResponse, SessionLogResponse, SessionOpenResponse } from '../domain/executor-api.ts';
import { AgentTextResponse, ApplyResponse, BackupResponse, DiskUsageResponse, ImagePruneResponse, InspectResponse, MigrateResponse, PreflightResponse, RollbackResponse, SessionCloseResponse, SessionLogResponse, SessionOpenResponse } from '../domain/executor-api.ts';
import { describeError } from '../domain/mask.ts';
import type { ExecutorCall, ExecutorClient } from '../domain/ports.ts';
@@ -28,14 +28,15 @@ export function createHttpExecutorClient(options: HttpExecutorClientOptions): Ex
const doFetch = options.fetch ?? fetch;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
async function post<T>(path: string, body: unknown, schema: z.ZodType<ExecutorCall<T>>, signal?: AbortSignal): Promise<ExecutorCall<T>> {
/** `token`: a session's own operation (`/agent/...`), authorised like the model's `hl`. */
async function post<T>(path: string, body: unknown, schema: z.ZodType<ExecutorCall<T>>, signal?: AbortSignal, token?: string): Promise<ExecutorCall<T>> {
const url = new URL(path, options.baseUrl).toString();
const timeout = AbortSignal.timeout(timeoutMs);
let response: Response;
try {
response = await doFetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', ...(token === undefined ? {} : { authorization: `Bearer ${token}` }) },
body: JSON.stringify(body),
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
});
@@ -72,5 +73,6 @@ export function createHttpExecutorClient(options: HttpExecutorClientOptions): Ex
openSession: (request, signal) => post('/session/open', request, SessionOpenResponse, signal),
closeSession: (request, signal) => post('/session/close', request, SessionCloseResponse, signal),
sessionLog: (request, signal) => post('/session/log', request, SessionLogResponse, signal),
sessionCompose: ({ token }, signal) => post('/agent/compose', {}, AgentTextResponse, signal, token),
};
}
+6
View File
@@ -17,6 +17,7 @@ import type {
SessionOpenRequest,
SessionOpenResult,
SessionLogResult,
AgentTextResult,
SessionSummary,
} from '../domain/executor-api.ts';
import type { PreflightReport } from '../domain/preflight.ts';
@@ -133,6 +134,7 @@ interface ExecutorOps {
openSession: [SessionOpenRequest, SessionOpenResult];
closeSession: [SessionCloseRequest, SessionSummary];
sessionLog: [SessionCloseRequest, SessionLogResult];
sessionCompose: [SessionCloseRequest, AgentTextResult];
}
type ExecutorOp = keyof ExecutorOps;
type ExecutorScript<K extends ExecutorOp> = (request: ExecutorOps[K][0]) => ExecutorCall<ExecutorOps[K][1]>;
@@ -198,4 +200,8 @@ export class FakeExecutorClient implements ExecutorClient {
sessionLog(request: SessionCloseRequest) {
return this.#call('sessionLog', request);
}
sessionCompose(request: SessionCloseRequest) {
return this.#call('sessionCompose', request);
}
}