mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): surface bounded console run logs
This commit is contained in:
@@ -577,6 +577,60 @@ input:focus-visible,
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.run-log {
|
||||
margin: 28px 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--deep);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.run-log-header {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
font: 650 10px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.run-log-header span,
|
||||
.run-log-meta,
|
||||
.run-log-placeholder {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.run-log-meta,
|
||||
.run-log-placeholder {
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.run-log-content {
|
||||
max-height: 320px;
|
||||
margin: 0;
|
||||
padding: 14px 12px 18px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: 500 11px/1.55 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.run-log[data-state='pending'],
|
||||
.run-log[data-state='not_started'] {
|
||||
border-left: 4px solid var(--amber);
|
||||
}
|
||||
|
||||
.run-log[data-state='retired'],
|
||||
.run-log[data-state='not_found'],
|
||||
.run-log[data-state='unavailable'] {
|
||||
border-left: 4px solid var(--line);
|
||||
}
|
||||
|
||||
.timeline-heading {
|
||||
margin: 30px 0 14px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TOKEN_PATTERN =
|
||||
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||
const LOG_READ_BYTES = 32 * 1024;
|
||||
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
created: '已创建',
|
||||
@@ -103,6 +104,27 @@
|
||||
: value || '—';
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length > 48 * 1024 ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(value)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const binary = window.atob(value);
|
||||
if (binary.length > LOG_READ_BYTES) return null;
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status === 'failed' || status === 'timed_out') return 'failed';
|
||||
if (
|
||||
@@ -485,13 +507,100 @@
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
|
||||
]);
|
||||
renderRunDetail(runValue.run, eventValue, stepValue);
|
||||
const logView = await readRunLog(runValue.run);
|
||||
renderRunDetail(runValue.run, eventValue, stepValue, logView);
|
||||
} catch (error) {
|
||||
detailEmpty(describeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRunDetail(run, eventPage, stepPage) {
|
||||
async function readRunLog(run) {
|
||||
const attempt = run?.latestAttempt;
|
||||
if (!attempt || typeof attempt.id !== 'string') {
|
||||
return Object.freeze({ status: 'not_started' });
|
||||
}
|
||||
try {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/runs/${run.id}/attempts/${attempt.id}/log?offset=0&length=${LOG_READ_BYTES}`,
|
||||
);
|
||||
if (value.status === 'pending') {
|
||||
return Object.freeze({ status: 'pending', attempt });
|
||||
}
|
||||
const content =
|
||||
value.status === 'available' && value.encoding === 'base64'
|
||||
? decodeBase64Utf8(value.content)
|
||||
: null;
|
||||
if (
|
||||
content === null ||
|
||||
!value.range ||
|
||||
!Number.isSafeInteger(value.range.start) ||
|
||||
!Number.isSafeInteger(value.range.endExclusive) ||
|
||||
!Number.isSafeInteger(value.range.totalBytes)
|
||||
) {
|
||||
return Object.freeze({ status: 'unavailable', attempt });
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'available',
|
||||
attempt,
|
||||
content,
|
||||
range: value.range,
|
||||
truncation: value.truncation,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConsoleRequestError && error.status === 410) {
|
||||
return Object.freeze({ status: 'retired', attempt });
|
||||
}
|
||||
if (error instanceof ConsoleRequestError && error.status === 404) {
|
||||
return Object.freeze({ status: 'not_found', attempt });
|
||||
}
|
||||
return Object.freeze({ status: 'unavailable', attempt });
|
||||
}
|
||||
}
|
||||
|
||||
function renderRunLog(logView) {
|
||||
const section = element('section', 'run-log');
|
||||
section.dataset.state = logView.status;
|
||||
const header = element('div', 'run-log-header');
|
||||
header.append(element('strong', null, 'Bounded log'));
|
||||
if (logView.attempt) {
|
||||
header.append(
|
||||
element(
|
||||
'span',
|
||||
null,
|
||||
`Attempt ${logView.attempt.attempt} · ${logView.attempt.status}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
section.append(header);
|
||||
if (logView.status === 'available') {
|
||||
const metadata = [
|
||||
`${logView.range.start}–${logView.range.endExclusive} / ${logView.range.totalBytes} bytes`,
|
||||
];
|
||||
if (logView.truncation?.truncated === true) metadata.push('执行端已截断');
|
||||
if (logView.truncation?.truncated === 'unknown')
|
||||
metadata.push('截断状态未知');
|
||||
if (logView.range.nextOffset !== undefined)
|
||||
metadata.push('后续内容可经 API 分页读取');
|
||||
section.append(element('p', 'run-log-meta', metadata.join(' · ')));
|
||||
section.append(
|
||||
element('pre', 'run-log-content', logView.content || '(空日志)'),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
const labels = {
|
||||
not_started: '当前 Run 还没有可读取的执行 Attempt。',
|
||||
pending: '日志尚未发布;运行中可使用“刷新”重新读取。',
|
||||
retired: '日志已按保留策略清理,Run 与 Event 事实仍然保留。',
|
||||
not_found: '当前 Project 下没有找到这份 Attempt 日志。',
|
||||
unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。',
|
||||
};
|
||||
section.append(
|
||||
element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderRunDetail(run, eventPage, stepPage, logView) {
|
||||
const events = Array.isArray(eventPage.events) ? eventPage.events : [];
|
||||
const steps = Array.isArray(stepPage.steps) ? stepPage.steps : [];
|
||||
const fragment = document.createDocumentFragment();
|
||||
@@ -525,6 +634,7 @@
|
||||
);
|
||||
fragment.append(actions);
|
||||
}
|
||||
fragment.append(renderRunLog(logView));
|
||||
fragment.append(element('h4', 'timeline-heading', 'Event sequence'));
|
||||
if (events.length === 0) {
|
||||
fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。'));
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
BoundedRunReadProjectionUnavailableError,
|
||||
executeBoundedRunReadProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-read-projection';
|
||||
import {
|
||||
RUN_ATTEMPT_STATUSES,
|
||||
type RunAttemptRecord,
|
||||
} from '@qinglong/runtime-core/run';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
@@ -22,10 +26,61 @@ function response(
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function latestAttemptProjection(
|
||||
value: Readonly<RunAttemptRecord>,
|
||||
runId: string,
|
||||
): Readonly<Record<string, boolean | string | number>> | null {
|
||||
const boundedText = (candidate: unknown, maximum: number) =>
|
||||
typeof candidate === 'string' &&
|
||||
candidate.length > 0 &&
|
||||
candidate.length <= maximum &&
|
||||
!/[\u0000-\u001f\u007f]/u.test(candidate);
|
||||
const timestamp = (candidate: unknown) =>
|
||||
Number.isSafeInteger(candidate) && Number(candidate) >= 0;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
value.runId !== runId ||
|
||||
!boundedText(value.id, 128) ||
|
||||
!Number.isSafeInteger(value.attempt) ||
|
||||
value.attempt < 1 ||
|
||||
value.attempt > 2_147_483_647 ||
|
||||
!RUN_ATTEMPT_STATUSES.includes(value.status) ||
|
||||
!timestamp(value.createdAtMs) ||
|
||||
(value.startedAtMs !== undefined && !timestamp(value.startedAtMs)) ||
|
||||
(value.finishedAtMs !== undefined && !timestamp(value.finishedAtMs)) ||
|
||||
(value.logArtifactId !== undefined &&
|
||||
!boundedText(value.logArtifactId, 128))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
attempt: value.attempt,
|
||||
status: value.status,
|
||||
logAvailable: value.logArtifactId !== undefined,
|
||||
createdAtMs: value.createdAtMs,
|
||||
...(value.startedAtMs === undefined
|
||||
? {}
|
||||
: { startedAtMs: value.startedAtMs }),
|
||||
...(value.finishedAtMs === undefined
|
||||
? {}
|
||||
: { finishedAtMs: value.finishedAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiRunReadRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
runs: Pick<
|
||||
RunRepositoryReader,
|
||||
'findRunById' | 'findLatestAttemptByRunId'
|
||||
>,
|
||||
): Readonly<LocalApiRunReadRoute> {
|
||||
if (!runs || typeof runs.findRunById !== 'function') {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
typeof runs.findLatestAttemptByRunId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local API Run read repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
@@ -39,9 +94,22 @@ export function createLocalApiRunReadRoute(
|
||||
if (projection.found !== true) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const latestAttempt = await runs.findLatestAttemptByRunId(
|
||||
request.runId,
|
||||
);
|
||||
const attemptView = latestAttempt
|
||||
? latestAttemptProjection(latestAttempt, request.runId)
|
||||
: null;
|
||||
if (latestAttempt && !attemptView) {
|
||||
throw new BoundedRunReadProjectionUnavailableError();
|
||||
}
|
||||
const { found: _found, ...view } = projection;
|
||||
return response(200, {
|
||||
run: Object.freeze({ projectId: request.projectId, ...view }),
|
||||
run: Object.freeze({
|
||||
projectId: request.projectId,
|
||||
...view,
|
||||
latestAttempt: attemptView,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -72,6 +72,10 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
);
|
||||
assert.match(text, /authorization: `Bearer \$\{state\.token\}`/u);
|
||||
assert.match(text, /credentials: 'omit'/u);
|
||||
assert.match(text, /attempts\/\$\{attempt\.id\}\/log/u);
|
||||
assert.match(text, /const LOG_READ_BYTES = 32 \* 1024/u);
|
||||
assert.match(text, /new TextDecoder\('utf-8'\)/u);
|
||||
assert.match(text, /日志已按保留策略清理/u);
|
||||
}
|
||||
}
|
||||
assert.ok(totalBytes <= 192 * 1024);
|
||||
|
||||
@@ -33,12 +33,32 @@ function run(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function attempt(overrides = {}) {
|
||||
return {
|
||||
id: 'attempt_123',
|
||||
runId: 'run_123',
|
||||
attempt: 2,
|
||||
status: 'running',
|
||||
executorType: 'local_process',
|
||||
executorHandle: 'private-executor-handle',
|
||||
logArtifactId: 'private-log-artifact-id',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 2_100,
|
||||
startedAtMs: 2_200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('returns the shared bounded Run projection without secret-adjacent fields', async () => {
|
||||
const route = createLocalApiRunReadRoute({
|
||||
async findRunById(runId) {
|
||||
assert.equal(runId, 'run_123');
|
||||
return run({ version: 0 });
|
||||
},
|
||||
async findLatestAttemptByRunId(runId) {
|
||||
assert.equal(runId, 'run_123');
|
||||
return attempt();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await route.handle({
|
||||
@@ -48,6 +68,14 @@ test('returns the shared bounded Run projection without secret-adjacent fields',
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.run.projectId, 'prj_default');
|
||||
assert.equal(response.body.run.version, 0);
|
||||
assert.deepEqual(response.body.run.latestAttempt, {
|
||||
id: 'attempt_123',
|
||||
attempt: 2,
|
||||
status: 'running',
|
||||
logAvailable: true,
|
||||
createdAtMs: 2_100,
|
||||
startedAtMs: 2_200,
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
});
|
||||
|
||||
@@ -57,6 +85,9 @@ test('collapses absent and cross-project Runs and fails closed on repository err
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
async findLatestAttemptByRunId() {
|
||||
throw new Error('must not inspect Attempt for an absent Run');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', runId: 'run_123' }),
|
||||
@@ -67,9 +98,45 @@ test('collapses absent and cross-project Runs and fails closed on repository err
|
||||
async findRunById() {
|
||||
throw new Error('database unavailable');
|
||||
},
|
||||
async findLatestAttemptByRunId() {
|
||||
throw new Error('database unavailable');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await unavailable.handle({ projectId: 'prj_default', runId: 'run_123' }),
|
||||
{ statusCode: 503, body: { code: 'run_query_unavailable' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null without an Attempt and fails closed on invalid Attempt projections', async () => {
|
||||
const withoutAttempt = createLocalApiRunReadRoute({
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async findLatestAttemptByRunId() {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const response = await withoutAttempt.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.run.latestAttempt, null);
|
||||
|
||||
const invalidAttempt = createLocalApiRunReadRoute({
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async findLatestAttemptByRunId() {
|
||||
return attempt({ runId: 'another_run' });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await invalidAttempt.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
}),
|
||||
{ statusCode: 503, body: { code: 'run_query_unavailable' } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -448,6 +448,14 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.equal(accepted.body.run.id, RUN_ID);
|
||||
assert.equal(accepted.body.run.projectId, 'default');
|
||||
assert.deepEqual(accepted.body.run.latestAttempt, {
|
||||
id: ATTEMPT_ID,
|
||||
attempt: 1,
|
||||
status: 'running',
|
||||
logAvailable: true,
|
||||
createdAtMs: NOW - 90,
|
||||
startedAtMs: NOW - 80,
|
||||
});
|
||||
assert.equal(JSON.stringify(accepted).includes('secret'), false);
|
||||
|
||||
const listed = await request(
|
||||
|
||||
@@ -50,7 +50,10 @@ export interface LocalApplicationProductSurfaceAuthority {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly runs: Pick<
|
||||
ReadyFreshStorage['runs'],
|
||||
'findRunById' | 'listEvents' | 'listRunsByProject'
|
||||
| 'findRunById'
|
||||
| 'findLatestAttemptByRunId'
|
||||
| 'listEvents'
|
||||
| 'listRunsByProject'
|
||||
>;
|
||||
readonly stepRuns: Awaited<ReturnType<ReadyFreshStorage['stepRunReader']>>;
|
||||
readonly runCancellation: Awaited<
|
||||
|
||||
Reference in New Issue
Block a user