fix(ql3): stabilize first automation live gate

This commit is contained in:
whyour
2026-08-28 08:17:33 +08:00
parent 6342e11a10
commit ce4284ff76
2 changed files with 246 additions and 89 deletions
@@ -474,15 +474,20 @@ async function apiRequest(pathname, token, options = {}) {
return Object.freeze({ status: response.status, body }); return Object.freeze({ status: response.status, body });
} }
async function consoleSurfaceContract(state) { async function consoleSurfaceContract(state, adapters = {}) {
let lastError; const request = adapters.apiRequest ?? apiRequest;
const fetchSurface = adapters.fetch ?? fetch;
const wait = adapters.delay ?? delay;
const readCredential = adapters.credentialToken ?? credentialToken;
let ready = false;
let lastReadyError;
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
try { try {
const root = await fetch('http://127.0.0.1:5700/', { const root = await fetchSurface('http://127.0.0.1:5700/', {
redirect: 'manual', redirect: 'manual',
signal: AbortSignal.timeout(2_000), signal: AbortSignal.timeout(2_000),
}); });
const api = await fetch( const api = await fetchSurface(
'http://127.0.0.1:5700/api/v3/projects/default/tasks', 'http://127.0.0.1:5700/api/v3/projects/default/tasks',
{ {
redirect: 'manual', redirect: 'manual',
@@ -496,8 +501,19 @@ async function consoleSurfaceContract(state) {
`Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`, `Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`,
); );
} }
const token = credentialToken(state); ready = true;
const task = await apiRequest( break;
} catch (error) {
lastReadyError = error;
await wait(250);
}
}
if (!ready) {
throw lastReadyError || new Error('Console listener did not become ready');
}
const token = readCredential(state);
const task = await request(
'/api/v3/projects/default/tasks/alpha-first-automation', '/api/v3/projects/default/tasks/alpha-first-automation',
token, token,
); );
@@ -506,11 +522,9 @@ async function consoleSurfaceContract(state) {
task.body?.task?.revision !== 1 || task.body?.task?.revision !== 1 ||
!/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '') !/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '')
) { ) {
fail( fail('starter Task is not visible through the authenticated Console API');
'starter Task is not visible through the authenticated Console API',
);
} }
const started = await apiRequest( const started = await request(
'/api/v3/projects/default/tasks/alpha-first-automation/runs', '/api/v3/projects/default/tasks/alpha-first-automation/runs',
token, token,
{ {
@@ -523,21 +537,36 @@ async function consoleSurfaceContract(state) {
}), }),
}, },
); );
const accepted =
(started.status === 202 && started.body?.status === 'accepted') ||
(started.status === 200 && started.body?.status === 'existing');
if ( if (
started.status !== 202 || !accepted ||
started.body?.status !== 'accepted' ||
typeof started.body?.runId !== 'string' || typeof started.body?.runId !== 'string' ||
typeof started.body?.attemptId !== 'string' typeof started.body?.attemptId !== 'string'
) { ) {
fail('starter Task did not accept one fenced Run'); fail(
`starter Task did not converge one fenced Run: status=${
started.status
}, code=${
started.body?.code ?? started.body?.status ?? 'unknown'
}, reason=${started.body?.reason ?? 'none'}`,
);
} }
let terminal; let terminal;
for (let attempt = 0; attempt < 80; attempt += 1) { for (let attempt = 0; attempt < 80; attempt += 1) {
const current = await apiRequest( const current = await request(
`/api/v3/projects/default/runs/${started.body.runId}`, `/api/v3/projects/default/runs/${started.body.runId}`,
token, token,
); );
if (current.status !== 200) fail('starter Run became unreadable'); if (current.status !== 200) {
fail(
`starter Run became unreadable: status=${current.status}, code=${
current.body?.code ?? 'unknown'
}`,
);
}
if ( if (
['succeeded', 'failed', 'cancelled', 'timed_out'].includes( ['succeeded', 'failed', 'cancelled', 'timed_out'].includes(
current.body?.run?.status, current.body?.run?.status,
@@ -546,20 +575,32 @@ async function consoleSurfaceContract(state) {
terminal = current.body.run.status; terminal = current.body.run.status;
break; break;
} }
await delay(250); await wait(250);
} }
if (terminal !== 'succeeded') { if (terminal !== 'succeeded') {
fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`); fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`);
} }
const log = await apiRequest(
let logText;
for (let attempt = 0; attempt < 20; attempt += 1) {
const log = await request(
`/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`, `/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`,
token, token,
); );
const logText = if (log.status === 200 && log.body?.status === 'available') {
log.status === 200 && log.body?.status === 'available' logText = Buffer.from(log.body.content, 'base64').toString('utf8');
? Buffer.from(log.body.content, 'base64').toString('utf8') break;
: ''; }
if (!logText.includes('qinglong3-alpha-first-automation')) { if (log.status !== 202 || log.body?.status !== 'pending') {
fail(
`starter Run log became unavailable: status=${log.status}, code=${
log.body?.code ?? log.body?.status ?? 'unknown'
}`,
);
}
await wait(250);
}
if (!logText?.includes('qinglong3-alpha-first-automation')) {
fail('starter Run log does not contain the bounded work marker'); fail('starter Run log does not contain the bounded work marker');
} }
return Object.freeze({ return Object.freeze({
@@ -572,12 +613,6 @@ async function consoleSurfaceContract(state) {
logMarkerObserved: true, logMarkerObserved: true,
}), }),
}); });
} catch (error) {
lastError = error;
await delay(250);
}
}
throw lastError || new Error('Console listener did not become ready');
} }
async function runApplication(state) { async function runApplication(state) {
@@ -777,9 +812,13 @@ async function main() {
} }
} }
main().catch((error) => { if (require.main === module) {
main().catch((error) => {
process.stderr.write( process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`, `${error instanceof Error ? error.message : String(error)}\n`,
); );
process.exitCode = 1; process.exitCode = 1;
}); });
}
module.exports = Object.freeze({ consoleSurfaceContract });
@@ -0,0 +1,118 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
consoleSurfaceContract,
} = require('../../scripts/ql3-local-alpha-trial-kit-live-contract.cjs');
const DIGEST = 'a'.repeat(64);
const RUN_ID = '019f8680-143d-7000-8000-000000000051';
const ATTEMPT_ID = '019f8680-143d-7000-8000-000000000052';
function surfaceResponse(status) {
return {
status,
body: { cancel: async () => {} },
};
}
function fixture(startStatus) {
let runReads = 0;
let logReads = 0;
const calls = [];
return {
calls,
adapters: {
async fetch(pathname) {
return surfaceResponse(pathname.endsWith('/tasks') ? 401 : 200);
},
credentialToken() {
return 'private-owner-token';
},
async delay() {},
async apiRequest(pathname, token, options = {}) {
calls.push({ pathname, token, method: options.method ?? 'GET' });
if (pathname.endsWith('/tasks/alpha-first-automation')) {
return {
status: 200,
body: { task: { revision: 1, contentDigest: DIGEST } },
};
}
if (pathname.endsWith('/tasks/alpha-first-automation/runs')) {
return {
status: startStatus === 'accepted' ? 202 : 200,
body: { status: startStatus, runId: RUN_ID, attemptId: ATTEMPT_ID },
};
}
if (pathname.endsWith(`/runs/${RUN_ID}`)) {
runReads += 1;
return {
status: 200,
body: { run: { status: runReads === 1 ? 'running' : 'succeeded' } },
};
}
if (pathname.includes('/log?')) {
logReads += 1;
return logReads === 1
? { status: 202, body: { status: 'pending' } }
: {
status: 200,
body: {
status: 'available',
content: Buffer.from(
'qinglong3-alpha-first-automation\n',
).toString('base64'),
},
};
}
throw new Error(`unexpected request ${pathname}`);
},
},
};
}
for (const startStatus of ['accepted', 'existing']) {
test(`proves one ${startStatus} fenced Run after the log becomes available`, async () => {
const { adapters, calls } = fixture(startStatus);
const result = await consoleSurfaceContract({}, adapters);
assert.deepEqual(result, {
listener: '127.0.0.1:5700',
rootStatus: 200,
unauthenticatedApiStatus: 401,
firstAutomation: {
taskId: 'alpha-first-automation',
runStatus: 'succeeded',
logMarkerObserved: true,
},
});
assert.equal(
calls.filter(({ pathname }) => pathname.includes('/log?')).length,
2,
);
});
}
test('preserves a rejected start status, code and reason for diagnosis', async () => {
const { adapters } = fixture('rejected');
adapters.apiRequest = async (pathname) => {
if (pathname.endsWith('/tasks/alpha-first-automation')) {
return {
status: 200,
body: { task: { revision: 1, contentDigest: DIGEST } },
};
}
return {
status: 409,
body: {
code: 'task_start_fence_rejected',
reason: 'definition_changed',
},
};
};
await assert.rejects(
consoleSurfaceContract({}, adapters),
/status=409, code=task_start_fence_rejected, reason=definition_changed/,
);
});