Files
pikasTech-HWLAB/internal/agent/runtime.mjs
T
2026-05-21 18:15:33 +00:00

658 lines
18 KiB
JavaScript

import crypto from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
AGENT_MGR_SERVICE_ID,
AGENT_WORKER_SERVICE_ID,
advanceAgentSession,
advanceWorkerSession,
createAgentSession,
createEvidenceRecord,
createTraceEvent,
createWorkerSession,
createWorkspaceVolume
} from "./index.mjs";
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const DEFAULT_AGENT_RUNTIME_STATE_DIR = path.join(repoRoot, ".state", "agent-runtime");
export const LOCAL_DRY_RUN_MODE = "local-dry-run";
export function resolveAgentRuntimeStateDir(stateDir = process.env.HWLAB_AGENT_RUNTIME_STATE_DIR) {
return path.resolve(stateDir || DEFAULT_AGENT_RUNTIME_STATE_DIR);
}
export function assessSkillsInjection({ skillCommitId = null, skillVersion = null, role = "runtime" } = {}) {
const commitId = explicitValue(skillCommitId);
const version = explicitValue(skillVersion);
const missing = [];
if (!commitId) missing.push("skillCommitId");
if (!version) missing.push("skillVersion");
return {
role,
status: missing.length === 0 ? "ready" : "degraded",
commitId,
version,
missing,
source: "explicit-field"
};
}
export function createRuntimeHealth({
stateDir,
managerSkills = null,
workerSkills = null,
workspaceCleaned = false
} = {}) {
const missing = [];
const mismatches = [];
for (const skills of [managerSkills, workerSkills].filter(Boolean)) {
for (const field of skills.missing) {
missing.push(`${skills.role}.${field}`);
}
}
if (managerSkills && workerSkills) {
if (managerSkills.commitId && workerSkills.commitId && managerSkills.commitId !== workerSkills.commitId) {
mismatches.push("skillCommitId");
}
if (managerSkills.version && workerSkills.version && managerSkills.version !== workerSkills.version) {
mismatches.push("skillVersion");
}
}
const status = missing.length === 0 && mismatches.length === 0 ? "ok" : "degraded";
return {
serviceId: AGENT_MGR_SERVICE_ID,
status,
environment: ENVIRONMENT_DEV,
runtimeMode: LOCAL_DRY_RUN_MODE,
stateDir: stateDir ? resolveAgentRuntimeStateDir(stateDir) : undefined,
workspaceCleaned,
skills: {
manager: managerSkills,
worker: workerSkills,
missing,
mismatches
}
};
}
export async function createLocalAgentSession({
stateDir,
agentSessionId,
projectId,
goal = "",
prompt = "",
requestedBy = "usr_local",
skillCommitId = null,
skillVersion = null,
now = () => new Date().toISOString()
}) {
requireId(agentSessionId, "agentSessionId");
requireId(projectId, "projectId");
const root = resolveAgentRuntimeStateDir(stateDir);
const sessionDir = localAgentSessionDir(root, agentSessionId);
if (await pathExists(sessionDir)) {
throw new Error(`agent session already exists: ${agentSessionId}`);
}
const timestamp = now();
const traceId = `trc_${agentSessionId}`;
const operationId = `op_${agentSessionId}`;
const workerSessionId = `wkr_${agentSessionId}`;
const taskId = `tsk_${agentSessionId}`;
const workspacePath = path.join(root, "workspaces", agentSessionId);
const managerSkills = assessSkillsInjection({
skillCommitId,
skillVersion,
role: "manager"
});
const health = createRuntimeHealth({ stateDir: root, managerSkills });
const workspaceVolume = {
...createWorkspaceVolume({ agentSessionId, projectId, now }),
mountPath: workspacePath,
localPath: workspacePath
};
const agentSession = createAgentSession({
agentSessionId,
projectId,
requestedBy,
goal,
now,
metadata: {
runtimeMode: LOCAL_DRY_RUN_MODE,
traceId,
operationId,
workerSessionId,
taskId,
prompt,
skills: managerSkills
}
});
const workerSession = createWorkerSession({
workerSessionId,
agentSessionId,
projectId,
now,
metadata: {
runtimeMode: LOCAL_DRY_RUN_MODE,
traceId,
operationId,
taskId,
scopedProjectId: projectId,
workspaceVolumeId: workspaceVolume.workspaceVolumeId,
expectedSkills: managerSkills
}
});
const state = {
schemaVersion: 1,
environment: ENVIRONMENT_DEV,
runtimeMode: LOCAL_DRY_RUN_MODE,
stateDir: root,
sessionDir,
createdAt: timestamp,
updatedAt: timestamp,
health,
task: {
taskId,
traceId,
operationId,
status: "queued",
goal,
prompt,
createdAt: timestamp,
updatedAt: timestamp
},
agentSession,
workerSession,
workspaceVolume,
workspace: {
workspaceVolumeId: workspaceVolume.workspaceVolumeId,
path: workspacePath,
cleaned: false,
existsAfterCleanup: null
},
traceEvents: [],
evidenceRecords: [],
cleanup: null
};
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "create",
serviceId: AGENT_MGR_SERVICE_ID,
message: "Agent manager created local dry-run session",
state: "create",
now,
metadata: {
taskId,
healthStatus: health.status,
skills: managerSkills
}
})
);
await writeLocalAgentState(root, state);
return summarizeLocalAgentSession(state);
}
export async function readLocalAgentSession({ stateDir, agentSessionId }) {
requireId(agentSessionId, "agentSessionId");
const root = resolveAgentRuntimeStateDir(stateDir);
return readLocalAgentState(root, agentSessionId);
}
export async function getLocalAgentStatus({ stateDir, agentSessionId }) {
const state = await readLocalAgentSession({ stateDir, agentSessionId });
return summarizeLocalAgentSession(state);
}
export async function getLocalAgentTrace({ stateDir, agentSessionId }) {
const state = await readLocalAgentSession({ stateDir, agentSessionId });
return {
serviceId: AGENT_MGR_SERVICE_ID,
agentSessionId,
traceId: state.task.traceId,
status: state.health.status,
tracePath: localAgentTracePath(state.sessionDir),
traceEvents: state.traceEvents
};
}
export async function getLocalAgentEvidence({ stateDir, agentSessionId }) {
const state = await readLocalAgentSession({ stateDir, agentSessionId });
const evidenceHealth = state.evidenceRecords.some((record) => record.metadata?.health?.status === "degraded")
? "degraded"
: state.health.status;
return {
serviceId: AGENT_MGR_SERVICE_ID,
agentSessionId,
operationId: state.task.operationId,
status: evidenceHealth,
evidenceDir: localAgentEvidenceDir(state.sessionDir),
evidenceRecords: state.evidenceRecords
};
}
export async function cleanupLocalAgentSession({
stateDir,
agentSessionId,
actor = AGENT_MGR_SERVICE_ID,
now = () => new Date().toISOString()
}) {
requireId(agentSessionId, "agentSessionId");
const root = resolveAgentRuntimeStateDir(stateDir);
const state = await readLocalAgentState(root, agentSessionId);
if (state.cleanup?.completed === true) {
return {
serviceId: actor,
agentSessionId,
idempotent: true,
cleanup: state.cleanup,
workspace: state.workspace,
status: state.health.status
};
}
await cleanupWorkspaceForState(state, { actor, now });
await writeLocalAgentState(root, state);
return {
serviceId: actor,
agentSessionId,
idempotent: false,
cleanup: state.cleanup,
workspace: state.workspace,
status: state.health.status
};
}
export async function runLocalWorkerDryRun({
stateDir,
agentSessionId,
skillCommitId = null,
skillVersion = null,
now = () => new Date().toISOString()
}) {
requireId(agentSessionId, "agentSessionId");
const root = resolveAgentRuntimeStateDir(stateDir);
const state = await readLocalAgentState(root, agentSessionId);
if (state.cleanup?.completed === true) {
throw new Error(`agent session is already cleaned up: ${agentSessionId}`);
}
const workerSkills = assessSkillsInjection({
skillCommitId,
skillVersion,
role: "worker"
});
const health = createRuntimeHealth({
stateDir: root,
managerSkills: state.health.skills.manager,
workerSkills
});
const timestamp = now();
state.updatedAt = timestamp;
state.health = health;
state.task = {
...state.task,
status: "claimed",
claimedAt: timestamp,
updatedAt: timestamp
};
state.agentSession = advanceAgentSession(state.agentSession, "start", {
now,
extra: {
workerSessionId: state.workerSession.workerSessionId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
taskStatus: "claimed",
healthStatus: health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "start", {
now,
extra: {
workspaceVolumeId: state.workspace.workspaceVolumeId,
workspacePath: state.workspace.path,
taskStatus: "claimed",
healthStatus: health.status,
skills: workerSkills
}
});
await mkdir(state.workspace.path, { recursive: true });
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "start",
serviceId: AGENT_WORKER_SERVICE_ID,
message: "Agent worker claimed local dry-run task",
state: "start",
now,
metadata: {
taskId: state.task.taskId,
workspacePath: state.workspace.path,
healthStatus: health.status,
skills: workerSkills
}
})
);
const workspaceTracePath = path.join(state.workspace.path, "trace.ndjson");
const workspaceTrace = createRuntimeTraceEvent(state, {
suffix: "trace",
serviceId: AGENT_WORKER_SERVICE_ID,
message: "Agent worker wrote local dry-run trace",
state: "trace",
now,
metadata: {
taskId: state.task.taskId,
workspaceTracePath,
healthStatus: health.status
}
});
state.agentSession = advanceAgentSession(state.agentSession, "trace", {
now,
extra: {
taskStatus: "running",
healthStatus: health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "trace", {
now,
extra: {
taskStatus: "running",
healthStatus: health.status
}
});
state.traceEvents.push(workspaceTrace);
await writeFile(workspaceTracePath, `${JSON.stringify(workspaceTrace)}\n`, "utf8");
const evidencePayload = {
schemaVersion: 1,
environment: ENVIRONMENT_DEV,
runtimeMode: LOCAL_DRY_RUN_MODE,
agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
taskId: state.task.taskId,
traceId: state.task.traceId,
operationId: state.task.operationId,
goal: state.task.goal,
prompt: state.task.prompt,
health,
traceEventIds: state.traceEvents.map((event) => event.traceEventId),
cleanup: {
workspacePath: state.workspace.path,
workspaceWillBeRemoved: true
},
createdAt: now()
};
const evidenceContent = `${JSON.stringify(evidencePayload, null, 2)}\n`;
const evidenceDir = localAgentEvidenceDir(state.sessionDir);
const evidenceArtifactPath = path.join(evidenceDir, `worker-dry-run-${agentSessionId}.json`);
await mkdir(evidenceDir, { recursive: true });
await writeFile(evidenceArtifactPath, evidenceContent, "utf8");
const evidenceStats = await stat(evidenceArtifactPath);
const evidenceRecord = createEvidenceRecord({
evidenceId: `evi_${agentSessionId}`,
projectId: state.agentSession.projectId,
operationId: state.task.operationId,
uri: pathToFileURL(evidenceArtifactPath).href,
sha256: sha256Hex(evidenceContent),
serviceId: AGENT_WORKER_SERVICE_ID,
environment: ENVIRONMENT_DEV,
kind: "report",
agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
mimeType: "application/json",
sizeBytes: evidenceStats.size,
metadata: {
runtimeMode: LOCAL_DRY_RUN_MODE,
traceId: state.task.traceId,
taskId: state.task.taskId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
health,
skills: {
manager: state.health.skills.manager,
worker: workerSkills
}
},
now
});
state.evidenceRecords.push(evidenceRecord);
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "finish",
serviceId: AGENT_WORKER_SERVICE_ID,
message: "Agent worker persisted local dry-run evidence",
state: "finish",
now,
metadata: {
taskId: state.task.taskId,
evidenceId: evidenceRecord.evidenceId,
healthStatus: health.status
}
})
);
state.task = {
...state.task,
status: "completed",
completedAt: now(),
updatedAt: now()
};
state.agentSession = advanceAgentSession(state.agentSession, "finish", {
now,
extra: {
taskStatus: "completed",
healthStatus: health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "finish", {
now,
extra: {
taskStatus: "completed",
evidenceId: evidenceRecord.evidenceId,
healthStatus: health.status
}
});
await cleanupWorkspaceForState(state, {
actor: AGENT_WORKER_SERVICE_ID,
now
});
await writeLocalAgentState(root, state);
return {
serviceId: AGENT_WORKER_SERVICE_ID,
dryRun: true,
agentSession: state.agentSession,
workerSession: state.workerSession,
task: state.task,
health: state.health,
traceEvents: state.traceEvents,
evidenceRecord,
evidencePath: evidenceArtifactPath,
cleanup: state.cleanup,
workspace: state.workspace
};
}
export function summarizeLocalAgentSession(state) {
return {
serviceId: AGENT_MGR_SERVICE_ID,
agentSessionId: state.agentSession.agentSessionId,
status: state.health.status,
environment: state.environment,
runtimeMode: state.runtimeMode,
stateDir: state.stateDir,
sessionDir: state.sessionDir,
health: state.health,
task: state.task,
agentSession: state.agentSession,
workerSession: state.workerSession,
workspace: state.workspace,
traceCount: state.traceEvents.length,
evidenceCount: state.evidenceRecords.length,
cleanup: state.cleanup
};
}
function createRuntimeTraceEvent(state, { suffix, serviceId, message, state: lifecycleState, metadata = {}, now }) {
return createTraceEvent({
traceEventId: `tev_${state.agentSession.agentSessionId}_${suffix}`,
traceId: state.task.traceId,
projectId: state.agentSession.projectId,
serviceId,
level: metadata.healthStatus === "degraded" ? "warn" : "info",
message,
environment: ENVIRONMENT_DEV,
agentSessionId: state.agentSession.agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
operationId: state.task.operationId,
metadata: {
...metadata,
state: lifecycleState
},
now
});
}
async function cleanupWorkspaceForState(state, { actor, now }) {
const existedBeforeCleanup = await pathExists(state.workspace.path);
await rm(state.workspace.path, { recursive: true, force: true });
const existsAfterCleanup = await pathExists(state.workspace.path);
const timestamp = now();
state.cleanup = {
cleanupId: `cln_${state.agentSession.agentSessionId}`,
serviceId: actor,
agentSessionId: state.agentSession.agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
workspacePath: state.workspace.path,
completed: true,
existedBeforeCleanup,
removed: !existsAfterCleanup,
existsAfterCleanup,
cleanedAt: timestamp
};
state.workspace = {
...state.workspace,
cleaned: true,
existsAfterCleanup
};
state.health = {
...state.health,
workspaceCleaned: true
};
state.agentSession = advanceAgentSession(state.agentSession, "cleanup", {
now,
extra: {
cleanedUp: true,
cleanupId: state.cleanup.cleanupId,
healthStatus: state.health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "cleanup", {
now,
extra: {
cleanedUp: true,
cleanupId: state.cleanup.cleanupId,
healthStatus: state.health.status
}
});
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "cleanup",
serviceId: actor,
message: "Agent worker cleaned local dry-run workspace",
state: "cleanup",
now,
metadata: {
cleanupId: state.cleanup.cleanupId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
workspacePath: state.workspace.path,
removed: state.cleanup.removed,
healthStatus: state.health.status
}
})
);
state.updatedAt = timestamp;
}
async function readLocalAgentState(root, agentSessionId) {
const statePath = localAgentStatePath(localAgentSessionDir(root, agentSessionId));
try {
const raw = await readFile(statePath, "utf8");
return JSON.parse(raw);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`agent session not found: ${agentSessionId}`);
}
throw error;
}
}
async function writeLocalAgentState(root, state) {
await mkdir(state.sessionDir, { recursive: true });
await mkdir(localAgentEvidenceDir(state.sessionDir), { recursive: true });
await writeFile(localAgentStatePath(state.sessionDir), `${JSON.stringify(state, null, 2)}\n`, "utf8");
const traceContent = state.traceEvents.map((event) => JSON.stringify(event)).join("\n");
await writeFile(localAgentTracePath(state.sessionDir), `${traceContent}${traceContent ? "\n" : ""}`, "utf8");
}
function localAgentSessionDir(root, agentSessionId) {
return path.join(root, "sessions", agentSessionId);
}
function localAgentStatePath(sessionDir) {
return path.join(sessionDir, "session.json");
}
function localAgentTracePath(sessionDir) {
return path.join(sessionDir, "trace.ndjson");
}
function localAgentEvidenceDir(sessionDir) {
return path.join(sessionDir, "evidence");
}
async function pathExists(targetPath) {
try {
await access(targetPath, fsConstants.F_OK);
return true;
} catch (error) {
if (error.code === "ENOENT") return false;
throw error;
}
}
function requireId(value, fieldName) {
if (!explicitValue(value)) {
throw new Error(`${fieldName} is required`);
}
}
function explicitValue(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function sha256Hex(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}