feat(migration): keep the compose.yaml the plan was made against
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:
1 parent
1946c187a7
commit
cba8532656
10 files changed
+65
-11
No files matched your search
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)' });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user