Separate staged command payloads from provisioning state
This commit is contained in:
@@ -36,8 +36,8 @@ import { safetyOverride, safetyOverrideCapability } from './safety-override.js';
|
||||
export type NativeSelection = import('../../hosts.js').NativePlacement;
|
||||
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
|
||||
interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
|
||||
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null }
|
||||
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
|
||||
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; applied_at: string | null }
|
||||
interface FilePlan { id: number; path: string; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
|
||||
|
||||
/** Validate a launch request without selecting a host or consuming capacity. */
|
||||
export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection {
|
||||
@@ -215,7 +215,7 @@ export function queueNativeRebuild(name: string, triggeredBy: string): CreateSer
|
||||
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
|
||||
}
|
||||
|
||||
function filesFor(job: Job): FilePlan[] {
|
||||
function filesFor(job: Job): Array<FilePlan & { contentBase64: string | null }> {
|
||||
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s
|
||||
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
||||
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as StagedFileChange[];
|
||||
@@ -224,10 +224,18 @@ function filesFor(job: Job): FilePlan[] {
|
||||
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
|
||||
}
|
||||
|
||||
function fileMetadata({ contentBase64, ...file }: FilePlan & { contentBase64?: string | null }): FilePlan { return file; }
|
||||
|
||||
function storedFiles(job: Job): FilePlan[] {
|
||||
const row = db().prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?').get(job.id) as { files_json: string | null } | undefined;
|
||||
return (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
|
||||
}
|
||||
|
||||
/** Agent receipts are the authoritative before images; do not read a controller mount. */
|
||||
function applyReceipt(job: Job, result: Record<string, unknown>) {
|
||||
if (job.applied_at || !job.files_json) return;
|
||||
const plans = JSON.parse(job.files_json) as FilePlan[];
|
||||
if (job.applied_at) return;
|
||||
const plans = storedFiles(job);
|
||||
if (!plans.length) return;
|
||||
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined;
|
||||
db().transaction(() => {
|
||||
for (const plan of plans) {
|
||||
@@ -257,7 +265,7 @@ function applyReceipt(job: Job, result: Record<string, unknown>) {
|
||||
|
||||
/** Retain confirmed file snapshots and instance identity even after observation is cancelled. */
|
||||
export function reconcileNativeProvisioningReceipt(id: string): void {
|
||||
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.files_json,n.applied_at
|
||||
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.applied_at
|
||||
FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined;
|
||||
if (!job) return;
|
||||
@@ -280,7 +288,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
|
||||
if (!job.operation_id) return;
|
||||
const operation = getHostOperationStatus(job.operation_id);
|
||||
if (operation?.state === 'failed') {
|
||||
for (const file of JSON.parse(job.files_json ?? '[]') as FilePlan[]) {
|
||||
for (const file of storedFiles(job)) {
|
||||
db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'")
|
||||
.run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id);
|
||||
}
|
||||
@@ -348,7 +356,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
|
||||
}
|
||||
}
|
||||
reconcileHostDrains();
|
||||
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.files_json,n.applied_at,q.selection
|
||||
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.applied_at,q.selection
|
||||
FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
LEFT JOIN native_creation_intents q ON q.deployment_id=d.id
|
||||
WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
|
||||
@@ -494,7 +502,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
// post-mutation failures remain unknown and retain their capacity.
|
||||
db().transaction(() => {
|
||||
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
|
||||
const files = JSON.parse(job.files_json ?? '[]') as FilePlan[];
|
||||
const files = storedFiles(job);
|
||||
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id);
|
||||
finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' });
|
||||
})();
|
||||
@@ -585,10 +593,14 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
if (job.action === 'destroy' || job.action === 'rebuild') {
|
||||
const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; }
|
||||
}
|
||||
const files = job.files_json ? JSON.parse(job.files_json) as FilePlan[] : filesFor(job);
|
||||
const files = job.stage_operation_id ? storedFiles(job) : filesFor(job);
|
||||
if (files.length && !job.stage_operation_id) {
|
||||
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` });
|
||||
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id);
|
||||
db().transaction(() => {
|
||||
const stage = queueHostOperationSerialized(instance.host_id, 'files.stage', Buffer.from(JSON.stringify({ stageId: job.id, files })),
|
||||
{ instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` }, { stageId: job.id });
|
||||
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?')
|
||||
.run(stage.id, JSON.stringify(files.map(fileMetadata)), job.id);
|
||||
})();
|
||||
hold(job, 'Staging and verifying files on the host.'); return;
|
||||
}
|
||||
if (job.stage_operation_id) {
|
||||
@@ -671,7 +683,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
const beta = activeBetaRemovalBlocker(job.server);
|
||||
if (beta) { hold(job, beta); return; }
|
||||
}
|
||||
if (JSON.stringify(filesFor(job)) !== JSON.stringify(files)) {
|
||||
if (JSON.stringify(filesFor(job).map(fileMetadata)) !== JSON.stringify(files.map(fileMetadata))) {
|
||||
finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return;
|
||||
}
|
||||
const capacityBlocker = concurrencyBlocker(job.id, job.action, instance!.host_id);
|
||||
|
||||
Reference in New Issue
Block a user