mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add profile-aware run log reads
This commit is contained in:
@@ -22,6 +22,7 @@ import type { LocalApiRunListRoute } from '../run/runListRoute';
|
||||
import type { LocalApiRunReadRoute } from '../run/runReadRoute';
|
||||
import type { LocalApiRunStepListRoute } from '../run/runStepListRoute';
|
||||
import type { LocalApiRunCancellationRoute } from '../run/runCancellationRoute';
|
||||
import type { LocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRoute';
|
||||
import type { LocalApiTaskListRoute } from '../task/taskListRoute';
|
||||
import type { LocalApiTaskReadRoute } from '../task/taskReadRoute';
|
||||
import type { LocalApiTaskStartRoute } from '../task/taskStartRoute';
|
||||
@@ -50,6 +51,14 @@ export type LocalApiAdmissionOperation =
|
||||
runId: string;
|
||||
input: Readonly<BoundedRunStepListInput>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'run.log.read';
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'run.cancel';
|
||||
projectId: string;
|
||||
@@ -99,6 +108,7 @@ export interface LocalApiAdmissionOptions {
|
||||
readonly runEventListRoute: LocalApiRunEventListRoute;
|
||||
readonly runStepListRoute: LocalApiRunStepListRoute;
|
||||
readonly runCancellationRoute: LocalApiRunCancellationRoute;
|
||||
readonly runAttemptLogReadRoute: LocalApiRunAttemptLogReadRoute;
|
||||
readonly taskListRoute: LocalApiTaskListRoute;
|
||||
readonly taskReadRoute: LocalApiTaskReadRoute;
|
||||
readonly taskStartRoute: LocalApiTaskStartRoute;
|
||||
@@ -176,6 +186,7 @@ export function createLocalApiAdmission(
|
||||
typeof options.runEventListRoute?.handle !== 'function' ||
|
||||
typeof options.runStepListRoute?.handle !== 'function' ||
|
||||
typeof options.runCancellationRoute?.handle !== 'function' ||
|
||||
typeof options.runAttemptLogReadRoute?.handle !== 'function' ||
|
||||
typeof options.taskListRoute?.handle !== 'function' ||
|
||||
typeof options.taskReadRoute?.handle !== 'function' ||
|
||||
typeof options.taskStartRoute?.handle !== 'function' ||
|
||||
@@ -236,12 +247,14 @@ export function createLocalApiAdmission(
|
||||
request.operation.projectId,
|
||||
request.operation.operationId === 'run.cancel'
|
||||
? 'run.stop'
|
||||
: request.operation.operationId === 'run.log.read'
|
||||
? 'artifact.read'
|
||||
: request.operation.operationId === 'task.start'
|
||||
? 'run.start'
|
||||
? 'run.start'
|
||||
: request.operation.operationId === 'task.list' ||
|
||||
request.operation.operationId === 'task.get'
|
||||
? 'task.read'
|
||||
: 'run.read',
|
||||
request.operation.operationId === 'task.get'
|
||||
? 'task.read'
|
||||
: 'run.read',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
@@ -279,9 +292,15 @@ export function createLocalApiAdmission(
|
||||
),
|
||||
);
|
||||
if (auditFailure) return auditFailure;
|
||||
if (decision.effect === 'deny') return response(403, 'forbidden');
|
||||
if (decision.effect === 'deny') {
|
||||
return request.operation.operationId === 'run.log.read'
|
||||
? response(404, 'artifact_not_found')
|
||||
: response(403, 'forbidden');
|
||||
}
|
||||
if (decision.effect === 'require_approval') {
|
||||
return response(403, 'approval_required');
|
||||
return request.operation.operationId === 'run.log.read'
|
||||
? response(404, 'artifact_not_found')
|
||||
: response(403, 'approval_required');
|
||||
}
|
||||
if (request.signal.aborted) return response(503, 'request_unavailable');
|
||||
try {
|
||||
@@ -337,6 +356,16 @@ export function createLocalApiAdmission(
|
||||
principal: authenticated.principal,
|
||||
policyFence: decision.fence,
|
||||
});
|
||||
case 'run.log.read':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.runAttemptLogReadRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
runId: request.operation.runId,
|
||||
attemptId: request.operation.attemptId,
|
||||
offset: request.operation.offset,
|
||||
length: request.operation.length,
|
||||
signal: request.signal,
|
||||
});
|
||||
case 'task.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.taskListRoute.handle({
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createLocalApiRunReadRoute } from '../run/runReadRoute';
|
||||
import { createLocalApiRunEventListRoute } from '../run/runEventListRoute';
|
||||
import { createLocalApiRunStepListRoute } from '../run/runStepListRoute';
|
||||
import { createLocalApiRunCancellationRoute } from '../run/runCancellationRoute';
|
||||
import { createLocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRoute';
|
||||
import { createLocalApiTaskListRoute } from '../task/taskListRoute';
|
||||
import { createLocalApiTaskReadRoute } from '../task/taskReadRoute';
|
||||
import { createLocalApiTaskStartRoute } from '../task/taskStartRoute';
|
||||
@@ -109,6 +110,9 @@ export function createLocalApiProductSurface(
|
||||
authority.runCancellation,
|
||||
options.randomUuid ?? randomUUID,
|
||||
);
|
||||
const runAttemptLogReadRoute = createLocalApiRunAttemptLogReadRoute(
|
||||
authority.runAttemptLogRead,
|
||||
);
|
||||
const taskListRoute = createLocalApiTaskListRoute(
|
||||
authority.taskDefinitions,
|
||||
);
|
||||
@@ -128,6 +132,7 @@ export function createLocalApiProductSurface(
|
||||
runEventListRoute,
|
||||
runStepListRoute,
|
||||
runCancellationRoute,
|
||||
runAttemptLogReadRoute,
|
||||
taskListRoute,
|
||||
taskReadRoute,
|
||||
taskStartRoute,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
InvalidRunAttemptLogReadError,
|
||||
RunAttemptLogReadUnavailableError,
|
||||
type RunAttemptLogReadRequest,
|
||||
type RunAttemptLogReadResult,
|
||||
} from '@qinglong/runtime-core/run-attempt-log-read';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiRunAttemptLogReadCapability {
|
||||
read(
|
||||
request: Readonly<RunAttemptLogReadRequest>,
|
||||
): Promise<RunAttemptLogReadResult>;
|
||||
}
|
||||
|
||||
export interface LocalApiRunAttemptLogReadRequest {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LocalApiRunAttemptLogReadRoute {
|
||||
handle(
|
||||
request: Readonly<LocalApiRunAttemptLogReadRequest>,
|
||||
): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function projection(
|
||||
result: Extract<RunAttemptLogReadResult, { readonly status: 'available' }>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
schema: 'qinglong/run-attempt-log-read-result@v1',
|
||||
status: 'available',
|
||||
projectId: result.projectId,
|
||||
runId: result.runId,
|
||||
attemptId: result.attemptId,
|
||||
range: Object.freeze({
|
||||
start: result.start,
|
||||
endExclusive: result.endExclusive,
|
||||
totalBytes: result.totalBytes,
|
||||
...(result.nextOffset === undefined
|
||||
? {}
|
||||
: { nextOffset: result.nextOffset }),
|
||||
}),
|
||||
encoding: 'base64',
|
||||
content: Buffer.from(
|
||||
result.content.buffer,
|
||||
result.content.byteOffset,
|
||||
result.content.byteLength,
|
||||
).toString('base64'),
|
||||
truncation: result.truncation,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiRunAttemptLogReadRoute(
|
||||
capability: LocalApiRunAttemptLogReadCapability,
|
||||
): Readonly<LocalApiRunAttemptLogReadRoute> {
|
||||
if (!capability || typeof capability.read !== 'function') {
|
||||
throw new TypeError('Local API Run Attempt log read capability is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiRunAttemptLogReadRequest>) {
|
||||
try {
|
||||
const result = await capability.read({
|
||||
projectId: request.projectId,
|
||||
runId: request.runId,
|
||||
attemptId: request.attemptId,
|
||||
range: Object.freeze({
|
||||
offset: request.offset,
|
||||
length: request.length,
|
||||
}),
|
||||
...(request.signal === undefined ? {} : { signal: request.signal }),
|
||||
});
|
||||
if (result.status === 'not_found') {
|
||||
return response(404, { code: 'artifact_not_found' });
|
||||
}
|
||||
if (result.status === 'pending') {
|
||||
return response(202, {
|
||||
schema: 'qinglong/run-attempt-log-read-result@v1',
|
||||
status: 'pending',
|
||||
projectId: result.projectId,
|
||||
runId: result.runId,
|
||||
attemptId: result.attemptId,
|
||||
});
|
||||
}
|
||||
if (result.status === 'missing') {
|
||||
return response(503, { code: 'artifact_unavailable' });
|
||||
}
|
||||
return response(200, projection(result));
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRunAttemptLogReadError) {
|
||||
return response(400, { code: 'invalid_run_log_read_request' });
|
||||
}
|
||||
if (error instanceof RunAttemptLogReadUnavailableError) {
|
||||
return response(503, { code: 'artifact_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'artifact_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const RUN_STEP_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/steps$/;
|
||||
const RUN_CANCELLATION_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/cancellation$/;
|
||||
const RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/attempts\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/log$/;
|
||||
const TASK_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks$/;
|
||||
const TASK_READ_ROUTE_PATTERN =
|
||||
@@ -44,6 +46,7 @@ type LocalApiRouteResolution =
|
||||
| 'invalid_run_list_query'
|
||||
| 'invalid_run_event_list_query'
|
||||
| 'invalid_run_step_list_query'
|
||||
| 'invalid_run_log_read_query'
|
||||
| 'invalid_task_list_query';
|
||||
}>;
|
||||
|
||||
@@ -340,10 +343,7 @@ function parseTaskListQuery(
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (
|
||||
values.has(name) ||
|
||||
(name !== 'limit' && name !== 'after_task_id')
|
||||
) {
|
||||
if (values.has(name) || (name !== 'limit' && name !== 'after_task_id')) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
@@ -363,13 +363,58 @@ function parseTaskListQuery(
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(taskId === undefined
|
||||
? {}
|
||||
: { after: Object.freeze({ taskId }) }),
|
||||
...(taskId === undefined ? {} : { after: Object.freeze({ taskId }) }),
|
||||
});
|
||||
}
|
||||
|
||||
function route(request: IncomingMessage): LocalApiRouteResolution | null {
|
||||
function parseRunAttemptLogReadQuery(
|
||||
rawQuery: string | undefined,
|
||||
profile: LocalApplicationProfile,
|
||||
): Readonly<{ offset: number; length: number }> {
|
||||
const defaultLength = profile === 'edge' ? 16 * 1024 : 32 * 1024;
|
||||
if (rawQuery === undefined) {
|
||||
return Object.freeze({ offset: 0, length: defaultLength });
|
||||
}
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (values.has(name) || (name !== 'offset' && name !== 'length')) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawOffset = values.get('offset');
|
||||
const offset = rawOffset === undefined ? 0 : Number(rawOffset);
|
||||
const rawLength = values.get('length');
|
||||
const length = rawLength === undefined ? defaultLength : Number(rawLength);
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
offset < 0 ||
|
||||
(rawOffset !== undefined && String(offset) !== rawOffset) ||
|
||||
!Number.isSafeInteger(length) ||
|
||||
length < 1 ||
|
||||
length > 32 * 1024 ||
|
||||
(rawLength !== undefined && String(length) !== rawLength)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({ offset, length });
|
||||
}
|
||||
|
||||
function route(
|
||||
request: IncomingMessage,
|
||||
profile: LocalApplicationProfile,
|
||||
): LocalApiRouteResolution | null {
|
||||
const rawUrl = request.url;
|
||||
if (
|
||||
typeof rawUrl !== 'string' ||
|
||||
@@ -403,6 +448,20 @@ function route(request: IncomingMessage): LocalApiRouteResolution | null {
|
||||
: null;
|
||||
}
|
||||
if (request.method !== 'GET') return null;
|
||||
const runAttemptLogReadMatch = RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN.exec(path);
|
||||
if (runAttemptLogReadMatch) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'run.log.read',
|
||||
projectId: runAttemptLogReadMatch[1]!,
|
||||
runId: runAttemptLogReadMatch[2]!,
|
||||
attemptId: runAttemptLogReadMatch[3]!,
|
||||
...parseRunAttemptLogReadQuery(rawQuery, profile),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_run_log_read_query' });
|
||||
}
|
||||
}
|
||||
const taskReadMatch = TASK_READ_ROUTE_PATTERN.exec(path);
|
||||
if (taskReadMatch) {
|
||||
return rawQuery === undefined
|
||||
@@ -552,7 +611,7 @@ export async function startLocalApiHttpSurface(
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const resolvedRoute = route(request);
|
||||
const resolvedRoute = route(request, options.profile);
|
||||
if (!resolvedRoute) {
|
||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||
return;
|
||||
@@ -608,9 +667,9 @@ export async function startLocalApiHttpSurface(
|
||||
error instanceof RangeError
|
||||
? 'request_body_too_large'
|
||||
: error instanceof Error &&
|
||||
error.message === 'request_unavailable'
|
||||
? 'request_unavailable'
|
||||
: 'invalid_request_body';
|
||||
error.message === 'request_unavailable'
|
||||
? 'request_unavailable'
|
||||
: 'invalid_request_body';
|
||||
send(
|
||||
response,
|
||||
requestId,
|
||||
@@ -618,8 +677,8 @@ export async function startLocalApiHttpSurface(
|
||||
code === 'request_body_too_large'
|
||||
? 413
|
||||
: code === 'request_unavailable'
|
||||
? 503
|
||||
: 400,
|
||||
? 503
|
||||
: 400,
|
||||
code,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -101,6 +101,14 @@ function fixture(overrides = {}) {
|
||||
return { statusCode: 202, body: { status: 'accepted' } };
|
||||
},
|
||||
},
|
||||
runAttemptLogReadRoute: {
|
||||
async handle(value) {
|
||||
events.push(
|
||||
`log:${value.projectId}:${value.runId}:${value.attemptId}:${value.offset}:${value.length}`,
|
||||
);
|
||||
return { statusCode: 200, body: { status: 'available' } };
|
||||
},
|
||||
},
|
||||
taskListRoute: {
|
||||
async handle(value) {
|
||||
events.push(`tasks:${value.projectId}:${value.input.limit ?? 32}`);
|
||||
@@ -148,6 +156,51 @@ test('authenticates, authorizes, durably audits and re-confirms before reading',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses artifact.read and masks denied or approval-fenced log existence', async () => {
|
||||
const operation = Object.freeze({
|
||||
operationId: 'run.log.read',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_123',
|
||||
offset: 4,
|
||||
length: 16,
|
||||
});
|
||||
const allowed = fixture();
|
||||
assert.deepEqual(await execute(allowed.admission, request({ operation })), {
|
||||
statusCode: 200,
|
||||
body: { status: 'available' },
|
||||
});
|
||||
assert.deepEqual(allowed.events, [
|
||||
'authenticate',
|
||||
'authorize:artifact.read:prj_default',
|
||||
'audit:allowed:run.log.read',
|
||||
'confirm',
|
||||
'log:prj_default:run_123:attempt_123:4:16',
|
||||
]);
|
||||
|
||||
for (const effect of ['deny', 'require_approval']) {
|
||||
let routed = false;
|
||||
const denied = fixture({
|
||||
policy: {
|
||||
async authorize() {
|
||||
return { effect, reasons: ['masked'], fence: null };
|
||||
},
|
||||
},
|
||||
runAttemptLogReadRoute: {
|
||||
async handle() {
|
||||
routed = true;
|
||||
throw new Error('must not route');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await execute(denied.admission, request({ operation })), {
|
||||
statusCode: 404,
|
||||
body: { code: 'artifact_not_found' },
|
||||
});
|
||||
assert.equal(routed, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses the same admission chain with a route-owned run.list audit identity', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -74,60 +74,71 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async (value, body) => {
|
||||
observed.push(value);
|
||||
if (
|
||||
value.operation.operationId === 'run.cancel' ||
|
||||
value.operation.operationId === 'task.start'
|
||||
) {
|
||||
return { statusCode: 202, body: { accepted: body } };
|
||||
}
|
||||
if (value.operation.operationId === 'run.get') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { run: { id: value.operation.runId } },
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'run.events.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
nextAfterSequence: value.operation.input.afterSequence ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'run.steps.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: [],
|
||||
hasMore: false,
|
||||
next: value.operation.input.after ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'task.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: [],
|
||||
hasMore: false,
|
||||
input: value.operation.input,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'task.get') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { task: { taskId: value.operation.taskId } },
|
||||
};
|
||||
}
|
||||
observed.push(value);
|
||||
if (
|
||||
value.operation.operationId === 'run.cancel' ||
|
||||
value.operation.operationId === 'task.start'
|
||||
) {
|
||||
return { statusCode: 202, body: { accepted: body } };
|
||||
}
|
||||
if (value.operation.operationId === 'run.get') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
||||
body: { run: { id: value.operation.runId } },
|
||||
};
|
||||
}),
|
||||
}
|
||||
if (value.operation.operationId === 'run.events.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
nextAfterSequence: value.operation.input.afterSequence ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'run.steps.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: [],
|
||||
hasMore: false,
|
||||
next: value.operation.input.after ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'run.log.read') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
range: {
|
||||
offset: value.operation.offset,
|
||||
length: value.operation.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'task.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: [],
|
||||
hasMore: false,
|
||||
input: value.operation.input,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'task.get') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { task: { taskId: value.operation.taskId } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
||||
};
|
||||
}),
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000003',
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
@@ -212,10 +223,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
input: { after: { taskId: 'task_100' }, limit: 8 },
|
||||
});
|
||||
|
||||
const task = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/tasks/task_1',
|
||||
);
|
||||
const task = await request(port, '/api/v3/projects/prj_default/tasks/task_1');
|
||||
assert.deepEqual(task.body, { task: { taskId: 'task_1' } });
|
||||
assert.deepEqual(observed[5].operation, {
|
||||
operationId: 'task.get',
|
||||
@@ -275,6 +283,28 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
taskId: 'task_1',
|
||||
});
|
||||
|
||||
const log = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
|
||||
);
|
||||
assert.deepEqual(log.body, { range: { offset: 4, length: 32 } });
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
operationId: 'run.log.read',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_1',
|
||||
offset: 4,
|
||||
length: 32,
|
||||
});
|
||||
|
||||
const defaultLog = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log',
|
||||
);
|
||||
assert.deepEqual(defaultLog.body, {
|
||||
range: { offset: 0, length: 16 * 1024 },
|
||||
});
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
'/api/v3/projects/prj_default/runs/run%5f123',
|
||||
@@ -299,6 +329,18 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?',
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=-1',
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=04',
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?length=0',
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?length=32769',
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?unknown=1',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_log_read_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/tasks?',
|
||||
'/api/v3/projects/prj_default/tasks?limit=08',
|
||||
@@ -332,7 +374,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 8);
|
||||
assert.equal(observed.length, 10);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
@@ -347,9 +389,9 @@ test('rejects GET bodies without invoking the prepared route handler', async (t)
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
handlers += 1;
|
||||
return { statusCode: 200, body: {} };
|
||||
}),
|
||||
handlers += 1;
|
||||
return { statusCode: 200, body: {} };
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
@@ -477,15 +519,15 @@ test('serves the reviewed worst-case 64-item Run list inside the fixed response
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
runs: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { createdAtMs: Number.MAX_SAFE_INTEGER, runId: id128 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
runs: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { createdAtMs: Number.MAX_SAFE_INTEGER, runId: id128 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
@@ -545,15 +587,15 @@ test('serves the reviewed worst-case 64-item RunEvent list inside the fixed resp
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: Object.freeze(Array.from({ length: 64 }, () => event)),
|
||||
hasMore: true,
|
||||
nextAfterSequence: event.sequence,
|
||||
},
|
||||
};
|
||||
}),
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: Object.freeze(Array.from({ length: 64 }, () => event)),
|
||||
hasMore: true,
|
||||
nextAfterSequence: event.sequence,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
@@ -589,15 +631,15 @@ test('serves the reviewed worst-case 64-item Run Step list inside the fixed resp
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { stepKey: id128, stepRunId: id128 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { stepKey: id128, stepRunId: id128 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
@@ -621,13 +663,13 @@ test('bounds Edge admission concurrency and drains accepted work', async (t) =>
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async (value) => {
|
||||
admissions += 1;
|
||||
await barrier;
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { run: { id: value.operation.runId } },
|
||||
};
|
||||
}),
|
||||
admissions += 1;
|
||||
await barrier;
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { run: { id: value.operation.runId } },
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const accepted = Array.from({ length: 4 }, () =>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
RunAttemptLogReadUnavailableError,
|
||||
} = require('@qinglong/runtime-core/run-attempt-log-read');
|
||||
const {
|
||||
createLocalApiRunAttemptLogReadRoute,
|
||||
} = require('../dist/run/runAttemptLogReadRoute.js');
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_123',
|
||||
offset: 2,
|
||||
length: 16,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('projects an available byte range as bounded base64 JSON', async () => {
|
||||
const route = createLocalApiRunAttemptLogReadRoute({
|
||||
async read(value) {
|
||||
assert.deepEqual(value.range, { offset: 2, length: 16 });
|
||||
return {
|
||||
status: 'available',
|
||||
projectId: value.projectId,
|
||||
runId: value.runId,
|
||||
attemptId: value.attemptId,
|
||||
logArtifactId: `local-${'a'.repeat(30)}`,
|
||||
content: Buffer.from('hello'),
|
||||
start: 2,
|
||||
endExclusive: 7,
|
||||
totalBytes: 9,
|
||||
nextOffset: 7,
|
||||
truncation: { truncated: 'unknown' },
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle(request()), {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
schema: 'qinglong/run-attempt-log-read-result@v1',
|
||||
status: 'available',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_123',
|
||||
range: { start: 2, endExclusive: 7, totalBytes: 9, nextOffset: 7 },
|
||||
encoding: 'base64',
|
||||
content: Buffer.from('hello').toString('base64'),
|
||||
truncation: { truncated: 'unknown' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('maps pending, masked absence, missing storage and unavailable evidence', async () => {
|
||||
const cases = [
|
||||
[
|
||||
{
|
||||
status: 'pending',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_123',
|
||||
},
|
||||
{
|
||||
statusCode: 202,
|
||||
body: {
|
||||
schema: 'qinglong/run-attempt-log-read-result@v1',
|
||||
status: 'pending',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_123',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ status: 'not_found' },
|
||||
{ statusCode: 404, body: { code: 'artifact_not_found' } },
|
||||
],
|
||||
[
|
||||
{
|
||||
status: 'missing',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
attemptId: 'attempt_123',
|
||||
logArtifactId: `local-${'a'.repeat(30)}`,
|
||||
},
|
||||
{ statusCode: 503, body: { code: 'artifact_unavailable' } },
|
||||
],
|
||||
];
|
||||
for (const [result, expected] of cases) {
|
||||
const route = createLocalApiRunAttemptLogReadRoute({
|
||||
async read() {
|
||||
return result;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle(request()), expected);
|
||||
}
|
||||
const unavailable = createLocalApiRunAttemptLogReadRoute({
|
||||
async read() {
|
||||
throw new RunAttemptLogReadUnavailableError();
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await unavailable.handle(request()), {
|
||||
statusCode: 503,
|
||||
body: { code: 'artifact_unavailable' },
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@ const {
|
||||
const {
|
||||
compileLocalCommandTaskDefinition,
|
||||
} = require('@qinglong/runtime-core/task-definition-execution-compiler');
|
||||
const {
|
||||
RunAttemptLogReadService,
|
||||
} = require('@qinglong/runtime-core/run-attempt-log-read');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('@qinglong/runtime-core/task-spec-semantic');
|
||||
@@ -34,11 +37,16 @@ const {
|
||||
const {
|
||||
createLocalApiProductSurface,
|
||||
} = require('../dist/application-runtime/localApiProductSurface.js');
|
||||
const {
|
||||
LocalRunAttemptLogRangeReader,
|
||||
} = require('../../ql3-local-execution/dist/artifact-read/localRunAttemptLogRangeReader.js');
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const PEPPER_KEY_ID = 'local-api-pepper-v1';
|
||||
const CREDENTIAL_ID = 'local-api-owner';
|
||||
const RUN_ID = 'run_local_api_1';
|
||||
const ATTEMPT_ID = 'attempt_local_api_1';
|
||||
const LOG_ARTIFACT_ID = `local-${'a'.repeat(30)}`;
|
||||
const SECRET = Buffer.alloc(32, 81).toString('base64url');
|
||||
const PEPPER = Buffer.alloc(32, 82).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
||||
@@ -206,15 +214,18 @@ function seed(databasePath, materialDigest) {
|
||||
enabled: true,
|
||||
occurredAtMs: NOW - 200,
|
||||
};
|
||||
const taskDefinition = createTaskDefinitionRecord({
|
||||
...taskCommand,
|
||||
spec: taskSemantics.normalize({
|
||||
projectId: taskCommand.projectId,
|
||||
taskId: taskCommand.taskId,
|
||||
kind: taskCommand.kind,
|
||||
spec: taskCommand.spec,
|
||||
}),
|
||||
}, NOW - 200);
|
||||
const taskDefinition = createTaskDefinitionRecord(
|
||||
{
|
||||
...taskCommand,
|
||||
spec: taskSemantics.normalize({
|
||||
projectId: taskCommand.projectId,
|
||||
taskId: taskCommand.taskId,
|
||||
kind: taskCommand.kind,
|
||||
spec: taskCommand.spec,
|
||||
}),
|
||||
},
|
||||
NOW - 200,
|
||||
);
|
||||
const taskExecution = compileLocalCommandTaskDefinition(
|
||||
taskDefinition,
|
||||
taskSemantics,
|
||||
@@ -298,6 +309,15 @@ function seed(databasePath, materialDigest) {
|
||||
'manual', 'runtime', 'running', 1, 1, 0, ?)`,
|
||||
)
|
||||
.run(RUN_ID, NOW - 100);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunAttempts" (
|
||||
"id", "run_id", "attempt", "status", "executor_type",
|
||||
"log_artifact_id", "callback_sequence", "created_at_ms",
|
||||
"started_at_ms"
|
||||
) VALUES (?, ?, 1, 'running', 'local_process', ?, 0, ?, ?)`,
|
||||
)
|
||||
.run(ATTEMPT_ID, RUN_ID, LOG_ARTIFACT_ID, NOW - 90, NOW - 80);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRuns" (
|
||||
@@ -357,6 +377,15 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const databasePath = path.join(root, 'qinglong3.sqlite');
|
||||
const artifactRoot = path.join(root, 'artifacts');
|
||||
const artifactShard = path.join(artifactRoot, 'aa');
|
||||
fs.mkdirSync(artifactShard, { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(artifactRoot, 0o700);
|
||||
fs.chmodSync(artifactShard, 0o700);
|
||||
const logContent = Buffer.from('local-api-log-line\n', 'utf8');
|
||||
const logPath = path.join(artifactShard, `${LOG_ARTIFACT_ID}.log`);
|
||||
fs.writeFileSync(logPath, logContent, { mode: 0o600 });
|
||||
fs.chmodSync(logPath, 0o600);
|
||||
const keyringDirectory = path.join(root, 'owner-pepper');
|
||||
fs.mkdirSync(keyringDirectory, { mode: 0o700 });
|
||||
const summary = provisionLocalOwnerPepperKey({
|
||||
@@ -398,6 +427,15 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
stepRuns: await runtime.stepRunReader(),
|
||||
runCancellation: await runtime.runCancellationRepository(),
|
||||
taskStart: await runtime.taskStartRepository(),
|
||||
runAttemptLogRead: new RunAttemptLogReadService(
|
||||
runtime.runRepository,
|
||||
new LocalRunAttemptLogRangeReader(artifactRoot),
|
||||
{
|
||||
executorType: 'local_process',
|
||||
artifactIdPattern: /^local-[a-f0-9]{30}$/,
|
||||
maximumReadBytes: 32 * 1024,
|
||||
},
|
||||
),
|
||||
taskDefinitions: runtime.taskDefinitions,
|
||||
apiCredentials: runtime.apiCredentials,
|
||||
ownerPepper: runtime.ownerPepper,
|
||||
@@ -572,12 +610,29 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
});
|
||||
assert.equal(JSON.stringify(steps).includes('private'), false);
|
||||
|
||||
const log = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
`/api/v3/projects/default/runs/${RUN_ID}/attempts/${ATTEMPT_ID}/log?offset=0&length=8`,
|
||||
);
|
||||
assert.equal(log.statusCode, 200);
|
||||
assert.equal(log.body.schema, 'qinglong/run-attempt-log-read-result@v1');
|
||||
assert.equal(log.body.status, 'available');
|
||||
assert.equal(log.body.encoding, 'base64');
|
||||
assert.equal(Buffer.from(log.body.content, 'base64').toString(), 'local-ap');
|
||||
assert.deepEqual(log.body.range, {
|
||||
start: 0,
|
||||
endExclusive: 8,
|
||||
totalBytes: logContent.byteLength,
|
||||
nextOffset: 8,
|
||||
});
|
||||
assert.deepEqual(log.body.truncation, { truncated: 'unknown' });
|
||||
|
||||
const cancellationBody = JSON.stringify({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'cancel-local-api-1',
|
||||
});
|
||||
const cancellationPath =
|
||||
`/api/v3/projects/default/runs/${RUN_ID}/cancellation`;
|
||||
const cancellationPath = `/api/v3/projects/default/runs/${RUN_ID}/cancellation`;
|
||||
const cancellationOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -623,7 +678,7 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
WHERE operation_id IN (
|
||||
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||
'run.cancel', 'task.get', 'task.list'
|
||||
, 'task.start'
|
||||
, 'task.start', 'run.log.read'
|
||||
)
|
||||
ORDER BY operation_id, outcome`,
|
||||
)
|
||||
@@ -636,6 +691,7 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.get:allowed',
|
||||
'run.get:authentication_rejected',
|
||||
'run.list:allowed',
|
||||
'run.log.read:allowed',
|
||||
'run.steps.list:allowed',
|
||||
'task.get:allowed',
|
||||
'task.get:allowed',
|
||||
|
||||
Reference in New Issue
Block a user