Merge pull request #393 from pikasTech/fix/reconcile-release-v02
合并 release 独有语义并恢复 v0.2 祖先关系
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -137,11 +137,17 @@ spec:
|
||||
ref: master
|
||||
subpath: git-spec
|
||||
targetPath: .agents/skills/git-spec
|
||||
- name: post-task-skill
|
||||
repoUrl: git@github.com:pikasTech/agent_skills.git
|
||||
ref: master
|
||||
subpath: post-task
|
||||
targetPath: .agents/skills/post-task
|
||||
requiredSkills:
|
||||
- name: dad-dev
|
||||
- name: cli-spec
|
||||
- name: docs-spec
|
||||
- name: git-spec
|
||||
- name: post-task
|
||||
- name: unidesk-trans
|
||||
- name: unidesk-gh
|
||||
- name: unidesk-agentrun
|
||||
@@ -153,7 +159,7 @@ spec:
|
||||
submodules: false
|
||||
lfs: false
|
||||
payloadDefaults:
|
||||
title: Artificer distributed development task
|
||||
title: Artificer 分布式开发任务
|
||||
prompt: |-
|
||||
你就是当前已派发任务的 Artificer 执行者,必须按任务要求直接完成工作;仅在任务需要源码修改时,才在 primary workspace 完成修改、测试和 PR。
|
||||
禁止通过 `agentrun create`、`agentrun apply`、`agentrun dispatch`、Code Queue 或任何等价入口创建或派发另一个 Artificer 来完成当前任务。
|
||||
|
||||
@@ -12,14 +12,14 @@ ENV AGENTRUN_APP_ROOT=/workspace/agentrun
|
||||
ENV AGENTRUN_BOOT_REPO_URL=http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git
|
||||
|
||||
RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \
|
||||
apk add --no-cache ca-certificates curl git github-cli kubectl nodejs npm openssh-client ripgrep
|
||||
apk add --no-cache ca-certificates curl git github-cli kubectl nodejs npm openssh-client ripgrep tini
|
||||
|
||||
COPY package.json bun.lock tsconfig.json ./
|
||||
RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \
|
||||
bun install --production
|
||||
RUN /opt/agentrun/node_modules/.bin/codex --version && /opt/agentrun/node_modules/.bin/codex app-server --help >/dev/null
|
||||
RUN set -eu; \
|
||||
for tool in bun node npm git ssh gh rg curl kubectl; do command -v "$tool" >/dev/null; done; \
|
||||
for tool in bun node npm git ssh gh rg curl kubectl tini; do command -v "$tool" >/dev/null; done; \
|
||||
bun --version >/tmp/agentrun-work-ready-smoke.txt; \
|
||||
node --version >>/tmp/agentrun-work-ready-smoke.txt; \
|
||||
npm --version >>/tmp/agentrun-work-ready-smoke.txt; \
|
||||
@@ -33,4 +33,5 @@ COPY deploy/runtime/boot ./deploy/runtime/boot
|
||||
RUN chmod +x /opt/agentrun/deploy/runtime/boot/*.sh
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["/opt/agentrun/deploy/runtime/boot/agentrun-mgr.sh"]
|
||||
|
||||
@@ -5,8 +5,8 @@ import { AgentRunError } from "../common/errors.js";
|
||||
import { redactJson } from "../common/redaction.js";
|
||||
import type { BackendProfile, BackendTurnResult, CancelRequestRecord, CancelStage, CancelTargetKind, CommandRecord, CommandState, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, EventType, FailureKind, JsonRecord, JsonValue, KafkaEventOutboxRecord, ListGcExpiredSessionsInput, QueueAttemptListResult, QueueAttemptRecord, QueueAttemptRef, QueueCommanderSnapshot, QueueReadCursorRecord, QueueRetryActivation, QueueRetryReservation, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerDispatchCompletion, RunnerDispatchIntentRecord, RunnerJobRecord, RunnerRecord, RunRecord, RunStatus, SessionEventPage, SessionListResult, SessionReadCursorRecord, SessionRecord, SessionRef, SessionStoragePatch, SessionSummary, TerminalStatus, UpsertSessionInput } from "../common/types.js";
|
||||
import { newId, nowIso, stableHash } from "../common/validation.js";
|
||||
import type { ActivateQueueRetryAttemptInput, AgentRunStore, CreateRunIdentity, DurableQueueClaim, EventOutboxConfig, ListQueueAttemptsInput, ListQueueTasksInput, ListSessionsInput, RecordQueueRetryAttemptFailureInput, ReserveQueueRetryAttemptInput, RunnerRetentionFenceFacts, RunnerRetentionFenceInput, SaveRunnerJobInput, SessionEventPageInput, SessionSteerAdmission, SessionSteerAdmissionInput, SessionTurnAdmission, SessionTurnAdmissionInput, StoreHealth, UpdateQueueTaskAttemptInput } from "./store.js";
|
||||
import { activeSessionAdmissionConflict, assertQueueAttemptActivationReplay, assertQueueRetryable, assertQueueTaskPayloadHash, assertRunCreateReplay, assertSessionBoundary, assertSessionCommandReplay, assertSessionSteerAdmissionContract, assertSessionTurnAdmissionContract, buildQueueStats, buildQueueTaskSummary, buildSessionSummary, cancelStagePayload, clampQueueLimit, clampSessionLimit, commandStateFromTerminal, fenceLateEventForCancelledRun, invalidSessionAdmissionProjection, isLeaseExpired, isSessionOutputEvent, isTerminalCommandState, isTerminalQueueAttemptState, isTerminalQueueTaskState, isTerminalRunStatus, latestQueueAttempt, lateWriteRejectedPayload, nextQueueRetryIndex, parseQueueCursor, parseSessionCursor, queueAttemptRef, queueRetryReservation, queueRetryStateError, queueTaskMatchesCommander, queueTaskPayloadHash, queueTaskSort, receivableActiveSessionTurn, runCreatedEventPayload, runnerDispatchFailureKind, runnerDispatchFailureReason, sessionListFilters, sessionMatchesListState, sessionRefFromRecord, sessionSort, sessionTitleFromCommand, staleActiveSessionTurn, statusFromTerminal, summarizeSessionRef, titleFromMetadata } from "./store.js";
|
||||
import type { ActivateQueueRetryAttemptInput, AgentRunStore, CreateRunIdentity, DurableQueueClaim, EventOutboxConfig, ListQueueAttemptsInput, ListQueueTasksInput, ListRunsInput, ListSessionsInput, RecordQueueRetryAttemptFailureInput, ReserveQueueRetryAttemptInput, RunCollectionResult, RunnerRetentionFenceFacts, RunnerRetentionFenceInput, SaveRunnerJobInput, SessionEventPageInput, SessionSteerAdmission, SessionSteerAdmissionInput, SessionTurnAdmission, SessionTurnAdmissionInput, StoreHealth, UpdateQueueTaskAttemptInput } from "./store.js";
|
||||
import { activeSessionAdmissionConflict, assertQueueAttemptActivationReplay, assertQueueRetryable, assertQueueTaskPayloadHash, assertRunCreateReplay, assertSessionBoundary, assertSessionCommandReplay, assertSessionSteerAdmissionContract, assertSessionTurnAdmissionContract, buildQueueStats, buildQueueTaskSummary, buildSessionSummary, cancelStagePayload, clampQueueLimit, clampSessionLimit, commandStateFromTerminal, fenceLateEventForCancelledRun, invalidSessionAdmissionProjection, isLeaseExpired, isSessionOutputEvent, isTerminalCommandState, isTerminalQueueAttemptState, isTerminalQueueTaskState, isTerminalRunStatus, latestQueueAttempt, lateWriteRejectedPayload, nextQueueRetryIndex, parseQueueCursor, parseSessionCursor, queueAttemptRef, queueRetryReservation, queueRetryStateError, queueTaskMatchesCommander, queueTaskPayloadHash, queueTaskSort, receivableActiveSessionTurn, runCollectionItem, runCollectionResult, runCreatedEventPayload, runnerDispatchFailureKind, runnerDispatchFailureReason, sessionListFilters, sessionMatchesListState, sessionRefFromRecord, sessionSort, sessionTitleFromCommand, staleActiveSessionTurn, statusFromTerminal, summarizeSessionRef, titleFromMetadata } from "./store.js";
|
||||
import { backendCapabilitiesSqlValues, mergeBackendCapability } from "../common/backend-profiles.js";
|
||||
import { normalizeRunEventPayload, requireEventType, requireExternallyAppendableEventType, userMessagePayloadForCommand } from "../common/events.js";
|
||||
import { buildKafkaEventOutboxRecord, canonicalAgentRunEventPartitionKey } from "./event-outbox.js";
|
||||
@@ -600,6 +600,80 @@ CREATE TABLE IF NOT EXISTS agentrun_schema_migrations (
|
||||
return runFromRow(row);
|
||||
}
|
||||
|
||||
async listRuns(input: ListRunsInput): Promise<RunCollectionResult> {
|
||||
const parameters: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
const equalFilter = (column: string, value: string | undefined) => {
|
||||
if (!value) return;
|
||||
parameters.push(value);
|
||||
conditions.push(`${column} = $${parameters.length}`);
|
||||
};
|
||||
if (input.statuses?.length) {
|
||||
parameters.push(input.statuses);
|
||||
conditions.push(`r.status = ANY($${parameters.length}::text[])`);
|
||||
}
|
||||
equalFilter("r.project_id", input.projectId);
|
||||
equalFilter("r.workspace_ref->>'path'", input.workspace);
|
||||
equalFilter("r.backend_profile", input.backendProfile);
|
||||
equalFilter("r.provider_id", input.providerId);
|
||||
if (input.updatedAfter) {
|
||||
parameters.push(input.updatedAfter);
|
||||
conditions.push(`r.updated_at >= $${parameters.length}::timestamptz`);
|
||||
}
|
||||
if (input.search) {
|
||||
parameters.push(`%${input.search.toLowerCase()}%`);
|
||||
const index = parameters.length;
|
||||
conditions.push(`LOWER(CONCAT_WS(E'\\n', r.id, r.project_id, r.workspace_ref->>'path', r.backend_profile, r.provider_id, r.session_ref->>'sessionId', r.session_ref->'metadata'->>'title', r.trace_sink->>'taskId', r.trace_sink->>'lane', r.failure_kind, r.failure_message)) LIKE $${index}`);
|
||||
}
|
||||
parameters.push(input.pageSize, (input.page - 1) * input.pageSize);
|
||||
const limitIndex = parameters.length - 1;
|
||||
const offsetIndex = parameters.length;
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const orderBy = input.sort === "updated-asc" ? "updated_at ASC, id ASC"
|
||||
: input.sort === "status-asc" ? "status ASC, updated_at DESC, id DESC"
|
||||
: input.sort === "id-asc" ? "id ASC"
|
||||
: "updated_at DESC, id DESC";
|
||||
const qualifiedOrderBy = orderBy.replaceAll("updated_at", "p.updated_at").replaceAll("status", "p.status").replaceAll("id", "p.id");
|
||||
const result = await this.pool.query(`
|
||||
WITH filtered AS (
|
||||
SELECT r.*
|
||||
FROM agentrun_runs r
|
||||
${where}
|
||||
), total AS (
|
||||
SELECT COUNT(*)::text AS total_count FROM filtered
|
||||
), facets AS (
|
||||
SELECT jsonb_build_object(
|
||||
'projects', COALESCE((SELECT jsonb_agg(project_id ORDER BY project_id) FROM (SELECT DISTINCT project_id FROM agentrun_runs) values_by_project), '[]'::jsonb),
|
||||
'workspaces', COALESCE((SELECT jsonb_agg(workspace ORDER BY workspace) FROM (SELECT DISTINCT workspace_ref->>'path' AS workspace FROM agentrun_runs WHERE workspace_ref->>'path' IS NOT NULL) values_by_workspace), '[]'::jsonb),
|
||||
'backends', COALESCE((SELECT jsonb_agg(backend_profile ORDER BY backend_profile) FROM (SELECT DISTINCT backend_profile FROM agentrun_runs) values_by_backend), '[]'::jsonb),
|
||||
'providers', COALESCE((SELECT jsonb_agg(provider_id ORDER BY provider_id) FROM (SELECT DISTINCT provider_id FROM agentrun_runs) values_by_provider), '[]'::jsonb),
|
||||
'statusCounts', COALESCE((SELECT jsonb_object_agg(status, count) FROM (SELECT status, COUNT(*)::int AS count FROM agentrun_runs GROUP BY status) values_by_status), '{}'::jsonb)
|
||||
) AS value
|
||||
), paged AS (
|
||||
SELECT * FROM filtered ORDER BY ${orderBy} LIMIT $${limitIndex} OFFSET $${offsetIndex}
|
||||
)
|
||||
SELECT p.*, total.total_count, facets.value AS facets, row_to_json(c.*) AS latest_command, row_to_json(j.*) AS latest_runner_job
|
||||
FROM total CROSS JOIN facets
|
||||
LEFT JOIN paged p ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM agentrun_commands command_row WHERE command_row.run_id = p.id ORDER BY command_row.seq DESC LIMIT 1
|
||||
) c ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM agentrun_runner_jobs job_row WHERE job_row.run_id = p.id ORDER BY job_row.created_at DESC, job_row.id DESC LIMIT 1
|
||||
) j ON TRUE
|
||||
ORDER BY ${qualifiedOrderBy} NULLS LAST
|
||||
`, parameters);
|
||||
const total = Number(result.rows[0]?.total_count ?? 0);
|
||||
const items = result.rows
|
||||
.filter((row) => row.id !== null && row.id !== undefined)
|
||||
.map((row) => runCollectionItem(
|
||||
runFromRow(row),
|
||||
row.latest_command ? commandFromRow(jsonRecord(row.latest_command)) : null,
|
||||
row.latest_runner_job ? runnerJobFromRow(jsonRecord(row.latest_runner_job)) : null,
|
||||
));
|
||||
return runCollectionResult(input, items, total, jsonRecord(result.rows[0]?.facets));
|
||||
}
|
||||
|
||||
async listEvents(runId: string, afterSeq: number, limit: number): Promise<RunEvent[]> {
|
||||
await this.getRun(runId);
|
||||
const result = await this.pool.query("SELECT * FROM agentrun_events WHERE run_id = $1 AND seq > $2 ORDER BY seq ASC LIMIT $3", [runId, afterSeq, clamp(limit, 1, 500)]);
|
||||
|
||||
+41
-1
@@ -1,7 +1,7 @@
|
||||
import type { Server } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { AgentRunStore, ListQueueTasksInput, ListSessionsInput, SessionEventPageInput } from "./store.js";
|
||||
import type { AgentRunStore, ListQueueTasksInput, ListRunsInput, ListSessionsInput, SessionEventPageInput } from "./store.js";
|
||||
import { assertSessionBoundary, isTerminalCommandState, isTerminalRunStatus, openAgentRunStoreFromEnv, receivableActiveSessionTurn } from "./store.js";
|
||||
import { agentRunKafkaConfig, type AgentRunKafkaConfig } from "../common/kafka-events.js";
|
||||
import { AgentRunError, errorToJson } from "../common/errors.js";
|
||||
@@ -959,6 +959,31 @@ async function route({ method, url, body, signal, store, sourceCommit, authSumma
|
||||
void emitAgentRunOtelSpan("run_created", run, process.env, { startTimeMs: startedAt, kind: 2, attributes: { "http.method": "POST", "http.route": "/api/v1/runs", "http.status_code": 200, backendProfile: run.backendProfile, providerId: run.providerId } });
|
||||
return run as unknown as JsonValue;
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/runs") {
|
||||
const page = positiveIntegerQuery(url, "page", 1);
|
||||
const pageSize = positiveIntegerQuery(url, "pageSize", 25, 100);
|
||||
const input: ListRunsInput = { page, pageSize };
|
||||
const status = url.searchParams.get("status");
|
||||
if (status) input.statuses = status.split(",").map((item) => validateRunCollectionStatus(item.trim()));
|
||||
const projectId = optionalTrimmedQuery(url, "projectId");
|
||||
const workspace = optionalTrimmedQuery(url, "workspace");
|
||||
const backendProfile = optionalTrimmedQuery(url, "backendProfile");
|
||||
const providerId = optionalTrimmedQuery(url, "providerId");
|
||||
const search = optionalTrimmedQuery(url, "search");
|
||||
const updatedAfter = optionalTrimmedQuery(url, "updatedAfter");
|
||||
const sort = optionalTrimmedQuery(url, "sort");
|
||||
if (projectId) input.projectId = projectId;
|
||||
if (workspace) input.workspace = workspace;
|
||||
if (backendProfile) input.backendProfile = backendProfile;
|
||||
if (providerId) input.providerId = providerId;
|
||||
if (search) input.search = search;
|
||||
if (updatedAfter) {
|
||||
if (!Number.isFinite(Date.parse(updatedAfter))) throw new AgentRunError("schema-invalid", "updatedAfter must be an ISO timestamp", { httpStatus: 400 });
|
||||
input.updatedAfter = updatedAfter;
|
||||
}
|
||||
if (sort) input.sort = validateRunCollectionSort(sort);
|
||||
return await store.listRuns(input) as unknown as JsonValue;
|
||||
}
|
||||
const runMatch = path.match(/^\/api\/v1\/runs\/([^/]+)$/u);
|
||||
if (method === "GET" && runMatch) return await store.getRun(runMatch[1] ?? "") as unknown as JsonValue;
|
||||
const eventMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/events$/u);
|
||||
@@ -1523,6 +1548,21 @@ function positiveIntegerQuery(url: URL, key: string, fallback?: number, maximum
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalTrimmedQuery(url: URL, key: string): string | undefined {
|
||||
const value = url.searchParams.get(key)?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function validateRunCollectionStatus(value: string): NonNullable<ListRunsInput["statuses"]>[number] {
|
||||
if (["pending", "claimed", "running", "completed", "failed", "blocked", "cancelled"].includes(value)) return value as NonNullable<ListRunsInput["statuses"]>[number];
|
||||
throw new AgentRunError("schema-invalid", `unsupported run status: ${value}`, { httpStatus: 400 });
|
||||
}
|
||||
|
||||
function validateRunCollectionSort(value: string): NonNullable<ListRunsInput["sort"]> {
|
||||
if (["updated-desc", "updated-asc", "status-asc", "id-asc"].includes(value)) return value as NonNullable<ListRunsInput["sort"]>;
|
||||
throw new AgentRunError("schema-invalid", `unsupported run collection sort: ${value}`, { httpStatus: 400 });
|
||||
}
|
||||
|
||||
async function waitForEventsPage(input: { store: AgentRunStore; runId: string; afterSeq: number; expect: number; timeoutMs: number; limit: number; signal: AbortSignal }): Promise<{ items: RunEvent[]; timedOut: boolean; observed: number; nextAfterSeq: number; hasMore: boolean; completionReason: "expect-reached" | "run-terminal" | "command-terminal" | "timeout" }> {
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
while (true) {
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface AgentRunStore {
|
||||
health(): MaybePromise<StoreHealth>;
|
||||
createRun(input: CreateRunInput, identity?: CreateRunIdentity): MaybePromise<RunRecord>;
|
||||
getRun(runId: string): MaybePromise<RunRecord>;
|
||||
listRuns(input: ListRunsInput): MaybePromise<RunCollectionResult>;
|
||||
listEvents(runId: string, afterSeq: number, limit: number): MaybePromise<RunEvent[]>;
|
||||
countEvents(runId: string, afterSeq: number): MaybePromise<number>;
|
||||
waitForRunChange(runId: string, afterSeq: number, timeoutMs: number, signal?: AbortSignal): Promise<"changed" | "timeout" | "aborted">;
|
||||
@@ -144,6 +145,62 @@ export interface CreateRunIdentity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ListRunsInput {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
statuses?: RunRecord["status"][];
|
||||
projectId?: string;
|
||||
workspace?: string;
|
||||
backendProfile?: string;
|
||||
providerId?: string;
|
||||
search?: string;
|
||||
updatedAfter?: string;
|
||||
sort?: "updated-desc" | "updated-asc" | "status-asc" | "id-asc";
|
||||
}
|
||||
|
||||
export interface RunCollectionItem extends JsonRecord {
|
||||
id: string;
|
||||
taskId: string | null;
|
||||
taskStatus: string;
|
||||
title: string | null;
|
||||
commandId: string | null;
|
||||
commandState: string | null;
|
||||
commandType: string | null;
|
||||
attemptId: string | null;
|
||||
attemptStatus: string;
|
||||
runnerId: string | null;
|
||||
runnerJobId: string | null;
|
||||
sessionId: string | null;
|
||||
traceId: string | null;
|
||||
projectId: string;
|
||||
workspace: string | null;
|
||||
backend: string;
|
||||
providerId: string;
|
||||
nodeId: string;
|
||||
lane: string | null;
|
||||
runStatus: string;
|
||||
terminalStatus: string | null;
|
||||
phase: string;
|
||||
result: string | null;
|
||||
errorCode: string | null;
|
||||
lastMessage: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RunCollectionResult extends JsonRecord {
|
||||
items: RunCollectionItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
facets: JsonRecord;
|
||||
filters: JsonRecord;
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
export interface ListQueueTasksInput {
|
||||
queue?: string;
|
||||
state?: QueueTaskState;
|
||||
@@ -177,6 +234,119 @@ export interface RecordQueueRetryAttemptFailureInput {
|
||||
failureMessage: string;
|
||||
}
|
||||
|
||||
export function runCollectionResult(input: ListRunsInput, items: RunCollectionItem[], total: number, facets: JsonRecord = {}): RunCollectionResult {
|
||||
const totalPages = Math.ceil(total / input.pageSize);
|
||||
return {
|
||||
items,
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: input.page < totalPages,
|
||||
facets,
|
||||
filters: {
|
||||
statuses: input.statuses ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
workspace: input.workspace ?? null,
|
||||
backendProfile: input.backendProfile ?? null,
|
||||
providerId: input.providerId ?? null,
|
||||
search: input.search ?? null,
|
||||
updatedAfter: input.updatedAfter ?? null,
|
||||
sort: input.sort ?? "updated-desc",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function runCollectionItem(run: RunRecord, command: CommandRecord | null, runnerJob: RunnerJobRecord | null): RunCollectionItem {
|
||||
const traceSink = jsonObject(run.traceSink);
|
||||
const commandPayload = command?.payload ?? null;
|
||||
const runnerResult = runnerJob?.result ?? null;
|
||||
const terminal = isTerminalRunStatus(run.status);
|
||||
const terminalStatus = run.terminalStatus ?? (terminal ? run.status as TerminalStatus : null);
|
||||
return {
|
||||
id: run.id,
|
||||
taskId: textField(traceSink, "taskId"),
|
||||
taskStatus: run.status,
|
||||
title: textField(run.sessionRef?.metadata ?? null, "title"),
|
||||
commandId: command?.id ?? null,
|
||||
commandState: command?.state ?? null,
|
||||
commandType: command?.type ?? null,
|
||||
attemptId: runnerJob?.attemptId ?? null,
|
||||
attemptStatus: textField(runnerResult, "status") ?? textField(runnerResult, "terminalStatus") ?? run.status,
|
||||
runnerId: runnerJob?.runnerId ?? run.claimedBy,
|
||||
runnerJobId: runnerJob?.id ?? null,
|
||||
sessionId: run.sessionRef?.sessionId ?? null,
|
||||
traceId: textField(commandPayload, "traceId") ?? textField(traceSink, "traceId"),
|
||||
projectId: run.projectId,
|
||||
workspace: run.workspaceRef.path ?? null,
|
||||
backend: run.backendProfile,
|
||||
providerId: run.providerId,
|
||||
nodeId: run.providerId,
|
||||
lane: textField(traceSink, "lane"),
|
||||
runStatus: run.status,
|
||||
terminalStatus,
|
||||
phase: textField(runnerResult, "phase") ?? (terminal ? `terminal:${terminalStatus}` : run.status),
|
||||
result: terminalStatus,
|
||||
errorCode: run.failureKind ?? textField(runnerResult, "failureKind"),
|
||||
lastMessage: run.failureMessage ?? textField(runnerResult, "failureMessage") ?? textField(runnerResult, "message"),
|
||||
startedAt: run.createdAt,
|
||||
finishedAt: terminal ? run.updatedAt : null,
|
||||
updatedAt: latestIso(run.updatedAt, command?.updatedAt, runnerJob?.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function runCollectionSearchText(run: RunRecord): string {
|
||||
const traceSink = jsonObject(run.traceSink);
|
||||
return [
|
||||
run.id,
|
||||
run.projectId,
|
||||
run.workspaceRef.path,
|
||||
run.backendProfile,
|
||||
run.providerId,
|
||||
run.sessionRef?.sessionId,
|
||||
textField(run.sessionRef?.metadata ?? null, "title"),
|
||||
textField(traceSink, "taskId"),
|
||||
textField(traceSink, "lane"),
|
||||
run.failureKind,
|
||||
run.failureMessage,
|
||||
].filter((value): value is string => typeof value === "string").join("\n").toLowerCase();
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown): JsonRecord | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
|
||||
}
|
||||
|
||||
function textField(value: unknown, key: string): string | null {
|
||||
const object = jsonObject(value);
|
||||
const field = object?.[key];
|
||||
return typeof field === "string" && field.trim().length > 0 ? field.trim() : null;
|
||||
}
|
||||
|
||||
function latestIso(...values: Array<string | null | undefined>): string {
|
||||
return values.filter((value): value is string => Boolean(value)).sort().at(-1) ?? nowIso();
|
||||
}
|
||||
|
||||
function compareRunCollection(left: RunRecord, right: RunRecord, sort: NonNullable<ListRunsInput["sort"]>): number {
|
||||
if (sort === "updated-asc") return left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id);
|
||||
if (sort === "status-asc") return left.status.localeCompare(right.status) || right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id);
|
||||
if (sort === "id-asc") return left.id.localeCompare(right.id);
|
||||
return right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id);
|
||||
}
|
||||
|
||||
function runCollectionFacets(runs: RunRecord[]): JsonRecord {
|
||||
const values = (items: Array<string | null | undefined>) => [...new Set(items.filter((item): item is string => Boolean(item)))].sort();
|
||||
const statusCounts: JsonRecord = {};
|
||||
for (const run of runs) statusCounts[run.status] = Number(statusCounts[run.status] ?? 0) + 1;
|
||||
return {
|
||||
projects: values(runs.map((run) => run.projectId)),
|
||||
workspaces: values(runs.map((run) => run.workspaceRef.path)),
|
||||
backends: values(runs.map((run) => run.backendProfile)),
|
||||
providers: values(runs.map((run) => run.providerId)),
|
||||
statusCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListSessionsInput {
|
||||
state?: SessionListState;
|
||||
backendProfile?: BackendProfile;
|
||||
@@ -300,6 +470,31 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
return run;
|
||||
}
|
||||
|
||||
listRuns(input: ListRunsInput): RunCollectionResult {
|
||||
const needle = input.search?.trim().toLowerCase() ?? "";
|
||||
const allRuns = Array.from(this.runs.values());
|
||||
const matching = allRuns
|
||||
.filter((run) => !input.statuses || input.statuses.includes(run.status))
|
||||
.filter((run) => !input.projectId || run.projectId === input.projectId)
|
||||
.filter((run) => !input.workspace || run.workspaceRef.path === input.workspace)
|
||||
.filter((run) => !input.backendProfile || run.backendProfile === input.backendProfile)
|
||||
.filter((run) => !input.providerId || run.providerId === input.providerId)
|
||||
.filter((run) => !needle || runCollectionSearchText(run).includes(needle))
|
||||
.filter((run) => !input.updatedAfter || run.updatedAt >= input.updatedAfter)
|
||||
.sort((left, right) => compareRunCollection(left, right, input.sort ?? "updated-desc"));
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const items = matching.slice(offset, offset + input.pageSize).map((run) => {
|
||||
const command = Array.from(this.commands.values())
|
||||
.filter((item) => item.runId === run.id)
|
||||
.sort((left, right) => right.seq - left.seq)[0] ?? null;
|
||||
const runnerJob = Array.from(this.runnerJobs.values())
|
||||
.filter((item) => item.runId === run.id)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null;
|
||||
return runCollectionItem(run, command, runnerJob);
|
||||
});
|
||||
return runCollectionResult(input, items, matching.length, runCollectionFacets(allRuns));
|
||||
}
|
||||
|
||||
listEvents(runId: string, afterSeq: number, limit: number): RunEvent[] {
|
||||
this.getRun(runId);
|
||||
return (this.eventsByRun.get(runId) ?? []).filter((event) => event.seq > afterSeq).slice(0, Math.max(1, Math.min(limit, 500)));
|
||||
|
||||
@@ -240,7 +240,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
|
||||
name: "runner",
|
||||
image: options.image,
|
||||
imagePullPolicy: options.imagePullPolicy ?? "IfNotPresent",
|
||||
command: ["/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh"],
|
||||
args: ["/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh"],
|
||||
env,
|
||||
volumeMounts: [
|
||||
{ name: "runner-home", mountPath: "/home/agentrun" },
|
||||
|
||||
@@ -19,14 +19,78 @@ const selfTest: SelfTestCase = async () => {
|
||||
assert.ok((((health.runnerWorkReady as { requiredImageTools?: string[] } | undefined)?.requiredImageTools) ?? []).includes("npm"));
|
||||
assert.equal(health.secretRefs?.valuesPrinted, false);
|
||||
await assertManagerAuthBoundary();
|
||||
await assertRunCollectionPagination(client, store);
|
||||
await assertEventsLongPolling(client, store);
|
||||
await assertLongResultUsesTerminalAssistant(client, store);
|
||||
return { name: "manager-memory", tests: ["manager-memory-lifecycle", "manager-auth-boundary", "manager-events-long-poll", "manager-result-long-trace"] };
|
||||
return { name: "manager-memory", tests: ["manager-memory-lifecycle", "manager-auth-boundary", "manager-run-collection-pagination", "manager-events-long-poll", "manager-result-long-trace"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
};
|
||||
|
||||
async function assertRunCollectionPagination(client: ManagerClient, store: MemoryAgentRunStore): Promise<void> {
|
||||
for (const suffix of ["a", "b", "c"]) {
|
||||
const run = store.createRun({
|
||||
tenantId: "unidesk",
|
||||
projectId: suffix === "a" ? "pikasTech/HWLAB" : "pikasTech/agentrun",
|
||||
workspaceRef: { kind: "git-worktree", path: `/tmp/agentrun-collection-${suffix}` },
|
||||
sessionRef: { sessionId: `sess_artificer_collection_${suffix}`, metadata: { title: `分页任务${suffix}` } },
|
||||
providerId: "NC01",
|
||||
backendProfile: suffix === "c" ? "gpt-pika" : "codex",
|
||||
executionPolicy: {
|
||||
sandbox: "workspace-write",
|
||||
approval: "never",
|
||||
timeoutMs: 1000,
|
||||
network: "none",
|
||||
secretScope: { allowCredentialEcho: false, providerCredentials: [] },
|
||||
},
|
||||
traceSink: { taskId: `qt_collection_${suffix}`, lane: "nc01-release", traceId: `trc_collection_${suffix}` },
|
||||
}, { id: `run_collection_${suffix}` });
|
||||
const command = store.createCommand(run.id, { type: "turn", payload: { prompt: suffix }, idempotencyKey: `collection-${suffix}` });
|
||||
if (suffix === "c") {
|
||||
store.saveRunnerJob({
|
||||
runId: run.id,
|
||||
commandId: command.id,
|
||||
payloadHash: "collection-c",
|
||||
attemptId: "attempt_collection_c",
|
||||
runnerId: "runner_collection_c",
|
||||
namespace: "agentrun-release",
|
||||
jobName: "collection-c",
|
||||
managerUrl: "http://127.0.0.1",
|
||||
image: "runner-image",
|
||||
sourceCommit: "self-test",
|
||||
serviceAccountName: null,
|
||||
result: { status: "running", phase: "runner:active" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const first = await client.get("/api/v1/runs?page=1&pageSize=2") as JsonRecord;
|
||||
assert.equal(first.total, 3);
|
||||
assert.equal(first.totalPages, 2);
|
||||
assert.equal(first.hasNextPage, true);
|
||||
assert.equal((first.items as JsonRecord[]).length, 2);
|
||||
const second = await client.get("/api/v1/runs?page=2&pageSize=2") as JsonRecord;
|
||||
assert.equal((second.items as JsonRecord[]).length, 1);
|
||||
assert.equal(second.hasNextPage, false);
|
||||
|
||||
const searched = await client.get(`/api/v1/runs?search=${encodeURIComponent("分页任务c")}`) as JsonRecord;
|
||||
const item = (searched.items as JsonRecord[])[0];
|
||||
assert.equal(searched.total, 1);
|
||||
assert.equal(item?.taskId, "qt_collection_c");
|
||||
assert.equal(item?.title, "分页任务c");
|
||||
assert.equal(item?.workspace, "/tmp/agentrun-collection-c");
|
||||
assert.equal(item?.lane, "nc01-release");
|
||||
assert.equal(item?.backend, "gpt-pika");
|
||||
assert.equal(item?.runnerId, "runner_collection_c");
|
||||
assert.equal(item?.attemptId, "attempt_collection_c");
|
||||
assert.equal(item?.commandState, "pending");
|
||||
|
||||
const filtered = await client.get("/api/v1/runs?projectId=pikasTech%2FHWLAB&backendProfile=codex") as JsonRecord;
|
||||
assert.equal(filtered.total, 1);
|
||||
assert.equal(((filtered.items as JsonRecord[])[0])?.id, "run_collection_a");
|
||||
}
|
||||
|
||||
async function assertManagerAuthBoundary(): Promise<void> {
|
||||
const store = new MemoryAgentRunStore();
|
||||
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store, auth: { apiKey: "self-test-secret", source: "self-test" } });
|
||||
|
||||
@@ -27,6 +27,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
"capability/probe": "#!/usr/bin/env sh\necho capability\n",
|
||||
"dad-dev/SKILL.md": "---\ndescription: Managed dad-dev capability\n---\n",
|
||||
"git-spec/SKILL.md": "---\ndescription: Managed git-spec capability\n---\n",
|
||||
"post-task/SKILL.md": "---\ndescription: Managed post-task capability\n---\n",
|
||||
"unidesk-gh/SKILL.md": "---\ndescription: Managed unidesk-gh capability\n---\n",
|
||||
});
|
||||
const workspaceRef: WorkspaceRef = { kind: "opaque", path: "." };
|
||||
@@ -40,9 +41,10 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
{ name: "addon-tools", repoUrl: addon.path, commitId: addon.commitId, subpath: "capability", targetPath: "tools" },
|
||||
{ name: "dad-dev-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "dad-dev", targetPath: ".agents/skills/dad-dev" },
|
||||
{ name: "git-spec-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "git-spec", targetPath: ".agents/skills/git-spec" },
|
||||
{ name: "post-task-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "post-task", targetPath: ".agents/skills/post-task" },
|
||||
{ name: "unidesk-gh-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "unidesk-gh", targetPath: ".agents/skills/unidesk-gh" },
|
||||
],
|
||||
requiredSkills: [{ name: "source-skill" }, { name: "dad-dev" }, { name: "git-spec" }, { name: "unidesk-gh" }],
|
||||
requiredSkills: [{ name: "source-skill" }, { name: "dad-dev" }, { name: "git-spec" }, { name: "post-task" }, { name: "unidesk-gh" }],
|
||||
};
|
||||
const workspacePath = path.join(context.tmp, "primary-workspace-materialized");
|
||||
const env = { AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "primary-workspace-assembly"), AGENTRUN_WORKSPACE_PATH: workspacePath, AGENTRUN_RUN_ID: "run_primary_workspace_selftest" };
|
||||
@@ -65,11 +67,13 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
await access(path.join(first.skillsDir, "source-skill", "SKILL.md"));
|
||||
await access(path.join(first.skillsDir, "dad-dev", "SKILL.md"));
|
||||
await access(path.join(first.skillsDir, "git-spec", "SKILL.md"));
|
||||
await access(path.join(first.skillsDir, "post-task", "SKILL.md"));
|
||||
await access(path.join(first.skillsDir, "unidesk-gh", "SKILL.md"));
|
||||
assert.equal(await readFile(path.join(first.binPath, "probe"), "utf8"), "#!/usr/bin/env sh\necho capability\n");
|
||||
assert.match(await readFile(path.join(first.skillsDir, "dad-dev", "SKILL.md"), "utf8"), /Managed dad-dev capability/u);
|
||||
assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/dad-dev\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u);
|
||||
assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/git-spec\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u);
|
||||
assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/post-task\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u);
|
||||
assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/unidesk-gh\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u);
|
||||
assert.equal((await execFile("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspacePath })).stdout, "");
|
||||
assert.equal((await execFile("git", ["rev-parse", "HEAD"], { cwd: workspacePath })).stdout.trim(), primary.commitId);
|
||||
@@ -87,9 +91,9 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(managedOverlay.rootRelativeToWorkspace, ".git/agentrun-managed-overlay");
|
||||
assert.equal(managedOverlay.sourceViewIsolated, true);
|
||||
const bundleItems = ((first.event.bundles as JsonRecord).items as JsonRecord[]);
|
||||
assert.deepEqual(bundleItems.map((item) => item.logicalTargetPath), [".agents/skills", "tools", ".agents/skills/dad-dev", ".agents/skills/git-spec", ".agents/skills/unidesk-gh"]);
|
||||
assert.deepEqual(bundleItems.map((item) => item.logicalTargetPath), [".agents/skills", "tools", ".agents/skills/dad-dev", ".agents/skills/git-spec", ".agents/skills/post-task", ".agents/skills/unidesk-gh"]);
|
||||
assert.equal(bundleItems.every((item) => item.storageKind === "git-metadata-overlay"), true);
|
||||
assert.deepEqual(((first.event.requiredSkills as JsonRecord).names), ["source-skill", "dad-dev", "git-spec", "unidesk-gh"]);
|
||||
assert.deepEqual(((first.event.requiredSkills as JsonRecord).names), ["source-skill", "dad-dev", "git-spec", "post-task", "unidesk-gh"]);
|
||||
const provenancePath = path.join(workspacePath, ".git", "agentrun-primary-workspace.json");
|
||||
const persistedProvenance = JSON.parse(await readFile(provenancePath, "utf8")) as JsonRecord;
|
||||
assert.equal(persistedProvenance.resolutionAuthority, "declared-commit");
|
||||
@@ -261,6 +265,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
"managed-capability-overlay-keeps-primary-source-view-clean",
|
||||
"managed-capability-overlay-preserves-nested-targets",
|
||||
"managed-capability-overlay-publishes-tools-and-skills",
|
||||
"managed-post-task-skill-materializes-and-indexes",
|
||||
"real-primary-source-modifications-remain-git-visible",
|
||||
"primary-workspace-continuation-preserves-dirty-tree",
|
||||
"primary-workspace-continuation-freezes-mutable-ref",
|
||||
|
||||
@@ -43,6 +43,12 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.ok(requiredSkills.some((item) => item.name === "unidesk-trans"), "Artificer must require the unidesk-trans skill");
|
||||
assert.ok(requiredSkills.some((item) => item.name === "unidesk-agentrun"), "Artificer must require the canonical unidesk-agentrun skill");
|
||||
assert.equal(requiredSkills.some((item) => item.name === "unidesk-code-queue"), false, "Artificer must not retain the retired unidesk-code-queue skill name");
|
||||
assert.ok(requiredSkills.some((item) => item.name === "post-task"), "Artificer must require the post-task skill");
|
||||
assert.ok(bundles.some((item) => item.name === "post-task-skill"
|
||||
&& item.repoUrl === "git@github.com:pikasTech/agent_skills.git"
|
||||
&& item.ref === "master"
|
||||
&& item.subpath === "post-task"
|
||||
&& item.targetPath === ".agents/skills/post-task"), "Artificer must source post-task from the agent_skills bundle authority");
|
||||
assert.ok(bundles.some((item) => item.name === "agentrun-runner-tools" && item.targetPath === "tools"), "Artificer must materialize the bundled trans executable");
|
||||
const unideskSsh = toolCredentials.find((item) => item.tool === "unidesk-ssh" && item.purpose === "ssh-passthrough");
|
||||
assert.ok(unideskSsh, "Artificer must bind the unidesk-ssh route credential");
|
||||
@@ -51,7 +57,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(effectivePrompt.endsWith(userPrompt), true, "rendered prompt must preserve the complete user business prompt");
|
||||
assert.equal(effectivePrompt.split(userPrompt).length - 1, 1, "rendered prompt must include the user business prompt exactly once");
|
||||
|
||||
return { name: "artificer-nonrecursive-prompt", tests: ["artificer-yaml-nonrecursive-prompt-render", "artificer-target-trans-prompt-render", "artificer-canonical-agentrun-skill-render", "artificer-trans-skill-tool-and-secretref-bound", "artificer-user-prompt-preserved"] };
|
||||
return { name: "artificer-nonrecursive-prompt", tests: ["artificer-yaml-nonrecursive-prompt-render", "artificer-target-trans-prompt-render", "artificer-canonical-agentrun-skill-render", "artificer-trans-skill-tool-and-secretref-bound", "artificer-post-task-agent-skills-authority", "artificer-user-prompt-preserved"] };
|
||||
};
|
||||
|
||||
export default selfTest;
|
||||
|
||||
Reference in New Issue
Block a user