mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +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 });
|
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,88 +501,118 @@ 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;
|
||||||
'/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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastReadyError = error;
|
||||||
await delay(250);
|
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) {
|
async function runApplication(state) {
|
||||||
@@ -777,9 +812,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
if (require.main === module) {
|
||||||
process.stderr.write(
|
main().catch((error) => {
|
||||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
process.stderr.write(
|
||||||
);
|
`${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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user