feat(ql3): surface bounded console run logs

This commit is contained in:
whyour
2026-08-28 12:27:19 +08:00
parent 920a31fb66
commit 57953ec80b
12 changed files with 391 additions and 14 deletions
@@ -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', '当前窗口没有可见事件。'));
+71 -3
View File
@@ -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<