mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
fix(ql3): stabilize first automation live gate
This commit is contained in:
@@ -474,15 +474,20 @@ async function apiRequest(pathname, token, options = {}) {
|
||||
return Object.freeze({ status: response.status, body });
|
||||
}
|
||||
|
||||
async function consoleSurfaceContract(state) {
|
||||
let lastError;
|
||||
async function consoleSurfaceContract(state, adapters = {}) {
|
||||
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) {
|
||||
try {
|
||||
const root = await fetch('http://127.0.0.1:5700/', {
|
||||
const root = await fetchSurface('http://127.0.0.1:5700/', {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
const api = await fetch(
|
||||
const api = await fetchSurface(
|
||||
'http://127.0.0.1:5700/api/v3/projects/default/tasks',
|
||||
{
|
||||
redirect: 'manual',
|
||||
@@ -496,88 +501,118 @@ async function consoleSurfaceContract(state) {
|
||||
`Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`,
|
||||
);
|
||||
}
|
||||
const token = credentialToken(state);
|
||||
const task = await apiRequest(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation',
|
||||
token,
|
||||
);
|
||||
if (
|
||||
task.status !== 200 ||
|
||||
task.body?.task?.revision !== 1 ||
|
||||
!/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '')
|
||||
) {
|
||||
fail(
|
||||
'starter Task is not visible through the authenticated Console API',
|
||||
);
|
||||
}
|
||||
const started = await apiRequest(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation/runs',
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f8680-143d-7000-8000-000000000041',
|
||||
expectedRevision: task.body.task.revision,
|
||||
expectedContentDigest: task.body.task.contentDigest,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (
|
||||
started.status !== 202 ||
|
||||
started.body?.status !== 'accepted' ||
|
||||
typeof started.body?.runId !== 'string' ||
|
||||
typeof started.body?.attemptId !== 'string'
|
||||
) {
|
||||
fail('starter Task did not accept one fenced Run');
|
||||
}
|
||||
let terminal;
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const current = await apiRequest(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}`,
|
||||
token,
|
||||
);
|
||||
if (current.status !== 200) fail('starter Run became unreadable');
|
||||
if (
|
||||
['succeeded', 'failed', 'cancelled', 'timed_out'].includes(
|
||||
current.body?.run?.status,
|
||||
)
|
||||
) {
|
||||
terminal = current.body.run.status;
|
||||
break;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
if (terminal !== 'succeeded') {
|
||||
fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`);
|
||||
}
|
||||
const log = await apiRequest(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`,
|
||||
token,
|
||||
);
|
||||
const logText =
|
||||
log.status === 200 && log.body?.status === 'available'
|
||||
? Buffer.from(log.body.content, 'base64').toString('utf8')
|
||||
: '';
|
||||
if (!logText.includes('qinglong3-alpha-first-automation')) {
|
||||
fail('starter Run log does not contain the bounded work marker');
|
||||
}
|
||||
return Object.freeze({
|
||||
listener: '127.0.0.1:5700',
|
||||
rootStatus: 200,
|
||||
unauthenticatedApiStatus: 401,
|
||||
firstAutomation: Object.freeze({
|
||||
taskId: 'alpha-first-automation',
|
||||
runStatus: terminal,
|
||||
logMarkerObserved: true,
|
||||
}),
|
||||
});
|
||||
ready = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await delay(250);
|
||||
lastReadyError = error;
|
||||
await wait(250);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('Console listener did not become ready');
|
||||
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',
|
||||
token,
|
||||
);
|
||||
if (
|
||||
task.status !== 200 ||
|
||||
task.body?.task?.revision !== 1 ||
|
||||
!/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '')
|
||||
) {
|
||||
fail('starter Task is not visible through the authenticated Console API');
|
||||
}
|
||||
const started = await request(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation/runs',
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f8680-143d-7000-8000-000000000041',
|
||||
expectedRevision: task.body.task.revision,
|
||||
expectedContentDigest: task.body.task.contentDigest,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const accepted =
|
||||
(started.status === 202 && started.body?.status === 'accepted') ||
|
||||
(started.status === 200 && started.body?.status === 'existing');
|
||||
if (
|
||||
!accepted ||
|
||||
typeof started.body?.runId !== 'string' ||
|
||||
typeof started.body?.attemptId !== 'string'
|
||||
) {
|
||||
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;
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const current = await request(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}`,
|
||||
token,
|
||||
);
|
||||
if (current.status !== 200) {
|
||||
fail(
|
||||
`starter Run became unreadable: status=${current.status}, code=${
|
||||
current.body?.code ?? 'unknown'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
['succeeded', 'failed', 'cancelled', 'timed_out'].includes(
|
||||
current.body?.run?.status,
|
||||
)
|
||||
) {
|
||||
terminal = current.body.run.status;
|
||||
break;
|
||||
}
|
||||
await wait(250);
|
||||
}
|
||||
if (terminal !== 'succeeded') {
|
||||
fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`);
|
||||
}
|
||||
|
||||
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`,
|
||||
token,
|
||||
);
|
||||
if (log.status === 200 && log.body?.status === 'available') {
|
||||
logText = Buffer.from(log.body.content, 'base64').toString('utf8');
|
||||
break;
|
||||
}
|
||||
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');
|
||||
}
|
||||
return Object.freeze({
|
||||
listener: '127.0.0.1:5700',
|
||||
rootStatus: 200,
|
||||
unauthenticatedApiStatus: 401,
|
||||
firstAutomation: Object.freeze({
|
||||
taskId: 'alpha-first-automation',
|
||||
runStatus: terminal,
|
||||
logMarkerObserved: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function runApplication(state) {
|
||||
@@ -777,9 +812,13 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user