Track runtime source tree in public repo

This commit is contained in:
OpenClaw Local
2026-03-12 12:30:10 +01:00
parent 58d97f7f2c
commit b0e15a0cb6
41 changed files with 11431 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules/
dist/
runtime/
/runtime/
coverage/
*.log
*.tsbuildinfo
+20
View File
@@ -1,5 +1,25 @@
# Progress
## Phase 150 (Tracked runtime source restored to the public repo) — Completed
- Scope:
- Fix the public repository so fresh clones include the full `src/runtime` source tree.
- Prevent future releases from passing when a core source directory exists locally but is missing from Git history.
- Changed files:
- `.gitignore`
- `src/runtime/*`
- `scripts/release-audit.sh`
- `test/oss-readiness.test.ts`
- `docs/PROGRESS.md`
- Implementation:
- Added the full `src/runtime` source tree to version control after confirming it had been left untracked.
- Kept `.gitignore` anchored to the root `runtime/` data directory so public source files under `src/runtime/` remain trackable.
- Hardened `release-audit` and `oss-readiness` checks to fail if `src/ui/server.ts` or `src/runtime/usage-cost.ts` are missing from Git.
- Verification:
- `npm run build`
- `npm test`
- `npm run smoke:ui`
- `npm run release:audit`
## Phase 149 (Repo completeness checks added to install docs) — Completed
- Scope:
- Prevent install agents from misdiagnosing the repo as incomplete when they are in the wrong directory or a bad checkout.
+12
View File
@@ -47,6 +47,8 @@ check_exists "LICENSE"
check_exists ".gitignore"
check_exists ".env.example"
check_exists "package.json"
check_exists "src/ui/server.ts"
check_exists "src/runtime/usage-cost.ts"
check_no_match "absolute macOS home paths" '/Users/[^/]+/'
check_no_match "absolute Linux home paths" '/home/[^/]+/'
@@ -64,6 +66,16 @@ if [ -d ".git" ]; then
exit 1
fi
rm -f /tmp/release-audit-match.txt
if ! git ls-files --error-unmatch src/ui/server.ts >/dev/null 2>&1; then
echo "release-audit: missing tracked source file: src/ui/server.ts" >&2
exit 1
fi
if ! git ls-files --error-unmatch src/runtime/usage-cost.ts >/dev/null 2>&1; then
echo "release-audit: missing tracked source file: src/runtime/usage-cost.ts" >&2
exit 1
fi
fi
echo "release-audit: passed"
+166
View File
@@ -0,0 +1,166 @@
import { actionQueueItemId } from "./notification-center";
import { listTasks } from "./task-store";
import type { ActionQueueLink, CommanderExceptionsFeed, ReadModelSnapshot } from "../types";
export function buildActionQueueLinks(
feed: CommanderExceptionsFeed,
snapshot: ReadModelSnapshot,
): Map<string, ActionQueueLink[]> {
const linksByItemId = new Map<string, ActionQueueLink[]>();
const tasks = listTasks(snapshot.tasks, projectTitleMap(snapshot));
const taskById = new Map(tasks.map((task) => [task.taskId, task]));
const projectById = new Map(snapshot.projects.projects.map((project) => [project.projectId, project]));
const approvalById = new Map(snapshot.approvals.map((approval) => [approval.approvalId, approval]));
const sessionsByAgent = buildSessionsByAgent(snapshot);
for (const item of feed.items) {
if (!(item.route === "action-queue" || item.level === "action-required")) continue;
const links: ActionQueueLink[] = [];
if (item.source === "session") {
addSessionLink(links, item.sourceId);
}
if (item.source === "task") {
const task = taskById.get(item.sourceId);
if (task) {
addTaskLink(links, task.taskId, task.projectId);
addProjectLink(links, task.projectId);
for (const sessionKey of task.sessionKeys) {
addSessionLink(links, sessionKey);
}
}
}
if (item.source === "approval") {
const approval = approvalById.get(item.sourceId);
if (approval?.sessionKey) {
addSessionLink(links, approval.sessionKey);
}
if (approval?.agentId) {
addAgentSessionsLink(links, approval.agentId);
const sessionKeys = sessionsByAgent.get(approval.agentId) ?? [];
for (const sessionKey of sessionKeys) {
addSessionLink(links, sessionKey);
}
}
}
if (item.source === "budget") {
const [scope, scopeId] = splitScopeId(item.sourceId);
if (scope === "project") {
if (projectById.has(scopeId)) addProjectLink(links, scopeId);
} else if (scope === "task") {
const task = taskById.get(scopeId);
if (task) {
addTaskLink(links, task.taskId, task.projectId);
addProjectLink(links, task.projectId);
for (const sessionKey of task.sessionKeys) {
addSessionLink(links, sessionKey);
}
}
} else if (scope === "agent") {
addAgentSessionsLink(links, scopeId);
const sessionKeys = sessionsByAgent.get(scopeId) ?? [];
for (const sessionKey of sessionKeys) {
addSessionLink(links, sessionKey);
}
}
}
linksByItemId.set(actionQueueItemId(item), dedupeLinks(links));
}
return linksByItemId;
}
function buildSessionsByAgent(snapshot: ReadModelSnapshot): Map<string, string[]> {
const byAgent = new Map<string, string[]>();
for (const session of snapshot.sessions) {
if (!session.agentId) continue;
const bucket = byAgent.get(session.agentId) ?? [];
bucket.push(session.sessionKey);
byAgent.set(session.agentId, bucket);
}
return byAgent;
}
function splitScopeId(input: string): ["agent" | "project" | "task" | "unknown", string] {
const idx = input.indexOf(":");
if (idx <= 0 || idx === input.length - 1) return ["unknown", input];
const scope = input.slice(0, idx);
const scopeId = input.slice(idx + 1);
if (scope === "agent" || scope === "project" || scope === "task") return [scope, scopeId];
return ["unknown", scopeId];
}
function addSessionLink(links: ActionQueueLink[], sessionKey: string): void {
const key = sessionKey.trim();
if (!key) return;
links.push({
type: "session",
id: key,
href: `/session/${encodeURIComponent(key)}`,
label: `session:${key}`,
});
}
function addTaskLink(links: ActionQueueLink[], taskId: string, projectId: string): void {
const taskKey = taskId.trim();
const projectKey = projectId.trim();
if (!taskKey || !projectKey) return;
links.push({
type: "task",
id: taskKey,
href: `/tasks?project=${encodeURIComponent(projectKey)}`,
label: `task:${taskKey}`,
});
}
function addProjectLink(links: ActionQueueLink[], projectId: string): void {
const key = projectId.trim();
if (!key) return;
links.push({
type: "project",
id: key,
href: `/projects?projectId=${encodeURIComponent(key)}`,
label: `project:${key}`,
});
}
function addAgentSessionsLink(links: ActionQueueLink[], agentId: string): void {
const key = agentId.trim();
if (!key) return;
links.push({
type: "session",
id: key,
href: `/sessions?agentId=${encodeURIComponent(key)}`,
label: `agent:${key}`,
});
}
function dedupeLinks(links: ActionQueueLink[]): ActionQueueLink[] {
const seen = new Set<string>();
const out: ActionQueueLink[] = [];
for (const link of links) {
const key = `${link.type}|${link.id}|${link.href}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(link);
}
return out;
}
function projectTitleMap(snapshot: ReadModelSnapshot): Map<string, string> {
return new Map(snapshot.projects.projects.map((project) => [project.projectId, project.title]));
}
+117
View File
@@ -0,0 +1,117 @@
import { join } from "node:path";
import { readdir } from "node:fs/promises";
import {
loadCurrentAgentCatalog,
resolveOpenClawConfigPath,
resolveOpenClawHomePath,
} from "./current-agent-catalog";
export type AgentRosterStatus = "connected" | "partial" | "not_connected";
export interface AgentRosterEntry {
agentId: string;
displayName: string;
}
export interface AgentRosterSnapshot {
status: AgentRosterStatus;
sourcePath: string;
detail: string;
entries: AgentRosterEntry[];
}
export async function loadBestEffortAgentRoster(): Promise<AgentRosterSnapshot> {
const homePath = resolveOpenClawHomePath();
const sourcePath = resolveOpenClawConfigPath();
const runtimeAgentsPath = join(homePath, "agents");
const fromConfig = await loadCurrentAgentCatalog();
if (fromConfig.entries.length > 0) {
return {
status: "connected",
sourcePath,
detail: `${fromConfig.detail} openclaw.json is treated as the current-project source of truth; runtime folders are ignored for roster discovery.`,
entries: fromConfig.entries.map((entry) => ({
agentId: entry.agentId,
displayName: entry.displayName,
})),
};
}
const fromRuntime = await loadRosterFromRuntimeDirs(runtimeAgentsPath);
const status = resolveMergedStatus(fromConfig.status, fromRuntime.status, fromRuntime.entries.length);
const detail = `Config: ${fromConfig.detail} Runtime: ${fromRuntime.detail} Using runtime fallback: ${fromRuntime.entries.length} agent(s).`;
return {
status,
sourcePath,
detail,
entries: fromRuntime.entries,
};
}
async function loadRosterFromRuntimeDirs(runtimeAgentsPath: string): Promise<{
status: AgentRosterStatus;
detail: string;
entries: AgentRosterEntry[];
}> {
try {
const dirEntries = await readdir(runtimeAgentsPath, { withFileTypes: true });
const entries = dirEntries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
agentId: entry.name,
displayName: entry.name,
}));
if (entries.length === 0) {
return {
status: "partial",
detail: "runtime agents directory found but empty.",
entries: [],
};
}
return {
status: "connected",
detail: `loaded ${entries.length} agent folder(s) from runtime.`,
entries,
};
} catch (error) {
if (isFsNotFound(error)) {
return {
status: "not_connected",
detail: "runtime agents directory not found.",
entries: [],
};
}
return {
status: "partial",
detail: "runtime agents directory exists but could not be read.",
entries: [],
};
}
}
function resolveMergedStatus(
configStatus: AgentRosterStatus,
runtimeStatus: AgentRosterStatus,
totalEntries: number,
): AgentRosterStatus {
if (totalEntries === 0) {
return configStatus === "not_connected" && runtimeStatus === "not_connected"
? "not_connected"
: "partial";
}
if (configStatus === "partial" || runtimeStatus === "partial") return "partial";
return "connected";
}
function isFsNotFound(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
typeof (error as { code?: unknown }).code === "string" &&
(error as { code: string }).code === "ENOENT",
);
}
+386
View File
@@ -0,0 +1,386 @@
export interface ApiRouteDoc {
method: "GET" | "POST" | "PATCH" | "PUT";
path: string;
summary: string;
query?: Record<string, string>;
body?: Record<string, string>;
response: Record<string, string>;
}
export interface ApiDocsPayload {
generatedAt: string;
version: string;
safetyDefaults: Record<string, string | boolean | number>;
routes: ApiRouteDoc[];
}
export function buildApiDocs(): ApiDocsPayload {
return {
generatedAt: new Date().toISOString(),
version: "phase-23",
safetyDefaults: {
READONLY_MODE: true,
APPROVAL_ACTIONS_ENABLED: false,
APPROVAL_ACTIONS_DRY_RUN: true,
IMPORT_MUTATION_ENABLED: false,
IMPORT_MUTATION_DRY_RUN: false,
LOCAL_TOKEN_AUTH_REQUIRED: true,
TASK_HEARTBEAT_ENABLED: true,
TASK_HEARTBEAT_DRY_RUN: true,
TASK_HEARTBEAT_MAX_TASKS_PER_RUN: 3,
LOCAL_API_TOKEN:
"Set explicitly to allow import/export and state-changing routes; present token via x-local-token or Authorization Bearer",
approvalExecutionGuard:
"Live approve/reject requires READONLY_MODE=false + APPROVAL_ACTIONS_ENABLED=true + APPROVAL_ACTIONS_DRY_RUN=false",
importMutationExecutionGuard:
"Live import apply requires LOCAL_API_TOKEN auth + IMPORT_MUTATION_ENABLED=true + READONLY_MODE=false; optional per-request dryRun=true keeps it non-mutating",
taskHeartbeatExecutionGuard:
"Live task heartbeat execution requires LOCAL_API_TOKEN when LOCAL_TOKEN_AUTH_REQUIRED=true; default mode is dry-run",
},
routes: [
{
method: "GET",
path: "/api/docs",
summary: "API reference summary for Mission Control",
response: {
ok: "boolean",
docs: "ApiDocsPayload",
},
},
{
method: "GET",
path: "/api/done-checklist",
summary: "Final integration checklist + readiness scoring snapshot",
response: {
ok: "boolean",
checklist: "{ basedOn, items[], counts, readiness{overall,categories[]} }",
},
},
{
method: "GET",
path: "/api/ui/preferences",
summary: "Read persisted dashboard UI preferences",
response: {
ok: "boolean",
preferences: "{ compactStatusStrip, quickFilter, taskFilters, updatedAt }",
path: "string",
issues: "string[]",
},
},
{
method: "PATCH",
path: "/api/ui/preferences",
summary:
"Update dashboard UI preferences persisted to runtime/ui-preferences.json (requires local token gate)",
body: {
compactStatusStrip: "boolean (optional)",
quickFilter: "all|attention|todo|in_progress|blocked|done (optional)",
taskFilters: "{ status?, owner?, project? } (optional)",
},
response: {
ok: "boolean",
preferences: "{ compactStatusStrip, quickFilter, taskFilters, updatedAt }",
},
},
{
method: "GET",
path: "/api/files",
summary: "List editable files for memory or workspace scope",
query: {
scope: "required: memory|workspace",
},
response: {
ok: "boolean",
scope: "memory|workspace",
count: "number",
files: "EditableFileEntry[]",
},
},
{
method: "GET",
path: "/api/files/content",
summary: "Read one editable file from the allowed memory/workspace scope",
query: {
scope: "required: memory|workspace",
path: "required absolute source path from /api/files list",
},
response: {
ok: "boolean",
scope: "memory|workspace",
entry: "EditableFileEntry",
content: "string",
},
},
{
method: "PUT",
path: "/api/files/content",
summary: "Write one editable file back to disk (requires local token gate if enabled)",
body: {
scope: "required: memory|workspace",
path: "required absolute source path from /api/files list",
content: "full file text",
},
response: {
ok: "boolean",
scope: "memory|workspace",
entry: "EditableFileEntry",
content: "string",
},
},
{
method: "GET",
path: "/api/search/tasks",
summary: "Substring search over tasks",
query: {
q: "required search term",
limit: "optional 1..200 (default 20)",
},
response: {
ok: "boolean",
scope: "tasks",
query: "{ q, limit }",
count: "number (total matches before limit)",
returned: "number (items returned in this response)",
items: "TaskListItem[]",
},
},
{
method: "GET",
path: "/api/search/projects",
summary: "Substring search over projects",
query: {
q: "required search term",
limit: "optional 1..200 (default 20)",
},
response: {
ok: "boolean",
scope: "projects",
query: "{ q, limit }",
count: "number (total matches before limit)",
returned: "number (items returned in this response)",
items: "ProjectRecord[]",
},
},
{
method: "GET",
path: "/api/search/sessions",
summary: "Substring search over session summaries",
query: {
q: "required search term",
limit: "optional 1..200 (default 20)",
},
response: {
ok: "boolean",
scope: "sessions",
query: "{ q, limit }",
count: "number (total matches before limit, including live-merged sessions)",
returned: "number (items returned in this response)",
items: "SessionSummary[]",
},
},
{
method: "GET",
path: "/api/search/exceptions",
summary: "Substring search over routed exception feed items",
query: {
q: "required search term",
limit: "optional 1..200 (default 20)",
},
response: {
ok: "boolean",
scope: "exceptions",
query: "{ q, limit }",
count: "number (total matches before limit)",
returned: "number (items returned in this response)",
items: "ExceptionFeedItem[]",
},
},
{
method: "GET",
path: "/api/usage-cost",
summary:
"Usage/cost observability snapshot with context-window, period totals, burn-rate status, and connector TODOs",
response: {
ok: "boolean",
usage:
"{ periods(today/7d/30d), contextWindows[], breakdown(byAgent/byProject/byModel/byProvider), budget, connectors }",
},
},
{
method: "GET",
path: "/api/replay/index",
summary: "Debug replay index from timeline, digests, export snapshots, and export bundles",
query: {
timelineLimit: "optional 1..400 (default 80)",
digestLimit: "optional 1..200 (default 30)",
exportLimit: "optional 1..200 (default 30)",
from: "optional ISO date-time lower bound",
to: "optional ISO date-time upper bound",
},
response: {
ok: "boolean",
replay:
"{ timeline, digests, exportSnapshots, exportBundles, stats:{timeline,digests,exportSnapshots,exportBundles,total} with per-source latencyMs/latencyBucketsMs(p50,p95)/totalSizeBytes/returnedSizeBytes }",
},
},
{
method: "GET",
path: "/api/commander/exceptions",
summary: "Exceptions-only summary for blocked/error/pending approval/over-budget/tasks due",
response: {
ok: "boolean",
exceptions: "CommanderExceptionsSummary",
},
},
{
method: "GET",
path: "/api/action-queue",
summary: "Action-required queue derived from exception feed with ack state",
response: {
ok: "boolean",
center: "{ generatedAt, queue[], total, acknowledged }",
},
},
{
method: "POST",
path: "/api/action-queue/:itemId/ack",
summary: "Acknowledge action queue item with optional snooze window (requires local token gate)",
body: {
note: "optional string <= 300",
ttlMinutes: "optional integer 1..10080 (ack expires after N minutes)",
snoozeUntil: "optional ISO date-time (future); mutually exclusive with ttlMinutes",
},
response: {
ok: "boolean",
path: "runtime/acks.json",
ack: "{ itemId, ackedAt, note?, expiresAt? }",
},
},
{
method: "GET",
path: "/api/action-queue/acks/prune-preview",
summary:
"Preview stale acknowledgement prune counts (no write, requires local token gate)",
response: {
ok: "boolean",
preview: "{ path, dryRun:true, before, removed, after, updatedAt }",
},
},
{
method: "GET",
path: "/api/tasks/heartbeat",
summary: "Read recent heartbeat runs for assigned backlog automation",
query: {
limit: "optional 1..200 (default 20)",
},
response: {
ok: "boolean",
path: "runtime/task-heartbeat.log",
count: "number",
runs: "TaskHeartbeatResult[] newest-first",
},
},
{
method: "POST",
path: "/api/tasks/heartbeat",
summary:
"Execute heartbeat task pickup (requires local token gate; defaults to dry-run unless explicitly set live)",
body: {
dryRun: "optional boolean",
maxTasksPerRun: "optional integer 1..200",
},
response: {
ok: "boolean",
mode: "blocked|dry_run|live",
message: "string",
checked: "number",
eligible: "number",
selected: "number",
executed: "number",
selections: "TaskHeartbeatSelection[]",
},
},
{
method: "GET",
path: "/api/export/state.json",
summary:
"Export state bundle and persist timestamped debug + backup snapshots (requires local token gate)",
response: {
ok: "boolean",
schemaVersion: "phase-9",
source: "api|command",
requestId: "string",
exportedAt: "ISO timestamp",
snapshotGeneratedAt: "ISO timestamp",
projects: "ProjectStoreSnapshot",
tasks: "TaskStoreSnapshot",
sessions: "SessionSummary[]",
budgets: "{ policy, issues, summary }",
exceptions: "CommanderExceptionsSummary",
exceptionsFeed: "CommanderExceptionsFeed",
exportSnapshot: "{ fileName, path, sizeBytes }",
backupExport: "{ fileName, path, sizeBytes }",
},
},
{
method: "POST",
path: "/api/import/dry-run",
summary:
"Validate exported bundle shape in dry-run mode (no state mutation, requires local token gate)",
body: {
fileName: "optional runtime/exports/*.json name or path",
bundle: "optional export bundle object; if omitted payload is validated directly",
},
response: {
ok: "boolean",
validation: "{ valid, issues[], warnings[], summary }",
},
},
{
method: "POST",
path: "/api/import/live",
summary:
"Optional local import mutation endpoint (HIGH RISK): requires local token + IMPORT_MUTATION_ENABLED=true; blocked in readonly unless dryRun=true",
body: {
fileName: "optional runtime/exports/*.json name or path",
bundle: "optional export bundle object; if omitted payload is treated as the bundle",
dryRun: "optional boolean; if true validates only and skips mutation",
},
response: {
ok: "boolean",
mode: "blocked|dry_run|live",
message: "string",
guard: "{ readonlyMode, localTokenAuthRequired, localTokenConfigured, mutationEnabled, mutationDryRunDefault, defaultMode, defaultMessage }",
validation: "{ valid, issues[], warnings[], summary }",
applied: "{ projectsPath, tasksPath, budgetsPath, projects, tasks, sessions, exceptions }",
},
},
{
method: "POST",
path: "/api/approvals/:approvalId/approve",
summary: "Approval action route (requires local token + existing approval env gates)",
body: {
reason: "string <= 220 (optional)",
},
response: {
ok: "boolean",
mode: "blocked|dry_run|live",
message: "string",
},
},
{
method: "POST",
path: "/api/approvals/:approvalId/reject",
summary: "Rejection action route (requires local token + existing approval env gates)",
body: {
reason: "string <= 220 (required)",
},
response: {
ok: "boolean",
mode: "blocked|dry_run|live",
message: "string",
},
},
],
};
}
+187
View File
@@ -0,0 +1,187 @@
import { appendFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import {
APPROVAL_ACTIONS_DRY_RUN,
APPROVAL_ACTIONS_ENABLED,
READONLY_MODE,
} from "../config";
import type { ApprovalsActionResponse } from "../contracts/openclaw-tools";
import type { ToolClient } from "../clients/tool-client";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const APPROVAL_ACTION_AUDIT_LOG_PATH = join(RUNTIME_DIR, "approval-actions.log");
export interface ApprovalRuntimeGate {
readonlyMode: boolean;
actionsEnabled: boolean;
dryRun: boolean;
}
export interface ApprovalActionInput {
action: "approve" | "reject";
approvalId: string;
reason?: string;
}
export interface ApprovalActionResult {
ok: boolean;
executed: boolean;
mode: "blocked" | "dry_run" | "live";
action: "approve" | "reject";
approvalId: string;
reason?: string;
message: string;
gate: ApprovalRuntimeGate;
rawText?: string;
auditLogPath: string;
timestamp: string;
}
export class ApprovalActionService {
constructor(
private readonly client: ToolClient,
private readonly gate: ApprovalRuntimeGate = runtimeApprovalGate(),
) {}
async execute(input: ApprovalActionInput): Promise<ApprovalActionResult> {
const approvalId = input.approvalId.trim();
const reason = input.reason?.trim();
const timestamp = new Date().toISOString();
if (!approvalId) {
return this.audit({
ok: false,
executed: false,
mode: "blocked",
action: input.action,
approvalId: input.approvalId,
reason,
message: "approvalId is required.",
gate: this.gate,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
}
if (input.action === "reject" && !reason) {
return this.audit({
ok: false,
executed: false,
mode: "blocked",
action: input.action,
approvalId,
message: "reason is required for reject action.",
gate: this.gate,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
}
if (!this.gate.actionsEnabled) {
return this.audit({
ok: false,
executed: false,
mode: "blocked",
action: input.action,
approvalId,
reason,
message:
"Approval actions are disabled by runtime gate. Set APPROVAL_ACTIONS_ENABLED=true to allow execution.",
gate: this.gate,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
}
if (this.gate.dryRun) {
return this.audit({
ok: true,
executed: false,
mode: "dry_run",
action: input.action,
approvalId,
reason,
message: "Dry-run mode active. No approve/reject command executed.",
gate: this.gate,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
}
if (this.gate.readonlyMode) {
return this.audit({
ok: false,
executed: false,
mode: "blocked",
action: input.action,
approvalId,
reason,
message: "Readonly mode blocks approval actions. Set READONLY_MODE=false for live execution.",
gate: this.gate,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
}
try {
const response = await this.invokeClient(input.action, { approvalId, reason });
return this.audit({
ok: response.ok,
executed: true,
mode: "live",
action: input.action,
approvalId,
reason,
message: response.ok ? "Approval action executed." : "Approval action response indicated failure.",
gate: this.gate,
rawText: response.rawText,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
} catch (error) {
return this.audit({
ok: false,
executed: true,
mode: "live",
action: input.action,
approvalId,
reason,
message: error instanceof Error ? error.message : "Unknown approval action error.",
gate: this.gate,
auditLogPath: APPROVAL_ACTION_AUDIT_LOG_PATH,
timestamp,
});
}
}
private async invokeClient(
action: "approve" | "reject",
payload: { approvalId: string; reason?: string },
): Promise<ApprovalsActionResponse> {
if (action === "approve") {
return this.client.approvalsApprove({
approvalId: payload.approvalId,
reason: payload.reason,
});
}
return this.client.approvalsReject({
approvalId: payload.approvalId,
reason: payload.reason ?? "",
});
}
private async audit(result: ApprovalActionResult): Promise<ApprovalActionResult> {
await mkdir(RUNTIME_DIR, { recursive: true });
await appendFile(APPROVAL_ACTION_AUDIT_LOG_PATH, `${JSON.stringify(result)}\n`, "utf8");
return result;
}
}
export function runtimeApprovalGate(): ApprovalRuntimeGate {
return {
readonlyMode: READONLY_MODE,
actionsEnabled: APPROVAL_ACTIONS_ENABLED,
dryRun: APPROVAL_ACTIONS_DRY_RUN,
};
}
+271
View File
@@ -0,0 +1,271 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { commanderExceptions } from "./commander";
import type { ReadModelSnapshot } from "../types";
import { OPERATION_AUDIT_LOG_PATH } from "./operation-audit";
export type AuditSeverity = "info" | "warn" | "action-required" | "error";
export interface AuditTimelineEvent {
timestamp: string;
severity: AuditSeverity;
source: "snapshot" | "monitor" | "approval-action" | "operation";
message: string;
}
export interface AuditTimelineSnapshot {
generatedAt: string;
events: AuditTimelineEvent[];
counts: Record<AuditSeverity, number>;
}
const RUNTIME_DIR = join(process.cwd(), "runtime");
const TIMELINE_LOG_PATH = join(RUNTIME_DIR, "timeline.log");
const APPROVAL_ACTIONS_LOG_PATH = join(RUNTIME_DIR, "approval-actions.log");
export async function loadAuditTimeline(snapshot: ReadModelSnapshot): Promise<AuditTimelineSnapshot> {
const [monitorEvents, approvalEvents, operationEvents] = await Promise.all([
loadMonitorEvents(),
loadApprovalActionEvents(),
loadOperationEvents(),
]);
const events = [snapshotEvent(snapshot), ...monitorEvents, ...approvalEvents, ...operationEvents].sort(
compareTimelineEvents,
);
return {
generatedAt: new Date().toISOString(),
events,
counts: {
info: events.filter((item) => item.severity === "info").length,
warn: events.filter((item) => item.severity === "warn").length,
"action-required": events.filter((item) => item.severity === "action-required").length,
error: events.filter((item) => item.severity === "error").length,
},
};
}
export function filterAuditTimeline(
timeline: AuditTimelineSnapshot,
severity: AuditSeverity | "all",
): AuditTimelineSnapshot {
if (severity === "all") return timeline;
const events = timeline.events.filter((event) => event.severity === severity);
return {
generatedAt: timeline.generatedAt,
events,
counts: {
info: events.filter((item) => item.severity === "info").length,
warn: events.filter((item) => item.severity === "warn").length,
"action-required": events.filter((item) => item.severity === "action-required").length,
error: events.filter((item) => item.severity === "error").length,
},
};
}
function snapshotEvent(snapshot: ReadModelSnapshot): AuditTimelineEvent {
const exceptions = commanderExceptions(snapshot);
const severity = deriveSnapshotSeverity(exceptions.counts);
return {
timestamp: snapshot.generatedAt,
severity,
source: "snapshot",
message:
`snapshot sessions=${snapshot.sessions.length} approvals=${snapshot.approvals.length} ` +
`blocked=${exceptions.counts.blocked} errors=${exceptions.counts.errors} ` +
`pendingApprovals=${exceptions.counts.pendingApprovals} overBudget=${exceptions.counts.overBudget} tasksDue=${exceptions.counts.tasksDue}`,
};
}
async function loadMonitorEvents(): Promise<AuditTimelineEvent[]> {
const raw = await safeReadFile(TIMELINE_LOG_PATH);
if (!raw) return [];
const events: AuditTimelineEvent[] = [];
const lines = raw.split(/\r?\n/).filter((line) => line.trim() !== "");
for (const line of lines) {
const parsed = parseMonitorLine(line);
if (parsed) events.push(parsed);
}
return events;
}
async function loadApprovalActionEvents(): Promise<AuditTimelineEvent[]> {
const raw = await safeReadFile(APPROVAL_ACTIONS_LOG_PATH);
if (!raw) return [];
const events: AuditTimelineEvent[] = [];
const lines = raw.split(/\r?\n/).filter((line) => line.trim() !== "");
for (const line of lines) {
const parsed = parseApprovalLine(line);
if (parsed) events.push(parsed);
}
return events;
}
async function loadOperationEvents(): Promise<AuditTimelineEvent[]> {
const raw = await safeReadFile(OPERATION_AUDIT_LOG_PATH);
if (!raw) return [];
const events: AuditTimelineEvent[] = [];
const lines = raw.split(/\r?\n/).filter((line) => line.trim() !== "");
for (const line of lines) {
const parsed = parseOperationLine(line);
if (parsed) events.push(parsed);
}
return events;
}
function parseMonitorLine(line: string): AuditTimelineEvent | null {
const match = line.match(/^(\S+)\s+\|\s+(.*)$/);
if (!match) return null;
const timestamp = toIso(match[1]);
if (!timestamp) return null;
const details = match[2].trim();
const alertsMatch = details.match(/alerts=(\d+)/);
const alerts = alertsMatch ? Number.parseInt(alertsMatch[1], 10) : 0;
return {
timestamp,
severity: alerts > 0 ? "warn" : "info",
source: "monitor",
message: details,
};
}
function parseApprovalLine(line: string): AuditTimelineEvent | null {
try {
const obj = JSON.parse(line) as Record<string, unknown>;
const timestamp =
typeof obj.timestamp === "string" && !Number.isNaN(Date.parse(obj.timestamp))
? new Date(obj.timestamp).toISOString()
: new Date().toISOString();
const action = asString(obj.action) ?? "approval-action";
const approvalId = asString(obj.approvalId) ?? "unknown";
const message = asString(obj.message) ?? "approval action event";
const mode = asString(obj.mode) ?? "unknown";
const ok = obj.ok === true;
return {
timestamp,
severity: deriveApprovalSeverity(ok, mode),
source: "approval-action",
message: `${action} ${approvalId} (${mode}) ${message}`,
};
} catch {
return null;
}
}
function parseOperationLine(line: string): AuditTimelineEvent | null {
try {
const obj = JSON.parse(line) as Record<string, unknown>;
const timestamp =
typeof obj.timestamp === "string" && !Number.isNaN(Date.parse(obj.timestamp))
? new Date(obj.timestamp).toISOString()
: new Date().toISOString();
const action = asString(obj.action) ?? "operation";
const source = asString(obj.source) ?? "unknown";
const detail = asString(obj.detail) ?? "operation audit";
const ok = obj.ok === true;
return {
timestamp,
severity: deriveOperationSeverity(action, ok, detail),
source: "operation",
message: `${action} (${source}) ${detail}`,
};
} catch {
return null;
}
}
function deriveSnapshotSeverity(counts: {
blocked: number;
errors: number;
pendingApprovals: number;
overBudget: number;
tasksDue: number;
}): AuditSeverity {
if (counts.errors > 0) return "error";
if (counts.pendingApprovals > 0 || counts.overBudget > 0) return "action-required";
if (counts.blocked > 0 || counts.tasksDue > 0) return "warn";
return "info";
}
function deriveApprovalSeverity(ok: boolean, mode: string): AuditSeverity {
if (!ok && mode === "live") return "error";
if (mode === "blocked") return "warn";
if (!ok) return "warn";
if (mode === "live") return "action-required";
return "info";
}
function deriveOperationSeverity(action: string, ok: boolean, detail: string): AuditSeverity {
if (action === "backup_export") {
return ok ? "info" : "error";
}
if (action === "import_dry_run") {
return ok ? "info" : "warn";
}
if (action === "import_apply") {
return ok ? "action-required" : "error";
}
if (action === "ack_prune") {
return ok ? "info" : "warn";
}
if (action === "task_heartbeat") {
if (!ok) return "warn";
return detail.startsWith("live ") ? "action-required" : "info";
}
return ok ? "info" : "warn";
}
async function safeReadFile(path: string): Promise<string> {
try {
return await readFile(path, "utf8");
} catch {
return "";
}
}
function toMs(value: string): number {
const ms = Date.parse(value);
return Number.isNaN(ms) ? 0 : ms;
}
function toIso(value: string): string | undefined {
const ms = Date.parse(value);
if (Number.isNaN(ms)) return undefined;
return new Date(ms).toISOString();
}
function compareTimelineEvents(a: AuditTimelineEvent, b: AuditTimelineEvent): number {
const timeDiff = toMs(b.timestamp) - toMs(a.timestamp);
if (timeDiff !== 0) return timeDiff;
const severityDiff = severityRank(a.severity) - severityRank(b.severity);
if (severityDiff !== 0) return severityDiff;
const sourceDiff = a.source.localeCompare(b.source);
if (sourceDiff !== 0) return sourceDiff;
return a.message.localeCompare(b.message);
}
function severityRank(severity: AuditSeverity): number {
if (severity === "error") return 0;
if (severity === "action-required") return 1;
if (severity === "warn") return 2;
return 3;
}
function asString(input: unknown): string | undefined {
return typeof input === "string" ? input : undefined;
}
+226
View File
@@ -0,0 +1,226 @@
import type {
BudgetEvaluation,
ProjectStoreSnapshot,
BudgetMetricEvaluation,
BudgetPolicyConfig,
BudgetStatus,
BudgetSummary,
BudgetThresholds,
BudgetUsageSnapshot,
SessionStatusSnapshot,
SessionSummary,
TaskStoreSnapshot,
} from "../types";
import { DEFAULT_BUDGET_POLICY } from "./budget-policy";
const DEFAULT_WARN_RATIO = 0.8;
export function computeBudgetSummary(
sessions: SessionSummary[],
statuses: SessionStatusSnapshot[],
tasks: TaskStoreSnapshot,
projects: ProjectStoreSnapshot,
policy: BudgetPolicyConfig = DEFAULT_BUDGET_POLICY,
): BudgetSummary {
const evaluations: BudgetEvaluation[] = [];
const statusBySessionKey = buildStatusMap(statuses);
const agentBySessionKey = buildAgentMap(sessions);
const projectById = new Map(projects.projects.map((project) => [project.projectId, project]));
for (const agentBudget of tasks.agentBudgets) {
const keys: string[] = [];
for (const [sessionKey, agentId] of agentBySessionKey.entries()) {
if (agentId === agentBudget.agentId) keys.push(sessionKey);
}
evaluations.push(
evaluateBudget(
"agent",
agentBudget.agentId,
agentBudget.label ?? agentBudget.agentId,
resolveThresholds("agent", agentBudget.agentId, agentBudget.thresholds, policy),
aggregateUsage(statusBySessionKey, keys),
),
);
}
const projectSessionKeys = new Map<string, Set<string>>();
for (const task of tasks.tasks) {
const project = projectById.get(task.projectId);
const projectTitle = project?.title ?? task.projectId;
let keySet = projectSessionKeys.get(task.projectId);
if (!keySet) {
keySet = new Set<string>();
projectSessionKeys.set(task.projectId, keySet);
}
for (const sessionKey of task.sessionKeys) keySet.add(sessionKey);
evaluations.push(
evaluateBudget(
"task",
task.taskId,
`${projectTitle} / ${task.title}`,
resolveThresholds("task", task.taskId, task.budget, policy),
aggregateUsage(statusBySessionKey, task.sessionKeys),
),
);
}
for (const project of projects.projects) {
evaluations.push(
evaluateBudget(
"project",
project.projectId,
project.title,
resolveThresholds("project", project.projectId, project.budget, policy),
aggregateUsage(statusBySessionKey, [...(projectSessionKeys.get(project.projectId) ?? new Set<string>())]),
),
);
}
return {
total: evaluations.length,
ok: evaluations.filter((item) => item.status === "ok").length,
warn: evaluations.filter((item) => item.status === "warn").length,
over: evaluations.filter((item) => item.status === "over").length,
evaluations,
};
}
function buildStatusMap(statuses: SessionStatusSnapshot[]): Map<string, SessionStatusSnapshot> {
const map = new Map<string, SessionStatusSnapshot>();
for (const status of statuses) {
map.set(status.sessionKey, status);
}
return map;
}
function buildAgentMap(sessions: SessionSummary[]): Map<string, string> {
const map = new Map<string, string>();
for (const session of sessions) {
if (!session.agentId) continue;
map.set(session.sessionKey, session.agentId);
}
return map;
}
function aggregateUsage(
statusBySessionKey: Map<string, SessionStatusSnapshot>,
sessionKeys: string[],
): BudgetUsageSnapshot {
const keySet = new Set(sessionKeys);
const usage: BudgetUsageSnapshot = { tokensIn: 0, tokensOut: 0, totalTokens: 0, cost: 0 };
for (const key of keySet) {
const status = statusBySessionKey.get(key);
if (!status) continue;
usage.tokensIn += status.tokensIn ?? 0;
usage.tokensOut += status.tokensOut ?? 0;
usage.totalTokens += (status.tokensIn ?? 0) + (status.tokensOut ?? 0);
usage.cost += status.cost ?? 0;
}
return usage;
}
function evaluateBudget(
scope: BudgetEvaluation["scope"],
scopeId: string,
label: string,
thresholds: BudgetThresholds,
usage: BudgetUsageSnapshot,
): BudgetEvaluation {
const metrics: BudgetMetricEvaluation[] = [];
const warnRatio = thresholds.warnRatio ?? DEFAULT_WARN_RATIO;
addMetric(metrics, "tokensIn", usage.tokensIn, thresholds.tokensIn, warnRatio);
addMetric(metrics, "tokensOut", usage.tokensOut, thresholds.tokensOut, warnRatio);
addMetric(metrics, "totalTokens", usage.totalTokens, thresholds.totalTokens, warnRatio);
addMetric(metrics, "cost", usage.cost, thresholds.cost, warnRatio);
return {
scope,
scopeId,
label,
thresholds,
usage,
metrics,
status: highestStatus(metrics),
};
}
function resolveThresholds(
scope: BudgetEvaluation["scope"],
scopeId: string,
raw: BudgetThresholds,
policy: BudgetPolicyConfig,
): BudgetThresholds {
const scopeOverrides =
scope === "agent"
? policy.agent[scopeId]
: scope === "project"
? policy.project[scopeId]
: scope === "task"
? policy.task[scopeId]
: undefined;
return normalizeThresholds({
...policy.defaults,
...scopeOverrides,
...raw,
});
}
function addMetric(
metrics: BudgetMetricEvaluation[],
metric: BudgetMetricEvaluation["metric"],
used: number,
limit: number | undefined,
warnRatio: number,
): void {
if (limit === undefined || limit <= 0) return;
const warnAt = limit * warnRatio;
let status: BudgetStatus = "ok";
if (used > limit) {
status = "over";
} else if (used >= warnAt) {
status = "warn";
}
metrics.push({
metric,
used,
limit,
warnAt,
status,
});
}
function highestStatus(metrics: BudgetMetricEvaluation[]): BudgetStatus {
if (metrics.some((metric) => metric.status === "over")) return "over";
if (metrics.some((metric) => metric.status === "warn")) return "warn";
return "ok";
}
function normalizeThresholds(input: BudgetThresholds): BudgetThresholds {
const warnRatio = input.warnRatio;
return {
tokensIn: asPositiveNumber(input.tokensIn),
tokensOut: asPositiveNumber(input.tokensOut),
totalTokens: asPositiveNumber(input.totalTokens),
cost: asPositiveNumber(input.cost),
warnRatio:
typeof warnRatio === "number" && Number.isFinite(warnRatio) && warnRatio > 0 && warnRatio < 1
? warnRatio
: DEFAULT_WARN_RATIO,
};
}
function asPositiveNumber(input: number | undefined): number | undefined {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) return undefined;
return input;
}
+155
View File
@@ -0,0 +1,155 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { BudgetPolicyConfig, BudgetThresholds } from "../types";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const BUDGET_POLICY_PATH = join(RUNTIME_DIR, "budgets.json");
const DEFAULT_WARN_RATIO = 0.8;
export const DEFAULT_BUDGET_POLICY: BudgetPolicyConfig = {
defaults: {
warnRatio: DEFAULT_WARN_RATIO,
},
agent: {},
project: {},
task: {},
};
export interface BudgetPolicyLoadResult {
policy: BudgetPolicyConfig;
path: string;
loadedFromFile: boolean;
issues: string[];
}
export async function loadBudgetPolicy(): Promise<BudgetPolicyLoadResult> {
try {
const raw = await readFile(BUDGET_POLICY_PATH, "utf8");
const parsed = JSON.parse(raw) as unknown;
const issues: string[] = [];
const policy = normalizePolicy(parsed, issues);
return {
policy,
path: BUDGET_POLICY_PATH,
loadedFromFile: true,
issues,
};
} catch (error) {
const issues: string[] = [];
if (!isErrorWithCode(error, "ENOENT")) {
issues.push(`failed to load budgets policy: ${error instanceof Error ? error.message : "unknown error"}`);
}
return {
policy: clonePolicy(DEFAULT_BUDGET_POLICY),
path: BUDGET_POLICY_PATH,
loadedFromFile: false,
issues,
};
}
}
function normalizePolicy(input: unknown, issues: string[]): BudgetPolicyConfig {
const obj = asObject(input);
if (!obj) {
issues.push("budgets policy must be a JSON object");
return clonePolicy(DEFAULT_BUDGET_POLICY);
}
return {
defaults: normalizeThresholds(obj.defaults, "defaults", issues, true),
agent: normalizeScopeRecord(obj.agent, "agent", issues),
project: normalizeScopeRecord(obj.project, "project", issues),
task: normalizeScopeRecord(obj.task, "task", issues),
};
}
function normalizeScopeRecord(
input: unknown,
label: string,
issues: string[],
): Record<string, BudgetThresholds> {
const obj = asObject(input);
if (!obj) {
if (input !== undefined) issues.push(`${label} must be an object`);
return {};
}
const out: Record<string, BudgetThresholds> = {};
for (const [scopeId, thresholds] of Object.entries(obj)) {
if (!scopeId.trim()) {
issues.push(`${label} contains empty key`);
continue;
}
out[scopeId] = normalizeThresholds(thresholds, `${label}.${scopeId}`, issues, false);
}
return out;
}
function normalizeThresholds(
input: unknown,
label: string,
issues: string[],
includeDefaultWarnRatio: boolean,
): BudgetThresholds {
const obj = asObject(input);
if (!obj) {
if (input !== undefined) issues.push(`${label} must be an object`);
return includeDefaultWarnRatio ? { warnRatio: DEFAULT_WARN_RATIO } : {};
}
const tokensIn = readPositiveNumber(obj.tokensIn, `${label}.tokensIn`, issues);
const tokensOut = readPositiveNumber(obj.tokensOut, `${label}.tokensOut`, issues);
const totalTokens = readPositiveNumber(obj.totalTokens, `${label}.totalTokens`, issues);
const cost = readPositiveNumber(obj.cost, `${label}.cost`, issues);
const warnRatio = readWarnRatio(obj.warnRatio, `${label}.warnRatio`, issues);
return {
...(tokensIn !== undefined ? { tokensIn } : {}),
...(tokensOut !== undefined ? { tokensOut } : {}),
...(totalTokens !== undefined ? { totalTokens } : {}),
...(cost !== undefined ? { cost } : {}),
...(warnRatio !== undefined
? { warnRatio }
: includeDefaultWarnRatio
? { warnRatio: DEFAULT_WARN_RATIO }
: {}),
};
}
function readPositiveNumber(input: unknown, label: string, issues: string[]): number | undefined {
if (input === undefined) return undefined;
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
issues.push(`${label} must be a finite number > 0`);
return undefined;
}
return input;
}
function readWarnRatio(input: unknown, label: string, issues: string[]): number | undefined {
if (input === undefined) return undefined;
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0 || input >= 1) {
issues.push(`${label} must be a finite number > 0 and < 1`);
return undefined;
}
return input;
}
function clonePolicy(policy: BudgetPolicyConfig): BudgetPolicyConfig {
return {
defaults: { ...policy.defaults },
agent: { ...policy.agent },
project: { ...policy.project },
task: { ...policy.task },
};
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function isErrorWithCode(error: unknown, code: string): boolean {
return error !== null && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === code;
}
+244
View File
@@ -0,0 +1,244 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { commanderExceptions, commanderExceptionsFeed, type CommanderAlert } from "./commander";
import type { ReadModelSnapshot } from "../types";
const RUNTIME_DIR = join(process.cwd(), "runtime");
const DIGEST_DIR = join(RUNTIME_DIR, "digests");
export interface CommanderDigest {
date: string;
generatedAt: string;
snapshotGeneratedAt: string;
sessions: {
total: number;
byState: Record<string, number>;
};
usage: {
statuses: number;
totalTokensIn: number;
totalTokensOut: number;
totalCost: number;
};
approvals: {
total: number;
pending: number;
approved: number;
denied: number;
unknown: number;
};
projects: {
total: number;
byStatus: Record<string, number>;
};
tasks: {
total: number;
todo: number;
inProgress: number;
blocked: number;
done: number;
dueNow: number;
};
budgets: {
total: number;
ok: number;
warn: number;
over: number;
};
alerts: CommanderAlert[];
exceptions: {
counts: {
blocked: number;
errors: number;
pendingApprovals: number;
overBudget: number;
tasksDue: number;
info: number;
warn: number;
actionRequired: number;
};
topItems: Array<{
level: string;
code: string;
source: string;
sourceId: string;
route: string;
message: string;
}>;
};
}
export interface CommanderDigestWriteResult {
jsonPath: string;
markdownPath: string;
digest: CommanderDigest;
}
export async function writeCommanderDigest(
snapshot: ReadModelSnapshot,
alerts: CommanderAlert[],
): Promise<CommanderDigestWriteResult> {
const digest = buildCommanderDigest(snapshot, alerts);
const jsonPath = join(DIGEST_DIR, `${digest.date}.json`);
const markdownPath = join(DIGEST_DIR, `${digest.date}.md`);
await mkdir(DIGEST_DIR, { recursive: true });
await Promise.all([
writeFile(jsonPath, `${JSON.stringify(digest, null, 2)}\n`, "utf8"),
writeFile(markdownPath, `${renderDigestMarkdown(digest)}\n`, "utf8"),
]);
return {
jsonPath,
markdownPath,
digest,
};
}
function buildCommanderDigest(snapshot: ReadModelSnapshot, alerts: CommanderAlert[]): CommanderDigest {
const generatedAt = new Date().toISOString();
const date = generatedAt.slice(0, 10);
const exceptions = commanderExceptions(snapshot);
const feed = commanderExceptionsFeed(snapshot);
const usage = snapshot.statuses.reduce(
(acc, status) => {
acc.totalTokensIn += status.tokensIn ?? 0;
acc.totalTokensOut += status.tokensOut ?? 0;
acc.totalCost += status.cost ?? 0;
return acc;
},
{
statuses: snapshot.statuses.length,
totalTokensIn: 0,
totalTokensOut: 0,
totalCost: 0,
},
);
return {
date,
generatedAt,
snapshotGeneratedAt: snapshot.generatedAt,
sessions: {
total: snapshot.sessions.length,
byState: countBy(snapshot.sessions.map((session) => session.state)),
},
usage,
approvals: {
total: snapshot.approvals.length,
pending: snapshot.approvals.filter((item) => item.status === "pending").length,
approved: snapshot.approvals.filter((item) => item.status === "approved").length,
denied: snapshot.approvals.filter((item) => item.status === "denied").length,
unknown: snapshot.approvals.filter((item) => item.status === "unknown").length,
},
projects: {
total: snapshot.projects.projects.length,
byStatus: countBy(snapshot.projects.projects.map((project) => project.status)),
},
tasks: {
total: snapshot.tasksSummary.tasks,
todo: snapshot.tasksSummary.todo,
inProgress: snapshot.tasksSummary.inProgress,
blocked: snapshot.tasksSummary.blocked,
done: snapshot.tasksSummary.done,
dueNow: exceptions.counts.tasksDue,
},
budgets: {
total: snapshot.budgetSummary.total,
ok: snapshot.budgetSummary.ok,
warn: snapshot.budgetSummary.warn,
over: snapshot.budgetSummary.over,
},
alerts,
exceptions: {
counts: {
blocked: exceptions.counts.blocked,
errors: exceptions.counts.errors,
pendingApprovals: exceptions.counts.pendingApprovals,
overBudget: exceptions.counts.overBudget,
tasksDue: exceptions.counts.tasksDue,
info: feed.counts.info,
warn: feed.counts.warn,
actionRequired: feed.counts.actionRequired,
},
topItems: feed.items.slice(0, 20).map((item) => ({
level: item.level,
code: item.code,
source: item.source,
sourceId: item.sourceId,
route: item.route,
message: item.message,
})),
},
};
}
function renderDigestMarkdown(digest: CommanderDigest): string {
const lines: string[] = [];
lines.push(`# Commander Digest ${digest.date}`);
lines.push("");
lines.push(`Generated: ${digest.generatedAt}`);
lines.push(`Snapshot: ${digest.snapshotGeneratedAt}`);
lines.push("");
lines.push("## Snapshot");
lines.push(`- sessions: ${digest.sessions.total}`);
lines.push(`- statuses: ${digest.usage.statuses}`);
lines.push(`- tokens: in=${digest.usage.totalTokensIn} out=${digest.usage.totalTokensOut}`);
lines.push(`- cost: ${digest.usage.totalCost.toFixed(4)}`);
lines.push(`- projects: ${digest.projects.total}`);
lines.push(`- tasks: total=${digest.tasks.total} todo=${digest.tasks.todo} in_progress=${digest.tasks.inProgress} blocked=${digest.tasks.blocked} done=${digest.tasks.done} due=${digest.tasks.dueNow}`);
lines.push(`- approvals: total=${digest.approvals.total} pending=${digest.approvals.pending} approved=${digest.approvals.approved} denied=${digest.approvals.denied} unknown=${digest.approvals.unknown}`);
lines.push(`- budgets: total=${digest.budgets.total} ok=${digest.budgets.ok} warn=${digest.budgets.warn} over=${digest.budgets.over}`);
lines.push("");
lines.push("## Session States");
lines.push(...renderKeyValueList(digest.sessions.byState));
lines.push("");
lines.push("## Project States");
lines.push(...renderKeyValueList(digest.projects.byStatus));
lines.push("");
lines.push("## Alerts");
if (digest.alerts.length === 0) {
lines.push("- none");
} else {
for (const alert of digest.alerts) {
lines.push(`- [${alert.level}] ${alert.code}: ${alert.message} (route=${alert.route})`);
}
}
lines.push("");
lines.push("## Exceptions");
lines.push(`- blocked=${digest.exceptions.counts.blocked} errors=${digest.exceptions.counts.errors} pending_approvals=${digest.exceptions.counts.pendingApprovals} over_budget=${digest.exceptions.counts.overBudget} tasks_due=${digest.exceptions.counts.tasksDue}`);
lines.push(`- feed: info=${digest.exceptions.counts.info} warn=${digest.exceptions.counts.warn} action_required=${digest.exceptions.counts.actionRequired}`);
lines.push("");
lines.push("### Top Items");
if (digest.exceptions.topItems.length === 0) {
lines.push("- none");
} else {
for (const item of digest.exceptions.topItems) {
lines.push(`- [${item.level}] ${item.code} ${item.source}:${item.sourceId} route=${item.route} ${item.message}`);
}
}
return lines.join("\n");
}
function renderKeyValueList(input: Record<string, number>): string[] {
const keys = Object.keys(input).sort((a, b) => a.localeCompare(b));
if (keys.length === 0) return ["- none"];
return keys.map((key) => `- ${key}: ${input[key]}`);
}
function countBy(values: string[]): Record<string, number> {
const out: Record<string, number> = {};
for (const value of values) {
out[value] = (out[value] ?? 0) + 1;
}
return out;
}
+279
View File
@@ -0,0 +1,279 @@
import { listTasks } from "./task-store";
import type {
AlertLevel,
BudgetEvaluation,
CommanderExceptionsFeed,
CommanderExceptionsSummary,
ExceptionFeedItem,
ReadModelSnapshot,
} from "../types";
const CURRENT_RUNTIME_ISSUE_WINDOW_MS = 6 * 60 * 60 * 1000;
export interface CommanderAlert {
level: AlertLevel;
code:
| "NO_SESSIONS"
| "HAS_ERRORS"
| "HAS_BLOCKED"
| "HAS_PENDING_APPROVALS"
| "HAS_OVER_BUDGET"
| "HAS_TASKS_DUE";
message: string;
route: "timeline" | "operator-watch" | "action-queue";
}
export function commanderAlerts(snapshot: ReadModelSnapshot): CommanderAlert[] {
const alerts: CommanderAlert[] = [];
const exceptions = commanderExceptions(snapshot);
if (snapshot.sessions.length === 0) {
alerts.push({
level: "info",
code: "NO_SESSIONS",
message: "No active sessions detected.",
route: routeForLevel("info"),
});
}
if (exceptions.counts.blocked > 0) {
alerts.push({
level: "warn",
code: "HAS_BLOCKED",
message: `${exceptions.counts.blocked} session(s) are blocked or waiting approval.`,
route: routeForLevel("warn"),
});
}
if (exceptions.counts.errors > 0) {
alerts.push({
level: "action-required",
code: "HAS_ERRORS",
message: `${exceptions.counts.errors} session(s) are in error state.`,
route: routeForLevel("action-required"),
});
}
if (exceptions.counts.pendingApprovals > 0) {
alerts.push({
level: "action-required",
code: "HAS_PENDING_APPROVALS",
message: `${exceptions.counts.pendingApprovals} approval request(s) are pending.`,
route: routeForLevel("action-required"),
});
}
if (exceptions.counts.overBudget > 0) {
alerts.push({
level: "action-required",
code: "HAS_OVER_BUDGET",
message: `${exceptions.counts.overBudget} budget scope(s) are over limit.`,
route: routeForLevel("action-required"),
});
}
if (exceptions.counts.tasksDue > 0) {
alerts.push({
level: "warn",
code: "HAS_TASKS_DUE",
message: `${exceptions.counts.tasksDue} task(s) are due.`,
route: routeForLevel("warn"),
});
}
return alerts;
}
export function commanderExceptions(snapshot: ReadModelSnapshot): CommanderExceptionsSummary {
const nowMs = toMs(snapshot.generatedAt) || Date.now();
const blocked = snapshot.sessions.filter(
(s) =>
(s.state === "blocked" || s.state === "waiting_approval") &&
isFreshRuntimeIssueSession(s.lastMessageAt, nowMs),
);
const errors = snapshot.sessions.filter(
(s) => s.state === "error" && isFreshRuntimeIssueSession(s.lastMessageAt, nowMs),
);
const pendingApprovals = snapshot.approvals.filter((approval) => approval.status === "pending");
const overBudget = snapshot.budgetSummary.evaluations.filter((evaluation) => evaluation.status === "over");
const projectTitleById = new Map(snapshot.projects.projects.map((project) => [project.projectId, project.title]));
const tasksDue = listTasks(snapshot.tasks, projectTitleById).filter((task) => {
if (!task.dueAt) return false;
if (task.status === "done") return false;
return Date.parse(task.dueAt) <= nowMs;
});
return {
generatedAt: new Date().toISOString(),
blocked,
errors,
pendingApprovals,
overBudget,
tasksDue,
counts: {
blocked: blocked.length,
errors: errors.length,
pendingApprovals: pendingApprovals.length,
overBudget: overBudget.length,
tasksDue: tasksDue.length,
},
};
}
export function commanderExceptionsFeed(snapshot: ReadModelSnapshot): CommanderExceptionsFeed {
const items: ExceptionFeedItem[] = [];
const exceptions = commanderExceptions(snapshot);
const fallbackOccurredAt = snapshot.generatedAt;
if (snapshot.sessions.length === 0) {
items.push({
level: "info",
code: "NO_SESSIONS",
source: "system",
sourceId: "sessions",
message: "No active sessions detected.",
route: routeForLevel("info"),
occurredAt: fallbackOccurredAt,
});
}
for (const session of exceptions.blocked) {
items.push({
level: "warn",
code: "SESSION_BLOCKED",
source: "session",
sourceId: session.sessionKey,
message: `Session ${session.sessionKey} is ${session.state}.`,
route: routeForLevel("warn"),
occurredAt: session.lastMessageAt ?? fallbackOccurredAt,
});
}
for (const session of exceptions.errors) {
items.push({
level: "action-required",
code: "SESSION_ERROR",
source: "session",
sourceId: session.sessionKey,
message: `Session ${session.sessionKey} is in error state.`,
route: routeForLevel("action-required"),
occurredAt: session.lastMessageAt ?? fallbackOccurredAt,
});
}
for (const approval of exceptions.pendingApprovals) {
items.push({
level: "action-required",
code: "PENDING_APPROVAL",
source: "approval",
sourceId: approval.approvalId,
message: `Approval ${approval.approvalId} is pending.`,
route: routeForLevel("action-required"),
occurredAt: approval.updatedAt ?? approval.requestedAt ?? fallbackOccurredAt,
});
}
for (const budget of exceptions.overBudget) {
items.push({
level: "action-required",
code: "OVER_BUDGET",
source: "budget",
sourceId: `${budget.scope}:${budget.scopeId}`,
message: `${budget.scope} ${budget.label} is over budget.`,
route: routeForLevel("action-required"),
occurredAt: deriveBudgetOccurredAt(snapshot, budget) ?? fallbackOccurredAt,
});
}
for (const task of exceptions.tasksDue) {
items.push({
level: "warn",
code: "TASK_DUE",
source: "task",
sourceId: task.taskId,
message: `Task ${task.taskId} (${task.projectTitle}) is due at ${task.dueAt ?? "n/a"}.`,
route: routeForLevel("warn"),
occurredAt: task.dueAt ?? task.updatedAt ?? fallbackOccurredAt,
});
}
const sortedItems = [...items].sort(compareFeedItems);
return {
generatedAt: new Date().toISOString(),
items: sortedItems,
counts: {
info: sortedItems.filter((item) => item.level === "info").length,
warn: sortedItems.filter((item) => item.level === "warn").length,
actionRequired: sortedItems.filter((item) => item.level === "action-required").length,
},
};
}
function routeForLevel(level: AlertLevel): "timeline" | "operator-watch" | "action-queue" {
if (level === "action-required") return "action-queue";
if (level === "warn") return "operator-watch";
return "timeline";
}
function compareFeedItems(a: ExceptionFeedItem, b: ExceptionFeedItem): number {
const severityDiff = severityRank(a.level) - severityRank(b.level);
if (severityDiff !== 0) return severityDiff;
const aTime = toMs(a.occurredAt);
const bTime = toMs(b.occurredAt);
if (aTime !== bTime) return bTime - aTime;
const codeDiff = a.code.localeCompare(b.code);
if (codeDiff !== 0) return codeDiff;
const sourceDiff = a.source.localeCompare(b.source);
if (sourceDiff !== 0) return sourceDiff;
const sourceIdDiff = a.sourceId.localeCompare(b.sourceId);
if (sourceIdDiff !== 0) return sourceIdDiff;
const routeDiff = a.route.localeCompare(b.route);
if (routeDiff !== 0) return routeDiff;
return a.message.localeCompare(b.message);
}
function severityRank(level: AlertLevel): number {
if (level === "action-required") return 0;
if (level === "warn") return 1;
return 2;
}
function toMs(value: string | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function isFreshRuntimeIssueSession(lastMessageAt: string | undefined, nowMs: number): boolean {
const latestAt = toMs(lastMessageAt);
if (latestAt <= 0) return true;
return nowMs - latestAt <= CURRENT_RUNTIME_ISSUE_WINDOW_MS;
}
function deriveBudgetOccurredAt(
snapshot: ReadModelSnapshot,
budget: BudgetEvaluation,
): string | undefined {
if (budget.scope === "task") {
const task = snapshot.tasks.tasks.find((item) => item.taskId === budget.scopeId);
return task?.updatedAt;
}
if (budget.scope === "project") {
const project = snapshot.projects.projects.find((item) => item.projectId === budget.scopeId);
return project?.updatedAt;
}
if (budget.scope === "agent") {
const sessionKeys = snapshot.sessions
.filter((session) => session.agentId === budget.scopeId)
.map((session) => session.sessionKey);
const statusTimes = snapshot.statuses
.filter((status) => sessionKeys.includes(status.sessionKey))
.map((status) => status.updatedAt)
.filter((value): value is string => typeof value === "string");
return statusTimes.sort((a, b) => toMs(b) - toMs(a))[0];
}
return undefined;
}
+206
View File
@@ -0,0 +1,206 @@
import type { CronJobSummary, ReadModelSnapshot } from "../types";
import { readMonitorLagSummary, type MonitorLagSummary } from "./monitor-health";
import { readTaskHeartbeatRuns } from "./task-heartbeat";
export type CronJobHealth = "scheduled" | "due" | "late" | "unknown" | "disabled";
export type CronOverviewHealth = "ok" | "warn";
export interface CronJobOverview {
jobId: string;
name?: string;
enabled: boolean;
nextRunAt?: string;
dueInSeconds?: number;
health: CronJobHealth;
}
export interface CronOverview {
generatedAt: string;
snapshotGeneratedAt: string;
nextRunAt?: string;
jobs: CronJobOverview[];
counts: Record<CronJobHealth, number>;
health: {
status: CronOverviewHealth;
enabledJobs: number;
totalJobs: number;
monitor: MonitorLagSummary;
};
}
export async function buildCronOverview(
snapshot: ReadModelSnapshot,
monitorIntervalMs: number,
now: Date = new Date(),
): Promise<CronOverview> {
const snapshotJobs = (snapshot.cronJobs ?? [])
.map((job) => toCronJobOverview(job, now))
.sort((a, b) => sortByNextRun(a.nextRunAt, b.nextRunAt));
const monitor = await readMonitorLagSummary(monitorIntervalMs, now);
const runtimeFallbackJobs = await buildRuntimeFallbackJobs(monitor, monitorIntervalMs, now);
const jobs = mergeCronJobs(snapshotJobs, runtimeFallbackJobs).sort((a, b) => sortByNextRun(a.nextRunAt, b.nextRunAt));
const counts: Record<CronJobHealth, number> = {
scheduled: 0,
due: 0,
late: 0,
unknown: 0,
disabled: 0,
};
for (const job of jobs) {
counts[job.health] += 1;
}
const nextRunAt = jobs
.filter((job) => job.enabled && typeof job.nextRunAt === "string")
.map((job) => job.nextRunAt as string)
.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
const enabledJobs = jobs.filter((job) => job.enabled).length;
const status: CronOverviewHealth =
counts.late > 0 || counts.unknown > 0 || enabledJobs === 0 || monitor.status === "missing" || monitor.status === "stale"
? "warn"
: "ok";
return {
generatedAt: now.toISOString(),
snapshotGeneratedAt: snapshot.generatedAt,
nextRunAt,
jobs,
counts,
health: {
status,
enabledJobs,
totalJobs: jobs.length,
monitor,
},
};
}
async function buildRuntimeFallbackJobs(
monitor: MonitorLagSummary,
monitorIntervalMs: number,
now: Date,
): Promise<CronJobOverview[]> {
const jobs: CronJobOverview[] = [];
const monitorJob = buildRuntimeMonitorJob(monitor, now);
if (monitorJob) jobs.push(monitorJob);
const heartbeatJob = await buildRuntimeHeartbeatJob(monitorIntervalMs, now);
if (heartbeatJob) jobs.push(heartbeatJob);
return jobs;
}
function buildRuntimeMonitorJob(
monitor: MonitorLagSummary,
now: Date,
): CronJobOverview | undefined {
if (!monitor.lastTickAt) return undefined;
const intervalMs = Math.max(1000, monitor.expectedIntervalMs);
const lastTickMs = Date.parse(monitor.lastTickAt);
if (!Number.isFinite(lastTickMs)) return undefined;
const nextRunMs = lastTickMs + intervalMs;
const dueInSeconds = Math.round((nextRunMs - now.getTime()) / 1000);
const health: CronJobHealth =
monitor.status === "ok" ? "scheduled" : monitor.status === "warn" ? "due" : "late";
return {
jobId: "runtime-monitor-loop",
name: "Runtime monitor loop",
enabled: true,
nextRunAt: new Date(nextRunMs).toISOString(),
dueInSeconds,
health,
};
}
async function buildRuntimeHeartbeatJob(
monitorIntervalMs: number,
now: Date,
): Promise<CronJobOverview | undefined> {
const runs = await readTaskHeartbeatRuns(1);
const latest = runs.runs[0];
if (!latest?.evaluatedAt) return undefined;
const latestMs = Date.parse(latest.evaluatedAt);
if (!Number.isFinite(latestMs)) return undefined;
const intervalMs = Math.max(1000, monitorIntervalMs);
const nextRunMs = latestMs + intervalMs;
const dueInSeconds = Math.round((nextRunMs - now.getTime()) / 1000);
const lagMs = Math.max(0, now.getTime() - latestMs);
const enabled = latest.gate.enabled;
const health: CronJobHealth = !enabled
? "disabled"
: lagMs <= intervalMs * 2
? "scheduled"
: lagMs <= intervalMs * 6
? "due"
: "late";
return {
jobId: "runtime-task-heartbeat-worker",
name: "Task heartbeat worker",
enabled,
nextRunAt: new Date(nextRunMs).toISOString(),
dueInSeconds,
health,
};
}
function mergeCronJobs(snapshotJobs: CronJobOverview[], runtimeJobs: CronJobOverview[]): CronJobOverview[] {
if (runtimeJobs.length === 0) return snapshotJobs;
const merged = [...snapshotJobs];
const seen = new Set(snapshotJobs.map((job) => job.jobId.trim().toLowerCase()));
for (const runtimeJob of runtimeJobs) {
const key = runtimeJob.jobId.trim().toLowerCase();
if (seen.has(key)) continue;
merged.push(runtimeJob);
seen.add(key);
}
return merged;
}
function toCronJobOverview(job: CronJobSummary, now: Date): CronJobOverview {
if (!job.enabled) {
return {
jobId: job.jobId,
name: job.name,
enabled: false,
nextRunAt: job.nextRunAt,
health: "disabled",
};
}
const nextRunMs = job.nextRunAt ? Date.parse(job.nextRunAt) : Number.NaN;
if (!Number.isFinite(nextRunMs)) {
return {
jobId: job.jobId,
name: job.name,
enabled: true,
nextRunAt: job.nextRunAt,
health: "unknown",
};
}
const lagMs = nextRunMs - now.getTime();
const dueInSeconds = Math.round(lagMs / 1000);
let health: CronJobHealth = "scheduled";
if (lagMs <= 0) health = "due";
if (lagMs < -5 * 60 * 1000) health = "late";
return {
jobId: job.jobId,
name: job.name,
enabled: true,
nextRunAt: new Date(nextRunMs).toISOString(),
dueInSeconds,
health,
};
}
function sortByNextRun(left?: string, right?: string): number {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return Date.parse(left) - Date.parse(right);
}
+112
View File
@@ -0,0 +1,112 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
export type CurrentAgentCatalogStatus = "connected" | "partial" | "not_connected";
export interface CurrentAgentCatalogEntry {
agentId: string;
displayName: string;
}
export interface CurrentAgentCatalog {
status: CurrentAgentCatalogStatus;
sourcePath: string;
detail: string;
entries: CurrentAgentCatalogEntry[];
}
export async function loadCurrentAgentCatalog(): Promise<CurrentAgentCatalog> {
const sourcePath = resolveOpenClawConfigPath();
try {
const raw = JSON.parse(await readFile(sourcePath, "utf8")) as unknown;
const root = asObject(raw) ?? {};
const agents = asObject(root.agents) ?? {};
const list = asArray(agents.list);
const merged = new Map<string, CurrentAgentCatalogEntry>();
for (const item of list) {
const obj = asObject(item);
if (!obj) continue;
const agentId = asString(obj.id)?.trim() ?? asString(obj.name)?.trim();
if (!agentId) continue;
const key = normalizeKey(agentId);
if (merged.has(key)) continue;
merged.set(key, {
agentId,
displayName: asString(obj.name)?.trim() || agentId,
});
}
const entries = [...merged.values()].sort((a, b) => a.agentId.localeCompare(b.agentId));
if (entries.length === 0) {
return {
status: "partial",
sourcePath,
detail: "openclaw.json found but agents.list is empty.",
entries: [],
};
}
return {
status: "connected",
sourcePath,
detail: `loaded ${entries.length} current agent(s) from openclaw.json.`,
entries,
};
} catch (error) {
if (isFsNotFound(error)) {
return {
status: "not_connected",
sourcePath,
detail: "openclaw.json not found.",
entries: [],
};
}
return {
status: "partial",
sourcePath,
detail: "openclaw.json exists but could not be parsed.",
entries: [],
};
}
}
export function resolveOpenClawHomePath(): string {
return process.env.OPENCLAW_HOME?.trim() || join(homedir(), ".openclaw");
}
export function resolveOpenClawConfigPath(): string {
const explicit = process.env.OPENCLAW_CONFIG_PATH?.trim();
if (explicit) return explicit;
return join(resolveOpenClawHomePath(), "openclaw.json");
}
function isFsNotFound(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
typeof (error as { code?: unknown }).code === "string" &&
(error as { code: string }).code === "ENOENT",
);
}
function normalizeKey(input: string): string {
return input.trim().toLowerCase();
}
function asObject(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
function asArray(input: unknown): unknown[] {
return Array.isArray(input) ? input : [];
}
function asString(input: unknown): string | undefined {
return typeof input === "string" ? input : undefined;
}
+20
View File
@@ -0,0 +1,20 @@
import type { SnapshotDiff } from "./snapshot-store";
export function formatDiffSummary(diff: SnapshotDiff): string {
const parts = [
`sessions ${signed(diff.sessionsDelta)}`,
`statuses ${signed(diff.statusesDelta)}`,
`cronJobs ${signed(diff.cronJobsDelta)}`,
`approvals ${signed(diff.approvalsDelta)}`,
`projects ${signed(diff.projectsDelta)}`,
`tasks ${signed(diff.tasksDelta)}`,
`budgets ${signed(diff.budgetEvaluationsDelta)}`,
];
return parts.join(" | ");
}
function signed(value: number): string {
if (value > 0) return `+${value}`;
return `${value}`;
}
+159
View File
@@ -0,0 +1,159 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
const DIGEST_DIR = join(process.cwd(), "runtime", "digests");
export interface LatestDigest {
generatedAt: string;
date?: string;
path?: string;
markdown?: string;
html?: string;
}
export async function loadLatestDigest(): Promise<LatestDigest> {
const generatedAt = new Date().toISOString();
try {
const files = await readdir(DIGEST_DIR);
const latest = files
.filter((name) => name.endsWith(".md"))
.sort((a, b) => b.localeCompare(a))[0];
if (!latest) {
return { generatedAt };
}
const path = join(DIGEST_DIR, latest);
const markdown = await readFile(path, "utf8");
return {
generatedAt,
date: latest.slice(0, -3),
path,
markdown,
html: renderMarkdownDigest(markdown),
};
} catch {
return { generatedAt };
}
}
export function renderLatestDigestPage(digest: LatestDigest): string {
if (!digest.markdown || !digest.html) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Digest Latest</title>
<style>
body { font-family: "SF Mono", Menlo, monospace; background: #0b1016; color: #d6e7f9; margin: 0; padding: 16px; }
.meta { color: #93aac2; font-size: 12px; }
a { color: #7dd3fc; }
.card { margin-top: 10px; border: 1px solid #27405a; border-radius: 8px; padding: 12px; background: #111923; }
</style>
</head>
<body>
<h1>Latest Digest</h1>
<div class="meta">generatedAt=${escapeHtml(digest.generatedAt)} | status=missing</div>
<div class="card">No digest markdown found in <code>${escapeHtml(DIGEST_DIR)}</code>.</div>
<p class="meta"><a href="/">home</a></p>
</body>
</html>`;
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Digest ${escapeHtml(digest.date ?? "latest")}</title>
<style>
body { font-family: "SF Mono", Menlo, monospace; background: #0b1016; color: #d6e7f9; margin: 0; padding: 16px; }
.meta { color: #93aac2; font-size: 12px; }
a { color: #7dd3fc; }
.card { margin-top: 10px; border: 1px solid #27405a; border-radius: 8px; padding: 12px; background: #111923; }
h1,h2,h3 { margin: 0; }
h2,h3 { margin-top: 14px; }
p { margin: 8px 0; }
ul { margin: 8px 0 8px 18px; padding: 0; }
code { color: #9bd5ff; }
</style>
</head>
<body>
<h1>Latest Digest</h1>
<div class="meta">date=${escapeHtml(digest.date ?? "unknown")} | generatedAt=${escapeHtml(digest.generatedAt)}</div>
<div class="meta">source=${escapeHtml(digest.path ?? "n/a")}</div>
<div class="card">${digest.html}</div>
<p class="meta"><a href="/">home</a></p>
</body>
</html>`;
}
function renderMarkdownDigest(markdown: string): string {
const lines = markdown.replace(/\r/g, "").split("\n");
const out: string[] = [];
let inList = false;
const closeList = (): void => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
closeList();
continue;
}
if (trimmed.startsWith("### ")) {
closeList();
out.push(`<h3>${renderInline(trimmed.slice(4))}</h3>`);
continue;
}
if (trimmed.startsWith("## ")) {
closeList();
out.push(`<h2>${renderInline(trimmed.slice(3))}</h2>`);
continue;
}
if (trimmed.startsWith("# ")) {
closeList();
out.push(`<h1>${renderInline(trimmed.slice(2))}</h1>`);
continue;
}
if (trimmed.startsWith("- ")) {
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push(`<li>${renderInline(trimmed.slice(2))}</li>`);
continue;
}
closeList();
out.push(`<p>${renderInline(trimmed)}</p>`);
}
closeList();
return out.join("\n");
}
function renderInline(input: string): string {
const escaped = escapeHtml(input);
return escaped.replace(/`([^`]+)`/g, (_match, group) => `<code>${group}</code>`);
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
+311
View File
@@ -0,0 +1,311 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { ToolClient } from "../clients/tool-client";
import {
getSessionConversationDetail,
type SessionConversationDetailResult,
type SessionHistoryMessage,
} from "./session-conversations";
import type { ReadModelSnapshot } from "../types";
const MAX_DOC_TITLE_CHARS = 88;
const MAX_DOC_EXCERPT_CHARS = 240;
const MAX_DOC_CONTENT_CHARS = 4000;
const CACHE_TTL_MS = 45_000;
export interface StructuredChatDocEntry {
id: string;
title: string;
excerpt: string;
content: string;
category: string;
sourceSessionKey: string;
sourceAgentId?: string;
sourceTimestamp: string;
updatedAt: string;
}
export interface StructuredDocHubSnapshot {
generatedAt: string;
sourcePath: string;
detail: string;
items: StructuredChatDocEntry[];
}
export interface StructuredDocHubBuildInput {
snapshot: ReadModelSnapshot;
client: ToolClient;
indexPath: string;
refreshFromSessions?: boolean;
maxSessions?: number;
historyLimit?: number;
maxDocsPerSession?: number;
maxStoredDocs?: number;
}
interface StructuredDocHubStore {
generatedAt: string;
items: StructuredChatDocEntry[];
}
let cachedSnapshot: StructuredDocHubSnapshot | undefined;
let cacheAtMs = 0;
let cachePath = "";
export async function buildStructuredDocHubFromSessions(
input: StructuredDocHubBuildInput,
): Promise<StructuredDocHubSnapshot> {
const now = new Date().toISOString();
const indexPath = input.indexPath;
const refreshFromSessions = input.refreshFromSessions ?? true;
const nowMs = Date.now();
if (
cachedSnapshot &&
cachePath === indexPath &&
nowMs - cacheAtMs < CACHE_TTL_MS &&
refreshFromSessions
) {
return cachedSnapshot;
}
const existing = await readStructuredDocStore(indexPath);
if (!refreshFromSessions) {
const result: StructuredDocHubSnapshot = {
generatedAt: existing.generatedAt ?? now,
sourcePath: indexPath,
detail: existing.items.length > 0 ? `已读取历史入库 ${existing.items.length} 条。` : "尚无聊天结构化入库记录。",
items: existing.items,
};
cachedSnapshot = result;
cacheAtMs = nowMs;
cachePath = indexPath;
return result;
}
const maxSessions = clampInt(input.maxSessions, 8, 48, 24);
const historyLimit = clampInt(input.historyLimit, 24, 240, 140);
const maxDocsPerSession = clampInt(input.maxDocsPerSession, 1, 8, 3);
const maxStoredDocs = clampInt(input.maxStoredDocs, 40, 800, 320);
const candidates = [...input.snapshot.sessions]
.sort((a, b) => toMs(b.lastMessageAt) - toMs(a.lastMessageAt) || a.sessionKey.localeCompare(b.sessionKey))
.slice(0, maxSessions);
const details = await Promise.all(
candidates.map((session) =>
getSessionConversationDetail({
snapshot: input.snapshot,
client: input.client,
sessionKey: session.sessionKey,
historyLimit,
}),
),
);
const extracted: StructuredChatDocEntry[] = [];
for (const detail of details) {
if (!detail) continue;
extracted.push(...extractStructuredDocsFromDetail(detail, maxDocsPerSession, now));
}
const mergedById = new Map(existing.items.map((item) => [item.id, item]));
for (const item of extracted) {
mergedById.set(item.id, item);
}
const merged = [...mergedById.values()]
.sort((a, b) => toMs(b.sourceTimestamp) - toMs(a.sourceTimestamp) || toMs(b.updatedAt) - toMs(a.updatedAt))
.slice(0, maxStoredDocs);
const stored: StructuredDocHubStore = {
generatedAt: now,
items: merged,
};
await writeStructuredDocStore(indexPath, stored);
const snapshot: StructuredDocHubSnapshot = {
generatedAt: now,
sourcePath: indexPath,
detail: `本轮扫描 ${candidates.length} 个会话,新增/刷新 ${extracted.length} 条,当前总计 ${merged.length} 条。`,
items: merged,
};
cachedSnapshot = snapshot;
cacheAtMs = nowMs;
cachePath = indexPath;
return snapshot;
}
function extractStructuredDocsFromDetail(
detail: SessionConversationDetailResult,
maxDocs: number,
updatedAt: string,
): StructuredChatDocEntry[] {
const rows: StructuredChatDocEntry[] = [];
const history = [...detail.history].reverse();
for (const entry of history) {
if (rows.length >= maxDocs) break;
if (!isDocumentLikeMessage(entry)) continue;
const content = normalizeInlineText(entry.content);
if (!content) continue;
const title = inferDocTitle(content, detail.session.label ?? detail.session.sessionKey);
const category = classifyDocCategory(title, content);
const excerpt = toExcerpt(content, MAX_DOC_EXCERPT_CHARS);
const sourceTimestamp =
normalizeIso(entry.timestamp) ??
normalizeIso(detail.latestHistoryAt) ??
normalizeIso(detail.session.lastMessageAt) ??
updatedAt;
const id = createDocId(detail.session.sessionKey, sourceTimestamp, title, excerpt);
rows.push({
id,
title,
excerpt,
content: safeTruncate(content, MAX_DOC_CONTENT_CHARS),
category,
sourceSessionKey: detail.session.sessionKey,
sourceAgentId: detail.session.agentId ?? undefined,
sourceTimestamp,
updatedAt,
});
}
return rows;
}
function isDocumentLikeMessage(item: SessionHistoryMessage): boolean {
if (item.kind !== "message") return false;
const role = item.role.toLowerCase();
if (!["assistant", "system", "model", "unknown"].includes(role)) return false;
const content = item.content.trim();
if (content.length < 80) return false;
if (/^#{1,4}\s+/m.test(content)) return true;
if (/```[\s\S]{12,}```/m.test(content)) return true;
if (/\n\d+\.\s+/.test(content) && /\n[-*]\s+/.test(content)) return true;
if (/\n[-*]\s+/.test(content) && content.length >= 320) return true;
if (/\b(prd|runbook|architecture|roadmap|spec|design doc|postmortem|brief)\b/i.test(content)) return true;
if (/(计划|方案|总结|日报|周报|复盘|提案|执行步骤|行动项|架构|需求)/.test(content)) return true;
return false;
}
function inferDocTitle(content: string, fallback: string): string {
const heading =
content
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => /^#{1,4}\s+/.test(line))
?.replace(/^#{1,4}\s+/, "")
.trim() ?? "";
if (heading) return safeTruncate(heading, MAX_DOC_TITLE_CHARS);
const firstLine = content
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 8) ?? fallback;
return safeTruncate(firstLine.replace(/^[-*]\s+/, ""), MAX_DOC_TITLE_CHARS);
}
function classifyDocCategory(title: string, content: string): string {
const raw = `${title}\n${content}`.toLowerCase();
if (/(日报|周报|总结|复盘|weekly|daily|retrospective)/.test(raw)) return "总结复盘";
if (/(计划|路线图|roadmap|milestone|next step)/.test(raw)) return "计划路线";
if (/(spec|prd|需求|设计|架构|architecture|api)/.test(raw)) return "规格设计";
if (/(runbook|sop|流程|操作手册|故障)/.test(raw)) return "操作手册";
if (/(newsletter|草稿|draft|公告|文案)/.test(raw)) return "内容草稿";
return "会话文档";
}
function createDocId(sessionKey: string, timestamp: string, title: string, excerpt: string): string {
const hash = createHash("sha1")
.update(`${sessionKey}|${timestamp}|${title}|${excerpt}`)
.digest("hex");
return `chatdoc-${hash.slice(0, 24)}`;
}
async function readStructuredDocStore(indexPath: string): Promise<StructuredDocHubStore> {
try {
const raw = await readFile(indexPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const generatedAt =
typeof parsed.generatedAt === "string" && parsed.generatedAt.trim()
? parsed.generatedAt
: new Date().toISOString();
const rawItems = Array.isArray(parsed.items) ? parsed.items : [];
const items: StructuredChatDocEntry[] = [];
for (const row of rawItems) {
if (!row || typeof row !== "object") continue;
const item = row as Record<string, unknown>;
const sourceSessionKey = asString(item.sourceSessionKey);
const title = asString(item.title);
const excerpt = asString(item.excerpt);
const category = asString(item.category);
const sourceTimestamp = normalizeIso(asString(item.sourceTimestamp)) ?? new Date().toISOString();
const updatedAt = normalizeIso(asString(item.updatedAt)) ?? sourceTimestamp;
if (!sourceSessionKey || !title || !excerpt || !category) continue;
const id = asString(item.id) ?? createDocId(sourceSessionKey, sourceTimestamp, title, excerpt);
items.push({
id,
title: safeTruncate(title, MAX_DOC_TITLE_CHARS),
excerpt: safeTruncate(excerpt, MAX_DOC_EXCERPT_CHARS),
content: safeTruncate(asString(item.content) ?? excerpt, MAX_DOC_CONTENT_CHARS),
category: safeTruncate(category, 24),
sourceSessionKey,
sourceAgentId: asString(item.sourceAgentId),
sourceTimestamp,
updatedAt,
});
}
return { generatedAt, items };
} catch {
return {
generatedAt: new Date().toISOString(),
items: [],
};
}
}
async function writeStructuredDocStore(indexPath: string, store: StructuredDocHubStore): Promise<void> {
await mkdir(dirname(indexPath), { recursive: true });
await writeFile(indexPath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
}
function toExcerpt(input: string, maxLength: number): string {
const normalized = normalizeInlineText(input);
return safeTruncate(normalized, maxLength);
}
function normalizeInlineText(input: string): string {
return input.replace(/\r/g, "\n").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
}
function safeTruncate(input: string, maxLength: number): string {
if (input.length <= maxLength) return input;
if (maxLength <= 3) return input.slice(0, Math.max(0, maxLength));
return `${input.slice(0, maxLength - 3)}...`;
}
function normalizeIso(input: string | undefined): string | undefined {
if (!input) return undefined;
const ms = Date.parse(input);
if (!Number.isFinite(ms)) return undefined;
return new Date(ms).toISOString();
}
function clampInt(value: number | undefined, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
const normalized = Math.trunc(value as number);
if (normalized < min) return min;
if (normalized > max) return max;
return normalized;
}
function toMs(value: string | undefined): number {
if (!value) return 0;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
+382
View File
@@ -0,0 +1,382 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import {
APPROVAL_ACTIONS_DRY_RUN,
APPROVAL_ACTIONS_ENABLED,
IMPORT_MUTATION_DRY_RUN,
IMPORT_MUTATION_ENABLED,
LOCAL_API_TOKEN,
LOCAL_TOKEN_AUTH_REQUIRED,
READONLY_MODE,
} from "../config";
import type {
ChecklistStatus,
DoneChecklistItem,
DoneChecklistSnapshot,
ReadModelSnapshot,
ReadinessCategory,
ReadinessCategoryScore,
ReadinessScoreSnapshot,
} from "../types";
import { buildApiDocs } from "./api-docs";
import { EXPORTS_DIR } from "./export-bundle";
const RUNTIME_DIR = join(process.cwd(), "runtime");
const SNAPSHOT_PATH = join(RUNTIME_DIR, "last-snapshot.json");
const TIMELINE_LOG_PATH = join(RUNTIME_DIR, "timeline.log");
const DIGEST_DIR = join(RUNTIME_DIR, "digests");
const BUDGETS_PATH = join(RUNTIME_DIR, "budgets.json");
export async function buildDoneChecklist(snapshot: ReadModelSnapshot): Promise<DoneChecklistSnapshot> {
const now = Date.now();
const docs = buildApiDocs();
const documentedRoutes = new Set(docs.routes.map((route) => route.path));
const actionQueueCapabilityReady = hasRouteCoverage(documentedRoutes, [
"/api/action-queue",
"/api/action-queue/:itemId/ack",
"/api/commander/exceptions",
]);
const [
snapshotFileExists,
budgetPolicyFileExists,
timelineLatestAt,
digestCount,
exportCount,
] = await Promise.all([
fileExists(SNAPSHOT_PATH),
fileExists(BUDGETS_PATH),
readLatestTimelineTimestamp(),
countJsonOrMarkdown(DIGEST_DIR),
countJson(EXPORTS_DIR),
]);
const snapshotAgeMs = ageMs(snapshot.generatedAt, now);
const timelineAgeMs = ageMs(timelineLatestAt, now);
const items: DoneChecklistItem[] = [
checklistItem(
"obs_snapshot_fresh",
"observability",
"Snapshot freshness",
"docs/RUNBOOK.md (startup + health checks)",
statusByAge(snapshotAgeMs, 10 * 60 * 1000, 60 * 60 * 1000),
`snapshot generatedAt=${snapshot.generatedAt} age=${formatAge(snapshotAgeMs)}`,
),
checklistItem(
"obs_timeline_recent",
"observability",
"Timeline log recency",
"docs/RUNBOOK.md (runtime artifacts)",
statusByAge(timelineAgeMs, 10 * 60 * 1000, 60 * 60 * 1000),
timelineLatestAt
? `latest timeline event at ${timelineLatestAt} age=${formatAge(timelineAgeMs)}`
: "timeline log has no parseable events yet",
),
checklistItem(
"obs_digest_available",
"observability",
"Digest artifact availability",
"docs/RUNBOOK.md (commander digest)",
digestCount > 0 ? "pass" : "warn",
`digest artifacts detected=${digestCount}`,
),
checklistItem(
"obs_route_coverage",
"observability",
"Operational API route coverage",
"docs/ARCHITECTURE.md + docs/RUNBOOK.md",
hasRouteCoverage(documentedRoutes, [
"/api/docs",
"/api/replay/index",
"/api/export/state.json",
"/api/import/live",
"/api/action-queue/acks/prune-preview",
])
? "pass"
: "fail",
"required: /api/docs, /api/replay/index, /api/export/state.json, /api/import/live, /api/action-queue/acks/prune-preview",
),
checklistItem(
"obs_ui_bind_env_only",
"observability",
"UI bind EPERM classification",
"docs/PROGRESS.md + docs/RUNBOOK.md",
"pass",
"listen EPERM during UI socket bind in restricted sandboxes is environment-only (not a control-center code regression); validate UI bind in unrestricted host runtime.",
),
checklistItem(
"gov_readonly_default",
"governance",
"Readonly guard default",
"docs/RUNBOOK.md (safety defaults)",
READONLY_MODE ? "pass" : "fail",
`READONLY_MODE=${String(READONLY_MODE)}`,
),
checklistItem(
"gov_approvals_disabled",
"governance",
"Approval execution disabled by default",
"docs/RUNBOOK.md (approval gate)",
APPROVAL_ACTIONS_ENABLED ? "fail" : "pass",
`APPROVAL_ACTIONS_ENABLED=${String(APPROVAL_ACTIONS_ENABLED)}`,
),
checklistItem(
"gov_approvals_dry_run",
"governance",
"Approval dry-run default",
"docs/RUNBOOK.md (approval gate)",
APPROVAL_ACTIONS_DRY_RUN ? "pass" : "warn",
`APPROVAL_ACTIONS_DRY_RUN=${String(APPROVAL_ACTIONS_DRY_RUN)}`,
),
checklistItem(
"gov_import_mutation_disabled",
"governance",
"Import mutation endpoint disabled by default",
"docs/RUNBOOK.md (import mutation gate)",
IMPORT_MUTATION_ENABLED ? "warn" : "pass",
`IMPORT_MUTATION_ENABLED=${String(IMPORT_MUTATION_ENABLED)}`,
),
checklistItem(
"gov_import_mutation_dry_run_default",
"governance",
"Import mutation env dry-run default",
"docs/RUNBOOK.md (import mutation gate)",
IMPORT_MUTATION_DRY_RUN ? "warn" : "pass",
`IMPORT_MUTATION_DRY_RUN=${String(IMPORT_MUTATION_DRY_RUN)}`,
),
checklistItem(
"gov_budget_policy",
"governance",
"Budget policy file state",
"docs/RUNBOOK.md (runtime artifacts)",
budgetPolicyFileExists ? "pass" : "warn",
`runtime/budgets.json ${budgetPolicyFileExists ? "present" : "missing (defaults fallback active)"}`,
),
checklistItem(
"collab_projects_loaded",
"collaboration",
"Project store loaded",
"docs/ARCHITECTURE.md (read model)",
snapshotFileExists ? "pass" : "warn",
`projects=${snapshot.projects.projects.length} updatedAt=${snapshot.projects.updatedAt}`,
),
checklistItem(
"collab_tasks_loaded",
"collaboration",
"Task store loaded",
"docs/ARCHITECTURE.md (read model)",
snapshot.tasks.tasks.length > 0 ? "pass" : "warn",
`tasks=${snapshot.tasks.tasks.length} updatedAt=${snapshot.tasks.updatedAt}`,
),
checklistItem(
"collab_action_queue_signal",
"collaboration",
"Commander/action queue signal",
"docs/RUNBOOK.md (exceptions + action queue)",
actionQueueCapabilityReady ? "pass" : "warn",
`routesReady=${String(actionQueueCapabilityReady)} sessions=${snapshot.sessions.length} approvals=${snapshot.approvals.length}`,
),
checklistItem(
"collab_exports_ready",
"collaboration",
"Export bundle history",
"docs/RUNBOOK.md (export APIs)",
exportCount > 0 ? "pass" : "warn",
`runtime/exports bundles=${exportCount}`,
),
checklistItem(
"sec_local_exports_only",
"security",
"Local export directory isolation",
"docs/ARCHITECTURE.md (write boundaries)",
EXPORTS_DIR.startsWith(join(process.cwd(), "runtime")) ? "pass" : "fail",
`exportsDir=${EXPORTS_DIR}`,
),
checklistItem(
"sec_import_dry_run",
"security",
"Import validator dry-run capability",
"docs/RUNBOOK.md (Phase 9 import dry-run)",
hasRouteCoverage(documentedRoutes, ["/api/import/dry-run"]) ? "pass" : "warn",
"required route: /api/import/dry-run",
),
checklistItem(
"sec_request_trace",
"security",
"Request correlation support",
"docs/ARCHITECTURE.md (telemetry)",
hasRouteCoverage(documentedRoutes, ["/api/docs"]) ? "pass" : "warn",
"x-request-id + JSON requestId correlation expected",
),
checklistItem(
"sec_runtime_defaults",
"security",
"Safe runtime defaults active",
"docs/RUNBOOK.md (safety defaults)",
READONLY_MODE &&
!APPROVAL_ACTIONS_ENABLED &&
!IMPORT_MUTATION_ENABLED &&
LOCAL_TOKEN_AUTH_REQUIRED
? "pass"
: "fail",
`READONLY_MODE=${String(READONLY_MODE)} APPROVAL_ACTIONS_ENABLED=${String(
APPROVAL_ACTIONS_ENABLED,
)} IMPORT_MUTATION_ENABLED=${String(IMPORT_MUTATION_ENABLED)} LOCAL_TOKEN_AUTH_REQUIRED=${String(
LOCAL_TOKEN_AUTH_REQUIRED,
)}`,
),
checklistItem(
"sec_local_token_gate",
"security",
"Local mutation/import token gate posture",
"docs/RUNBOOK.md (local token auth gate)",
LOCAL_TOKEN_AUTH_REQUIRED ? "pass" : "warn",
LOCAL_TOKEN_AUTH_REQUIRED
? LOCAL_API_TOKEN !== ""
? "LOCAL_API_TOKEN configured for protected operations"
: "LOCAL_API_TOKEN missing: protected operations remain blocked by default until explicitly enabled"
: "LOCAL_TOKEN_AUTH_REQUIRED=false (auth gate disabled)",
),
];
const readiness = computeReadiness(items);
const counts = {
pass: items.filter((item) => item.status === "pass").length,
warn: items.filter((item) => item.status === "warn").length,
fail: items.filter((item) => item.status === "fail").length,
};
return {
generatedAt: new Date().toISOString(),
basedOn: ["docs/RUNBOOK.md", "docs/ARCHITECTURE.md", "runtime capabilities"],
items,
counts,
readiness,
};
}
function checklistItem(
id: string,
category: ReadinessCategory,
title: string,
docRef: string,
status: ChecklistStatus,
detail: string,
): DoneChecklistItem {
return { id, category, title, docRef, status, detail };
}
function computeReadiness(items: DoneChecklistItem[]): ReadinessScoreSnapshot {
const categories: ReadinessCategory[] = ["observability", "governance", "collaboration", "security"];
const byCategory: ReadinessCategoryScore[] = categories.map((category) => {
const selected = items.filter((item) => item.category === category);
const passed = selected.filter((item) => item.status === "pass").length;
const warn = selected.filter((item) => item.status === "warn").length;
const failed = selected.filter((item) => item.status === "fail").length;
const score = selected.length === 0 ? 0 : Math.round(((passed + warn * 0.5) / selected.length) * 100);
return {
category,
score,
passed,
warn,
failed,
total: selected.length,
};
});
const totalItems = items.length;
const totalPoints = items.reduce((acc, item) => {
if (item.status === "pass") return acc + 1;
if (item.status === "warn") return acc + 0.5;
return acc;
}, 0);
const overall = totalItems === 0 ? 0 : Math.round((totalPoints / totalItems) * 100);
return {
overall,
categories: byCategory,
};
}
function hasRouteCoverage(routes: Set<string>, required: string[]): boolean {
return required.every((route) => routes.has(route));
}
function ageMs(input: string | undefined, nowMs: number): number | undefined {
if (!input) return undefined;
const ms = Date.parse(input);
if (Number.isNaN(ms)) return undefined;
return Math.max(0, nowMs - ms);
}
function statusByAge(
age: number | undefined,
passThresholdMs: number,
warnThresholdMs: number,
): ChecklistStatus {
if (!Number.isFinite(age)) return "warn";
if ((age as number) <= passThresholdMs) return "pass";
if ((age as number) <= warnThresholdMs) return "warn";
return "fail";
}
function formatAge(age: number | undefined): string {
if (!Number.isFinite(age)) return "n/a";
const seconds = Math.round((age as number) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
async function countJson(path: string): Promise<number> {
try {
const files = await readdir(path);
return files.filter((name) => name.endsWith(".json")).length;
} catch {
return 0;
}
}
async function countJsonOrMarkdown(path: string): Promise<number> {
try {
const files = await readdir(path);
return files.filter((name) => name.endsWith(".json") || name.endsWith(".md")).length;
} catch {
return 0;
}
}
async function readLatestTimelineTimestamp(): Promise<string | undefined> {
try {
const raw = await readFile(TIMELINE_LOG_PATH, "utf8");
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "");
if (lines.length === 0) return undefined;
for (let idx = lines.length - 1; idx >= 0; idx -= 1) {
const line = lines[idx];
const firstToken = line.split("|")[0]?.trim();
if (!firstToken) continue;
const parsed = Date.parse(firstToken);
if (Number.isNaN(parsed)) continue;
return new Date(parsed).toISOString();
}
return undefined;
} catch {
return undefined;
}
}
async function fileExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
+72
View File
@@ -0,0 +1,72 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { ExportBundle, ReadModelSnapshot } from "../types";
import { loadBudgetPolicy } from "./budget-policy";
import { commanderExceptions, commanderExceptionsFeed } from "./commander";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const EXPORTS_DIR = join(RUNTIME_DIR, "exports");
export interface ExportBundleWriteResult {
fileName: string;
path: string;
sizeBytes: number;
}
export async function buildExportBundle(
snapshot: ReadModelSnapshot,
source: "api" | "command",
requestId?: string,
): Promise<ExportBundle> {
const budgetPolicy = await loadBudgetPolicy();
return {
ok: true,
schemaVersion: "phase-9",
source,
requestId,
exportedAt: new Date().toISOString(),
snapshotGeneratedAt: snapshot.generatedAt,
sessions: snapshot.sessions,
projects: snapshot.projects,
tasks: snapshot.tasks,
budgets: {
policy: budgetPolicy.policy,
issues: budgetPolicy.issues,
summary: snapshot.budgetSummary,
},
exceptions: commanderExceptions(snapshot),
exceptionsFeed: commanderExceptionsFeed(snapshot),
};
}
export async function writeExportBundle(
bundle: ExportBundle,
label: string,
): Promise<ExportBundleWriteResult> {
await mkdir(EXPORTS_DIR, { recursive: true });
const stamp = compactIsoStamp(bundle.exportedAt);
const safeLabel = sanitizeSegment(label, "export");
const safeRequest = sanitizeSegment(bundle.requestId, "req");
const fileName = `${stamp}-${safeLabel}-${safeRequest}.json`;
const path = join(EXPORTS_DIR, fileName);
const body = `${JSON.stringify(bundle, null, 2)}\n`;
await writeFile(path, body, "utf8");
return {
fileName,
path,
sizeBytes: Buffer.byteLength(body, "utf8"),
};
}
function sanitizeSegment(input: string | undefined, fallback: string): string {
if (!input) return fallback;
const sanitized = input.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
return sanitized || fallback;
}
function compactIsoStamp(iso: string): string {
const parsed = Date.parse(iso);
const value = Number.isNaN(parsed) ? new Date() : new Date(parsed);
return value.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-");
}
+142
View File
@@ -0,0 +1,142 @@
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import {
APPROVAL_ACTIONS_DRY_RUN,
APPROVAL_ACTIONS_ENABLED,
POLLING_INTERVALS_MS,
READONLY_MODE,
} from "../config";
import type { ReadModelSnapshot } from "../types";
import { readMonitorLagSummary, type MonitorLagSummary } from "./monitor-health";
const PACKAGE_JSON_PATH = join(process.cwd(), "package.json");
const DIST_INDEX_PATH = join(process.cwd(), "dist", "index.js");
type HealthStatus = "ok" | "warn" | "stale";
interface BuildInfo {
name: string;
version: string;
node: string;
readonlyMode: boolean;
approvalActionsEnabled: boolean;
approvalActionsDryRun: boolean;
distIndexPath: string;
distBuiltAt?: string;
}
interface SnapshotFreshness {
generatedAt: string;
ageMs: number;
status: HealthStatus;
thresholdsMs: {
ok: number;
warn: number;
};
}
export interface HealthzPayload {
generatedAt: string;
status: HealthStatus;
build: BuildInfo;
snapshot: SnapshotFreshness;
monitor: MonitorLagSummary;
}
export async function buildHealthzPayload(
snapshot: ReadModelSnapshot,
now: Date = new Date(),
): Promise<HealthzPayload> {
const monitor = await readMonitorLagSummary(POLLING_INTERVALS_MS.sessionsList, now);
const [build, snapshotFreshness] = await Promise.all([
readBuildInfo(),
computeSnapshotFreshness(snapshot, POLLING_INTERVALS_MS.sessionsList, now),
]);
const status = resolveOverallStatus(snapshotFreshness.status, monitor.status);
return {
generatedAt: now.toISOString(),
status,
build,
snapshot: snapshotFreshness,
monitor,
};
}
async function readBuildInfo(): Promise<BuildInfo> {
let name = "unknown";
let version = "0.0.0";
try {
const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as Record<string, unknown>;
if (typeof pkg.name === "string" && pkg.name.trim()) name = pkg.name;
if (typeof pkg.version === "string" && pkg.version.trim()) version = pkg.version;
} catch {
// keep defaults
}
let distBuiltAt: string | undefined;
try {
const stats = await stat(DIST_INDEX_PATH);
distBuiltAt = stats.mtime.toISOString();
} catch {
distBuiltAt = undefined;
}
return {
name,
version,
node: process.version,
readonlyMode: READONLY_MODE,
approvalActionsEnabled: APPROVAL_ACTIONS_ENABLED,
approvalActionsDryRun: APPROVAL_ACTIONS_DRY_RUN,
distIndexPath: DIST_INDEX_PATH,
distBuiltAt,
};
}
function computeSnapshotFreshness(
snapshot: ReadModelSnapshot,
monitorIntervalMs: number,
now: Date,
): SnapshotFreshness {
const snapshotTime = Date.parse(snapshot.generatedAt);
const ageMs = Number.isNaN(snapshotTime) ? Number.MAX_SAFE_INTEGER : Math.max(0, now.getTime() - snapshotTime);
const okThreshold = Math.max(60_000, monitorIntervalMs * 12);
const warnThreshold = Math.max(5 * 60_000, monitorIntervalMs * 48);
let status: HealthStatus = "ok";
if (ageMs > warnThreshold) {
status = "stale";
} else if (ageMs > okThreshold) {
status = "warn";
}
return {
generatedAt: snapshot.generatedAt,
ageMs,
status,
thresholdsMs: {
ok: okThreshold,
warn: warnThreshold,
},
};
}
function resolveOverallStatus(
snapshotStatus: HealthStatus,
monitorStatus: MonitorLagSummary["status"],
): HealthStatus {
if (snapshotStatus === "stale" || monitorStatus === "stale") {
return "stale";
}
if (snapshotStatus === "warn" || monitorStatus === "warn" || monitorStatus === "missing") {
return "warn";
}
return "ok";
}
+261
View File
@@ -0,0 +1,261 @@
import { readFile } from "node:fs/promises";
import { isAbsolute, join, normalize, resolve } from "node:path";
import type { ImportDryRunResult } from "../types";
import { EXPORTS_DIR } from "./export-bundle";
export async function validateExportFileDryRun(inputPath: string): Promise<ImportDryRunResult> {
const path = resolveExportPath(inputPath);
const source = `file:${path}`;
try {
const raw = await readFile(path, "utf8");
const parsed = JSON.parse(raw) as unknown;
return validateExportBundleDryRun(parsed, source);
} catch (error) {
return {
validatedAt: new Date().toISOString(),
source,
valid: false,
issues: [error instanceof Error ? error.message : "Failed to read export bundle file."],
warnings: [],
summary: {
sessions: 0,
projects: 0,
tasks: 0,
exceptions: 0,
},
};
}
}
export function validateExportBundleDryRun(
input: unknown,
source = "payload",
): ImportDryRunResult {
const issues: string[] = [];
const warnings: string[] = [];
const validatedAt = new Date().toISOString();
const root = asObject(input);
if (!root) {
return {
validatedAt,
source,
valid: false,
issues: ["bundle must be a JSON object."],
warnings: [],
summary: {
sessions: 0,
projects: 0,
tasks: 0,
exceptions: 0,
},
};
}
const schemaVersion = asString(root.schemaVersion);
if (!schemaVersion) {
issues.push("schemaVersion is required.");
} else if (schemaVersion !== "phase-9") {
warnings.push(`schemaVersion '${schemaVersion}' differs from expected 'phase-9'.`);
}
requireIsoString(root.exportedAt, "exportedAt", issues);
requireIsoString(root.snapshotGeneratedAt, "snapshotGeneratedAt", issues);
const sessions = asArray(root.sessions);
if (!sessions) {
issues.push("sessions must be an array.");
} else {
validateSessions(sessions, issues);
}
const projectsRoot = asObject(root.projects);
if (!projectsRoot) {
issues.push("projects must be an object.");
} else {
requireIsoString(projectsRoot.updatedAt, "projects.updatedAt", issues);
const projects = asArray(projectsRoot.projects);
if (!projects) {
issues.push("projects.projects must be an array.");
} else {
validateProjects(projects, issues);
}
}
const tasksRoot = asObject(root.tasks);
if (!tasksRoot) {
issues.push("tasks must be an object.");
} else {
requireIsoString(tasksRoot.updatedAt, "tasks.updatedAt", issues);
const tasks = asArray(tasksRoot.tasks);
if (!tasks) {
issues.push("tasks.tasks must be an array.");
} else {
validateTasks(tasks, issues);
}
}
const budgets = asObject(root.budgets);
if (!budgets) {
issues.push("budgets must be an object.");
} else {
if (!asObject(budgets.policy)) issues.push("budgets.policy must be an object.");
if (!asObject(budgets.summary)) issues.push("budgets.summary must be an object.");
const budgetIssues = asArray(budgets.issues);
if (budgetIssues && budgetIssues.length > 0) {
warnings.push(`budgets.issues contains ${budgetIssues.length} warning(s).`);
}
}
const exceptions = asObject(root.exceptions);
if (!exceptions) {
issues.push("exceptions must be an object.");
} else if (!asObject(exceptions.counts)) {
issues.push("exceptions.counts must be an object.");
}
const exceptionsFeed = asObject(root.exceptionsFeed);
if (!exceptionsFeed) {
issues.push("exceptionsFeed must be an object.");
} else {
const items = asArray(exceptionsFeed.items);
if (!items) {
issues.push("exceptionsFeed.items must be an array.");
}
if (!asObject(exceptionsFeed.counts)) {
issues.push("exceptionsFeed.counts must be an object.");
}
}
if (asBoolean(root.ok) !== true) {
warnings.push("ok is not true; bundle may be incomplete.");
}
const projectCount = asArray(projectsRoot?.projects)?.length ?? 0;
const taskCount = asArray(tasksRoot?.tasks)?.length ?? 0;
const sessionCount = sessions?.length ?? 0;
const exceptionCount = asArray(exceptionsFeed?.items)?.length ?? 0;
return {
validatedAt,
source,
valid: issues.length === 0,
issues,
warnings,
summary: {
sessions: sessionCount,
projects: projectCount,
tasks: taskCount,
exceptions: exceptionCount,
},
};
}
function validateSessions(items: unknown[], issues: string[]): void {
const seen = new Set<string>();
items.forEach((item, index) => {
const obj = asObject(item);
if (!obj) {
issues.push(`sessions[${index}] must be an object.`);
return;
}
const key = asString(obj.sessionKey)?.trim();
const state = asString(obj.state)?.trim();
if (!key) issues.push(`sessions[${index}].sessionKey is required.`);
if (!state) issues.push(`sessions[${index}].state is required.`);
if (key && seen.has(key)) issues.push(`sessions contains duplicate sessionKey '${key}'.`);
if (key) seen.add(key);
});
}
function validateProjects(items: unknown[], issues: string[]): void {
const seen = new Set<string>();
items.forEach((item, index) => {
const obj = asObject(item);
if (!obj) {
issues.push(`projects.projects[${index}] must be an object.`);
return;
}
const id = asString(obj.projectId)?.trim();
if (!id) issues.push(`projects.projects[${index}].projectId is required.`);
if (id && seen.has(id)) issues.push(`projects.projects has duplicate projectId '${id}'.`);
if (id) seen.add(id);
});
}
function validateTasks(items: unknown[], issues: string[]): void {
const seen = new Set<string>();
items.forEach((item, index) => {
const obj = asObject(item);
if (!obj) {
issues.push(`tasks.tasks[${index}] must be an object.`);
return;
}
const taskId = asString(obj.taskId)?.trim();
const projectId = asString(obj.projectId)?.trim();
if (!taskId) issues.push(`tasks.tasks[${index}].taskId is required.`);
if (!projectId) issues.push(`tasks.tasks[${index}].projectId is required.`);
const composite = taskId && projectId ? `${projectId}:${taskId}` : "";
if (composite && seen.has(composite)) issues.push(`tasks.tasks has duplicate key '${composite}'.`);
if (composite) seen.add(composite);
});
}
function requireIsoString(input: unknown, label: string, issues: string[]): void {
const value = asString(input);
if (!value) {
issues.push(`${label} is required.`);
return;
}
if (Number.isNaN(Date.parse(value))) {
issues.push(`${label} must be an ISO date-time string.`);
}
}
export function resolveExportPath(inputPath: string): string {
const trimmed = inputPath.trim();
if (!trimmed) {
throw new Error("Export path is required.");
}
const normalized = normalize(trimmed).replaceAll("\\", "/");
const looksLikeWorkspaceRuntimePath =
normalized === "runtime/exports" ||
normalized.startsWith("runtime/exports/") ||
normalized.startsWith("./runtime/exports/");
const candidate = isAbsolute(trimmed)
? resolve(trimmed)
: looksLikeWorkspaceRuntimePath
? resolve(trimmed)
: resolve(join(EXPORTS_DIR, normalized));
const allowedRoot = resolve(EXPORTS_DIR);
if (!candidate.startsWith(allowedRoot)) {
throw new Error(`Path is outside runtime exports directory: ${candidate}`);
}
if (!candidate.endsWith(".json")) {
throw new Error("Export file must be a .json file.");
}
return candidate;
}
function asString(input: unknown): string | undefined {
return typeof input === "string" ? input : undefined;
}
function asBoolean(input: unknown): boolean | undefined {
return typeof input === "boolean" ? input : undefined;
}
function asObject(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
function asArray(input: unknown): unknown[] | undefined {
return Array.isArray(input) ? input : undefined;
}
+364
View File
@@ -0,0 +1,364 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import {
IMPORT_MUTATION_DRY_RUN,
IMPORT_MUTATION_ENABLED,
LOCAL_API_TOKEN,
LOCAL_TOKEN_AUTH_REQUIRED,
READONLY_MODE,
} from "../config";
import type { ImportDryRunResult, ProjectStoreSnapshot, TaskStoreSnapshot } from "../types";
import { BUDGET_POLICY_PATH } from "./budget-policy";
import { resolveExportPath, validateExportBundleDryRun } from "./import-dry-run";
import { saveProjectStore } from "./project-store";
import { saveTaskStore } from "./task-store";
export type ImportMutationMode = "blocked" | "dry_run" | "live";
export interface ImportMutationGuardInput {
mutationEnabled: boolean;
mutationDryRunDefault: boolean;
readonlyMode: boolean;
routeLabel: string;
requestedDryRun?: boolean;
}
export interface ImportMutationGuardDecision {
ok: boolean;
statusCode: number;
mode: ImportMutationMode;
dryRun: boolean;
message: string;
}
export interface ImportMutationGuardState {
readonlyMode: boolean;
localTokenAuthRequired: boolean;
localTokenConfigured: boolean;
mutationEnabled: boolean;
mutationDryRunDefault: boolean;
defaultMode: ImportMutationMode;
defaultMessage: string;
}
export interface ImportMutationRequest {
fileName?: string;
bundle?: unknown;
dryRun?: boolean;
}
export interface ImportMutationApplyResult {
ok: boolean;
statusCode: number;
mode: ImportMutationMode;
message: string;
validation?: ImportDryRunResult;
guard: ImportMutationGuardState;
source?: string;
applied?: {
projectsPath: string;
tasksPath: string;
budgetsPath: string;
projects: number;
tasks: number;
sessions: number;
exceptions: number;
};
}
export async function applyImportMutation(request: ImportMutationRequest): Promise<ImportMutationApplyResult> {
const gate = evaluateImportMutationGuard({
mutationEnabled: IMPORT_MUTATION_ENABLED,
mutationDryRunDefault: IMPORT_MUTATION_DRY_RUN,
readonlyMode: READONLY_MODE,
routeLabel: "/api/import/live",
requestedDryRun: request.dryRun,
});
const guard = readImportMutationGuardState();
if (!gate.ok) {
return {
ok: false,
statusCode: gate.statusCode,
mode: gate.mode,
message: gate.message,
guard,
};
}
const loaded = await resolveImportInput(request);
if (!loaded.ok) {
return {
ok: false,
statusCode: 400,
mode: gate.mode,
message: loaded.message,
validation: loaded.validation,
guard,
source: loaded.source,
};
}
if (!loaded.validation.valid) {
return {
ok: false,
statusCode: 400,
mode: gate.mode,
message: "Import payload validation failed.",
validation: loaded.validation,
guard,
source: loaded.source,
};
}
if (gate.mode === "dry_run") {
return {
ok: true,
statusCode: 200,
mode: gate.mode,
message: "Import dry-run passed; no files were mutated.",
validation: loaded.validation,
guard,
source: loaded.source,
};
}
const root = asObject(loaded.bundle);
if (!root) {
return {
ok: false,
statusCode: 400,
mode: gate.mode,
message: "Import payload must be a JSON object.",
validation: loaded.validation,
guard,
source: loaded.source,
};
}
const projectsRoot = asObject(root.projects);
const tasksRoot = asObject(root.tasks);
const budgetsRoot = asObject(root.budgets);
const policyRoot = asObject(budgetsRoot?.policy);
if (!projectsRoot || !tasksRoot || !policyRoot) {
return {
ok: false,
statusCode: 400,
mode: gate.mode,
message: "Import payload is missing required projects/tasks/budgets.policy objects.",
validation: loaded.validation,
guard,
source: loaded.source,
};
}
const [projectsPath, tasksPath, budgetsPath] = await Promise.all([
saveProjectStore(projectsRoot as unknown as ProjectStoreSnapshot),
saveTaskStore(tasksRoot as unknown as TaskStoreSnapshot),
writeBudgetPolicy(policyRoot),
]);
return {
ok: true,
statusCode: 200,
mode: gate.mode,
message: "Import applied to local runtime stores.",
validation: loaded.validation,
guard,
source: loaded.source,
applied: {
projectsPath,
tasksPath,
budgetsPath,
projects: loaded.validation.summary.projects,
tasks: loaded.validation.summary.tasks,
sessions: loaded.validation.summary.sessions,
exceptions: loaded.validation.summary.exceptions,
},
};
}
export function evaluateImportMutationGuard(input: ImportMutationGuardInput): ImportMutationGuardDecision {
const effectiveDryRun = input.requestedDryRun ?? input.mutationDryRunDefault;
if (!input.mutationEnabled) {
return {
ok: false,
statusCode: 403,
mode: "blocked",
dryRun: effectiveDryRun,
message: `${input.routeLabel} is disabled. Set IMPORT_MUTATION_ENABLED=true to allow live import mutation endpoint usage.`,
};
}
if (input.readonlyMode && !effectiveDryRun) {
return {
ok: false,
statusCode: 403,
mode: "blocked",
dryRun: effectiveDryRun,
message:
`${input.routeLabel} is blocked by readonly mode. Set READONLY_MODE=false or send {\"dryRun\":true} for non-mutating validation mode.`,
};
}
return {
ok: true,
statusCode: 200,
mode: effectiveDryRun ? "dry_run" : "live",
dryRun: effectiveDryRun,
message: effectiveDryRun
? "Import mutation request accepted in dry-run mode."
: "Import mutation request accepted in live mode.",
};
}
export function readImportMutationGuardState(): ImportMutationGuardState {
const decision = evaluateImportMutationGuard({
mutationEnabled: IMPORT_MUTATION_ENABLED,
mutationDryRunDefault: IMPORT_MUTATION_DRY_RUN,
readonlyMode: READONLY_MODE,
routeLabel: "/api/import/live",
});
return {
readonlyMode: READONLY_MODE,
localTokenAuthRequired: LOCAL_TOKEN_AUTH_REQUIRED,
localTokenConfigured: LOCAL_API_TOKEN !== "",
mutationEnabled: IMPORT_MUTATION_ENABLED,
mutationDryRunDefault: IMPORT_MUTATION_DRY_RUN,
defaultMode: decision.mode,
defaultMessage: decision.message,
};
}
async function resolveImportInput(
request: ImportMutationRequest,
): Promise<
| {
ok: true;
source: string;
bundle: unknown;
validation: ImportDryRunResult;
message: string;
}
| {
ok: false;
source: string;
bundle: undefined;
validation: ImportDryRunResult;
message: string;
}
> {
if (typeof request.fileName === "string" && request.fileName.trim() !== "") {
let sourcePath = "";
let source = `file:${request.fileName.trim()}`;
try {
sourcePath = resolveExportPath(request.fileName);
source = `file:${sourcePath}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to resolve import file.";
return {
ok: false,
source,
bundle: undefined,
validation: {
validatedAt: new Date().toISOString(),
source,
valid: false,
issues: [message],
warnings: [],
summary: {
sessions: 0,
projects: 0,
tasks: 0,
exceptions: 0,
},
},
message,
};
}
try {
const raw = await readFile(sourcePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const validation = validateExportBundleDryRun(parsed, source);
if (!validation.valid) {
return {
ok: false,
source,
bundle: undefined,
validation,
message: "Import payload validation failed.",
};
}
return {
ok: true,
source,
bundle: parsed,
validation,
message: "Import payload loaded from file.",
};
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to read import file.";
return {
ok: false,
source,
bundle: undefined,
validation: {
validatedAt: new Date().toISOString(),
source,
valid: false,
issues: [message],
warnings: [],
summary: {
sessions: 0,
projects: 0,
tasks: 0,
exceptions: 0,
},
},
message,
};
}
}
const source = request.bundle !== undefined ? "payload.bundle" : "payload";
const bundle = request.bundle !== undefined ? request.bundle : (request as unknown);
const validation = validateExportBundleDryRun(bundle, source);
if (!validation.valid) {
return {
ok: false,
source,
bundle: undefined,
validation,
message: "Import payload validation failed.",
};
}
return {
ok: true,
source,
bundle,
validation,
message: "Import payload loaded.",
};
}
export async function resolveImportInputForSmoke(
request: ImportMutationRequest,
): ReturnType<typeof resolveImportInput> {
return resolveImportInput(request);
}
async function writeBudgetPolicy(policy: Record<string, unknown>): Promise<string> {
await mkdir(dirname(BUDGET_POLICY_PATH), { recursive: true });
await writeFile(BUDGET_POLICY_PATH, `${JSON.stringify(policy, null, 2)}\n`, "utf8");
return BUDGET_POLICY_PATH;
}
function asObject(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
+69
View File
@@ -0,0 +1,69 @@
export interface LocalTokenGateInput {
gateRequired: boolean;
configuredToken: string;
providedToken?: string;
routeLabel: string;
}
export interface LocalTokenGateDecision {
ok: boolean;
statusCode: number;
message: string;
}
export function evaluateLocalTokenGate(input: LocalTokenGateInput): LocalTokenGateDecision {
if (!input.gateRequired) {
return {
ok: true,
statusCode: 200,
message: "Local token auth gate disabled.",
};
}
const expected = normalizeToken(input.configuredToken);
if (!expected) {
return {
ok: false,
statusCode: 403,
message: `Local token auth gate blocked ${input.routeLabel}. Set LOCAL_API_TOKEN to explicitly allow protected operations.`,
};
}
const provided = normalizeToken(input.providedToken);
if (!provided) {
return {
ok: false,
statusCode: 401,
message: `Missing local token for ${input.routeLabel}. Provide 'x-local-token' header or 'Authorization: Bearer <token>'.`,
};
}
if (provided !== expected) {
return {
ok: false,
statusCode: 403,
message: `Invalid local token for ${input.routeLabel}.`,
};
}
return {
ok: true,
statusCode: 200,
message: "Local token authorized.",
};
}
export function normalizeToken(value: string | null | undefined): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length > 256) return undefined;
if (/[\u0000-\u001F\u007F]/.test(trimmed)) return undefined;
return trimmed;
}
export function readAuthorizationBearer(value: string | undefined): string | undefined {
if (!value) return undefined;
const match = value.match(/^Bearer\s+(.+)$/i);
return match?.[1];
}
+71
View File
@@ -0,0 +1,71 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const TIMELINE_LOG_PATH = join(process.cwd(), "runtime", "timeline.log");
export type MonitorLagStatus = "ok" | "warn" | "stale" | "missing";
export interface MonitorLagSummary {
generatedAt: string;
lastTickAt?: string;
expectedIntervalMs: number;
lagMs?: number;
status: MonitorLagStatus;
sourcePath: string;
}
export async function readMonitorLagSummary(
expectedIntervalMs: number,
now: Date = new Date(),
): Promise<MonitorLagSummary> {
const safeExpected = Number.isFinite(expectedIntervalMs) && expectedIntervalMs > 0 ? expectedIntervalMs : 5000;
const lastTickAt = await readLastMonitorTickAt();
if (!lastTickAt) {
return {
generatedAt: now.toISOString(),
expectedIntervalMs: safeExpected,
status: "missing",
sourcePath: TIMELINE_LOG_PATH,
};
}
const lagMs = Math.max(0, now.getTime() - Date.parse(lastTickAt));
let status: MonitorLagStatus = "ok";
if (lagMs > safeExpected * 6) {
status = "stale";
} else if (lagMs > safeExpected * 2) {
status = "warn";
}
return {
generatedAt: now.toISOString(),
lastTickAt,
expectedIntervalMs: safeExpected,
lagMs,
status,
sourcePath: TIMELINE_LOG_PATH,
};
}
async function readLastMonitorTickAt(): Promise<string | undefined> {
try {
const raw = await readFile(TIMELINE_LOG_PATH, "utf8");
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (let idx = lines.length - 1; idx >= 0; idx -= 1) {
const match = lines[idx].match(/^(\S+)\s+\|\s+/);
if (!match) continue;
const ms = Date.parse(match[1]);
if (Number.isNaN(ms)) continue;
return new Date(ms).toISOString();
}
return undefined;
} catch {
return undefined;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { appendFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { OpenClawReadonlyAdapter } from "../adapters/openclaw-readonly";
import { POLLING_INTERVALS_MS } from "../config";
import { commanderAlerts } from "./commander";
import { writeCommanderDigest } from "./commander-digest";
import { formatDiffSummary } from "./diff-summary";
import { saveSnapshot } from "./snapshot-store";
import { runTaskHeartbeat } from "./task-heartbeat";
const RUNTIME_DIR = join(process.cwd(), "runtime");
const TIMELINE_LOG = join(RUNTIME_DIR, "timeline.log");
export async function runMonitorOnce(adapter: OpenClawReadonlyAdapter): Promise<void> {
const snapshot = await adapter.snapshot();
const stored = await saveSnapshot(snapshot);
const alerts = commanderAlerts(snapshot);
const digest = await writeCommanderDigest(snapshot, alerts);
const heartbeat = await runTaskHeartbeat();
const heartbeatSummary = `heartbeat=${heartbeat.mode}:${heartbeat.executed}/${heartbeat.selected}`;
await mkdir(RUNTIME_DIR, { recursive: true });
await appendFile(
TIMELINE_LOG,
`${new Date().toISOString()} | ${formatDiffSummary(stored.diff)} | alerts=${alerts.length} | ${heartbeatSummary}\n`,
"utf8",
);
console.log("[mission-control] monitor", {
diffSummary: formatDiffSummary(stored.diff),
alerts,
heartbeat,
timelineLog: TIMELINE_LOG,
digestJson: digest.jsonPath,
digestMarkdown: digest.markdownPath,
});
}
export function monitorIntervalMs(): number {
return POLLING_INTERVALS_MS.sessionsList;
}
+409
View File
@@ -0,0 +1,409 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type {
AcksStoreSnapshot,
ActionQueueLink,
CommanderExceptionsFeed,
ExceptionFeedItem,
NotificationCenterSnapshot,
NotificationAck,
} from "../types";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const ACKS_PATH = join(RUNTIME_DIR, "acks.json");
const EMPTY_ACKS: AcksStoreSnapshot = {
acks: [],
updatedAt: "1970-01-01T00:00:00.000Z",
};
export class NotificationCenterValidationError extends Error {
readonly statusCode: number;
readonly issues: string[];
constructor(message: string, issues: string[] = [], statusCode = 400) {
super(message);
this.name = "NotificationCenterValidationError";
this.statusCode = statusCode;
this.issues = issues;
}
}
export interface AcknowledgeQueueInput {
itemId: string;
note?: string;
ttlMinutes?: number;
snoozeUntil?: string;
}
export interface AcknowledgeQueueResult {
path: string;
ack: NotificationAck;
}
export interface PruneStaleAcksOptions {
dryRun?: boolean;
nowMs?: number;
}
export interface PruneStaleAcksResult {
path: string;
dryRun: boolean;
before: number;
removed: number;
after: number;
removedItemIds: string[];
updatedAt: string;
}
export interface PruneStaleAcksPreview {
path: string;
dryRun: true;
before: number;
removed: number;
after: number;
updatedAt: string;
}
export async function loadAcksStore(): Promise<AcksStoreSnapshot> {
try {
const raw = await readFile(ACKS_PATH, "utf8");
return normalizeAcksStore(JSON.parse(raw));
} catch {
return cloneEmptyAcks();
}
}
export async function saveAcksStore(next: AcksStoreSnapshot): Promise<string> {
const normalized = normalizeAcksStore({
...next,
updatedAt: new Date().toISOString(),
});
await mkdir(RUNTIME_DIR, { recursive: true });
await writeFile(ACKS_PATH, JSON.stringify(normalized, null, 2), "utf8");
return ACKS_PATH;
}
export function actionQueueItemId(item: ExceptionFeedItem): string {
return `${item.code}:${item.source}:${item.sourceId}`;
}
export function buildNotificationCenter(
feed: CommanderExceptionsFeed,
ackStore: AcksStoreSnapshot,
linksByItemId: ReadonlyMap<string, ActionQueueLink[]> = new Map(),
): NotificationCenterSnapshot {
const nowMs = Date.now();
const ackByItemId = new Map(ackStore.acks.map((ack) => [ack.itemId, ack]));
const queue = feed.items
.filter((item) => item.route === "action-queue" || item.level === "action-required")
.map((item) => {
const itemId = actionQueueItemId(item);
const ack = resolveActiveAck(ackByItemId.get(itemId), nowMs);
return {
...item,
itemId,
acknowledged: Boolean(ack),
ackedAt: ack?.ackedAt,
note: ack?.note,
ackExpiresAt: ack?.expiresAt,
links: linksByItemId.get(itemId) ?? [],
};
});
return {
generatedAt: new Date().toISOString(),
queue,
counts: {
total: queue.length,
acked: queue.filter((item) => item.acknowledged).length,
unacked: queue.filter((item) => !item.acknowledged).length,
},
};
}
export async function acknowledgeActionQueueItem(
input: unknown,
center: NotificationCenterSnapshot,
): Promise<AcknowledgeQueueResult> {
const payload = validateAcknowledgeInput(input);
const target = center.queue.find((item) => item.itemId === payload.itemId);
if (!target) {
throw new NotificationCenterValidationError(
`itemId '${payload.itemId}' was not found in the current action queue.`,
["itemId"],
404,
);
}
const ackStore = await loadAcksStore();
const now = new Date().toISOString();
const nowMs = Date.now();
const expiresAt = resolveAckExpiresAt(payload, nowMs);
const ack: NotificationAck = {
itemId: payload.itemId,
ackedAt: now,
note: payload.note,
expiresAt,
};
const pruned = pruneStaleAcksFromStore(ackStore, nowMs);
ackStore.acks = pruned.store.acks;
const existingIdx = ackStore.acks.findIndex((item) => item.itemId === payload.itemId);
if (existingIdx >= 0) {
ackStore.acks[existingIdx] = ack;
} else {
ackStore.acks.push(ack);
}
ackStore.updatedAt = now;
const path = await saveAcksStore(ackStore);
return { path, ack };
}
export async function pruneStaleAcks(options: PruneStaleAcksOptions = {}): Promise<PruneStaleAcksResult> {
const nowMs =
typeof options.nowMs === "number" && Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const dryRun = options.dryRun === true;
const ackStore = await loadAcksStore();
const pruned = pruneStaleAcksFromStore(ackStore, nowMs);
let path = ACKS_PATH;
let updatedAt = ackStore.updatedAt;
if (!dryRun && pruned.removed > 0) {
path = await saveAcksStore(pruned.store);
updatedAt = pruned.store.updatedAt;
}
return {
path,
dryRun,
before: pruned.before,
removed: pruned.removed,
after: pruned.after,
removedItemIds: pruned.removedItemIds,
updatedAt,
};
}
export async function previewStaleAcksPrune(options: { nowMs?: number } = {}): Promise<PruneStaleAcksPreview> {
const result = await pruneStaleAcks({
dryRun: true,
nowMs: options.nowMs,
});
return {
path: result.path,
dryRun: true,
before: result.before,
removed: result.removed,
after: result.after,
updatedAt: result.updatedAt,
};
}
export function pruneStaleAcksFromStore(
input: AcksStoreSnapshot,
nowMs = Date.now(),
): { store: AcksStoreSnapshot; before: number; removed: number; after: number; removedItemIds: string[] } {
const normalized = normalizeAcksStore(input);
const nextAcks: NotificationAck[] = [];
const removedItemIds: string[] = [];
for (const ack of normalized.acks) {
if (isAckExpired(ack, nowMs)) {
removedItemIds.push(ack.itemId);
} else {
nextAcks.push(ack);
}
}
const store: AcksStoreSnapshot = {
acks: nextAcks,
updatedAt: removedItemIds.length > 0 ? new Date(nowMs).toISOString() : normalized.updatedAt,
};
return {
store,
before: normalized.acks.length,
removed: removedItemIds.length,
after: nextAcks.length,
removedItemIds,
};
}
function validateAcknowledgeInput(input: unknown): AcknowledgeQueueInput {
const obj = asObject(input);
if (!obj) {
throw new NotificationCenterValidationError("ack payload must be a JSON object.", [], 400);
}
const issues: string[] = [];
const itemId = requiredString(obj.itemId, "itemId", 260, issues);
const note = optionalString(obj.note, "note", 300, issues);
const ttlMinutes = optionalPositiveInt(obj.ttlMinutes, "ttlMinutes", 1, 7 * 24 * 60, issues);
const snoozeUntil = optionalIsoString(obj.snoozeUntil, "snoozeUntil", issues);
if (ttlMinutes !== undefined && snoozeUntil !== undefined) {
issues.push("Provide either ttlMinutes or snoozeUntil, not both");
}
if (snoozeUntil !== undefined && Date.parse(snoozeUntil) <= Date.now()) {
issues.push("snoozeUntil must be a future ISO timestamp");
}
if (issues.length > 0) {
throw new NotificationCenterValidationError("Invalid acknowledge payload.", issues, 400);
}
return { itemId, note, ttlMinutes, snoozeUntil };
}
function normalizeAcksStore(input: unknown): AcksStoreSnapshot {
const obj = asObject(input);
if (!obj) return cloneEmptyAcks();
return {
acks: normalizeAcks(obj.acks),
updatedAt: asIsoString(obj.updatedAt),
};
}
function normalizeAcks(input: unknown): NotificationAck[] {
if (!Array.isArray(input)) return [];
const unique = new Map<string, NotificationAck>();
for (const item of input) {
const obj = asObject(item);
if (!obj) continue;
const itemId = asString(obj.itemId)?.trim();
if (!itemId) continue;
unique.set(itemId, {
itemId,
ackedAt: asIsoString(obj.ackedAt),
note: asString(obj.note)?.trim() || undefined,
expiresAt: asIsoStringOptional(obj.expiresAt),
});
}
return [...unique.values()].sort((a, b) => a.itemId.localeCompare(b.itemId));
}
function requiredString(
input: unknown,
label: string,
maxLength: number,
issues: string[],
): string {
if (typeof input !== "string" || input.trim() === "") {
issues.push(`${label} must be a non-empty string`);
return "";
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
issues.push(`${label} must be <= ${maxLength} characters`);
}
return trimmed;
}
function optionalString(
input: unknown,
label: string,
maxLength: number,
issues: string[],
): string | undefined {
if (input === undefined) return undefined;
if (typeof input !== "string") {
issues.push(`${label} must be a string`);
return undefined;
}
const trimmed = input.trim();
if (!trimmed) return undefined;
if (trimmed.length > maxLength) {
issues.push(`${label} must be <= ${maxLength} characters`);
return undefined;
}
return trimmed;
}
function optionalPositiveInt(
input: unknown,
label: string,
min: number,
max: number,
issues: string[],
): number | undefined {
if (input === undefined) return undefined;
if (typeof input !== "number" || !Number.isInteger(input)) {
issues.push(`${label} must be an integer`);
return undefined;
}
if (input < min || input > max) {
issues.push(`${label} must be in range ${min}..${max}`);
return undefined;
}
return input;
}
function optionalIsoString(
input: unknown,
label: string,
issues: string[],
): string | undefined {
if (input === undefined) return undefined;
if (typeof input !== "string" || input.trim() === "") {
issues.push(`${label} must be a non-empty ISO timestamp string`);
return undefined;
}
const parsed = Date.parse(input);
if (Number.isNaN(parsed)) {
issues.push(`${label} must be a valid ISO timestamp`);
return undefined;
}
return new Date(parsed).toISOString();
}
function cloneEmptyAcks(): AcksStoreSnapshot {
return {
acks: [],
updatedAt: EMPTY_ACKS.updatedAt,
};
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function asIsoString(v: unknown): string {
if (typeof v === "string" && !Number.isNaN(Date.parse(v))) return new Date(v).toISOString();
return new Date().toISOString();
}
function asIsoStringOptional(v: unknown): string | undefined {
if (typeof v !== "string" || Number.isNaN(Date.parse(v))) return undefined;
return new Date(v).toISOString();
}
function resolveAckExpiresAt(input: AcknowledgeQueueInput, nowMs: number): string | undefined {
if (input.snoozeUntil) return input.snoozeUntil;
if (typeof input.ttlMinutes === "number") {
return new Date(nowMs + input.ttlMinutes * 60_000).toISOString();
}
return undefined;
}
function isAckExpired(ack: NotificationAck, nowMs: number): boolean {
const expiresMs = typeof ack.expiresAt === "string" ? Date.parse(ack.expiresAt) : NaN;
return Number.isFinite(expiresMs) && expiresMs <= nowMs;
}
function resolveActiveAck(ack: NotificationAck | undefined, nowMs: number): NotificationAck | undefined {
if (!ack) return undefined;
if (isAckExpired(ack, nowMs)) return undefined;
return ack;
}
+268
View File
@@ -0,0 +1,268 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { CommanderExceptionsFeed, ExceptionFeedItem } from "../types";
const POLICY_PATH = join(process.cwd(), "runtime", "notification-policy.json");
type NotificationLevel = ExceptionFeedItem["level"];
export type NotificationRoute = ExceptionFeedItem["route"] | "silent";
export interface NotificationPolicyConfig {
quietHours: {
enabled: boolean;
startHour: number;
endHour: number;
timezoneOffsetMinutes: number;
suppressLevels: NotificationLevel[];
};
routing: Record<NotificationLevel, ExceptionFeedItem["route"]>;
}
export interface NotificationPolicyLoadResult {
path: string;
policy: NotificationPolicyConfig;
issues: string[];
}
export interface NotificationPreviewItem {
itemId: string;
level: NotificationLevel;
code: ExceptionFeedItem["code"];
source: ExceptionFeedItem["source"];
sourceId: string;
message: string;
sourceRoute: ExceptionFeedItem["route"];
routedTo: NotificationRoute;
suppressedByQuietHours: boolean;
}
export interface NotificationPreview {
generatedAt: string;
evaluatedAt: string;
inQuietHours: boolean;
path: string;
issues: string[];
policy: NotificationPolicyConfig;
counts: {
input: number;
suppressed: number;
routed: number;
byRoute: Record<NotificationRoute, number>;
byLevel: Record<NotificationLevel, number>;
};
items: NotificationPreviewItem[];
}
export async function loadNotificationPolicy(): Promise<NotificationPolicyLoadResult> {
try {
const raw = await readFile(POLICY_PATH, "utf8");
const parsed = JSON.parse(raw) as unknown;
return normalizePolicy(parsed);
} catch {
return {
path: POLICY_PATH,
policy: defaultNotificationPolicy(),
issues: [],
};
}
}
export function buildNotificationPreview(
feed: CommanderExceptionsFeed,
loaded: NotificationPolicyLoadResult,
evaluatedAt: Date = new Date(),
): NotificationPreview {
const inQuietHours = isInQuietHours(loaded.policy, evaluatedAt);
const items = feed.items.map((item) => {
const suppressedByQuietHours =
inQuietHours && loaded.policy.quietHours.suppressLevels.includes(item.level);
const routedTo: NotificationRoute = suppressedByQuietHours
? "silent"
: loaded.policy.routing[item.level] ?? item.route;
return {
itemId: `${item.code}:${item.source}:${item.sourceId}`,
level: item.level,
code: item.code,
source: item.source,
sourceId: item.sourceId,
message: item.message,
sourceRoute: item.route,
routedTo,
suppressedByQuietHours,
};
});
const byRoute: Record<NotificationRoute, number> = {
timeline: 0,
"operator-watch": 0,
"action-queue": 0,
silent: 0,
};
const byLevel: Record<NotificationLevel, number> = {
info: 0,
warn: 0,
"action-required": 0,
};
for (const item of items) {
byRoute[item.routedTo] += 1;
byLevel[item.level] += 1;
}
return {
generatedAt: new Date().toISOString(),
evaluatedAt: evaluatedAt.toISOString(),
inQuietHours,
path: loaded.path,
issues: loaded.issues,
policy: loaded.policy,
counts: {
input: items.length,
suppressed: byRoute.silent,
routed: items.length - byRoute.silent,
byRoute,
byLevel,
},
items,
};
}
function normalizePolicy(input: unknown): NotificationPolicyLoadResult {
const obj = asObject(input);
const issues: string[] = [];
const fallback = defaultNotificationPolicy();
const quietHours = asObject(obj?.quietHours);
const routing = asObject(obj?.routing);
const startHour = clampHour(asNumber(quietHours?.startHour), fallback.quietHours.startHour, issues, "quietHours.startHour");
const endHour = clampHour(asNumber(quietHours?.endHour), fallback.quietHours.endHour, issues, "quietHours.endHour");
const suppressLevelsRaw = Array.isArray(quietHours?.suppressLevels)
? quietHours?.suppressLevels
: fallback.quietHours.suppressLevels;
const suppressLevels = normalizeLevels(suppressLevelsRaw, fallback.quietHours.suppressLevels, issues);
return {
path: POLICY_PATH,
policy: {
quietHours: {
enabled: asBoolean(quietHours?.enabled, fallback.quietHours.enabled),
startHour,
endHour,
timezoneOffsetMinutes: asFiniteInt(
quietHours?.timezoneOffsetMinutes,
fallback.quietHours.timezoneOffsetMinutes,
),
suppressLevels,
},
routing: {
info: normalizeRoute(routing?.info, fallback.routing.info, issues, "routing.info"),
warn: normalizeRoute(routing?.warn, fallback.routing.warn, issues, "routing.warn"),
"action-required": normalizeRoute(
routing?.["action-required"],
fallback.routing["action-required"],
issues,
"routing.action-required",
),
},
},
issues,
};
}
function isInQuietHours(policy: NotificationPolicyConfig, at: Date): boolean {
if (!policy.quietHours.enabled) return false;
const minutesUtc = at.getUTCHours() * 60 + at.getUTCMinutes();
const localMinutes = modulo(minutesUtc + policy.quietHours.timezoneOffsetMinutes, 24 * 60);
const startMinutes = policy.quietHours.startHour * 60;
const endMinutes = policy.quietHours.endHour * 60;
if (startMinutes === endMinutes) return true;
if (startMinutes < endMinutes) {
return localMinutes >= startMinutes && localMinutes < endMinutes;
}
return localMinutes >= startMinutes || localMinutes < endMinutes;
}
function defaultNotificationPolicy(): NotificationPolicyConfig {
return {
quietHours: {
enabled: true,
startHour: 23,
endHour: 8,
timezoneOffsetMinutes: 0,
suppressLevels: ["info", "warn"],
},
routing: {
info: "timeline",
warn: "operator-watch",
"action-required": "action-queue",
},
};
}
function normalizeLevels(input: unknown, fallback: NotificationLevel[], issues: string[]): NotificationLevel[] {
if (!Array.isArray(input)) return fallback;
const set = new Set<NotificationLevel>();
for (const raw of input) {
if (raw === "info" || raw === "warn" || raw === "action-required") {
set.add(raw);
} else {
issues.push(`Unsupported quietHours.suppressLevels entry: ${String(raw)}`);
}
}
return set.size > 0 ? [...set] : [];
}
function normalizeRoute(
input: unknown,
fallback: ExceptionFeedItem["route"],
issues: string[],
label: string,
): ExceptionFeedItem["route"] {
if (input === "timeline" || input === "operator-watch" || input === "action-queue") {
return input;
}
if (input !== undefined) {
issues.push(`Unsupported ${label}: ${String(input)}`);
}
return fallback;
}
function clampHour(input: number | undefined, fallback: number, issues: string[], label: string): number {
if (input === undefined) return fallback;
if (input < 0 || input > 23) {
issues.push(`${label} must be in range 0..23`);
return fallback;
}
return input;
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function asNumber(v: unknown): number | undefined {
if (typeof v !== "number" || !Number.isFinite(v)) return undefined;
return v;
}
function asBoolean(v: unknown, fallback: boolean): boolean {
return typeof v === "boolean" ? v : fallback;
}
function asFiniteInt(v: unknown, fallback: number): number {
if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
return Math.round(v);
}
function modulo(v: number, m: number): number {
return ((v % m) + m) % m;
}
+352
View File
@@ -0,0 +1,352 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { loadCurrentAgentCatalog, resolveOpenClawHomePath } from "./current-agent-catalog";
export type OfficeSessionPresenceStatus = "connected" | "partial" | "not_connected";
export interface OfficeSessionPresenceSnapshot {
status: OfficeSessionPresenceStatus;
sourcePath: string;
detail: string;
activeSessionsByAgent: Map<string, number>;
totalActiveSessions: number;
}
const ACTIVE_SESSION_STATES = new Set([
"running",
"active",
"busy",
"blocked",
"waiting_approval",
"working",
"in_progress",
"processing",
"thinking",
"executing",
"streaming",
]);
const INACTIVE_SESSION_STATES = new Set([
"idle",
"inactive",
"error",
"failed",
"stopped",
"stopping",
"closed",
"done",
"completed",
"complete",
"paused",
"aborted",
"terminated",
"cancelled",
"canceled",
]);
const ACTIVE_RECENCY_WINDOWS_MS = resolveActiveRecencyWindowsMs();
export async function loadBestEffortOfficeSessionPresence(): Promise<OfficeSessionPresenceSnapshot> {
const openclawHome = resolveOpenClawHomePath();
const agentsPath = join(openclawHome, "agents");
const sourcePath = join(agentsPath, "*/sessions/sessions.json");
const currentCatalog = await loadCurrentAgentCatalog();
const configuredAgentKeys = new Set(currentCatalog.entries.map((entry) => normalizeAgentKey(entry.agentId)));
let agentDirs: string[] = [];
try {
const entries = await readdir(agentsPath, { withFileTypes: true });
agentDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
} catch (error) {
if (!isFsNotFound(error)) {
return {
status: "partial",
sourcePath,
detail: "Runtime agent directory exists but could not be read cleanly.",
activeSessionsByAgent: new Map(),
totalActiveSessions: 0,
};
}
return {
status: "not_connected",
sourcePath,
detail: "Runtime agent directory not found.",
activeSessionsByAgent: new Map(),
totalActiveSessions: 0,
};
}
if (configuredAgentKeys.size > 0) {
agentDirs = agentDirs.filter((agentId) => configuredAgentKeys.has(normalizeAgentKey(agentId)));
}
if (agentDirs.length === 0) {
return {
status: "not_connected",
sourcePath,
detail:
configuredAgentKeys.size > 0
? "No runtime session stores were found for the current configured agents."
: "Runtime agent directory is empty.",
activeSessionsByAgent: new Map(),
totalActiveSessions: 0,
};
}
const recordsByAgent = new Map<string, Record<string, unknown>[]>();
let parsedStores = 0;
let parseErrors = 0;
for (const agentId of agentDirs) {
const sessionsPath = join(agentsPath, agentId, "sessions", "sessions.json");
try {
const parsed = JSON.parse(await readFile(sessionsPath, "utf8")) as unknown;
parsedStores += 1;
const records = extractSessionRecords(parsed);
recordsByAgent.set(agentId, records);
} catch (error) {
if (isFsNotFound(error)) continue;
parseErrors += 1;
}
}
let selectedWindowMs = ACTIVE_RECENCY_WINDOWS_MS[0] ?? 45 * 60 * 1000;
let selectedActiveByAgent = new Map<string, number>();
let totalActiveSessions = 0;
const nowMs = Date.now();
for (const windowMs of ACTIVE_RECENCY_WINDOWS_MS) {
const activeByAgent = deriveActiveSessionsByAgent(recordsByAgent, windowMs, nowMs);
const total = [...activeByAgent.values()].reduce((sum, value) => sum + value, 0);
selectedWindowMs = windowMs;
selectedActiveByAgent = activeByAgent;
totalActiveSessions = total;
if (total > 0) break;
}
const usedAdaptiveFallback =
totalActiveSessions > 0 &&
selectedWindowMs !== (ACTIVE_RECENCY_WINDOWS_MS[0] ?? selectedWindowMs);
if (parsedStores === 0 && parseErrors === 0) {
return {
status: "not_connected",
sourcePath,
detail: "No runtime session stores found.",
activeSessionsByAgent: selectedActiveByAgent,
totalActiveSessions,
};
}
const status: OfficeSessionPresenceStatus = parseErrors > 0 ? "partial" : "connected";
return {
status,
sourcePath,
detail:
`Derived ${totalActiveSessions} active session(s) from ${parsedStores} session store(s)` +
` using state + ${Math.round(selectedWindowMs / 60000)}m recency window.` +
(configuredAgentKeys.size > 0 ? ` Filtered to ${configuredAgentKeys.size} configured current agent(s).` : "") +
(usedAdaptiveFallback
? ` Window auto-expanded from ${Math.round((ACTIVE_RECENCY_WINDOWS_MS[0] ?? selectedWindowMs) / 60000)}m after an all-zero pass.`
: "") +
(parseErrors > 0 ? ` ${parseErrors} store(s) could not be parsed.` : ""),
activeSessionsByAgent: selectedActiveByAgent,
totalActiveSessions,
};
}
function deriveActiveSessionsByAgent(
recordsByAgent: Map<string, Record<string, unknown>[]>,
recencyWindowMs: number,
nowMs: number,
): Map<string, number> {
const activeByAgent = new Map<string, number>();
for (const [agentId, records] of recordsByAgent.entries()) {
let active = 0;
for (const item of records) {
if (isSessionActive(item, recencyWindowMs, nowMs)) active += 1;
}
if (active > 0) activeByAgent.set(agentId, active);
}
return activeByAgent;
}
function isSessionActive(item: Record<string, unknown>, recencyWindowMs: number, nowMs: number): boolean {
const explicitActive = readExplicitActiveFlag(item);
if (typeof explicitActive === "boolean") return explicitActive;
const explicitState = readSessionState(item);
if (explicitState) {
if (ACTIVE_SESSION_STATES.has(explicitState)) return true;
if (INACTIVE_SESSION_STATES.has(explicitState)) return false;
}
const updatedAtMs = readUpdatedAtMs(item);
if (!Number.isFinite(updatedAtMs)) return false;
return nowMs - updatedAtMs <= recencyWindowMs;
}
function readSessionState(item: Record<string, unknown>): string | undefined {
const direct =
asString(item.state) ??
asString(item.status) ??
asString(item.runState) ??
asString(item.lifecycleState);
if (direct) return direct.trim().toLowerCase();
const acp = asObject(item.acp);
const acpState = asString(acp?.state);
return acpState ? acpState.trim().toLowerCase() : undefined;
}
function readExplicitActiveFlag(item: Record<string, unknown>): boolean | undefined {
const direct = asBoolean(item.active) ?? asBoolean(item.isActive);
if (typeof direct === "boolean") return direct;
const acp = asObject(item.acp);
const acpActive = asBoolean(acp?.active) ?? asBoolean(acp?.isActive);
return typeof acpActive === "boolean" ? acpActive : undefined;
}
function readUpdatedAtMs(item: Record<string, unknown>): number {
const candidates = [
item.updatedAt,
item.lastActivityAt,
item.createdAt,
asObject(item.acp)?.lastActivityAt,
asObject(item.acp)?.updatedAt,
asObject(item.acp)?.createdAt,
];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return normalizeEpochMs(candidate);
if (typeof candidate === "string" && candidate.trim() !== "") {
const trimmed = candidate.trim();
if (/^\d+(\.\d+)?$/.test(trimmed)) {
const parsedNumeric = Number(trimmed);
if (Number.isFinite(parsedNumeric)) return normalizeEpochMs(parsedNumeric);
}
const parsedDate = Date.parse(trimmed);
if (!Number.isNaN(parsedDate)) return parsedDate;
}
}
return Number.NaN;
}
function normalizeEpochMs(value: number): number {
const abs = Math.abs(value);
if (abs >= 1e14) return value / 1000;
if (abs > 0 && abs < 1e12) return value * 1000;
return value;
}
function extractSessionRecords(parsed: unknown): Record<string, unknown>[] {
const direct = normalizeRecordCollection(parsed).filter(looksLikeSessionRecord);
if (direct.length > 0) return direct;
const root = asObject(parsed);
if (!root) return [];
const topLevelCollections = [
normalizeRecordCollection(root.sessions),
normalizeRecordCollection(root.items),
normalizeRecordCollection(root.records),
];
for (const collection of topLevelCollections) {
const records = collection.filter(looksLikeSessionRecord);
if (records.length > 0) return records;
}
const data = asObject(root.data);
if (data) {
const nestedCollections = [
normalizeRecordCollection(data.sessions),
normalizeRecordCollection(data.items),
normalizeRecordCollection(data.records),
];
for (const collection of nestedCollections) {
const records = collection.filter(looksLikeSessionRecord);
if (records.length > 0) return records;
}
}
return [];
}
function normalizeRecordCollection(input: unknown): Record<string, unknown>[] {
if (Array.isArray(input)) {
return input
.map((item) => asObject(item))
.filter((item): item is Record<string, unknown> => Boolean(item));
}
const object = asObject(input);
if (!object) return [];
return Object.values(object)
.map((item) => asObject(item))
.filter((item): item is Record<string, unknown> => Boolean(item));
}
function looksLikeSessionRecord(item: Record<string, unknown>): boolean {
if (
asString(item.sessionId) ||
asString(item.sessionKey) ||
asString(item.key) ||
asString(item.sessionFile)
) {
return true;
}
if (asObject(item.acp) || asObject(item.origin) || asObject(item.deliveryContext)) return true;
if (readSessionState(item)) return true;
return false;
}
function resolveActiveRecencyWindowsMs(): number[] {
const fallbackMinutes = [45, 180, 720, 1440];
const rawMinutes = process.env.OFFICE_SESSION_ACTIVE_WINDOW_MINUTES;
if (!rawMinutes) return fallbackMinutes.map((minutes) => minutes * 60 * 1000);
const parsedMinutes = rawMinutes
.split(",")
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
.map((item) => Math.max(1, Math.trunc(item)));
if (parsedMinutes.length === 0) return fallbackMinutes.map((minutes) => minutes * 60 * 1000);
const deduped: number[] = [];
for (const minutes of parsedMinutes) {
if (!deduped.includes(minutes)) deduped.push(minutes);
}
return deduped.map((minutes) => minutes * 60 * 1000);
}
function isFsNotFound(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
typeof (error as { code?: unknown }).code === "string" &&
(error as { code: string }).code === "ENOENT",
);
}
function normalizeAgentKey(input: string): string {
return input.trim().toLowerCase();
}
function asObject(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
function asString(input: unknown): string | undefined {
return typeof input === "string" ? input : undefined;
}
function asBoolean(input: unknown): boolean | undefined {
return typeof input === "boolean" ? input : undefined;
}
+36
View File
@@ -0,0 +1,36 @@
import { appendFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const OPERATION_AUDIT_LOG_PATH = join(RUNTIME_DIR, "operation-audit.log");
export type OperationAuditAction =
| "import_dry_run"
| "backup_export"
| "import_apply"
| "ack_prune"
| "task_heartbeat";
export type OperationAuditSource = "api" | "command";
export interface OperationAuditInput {
action: OperationAuditAction;
source: OperationAuditSource;
ok: boolean;
requestId?: string;
detail: string;
metadata?: Record<string, unknown>;
}
export interface OperationAuditEntry extends OperationAuditInput {
timestamp: string;
}
export async function appendOperationAudit(input: OperationAuditInput): Promise<OperationAuditEntry> {
const entry: OperationAuditEntry = {
...input,
timestamp: new Date().toISOString(),
};
await mkdir(RUNTIME_DIR, { recursive: true });
await appendFile(OPERATION_AUDIT_LOG_PATH, `${JSON.stringify(entry)}\n`, "utf8");
return entry;
}
+275
View File
@@ -0,0 +1,275 @@
import type { ReadModelSnapshot } from "../types";
export interface PixelRoom {
id: string;
label: string;
kind: "ops" | "project" | "backlog";
x: number;
y: number;
w: number;
h: number;
status?: string;
}
export interface PixelEntity {
id: string;
roomId: string;
kind: "project" | "task" | "session" | "agent";
label: string;
status?: string;
x: number;
y: number;
}
export interface PixelLink {
id: string;
from: string;
to: string;
type: "project_task" | "task_session" | "agent_session";
}
export interface PixelState {
generatedAt: string;
snapshotGeneratedAt: string;
rooms: PixelRoom[];
entities: PixelEntity[];
links: PixelLink[];
counts: {
rooms: number;
entities: number;
links: number;
projects: number;
tasks: number;
sessions: number;
agents: number;
};
}
export function buildPixelState(snapshot: ReadModelSnapshot): PixelState {
const generatedAt = new Date().toISOString();
const rooms: PixelRoom[] = [
{
id: "room:ops",
label: "Ops",
kind: "ops",
x: 0,
y: 0,
w: 24,
h: 18,
},
];
const projectSet = new Set(snapshot.projects.projects.map((project) => project.projectId));
const tasksByProject = new Map<string, ReadModelSnapshot["tasks"]["tasks"]>();
const backlogTasks: ReadModelSnapshot["tasks"]["tasks"] = [];
for (const task of snapshot.tasks.tasks) {
if (!projectSet.has(task.projectId)) {
backlogTasks.push(task);
continue;
}
const list = tasksByProject.get(task.projectId) ?? [];
list.push(task);
tasksByProject.set(task.projectId, list);
}
const roomByProjectId = new Map<string, string>();
for (const [idx, project] of snapshot.projects.projects.entries()) {
const row = Math.floor(idx / 3);
const col = idx % 3;
const roomId = `room:project:${project.projectId}`;
roomByProjectId.set(project.projectId, roomId);
rooms.push({
id: roomId,
label: project.title,
kind: "project",
x: 24 + col * 24,
y: row * 16,
w: 23,
h: 15,
status: project.status,
});
}
if (backlogTasks.length > 0) {
rooms.push({
id: "room:backlog",
label: "Backlog",
kind: "backlog",
x: 24,
y: Math.max(1, Math.ceil(snapshot.projects.projects.length / 3)) * 16,
w: 23,
h: 15,
status: "todo",
});
}
const entitiesById = new Map<string, PixelEntity>();
const linksById = new Map<string, PixelLink>();
for (const [idx, project] of snapshot.projects.projects.entries()) {
const roomId = roomByProjectId.get(project.projectId) as string;
addEntity(entitiesById, {
id: `project:${project.projectId}`,
roomId,
kind: "project",
label: project.title,
status: project.status,
...slotCoords(0, idx),
});
}
for (const [projectId, tasks] of tasksByProject.entries()) {
const roomId = roomByProjectId.get(projectId) as string;
for (const [idx, task] of tasks.entries()) {
const entityId = taskEntityId(task.projectId, task.taskId);
addEntity(entitiesById, {
id: entityId,
roomId,
kind: "task",
label: task.title,
status: task.status,
...slotCoords(1, idx),
});
addLink(linksById, {
id: `project_task:${task.projectId}:${task.taskId}`,
from: `project:${task.projectId}`,
to: entityId,
type: "project_task",
});
}
}
for (const [idx, task] of backlogTasks.entries()) {
addEntity(entitiesById, {
id: taskEntityId(task.projectId, task.taskId),
roomId: "room:backlog",
kind: "task",
label: task.title,
status: task.status,
...slotCoords(0, idx),
});
}
const sessionByKey = new Map(snapshot.sessions.map((session) => [session.sessionKey, session]));
const sessionsWithEntity = new Set<string>();
for (const [idx, session] of snapshot.sessions.entries()) {
sessionsWithEntity.add(session.sessionKey);
addEntity(entitiesById, {
id: `session:${session.sessionKey}`,
roomId: "room:ops",
kind: "session",
label: session.label ?? session.sessionKey,
status: session.state,
...slotCoords(0, idx),
});
if (session.agentId) {
addEntity(entitiesById, {
id: `agent:${session.agentId}`,
roomId: "room:ops",
kind: "agent",
label: session.agentId,
...slotCoords(1, hashIndex(session.agentId)),
});
addLink(linksById, {
id: `agent_session:${session.agentId}:${session.sessionKey}`,
from: `agent:${session.agentId}`,
to: `session:${session.sessionKey}`,
type: "agent_session",
});
}
}
for (const task of snapshot.tasks.tasks) {
for (const [idx, sessionKey] of task.sessionKeys.entries()) {
if (!sessionsWithEntity.has(sessionKey)) {
sessionsWithEntity.add(sessionKey);
const session = sessionByKey.get(sessionKey);
addEntity(entitiesById, {
id: `session:${sessionKey}`,
roomId: "room:ops",
kind: "session",
label: session?.label ?? sessionKey,
status: session?.state ?? "unknown",
...slotCoords(0, entitiesById.size + idx),
});
if (session?.agentId) {
addEntity(entitiesById, {
id: `agent:${session.agentId}`,
roomId: "room:ops",
kind: "agent",
label: session.agentId,
...slotCoords(1, hashIndex(session.agentId)),
});
addLink(linksById, {
id: `agent_session:${session.agentId}:${sessionKey}`,
from: `agent:${session.agentId}`,
to: `session:${sessionKey}`,
type: "agent_session",
});
}
}
addLink(linksById, {
id: `task_session:${task.projectId}:${task.taskId}:${sessionKey}`,
from: taskEntityId(task.projectId, task.taskId),
to: `session:${sessionKey}`,
type: "task_session",
});
}
}
const entities = [...entitiesById.values()].sort((a, b) => a.id.localeCompare(b.id));
const links = [...linksById.values()].sort((a, b) => a.id.localeCompare(b.id));
return {
generatedAt,
snapshotGeneratedAt: snapshot.generatedAt,
rooms: rooms.sort((a, b) => a.id.localeCompare(b.id)),
entities,
links,
counts: {
rooms: rooms.length,
entities: entities.length,
links: links.length,
projects: entities.filter((entity) => entity.kind === "project").length,
tasks: entities.filter((entity) => entity.kind === "task").length,
sessions: entities.filter((entity) => entity.kind === "session").length,
agents: entities.filter((entity) => entity.kind === "agent").length,
},
};
}
function slotCoords(lane: number, index: number): { x: number; y: number } {
const col = index % 4;
const row = Math.floor(index / 4);
return {
x: 2 + col * 5,
y: 2 + lane * 6 + row * 2,
};
}
function hashIndex(input: string): number {
let hash = 0;
for (const ch of input) {
hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
}
return hash % 12;
}
function taskEntityId(projectId: string, taskId: string): string {
return `task:${projectId}:${taskId}`;
}
function addEntity(target: Map<string, PixelEntity>, entity: PixelEntity): void {
if (target.has(entity.id)) return;
target.set(entity.id, entity);
}
function addLink(target: Map<string, PixelLink>, link: PixelLink): void {
if (target.has(link.id)) return;
target.set(link.id, link);
}
+329
View File
@@ -0,0 +1,329 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type {
BudgetThresholds,
ProjectRecord,
ProjectState,
ProjectStoreSnapshot,
} from "../types";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const PROJECTS_PATH = join(RUNTIME_DIR, "projects.json");
const DEFAULT_WARN_RATIO = 0.8;
const PROJECT_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
export const PROJECT_STATES: ProjectState[] = ["planned", "active", "blocked", "done"];
const EMPTY_STORE: ProjectStoreSnapshot = {
projects: [],
updatedAt: "1970-01-01T00:00:00.000Z",
};
export class ProjectStoreValidationError extends Error {
readonly statusCode: number;
readonly issues: string[];
constructor(message: string, issues: string[] = [], statusCode = 400) {
super(message);
this.name = "ProjectStoreValidationError";
this.statusCode = statusCode;
this.issues = issues;
}
}
export interface CreateProjectInput {
projectId: string;
title: string;
status?: ProjectState;
owner?: string;
}
export interface UpdateProjectInput {
projectId: string;
title?: string;
status?: ProjectState;
owner?: string;
}
export interface ProjectMutationResult {
path: string;
project: ProjectRecord;
}
export async function loadProjectStore(): Promise<ProjectStoreSnapshot> {
try {
const raw = await readFile(PROJECTS_PATH, "utf8");
return normalizeProjectStore(JSON.parse(raw));
} catch {
return cloneEmptyStore();
}
}
export async function saveProjectStore(next: ProjectStoreSnapshot): Promise<string> {
const normalized = normalizeProjectStore({
...next,
updatedAt: new Date().toISOString(),
});
await mkdir(RUNTIME_DIR, { recursive: true });
await writeFile(PROJECTS_PATH, JSON.stringify(normalized, null, 2), "utf8");
return PROJECTS_PATH;
}
export function listProjects(store: ProjectStoreSnapshot): ProjectRecord[] {
return [...store.projects].sort((a, b) => a.projectId.localeCompare(b.projectId));
}
export async function createProject(input: unknown): Promise<ProjectMutationResult> {
const payload = validateCreateProjectInput(input);
const store = await loadProjectStore();
if (store.projects.some((item) => item.projectId === payload.projectId)) {
throw new ProjectStoreValidationError(
`projectId '${payload.projectId}' already exists.`,
["projectId"],
409,
);
}
const now = new Date().toISOString();
const project: ProjectRecord = {
projectId: payload.projectId,
title: payload.title,
status: payload.status ?? "planned",
owner: payload.owner ?? "unassigned",
budget: normalizeThresholds(undefined),
updatedAt: now,
};
store.projects.push(project);
store.updatedAt = now;
const path = await saveProjectStore(store);
return { path, project };
}
export async function updateProject(input: unknown): Promise<ProjectMutationResult> {
const payload = validateUpdateProjectInput(input);
const store = await loadProjectStore();
const project = store.projects.find((item) => item.projectId === payload.projectId);
if (!project) {
throw new ProjectStoreValidationError(`projectId '${payload.projectId}' was not found.`, [], 404);
}
const now = new Date().toISOString();
if (payload.title !== undefined) project.title = payload.title;
if (payload.status !== undefined) project.status = payload.status;
if (payload.owner !== undefined) project.owner = payload.owner;
project.updatedAt = now;
store.updatedAt = now;
const path = await saveProjectStore(store);
return { path, project };
}
function validateCreateProjectInput(input: unknown): CreateProjectInput {
const obj = ensureObject(input, "create project payload");
const issues: string[] = [];
const projectId = requiredProjectId(obj.projectId, "projectId", issues);
const title = requiredBoundedString(obj.title, "title", 120, issues);
const status = optionalProjectState(obj.status, "status", issues);
const owner = optionalBoundedString(obj.owner, "owner", 80, issues);
if (issues.length > 0) {
throw new ProjectStoreValidationError("Invalid create project payload.", issues, 400);
}
return { projectId, title, status, owner };
}
function validateUpdateProjectInput(input: unknown): UpdateProjectInput {
const obj = ensureObject(input, "update project payload");
const issues: string[] = [];
const projectId = requiredProjectId(obj.projectId, "projectId", issues);
const title = optionalBoundedString(obj.title, "title", 120, issues);
const status = optionalProjectState(obj.status, "status", issues);
const owner = optionalBoundedString(obj.owner, "owner", 80, issues);
if (title === undefined && status === undefined && owner === undefined) {
issues.push("at least one updatable field is required: title, status, owner");
}
if (issues.length > 0) {
throw new ProjectStoreValidationError("Invalid update project payload.", issues, 400);
}
return { projectId, title, status, owner };
}
function normalizeProjectStore(input: unknown): ProjectStoreSnapshot {
const obj = asObject(input);
if (!obj) return cloneEmptyStore();
return {
projects: normalizeProjects(asArray(obj.projects)),
updatedAt: asIsoString(obj.updatedAt),
};
}
function normalizeProjects(projects: unknown[] | undefined): ProjectRecord[] {
if (!projects) return [];
const unique = new Map<string, ProjectRecord>();
for (const input of projects) {
const project = normalizeProject(input);
if (!project) continue;
unique.set(project.projectId, project);
}
return [...unique.values()].sort((a, b) => a.projectId.localeCompare(b.projectId));
}
function normalizeProject(input: unknown): ProjectRecord | null {
const obj = asObject(input);
if (!obj) return null;
const projectId = asString(obj.projectId)?.trim();
if (!projectId || !PROJECT_ID_REGEX.test(projectId)) return null;
return {
projectId,
title: asString(obj.title)?.trim() || projectId,
status: normalizeProjectState(asString(obj.status)),
owner: asString(obj.owner)?.trim() || "unassigned",
budget: normalizeThresholds(asObject(obj.budget)),
updatedAt: asIsoString(obj.updatedAt),
};
}
function normalizeProjectState(input: string | undefined): ProjectState {
if (input === "planned" || input === "active" || input === "blocked" || input === "done") {
return input;
}
return "planned";
}
function normalizeThresholds(input: Record<string, unknown> | undefined): BudgetThresholds {
const warnRatio = asNumber(input?.warnRatio) ?? DEFAULT_WARN_RATIO;
return {
tokensIn: asPositiveNumber(input?.tokensIn),
tokensOut: asPositiveNumber(input?.tokensOut),
totalTokens: asPositiveNumber(input?.totalTokens),
cost: asPositiveNumber(input?.cost),
warnRatio: warnRatio > 0 && warnRatio < 1 ? warnRatio : DEFAULT_WARN_RATIO,
};
}
function cloneEmptyStore(): ProjectStoreSnapshot {
return {
projects: [],
updatedAt: EMPTY_STORE.updatedAt,
};
}
function ensureObject(input: unknown, label: string): Record<string, unknown> {
const obj = asObject(input);
if (!obj) throw new ProjectStoreValidationError(`${label} must be a JSON object.`, [], 400);
return obj;
}
function requiredProjectId(value: unknown, field: string, issues: string[]): string {
if (typeof value !== "string" || value.trim() === "") {
issues.push(`${field} must be a non-empty string`);
return "";
}
const trimmed = value.trim();
if (!PROJECT_ID_REGEX.test(trimmed)) {
issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`);
}
if (trimmed.length > 100) {
issues.push(`${field} must be <= 100 characters`);
}
return trimmed;
}
function requiredBoundedString(
value: unknown,
field: string,
maxLength: number,
issues: string[],
): string {
if (typeof value !== "string" || value.trim() === "") {
issues.push(`${field} must be a non-empty string`);
return "";
}
const trimmed = value.trim();
if (trimmed.length > maxLength) {
issues.push(`${field} must be <= ${maxLength} characters`);
}
return trimmed;
}
function optionalBoundedString(
value: unknown,
field: string,
maxLength: number,
issues: string[],
): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") {
issues.push(`${field} must be a string`);
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
issues.push(`${field} cannot be empty when provided`);
return undefined;
}
if (trimmed.length > maxLength) {
issues.push(`${field} must be <= ${maxLength} characters`);
return undefined;
}
return trimmed;
}
function optionalProjectState(
value: unknown,
field: string,
issues: string[],
): ProjectState | undefined {
if (value === undefined) return undefined;
if (value === "planned" || value === "active" || value === "blocked" || value === "done") {
return value;
}
issues.push(`${field} must be one of: planned, active, blocked, done`);
return undefined;
}
function asIsoString(v: unknown): string {
if (typeof v === "string" && !Number.isNaN(Date.parse(v))) return new Date(v).toISOString();
return new Date().toISOString();
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function asArray(v: unknown): unknown[] | undefined {
return Array.isArray(v) ? v : undefined;
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function asNumber(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function asPositiveNumber(v: unknown): number | undefined {
const parsed = asNumber(v);
if (parsed === undefined || parsed <= 0) return undefined;
return parsed;
}
+57
View File
@@ -0,0 +1,57 @@
import type {
ProjectStoreSnapshot,
ProjectSummary,
TaskStoreSnapshot,
} from "../types";
export function computeProjectSummaries(
projectStore: ProjectStoreSnapshot,
taskStore: TaskStoreSnapshot,
): ProjectSummary[] {
const nowMs = Date.now();
const tasksByProject = new Map<string, TaskStoreSnapshot["tasks"]>();
for (const task of taskStore.tasks) {
const list = tasksByProject.get(task.projectId);
if (list) {
list.push(task);
} else {
tasksByProject.set(task.projectId, [task]);
}
}
return projectStore.projects
.map((project) => {
const tasks = tasksByProject.get(project.projectId) ?? [];
let todo = 0;
let inProgress = 0;
let blocked = 0;
let done = 0;
let due = 0;
for (const task of tasks) {
if (task.status === "todo") todo += 1;
if (task.status === "in_progress") inProgress += 1;
if (task.status === "blocked") blocked += 1;
if (task.status === "done") done += 1;
if (task.dueAt && task.status !== "done" && Date.parse(task.dueAt) <= nowMs) {
due += 1;
}
}
return {
projectId: project.projectId,
title: project.title,
status: project.status,
owner: project.owner,
totalTasks: tasks.length,
todo,
inProgress,
blocked,
done,
due,
updatedAt: project.updatedAt,
};
})
.sort((a, b) => a.projectId.localeCompare(b.projectId));
}
+536
View File
@@ -0,0 +1,536 @@
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
const RUNTIME_DIR = join(process.cwd(), "runtime");
const TIMELINE_LOG_PATH = join(RUNTIME_DIR, "timeline.log");
const DIGEST_DIR = join(RUNTIME_DIR, "digests");
const EXPORT_SNAPSHOT_DIR = join(RUNTIME_DIR, "export-snapshots");
const EXPORT_BUNDLES_DIR = join(RUNTIME_DIR, "exports");
export interface ReplayIndexOptions {
timelineLimit: number;
digestLimit: number;
exportLimit: number;
from?: string;
to?: string;
}
export interface ReplayTimelineEntry {
timestamp: string;
summary: string;
}
export interface ReplayDigestEntry {
date: string;
jsonPath: string;
markdownPath?: string;
generatedAt?: string;
snapshotGeneratedAt?: string;
sizeBytes: number;
}
export interface ReplayExportSnapshotEntry {
fileName: string;
path: string;
sizeBytes: number;
exportedAt?: string;
snapshotGeneratedAt?: string;
requestId?: string;
counts: {
projects: number;
tasks: number;
sessions: number;
exceptions: number;
};
}
export interface ReplayFilterStats {
total: number;
returned: number;
filteredOut: number;
filteredOutByWindow: number;
filteredOutByLimit: number;
latencyMs: number;
latencyBucketsMs: ReplayLatencyBuckets;
totalSizeBytes: number;
returnedSizeBytes: number;
}
export interface ReplayLatencyBuckets {
p50: number;
p95: number;
}
export interface ReplayIndexSnapshot {
generatedAt: string;
window?: {
from?: string;
to?: string;
};
timeline: {
path: string;
totalLines: number;
entries: ReplayTimelineEntry[];
};
digests: ReplayDigestEntry[];
exportSnapshots: ReplayExportSnapshotEntry[];
exportBundles: ReplayExportSnapshotEntry[];
stats: {
timeline: ReplayFilterStats;
digests: ReplayFilterStats;
exportSnapshots: ReplayFilterStats;
exportBundles: ReplayFilterStats;
total: ReplayFilterStats;
};
}
export interface ExportSnapshotWriteResult {
fileName: string;
path: string;
sizeBytes: number;
}
export async function loadReplayIndex(
options: Partial<ReplayIndexOptions> = {},
): Promise<ReplayIndexSnapshot> {
const window = parseReplayWindow(options.from, options.to);
const resolved: ReplayIndexOptions = {
timelineLimit: options.timelineLimit ?? 80,
digestLimit: options.digestLimit ?? 30,
exportLimit: options.exportLimit ?? 30,
from: window.fromIso,
to: window.toIso,
};
const [timeline, digests, exportSnapshots, exportBundles] = await Promise.all([
loadTimelineEntries(resolved.timelineLimit, window),
loadDigestEntries(resolved.digestLimit, window),
loadExportArtifacts(EXPORT_SNAPSHOT_DIR, resolved.exportLimit, window),
loadExportArtifacts(EXPORT_BUNDLES_DIR, resolved.exportLimit, window),
]);
const totalLatencySamplesMs = [
...timeline.latencySamplesMs,
...digests.latencySamplesMs,
...exportSnapshots.latencySamplesMs,
...exportBundles.latencySamplesMs,
];
const stats = {
timeline: timeline.stats,
digests: digests.stats,
exportSnapshots: exportSnapshots.stats,
exportBundles: exportBundles.stats,
total: sumReplayFilterStats([
timeline.stats,
digests.stats,
exportSnapshots.stats,
exportBundles.stats,
], totalLatencySamplesMs),
};
return {
generatedAt: new Date().toISOString(),
window:
typeof resolved.from === "string" || typeof resolved.to === "string"
? {
from: resolved.from,
to: resolved.to,
}
: undefined,
timeline: {
path: timeline.path,
totalLines: timeline.totalLines,
entries: timeline.entries,
},
digests: digests.entries,
exportSnapshots: exportSnapshots.entries,
exportBundles: exportBundles.entries,
stats,
};
}
export async function writeExportSnapshot(
payload: Record<string, unknown>,
requestId: string,
): Promise<ExportSnapshotWriteResult> {
await mkdir(EXPORT_SNAPSHOT_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[-:.]/g, "").replace("T", "-").slice(0, 19);
const safeRequestId = requestId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40) || "unknown";
const fileName = `${stamp}-${safeRequestId}.json`;
const path = join(EXPORT_SNAPSHOT_DIR, fileName);
const body = `${JSON.stringify(payload, null, 2)}\n`;
await writeFile(path, body, "utf8");
return {
fileName,
path,
sizeBytes: Buffer.byteLength(body, "utf8"),
};
}
async function loadTimelineEntries(
limit: number,
window: ReplayWindow,
): Promise<ReplayTimelineResult> {
const startedAt = performance.now();
const raw = await safeReadFile(TIMELINE_LOG_PATH);
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "");
const parsed = lines.map((line): {
timestampMs?: number;
sizeBytes: number;
parseLatencyMs: number;
entry: ReplayTimelineEntry;
} => {
const parseStartedAt = performance.now();
const sizeBytes = Buffer.byteLength(line, "utf8");
const match = line.match(/^(\S+)\s+\|\s+(.*)$/);
if (!match) {
return {
sizeBytes,
parseLatencyMs: performance.now() - parseStartedAt,
entry: {
timestamp: "",
summary: line,
},
};
}
const timestamp = !Number.isNaN(Date.parse(match[1]))
? new Date(match[1]).toISOString()
: match[1];
return {
timestampMs: parseTimestampMs(timestamp),
sizeBytes,
parseLatencyMs: performance.now() - parseStartedAt,
entry: {
timestamp,
summary: match[2],
},
};
});
const within = parsed.filter((item) => withinWindow(item.timestampMs, window));
const limited = within.slice(-limit);
const entries = limited.reverse().map((item) => item.entry);
const totalSizeBytes = parsed.reduce((sum, item) => sum + item.sizeBytes, 0);
const returnedSizeBytes = limited.reduce((sum, item) => sum + item.sizeBytes, 0);
return {
path: TIMELINE_LOG_PATH,
totalLines: lines.length,
entries,
stats: buildReplayFilterStats(parsed.length, within.length, entries.length, {
latencyMs: performance.now() - startedAt,
latencySamplesMs: parsed.map((item) => item.parseLatencyMs),
totalSizeBytes,
returnedSizeBytes,
}),
latencySamplesMs: parsed.map((item) => item.parseLatencyMs),
};
}
async function loadDigestEntries(
limit: number,
window: ReplayWindow,
): Promise<ReplayCollectionResult<ReplayDigestEntry>> {
const startedAt = performance.now();
let files: string[] = [];
try {
files = await readdir(DIGEST_DIR);
} catch {
return emptyReplayCollection();
}
const markdownSet = new Set(files.filter((name) => name.endsWith(".md")));
const jsonFiles = files.filter((name) => name.endsWith(".json")).sort((a, b) => b.localeCompare(a));
const parsed = await Promise.all(
jsonFiles.map(async (name): Promise<{ timestampMs?: number; loadLatencyMs: number; entry: ReplayDigestEntry }> => {
const itemStartedAt = performance.now();
const path = join(DIGEST_DIR, name);
const digest = await safeReadJson(path);
const digestDate = asString(digest?.date) ?? name.slice(0, -5);
const markdownName = `${digestDate}.md`;
const fileStat = await safeStat(path);
const timestampMs =
parseTimestampMs(asString(digest?.generatedAt)) ??
parseTimestampMs(asString(digest?.snapshotGeneratedAt)) ??
parseTimestampMs(digestDate);
return {
timestampMs,
loadLatencyMs: performance.now() - itemStartedAt,
entry: {
date: digestDate,
jsonPath: path,
markdownPath: markdownSet.has(markdownName) ? join(DIGEST_DIR, markdownName) : undefined,
generatedAt: asString(digest?.generatedAt),
snapshotGeneratedAt: asString(digest?.snapshotGeneratedAt),
sizeBytes: fileStat?.size ?? 0,
},
};
}),
);
const within = parsed.filter((item) => withinWindow(item.timestampMs, window));
const limited = within.slice(0, limit);
const entries = limited.map((item) => item.entry);
const totalSizeBytes = parsed.reduce((sum, item) => sum + item.entry.sizeBytes, 0);
const returnedSizeBytes = limited.reduce((sum, item) => sum + item.entry.sizeBytes, 0);
return {
entries,
stats: buildReplayFilterStats(parsed.length, within.length, entries.length, {
latencyMs: performance.now() - startedAt,
latencySamplesMs: parsed.map((item) => item.loadLatencyMs),
totalSizeBytes,
returnedSizeBytes,
}),
latencySamplesMs: parsed.map((item) => item.loadLatencyMs),
};
}
async function loadExportArtifacts(
dirPath: string,
limit: number,
window: ReplayWindow,
): Promise<ReplayCollectionResult<ReplayExportSnapshotEntry>> {
const startedAt = performance.now();
let files: string[] = [];
try {
files = await readdir(dirPath);
} catch {
return emptyReplayCollection();
}
const jsonFiles = files.filter((name) => name.endsWith(".json")).sort((a, b) => b.localeCompare(a));
const parsed = await Promise.all(
jsonFiles.map(async (name): Promise<{ timestampMs?: number; loadLatencyMs: number; entry: ReplayExportSnapshotEntry }> => {
const itemStartedAt = performance.now();
const path = join(dirPath, name);
const raw = await safeReadJson(path);
const fileStat = await safeStat(path);
const projects = asArray(asObject(raw?.projects)?.projects).length;
const tasks = asArray(asObject(raw?.tasks)?.tasks).length;
const sessions = asArray(raw?.sessions).length;
const exceptions = asArray(asObject(raw?.exceptionsFeed)?.items).length;
const exportedAt = asString(raw?.exportedAt);
const snapshotGeneratedAt = asString(raw?.snapshotGeneratedAt);
const timestampMs = parseTimestampMs(exportedAt) ?? parseTimestampMs(snapshotGeneratedAt);
return {
timestampMs,
loadLatencyMs: performance.now() - itemStartedAt,
entry: {
fileName: name,
path,
sizeBytes: fileStat?.size ?? 0,
exportedAt,
snapshotGeneratedAt,
requestId: asString(raw?.requestId),
counts: {
projects,
tasks,
sessions,
exceptions,
},
},
};
}),
);
const within = parsed.filter((item) => withinWindow(item.timestampMs, window));
const limited = within.slice(0, limit);
const entries = limited.map((item) => item.entry);
const totalSizeBytes = parsed.reduce((sum, item) => sum + item.entry.sizeBytes, 0);
const returnedSizeBytes = limited.reduce((sum, item) => sum + item.entry.sizeBytes, 0);
return {
entries,
stats: buildReplayFilterStats(parsed.length, within.length, entries.length, {
latencyMs: performance.now() - startedAt,
latencySamplesMs: parsed.map((item) => item.loadLatencyMs),
totalSizeBytes,
returnedSizeBytes,
}),
latencySamplesMs: parsed.map((item) => item.loadLatencyMs),
};
}
interface ReplayCollectionResult<T> {
entries: T[];
stats: ReplayFilterStats;
latencySamplesMs: number[];
}
interface ReplayTimelineResult extends ReplayCollectionResult<ReplayTimelineEntry> {
path: string;
totalLines: number;
}
interface ReplayWindow {
fromMs?: number;
toMs?: number;
fromIso?: string;
toIso?: string;
}
function parseReplayWindow(from?: string, to?: string): ReplayWindow {
const fromMs = parseTimestampMs(from);
const toMs = parseTimestampMs(to);
if (from && fromMs === undefined) {
throw new Error(`Invalid replay from timestamp '${from}'.`);
}
if (to && toMs === undefined) {
throw new Error(`Invalid replay to timestamp '${to}'.`);
}
if (typeof fromMs === "number" && typeof toMs === "number" && fromMs > toMs) {
throw new Error("Invalid replay window: from must be less than or equal to to.");
}
return {
fromMs,
toMs,
fromIso: typeof fromMs === "number" ? new Date(fromMs).toISOString() : undefined,
toIso: typeof toMs === "number" ? new Date(toMs).toISOString() : undefined,
};
}
function withinWindow(timestampMs: number | undefined, window: ReplayWindow): boolean {
if (window.fromMs === undefined && window.toMs === undefined) return true;
if (timestampMs === undefined) return false;
if (window.fromMs !== undefined && timestampMs < window.fromMs) return false;
if (window.toMs !== undefined && timestampMs > window.toMs) return false;
return true;
}
function emptyReplayCollection<T>(): ReplayCollectionResult<T> {
return {
entries: [],
stats: buildReplayFilterStats(0, 0, 0),
latencySamplesMs: [],
};
}
function buildReplayFilterStats(
total: number,
withinWindowCount: number,
returned: number,
extra: {
latencyMs?: number;
latencySamplesMs?: number[];
totalSizeBytes?: number;
returnedSizeBytes?: number;
} = {},
): ReplayFilterStats {
const filteredOutByWindow = Math.max(0, total - withinWindowCount);
const filteredOutByLimit = Math.max(0, withinWindowCount - returned);
const normalizedLatencySamples = normalizeLatencySamples(extra.latencySamplesMs);
return {
total,
returned,
filteredOut: Math.max(0, total - returned),
filteredOutByWindow,
filteredOutByLimit,
latencyMs: Math.max(0, Math.round(extra.latencyMs ?? 0)),
latencyBucketsMs: buildReplayLatencyBuckets(normalizedLatencySamples),
totalSizeBytes: Math.max(0, Math.round(extra.totalSizeBytes ?? 0)),
returnedSizeBytes: Math.max(0, Math.round(extra.returnedSizeBytes ?? 0)),
};
}
function sumReplayFilterStats(
stats: ReplayFilterStats[],
latencySamplesMs: number[] = [],
): ReplayFilterStats {
const summed = stats.reduce<ReplayFilterStats>(
(acc, item) => ({
total: acc.total + item.total,
returned: acc.returned + item.returned,
filteredOut: acc.filteredOut + item.filteredOut,
filteredOutByWindow: acc.filteredOutByWindow + item.filteredOutByWindow,
filteredOutByLimit: acc.filteredOutByLimit + item.filteredOutByLimit,
latencyMs: acc.latencyMs + item.latencyMs,
latencyBucketsMs: acc.latencyBucketsMs,
totalSizeBytes: acc.totalSizeBytes + item.totalSizeBytes,
returnedSizeBytes: acc.returnedSizeBytes + item.returnedSizeBytes,
}),
buildReplayFilterStats(0, 0, 0),
);
return {
...summed,
latencyBucketsMs: buildReplayLatencyBuckets(normalizeLatencySamples(latencySamplesMs)),
};
}
function normalizeLatencySamples(input: number[] | undefined): number[] {
if (!Array.isArray(input)) return [];
return input
.filter((value) => Number.isFinite(value))
.map((value) => Math.max(0, value));
}
function buildReplayLatencyBuckets(samples: number[]): ReplayLatencyBuckets {
return {
p50: percentile(samples, 50),
p95: percentile(samples, 95),
};
}
function percentile(samples: number[], target: number): number {
if (samples.length === 0) return 0;
const sorted = [...samples].sort((a, b) => a - b);
const rank = (Math.min(100, Math.max(0, target)) / 100) * (sorted.length - 1);
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return Math.max(0, Math.round(sorted[lower]));
const weight = rank - lower;
return Math.max(0, Math.round(sorted[lower] + (sorted[upper] - sorted[lower]) * weight));
}
function parseTimestampMs(input: string | undefined): number | undefined {
if (typeof input !== "string" || input.trim() === "") return undefined;
const parsed = Date.parse(input);
return Number.isNaN(parsed) ? undefined : parsed;
}
async function safeReadFile(path: string): Promise<string> {
try {
return await readFile(path, "utf8");
} catch {
return "";
}
}
async function safeReadJson(path: string): Promise<Record<string, unknown> | undefined> {
try {
const text = await readFile(path, "utf8");
const parsed = JSON.parse(text) as unknown;
return asObject(parsed);
} catch {
return undefined;
}
}
async function safeStat(path: string): Promise<{ size: number } | undefined> {
try {
const fileStat = await stat(path);
return { size: fileStat.size };
} catch {
return undefined;
}
}
function asString(input: unknown): string | undefined {
return typeof input === "string" ? input : undefined;
}
function asObject(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
function asArray(input: unknown): unknown[] {
return Array.isArray(input) ? input : [];
}
+967
View File
@@ -0,0 +1,967 @@
import type { ToolClient } from "../clients/tool-client";
import type { SessionsHistoryResponse } from "../contracts/openclaw-tools";
import type {
AgentRunState,
ReadModelSnapshot,
SessionStatusSnapshot,
SessionSummary,
} from "../types";
export interface SessionConversationFilters {
state?: AgentRunState;
agentId?: string;
q?: string;
}
export type SessionHistoryKind = "message" | "tool_event" | "accepted" | "spawn";
export interface SessionExecutionChainSummary {
accepted: boolean;
spawned: boolean;
acceptedAt?: string;
spawnedAt?: string;
parentSessionKey?: string;
childSessionKey?: string;
stage: "idle" | "accepted" | "spawned" | "running";
source: "history" | "session_key";
inferred: boolean;
detail: string;
}
export interface SessionConversationListItem extends SessionSummary {
latestSnippet?: string;
latestRole?: string;
latestKind?: SessionHistoryKind;
latestToolName?: string;
latestHistoryAt?: string;
historyCount: number;
toolEventCount: number;
historyError?: string;
executionChain?: SessionExecutionChainSummary;
}
export interface SessionConversationListResult {
generatedAt: string;
total: number;
page: number;
pageSize: number;
filters: SessionConversationFilters;
items: SessionConversationListItem[];
}
export interface SessionHistoryMessage {
kind: SessionHistoryKind;
role: string;
author?: string;
content: string;
timestamp?: string;
toolName?: string;
toolStatus?: string;
truncated?: boolean;
parentSessionKey?: string;
childSessionKey?: string;
inferred?: boolean;
}
export interface SessionConversationDetailResult {
generatedAt: string;
session: SessionSummary;
status?: SessionStatusSnapshot;
latestSnippet?: string;
latestRole?: string;
latestKind?: SessionHistoryKind;
latestToolName?: string;
latestHistoryAt?: string;
historyCount: number;
history: SessionHistoryMessage[];
historyError?: string;
executionChain?: SessionExecutionChainSummary;
}
export interface SessionConversationListInput {
snapshot: ReadModelSnapshot;
client: ToolClient;
filters: SessionConversationFilters;
page: number;
pageSize: number;
historyLimit: number;
}
export interface SessionConversationDetailInput {
snapshot: ReadModelSnapshot;
client: ToolClient;
sessionKey: string;
historyLimit: number;
}
interface SessionHistoryReadResult {
messages: SessionHistoryMessage[];
error?: string;
}
const HISTORY_ARRAY_KEYS = ["history", "messages", "items", "entries", "events", "conversation"];
const ROLE_KEYS = ["role", "speaker", "source"];
const ROLE_TYPE_KEYS = ["type"];
const AUTHOR_KEYS = ["author", "agent", "agentId", "name", "from"];
const TIME_KEYS = ["timestamp", "time", "createdAt", "updatedAt", "at", "ts"];
const TOOL_NAME_KEYS = [
"toolName",
"tool",
"toolId",
"tool_id",
"function",
"functionName",
"toolCall",
"tool_call",
"action",
];
const TOOL_STATUS_KEYS = ["status", "state", "outcome", "resultStatus"];
const TOOL_INPUT_KEYS = ["arguments", "args", "input", "params", "command", "query", "payload"];
const TOOL_OUTPUT_KEYS = ["result", "output", "response", "return", "observation", "error"];
const TOOL_HINT_KEYS = [
"tool",
"toolName",
"tool_call",
"toolCall",
"function",
"arguments",
"args",
"result",
"output",
];
const EXECUTION_EVENT_KEYS = [
"event",
"kind",
"action",
"type",
"operation",
"eventType",
"name",
"status",
] as const;
const PARENT_SESSION_KEYS = [
"parentSessionKey",
"parent_session_key",
"parentSession",
"parent_session",
"sourceSessionKey",
"source_session_key",
] as const;
const CHILD_SESSION_KEYS = [
"childSessionKey",
"child_session_key",
"spawnedSessionKey",
"spawned_session_key",
"targetSessionKey",
"target_session_key",
"sessionKey",
"session_key",
] as const;
const SESSION_KEY_REGEX = /agent:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)+/g;
const MAX_ENTRY_CONTENT_CHARS = 1200;
const MAX_SNIPPET_CHARS = 220;
const MAX_TOOL_SEGMENT_CHARS = 280;
const KNOWN_ROLE_TYPES = new Set(["user", "assistant", "system", "tool"]);
export async function listSessionConversations(
input: SessionConversationListInput,
): Promise<SessionConversationListResult> {
const page = normalizePage(input.page);
const pageSize = normalizePageSize(input.pageSize);
const historyLimit = normalizeHistoryLimit(input.historyLimit);
const sessions = input.snapshot.sessions
.filter((session) => matchesSession(session, input.filters))
.sort(compareSessions);
const total = sessions.length;
const start = (page - 1) * pageSize;
const paged = sessions.slice(start, start + pageSize);
const items = await Promise.all(
paged.map(async (session) => {
const history = await readSessionHistory(input.client, session.sessionKey, historyLimit);
const latest = pickLatestMessage(history.messages);
return {
...session,
latestSnippet: latest ? summarizeSnippet(latest.content) : undefined,
latestRole: latest?.role,
latestKind: latest?.kind,
latestToolName: latest?.toolName,
latestHistoryAt: latest?.timestamp,
historyCount: history.messages.length,
toolEventCount: history.messages.filter((message) => message.kind === "tool_event").length,
historyError: history.error,
executionChain: inferSessionExecutionChain(session, history.messages),
};
}),
);
return {
generatedAt: new Date().toISOString(),
total,
page,
pageSize,
filters: input.filters,
items,
};
}
export async function getSessionConversationDetail(
input: SessionConversationDetailInput,
): Promise<SessionConversationDetailResult | null> {
const sessionKey = input.sessionKey.trim();
if (!sessionKey) return null;
const session = input.snapshot.sessions.find((item) => item.sessionKey === sessionKey);
if (!session) return null;
const historyLimit = normalizeHistoryLimit(input.historyLimit, 50);
const history = await readSessionHistory(input.client, sessionKey, historyLimit);
const latest = pickLatestMessage(history.messages);
const status = input.snapshot.statuses.find((item) => item.sessionKey === sessionKey);
return {
generatedAt: new Date().toISOString(),
session,
status,
latestSnippet: latest ? summarizeSnippet(latest.content) : undefined,
latestRole: latest?.role,
latestKind: latest?.kind,
latestToolName: latest?.toolName,
latestHistoryAt: latest?.timestamp,
historyCount: history.messages.length,
history: history.messages,
historyError: history.error,
executionChain: inferSessionExecutionChain(session, history.messages),
};
}
export function inferSessionExecutionChainFromSessionKey(
session: SessionSummary,
): SessionExecutionChainSummary | undefined {
return inferSessionExecutionChain(session, []);
}
async function readSessionHistory(
client: ToolClient,
sessionKey: string,
limit: number,
): Promise<SessionHistoryReadResult> {
try {
const response = await client.sessionsHistory({ sessionKey, limit });
return {
messages: normalizeHistoryMessages(response, limit),
};
} catch (error) {
return {
messages: [],
error: error instanceof Error ? error.message : "Failed to read session history.",
};
}
}
function normalizeHistoryMessages(response: SessionsHistoryResponse, limit: number): SessionHistoryMessage[] {
const fromJson = response.json ? normalizeHistoryFromJson(response.json) : [];
const normalized = fromJson.length > 0 ? fromJson : normalizeHistoryFromText(response.rawText);
if (normalized.length <= limit) return normalized;
return normalized.slice(-limit);
}
function normalizeHistoryFromJson(input: unknown): SessionHistoryMessage[] {
const entries = extractHistoryArray(input);
if (entries.length === 0) return [];
const messages: SessionHistoryMessage[] = [];
for (const entry of entries) {
const parsed = parseHistoryEntry(entry);
if (!parsed) continue;
messages.push(parsed);
}
return messages;
}
function extractHistoryArray(input: unknown): unknown[] {
if (Array.isArray(input)) return input;
const obj = asObject(input);
if (!obj) return [];
for (const key of HISTORY_ARRAY_KEYS) {
const value = obj[key];
if (Array.isArray(value)) return value;
}
for (const key of ["data", "result", "payload"]) {
const nested = obj[key];
const nestedObj = asObject(nested);
if (!nestedObj) continue;
for (const historyKey of HISTORY_ARRAY_KEYS) {
const value = nestedObj[historyKey];
if (Array.isArray(value)) return value;
}
}
return [];
}
function parseHistoryEntry(input: unknown): SessionHistoryMessage | null {
if (typeof input === "string") {
const content = normalizeSpace(input);
if (!content) return null;
const executionEvent = parseExecutionEventFromText(content);
if (executionEvent) return executionEvent;
return buildMessageEntry({
kind: "message",
role: "unknown",
content,
});
}
const obj = asObject(input);
if (!obj) return null;
const messageObj = asObject(obj.message);
const executionEvent = parseExecutionEvent(obj);
if (executionEvent) {
return executionEvent;
}
if (looksLikeToolEvent(obj)) {
return parseToolEvent(obj);
}
const role = extractRole(obj, messageObj) ?? "unknown";
const author = extractAuthor(obj, messageObj);
const timestamp = extractTimestamp(obj, messageObj);
const content = extractEntryContent(obj, messageObj);
if (!content) return null;
return buildMessageEntry({
kind: "message",
role,
author,
content,
timestamp,
});
}
function parseToolEvent(obj: Record<string, unknown>): SessionHistoryMessage | null {
const toolName = inferToolName(obj) ?? "tool";
const toolStatus = firstString(obj, TOOL_STATUS_KEYS);
const messageObj = asObject(obj.message);
const role = extractRole(obj, messageObj) ?? "tool";
const author = extractAuthor(obj, messageObj);
const timestamp = extractTimestamp(obj, messageObj);
const inputPreview = firstPreview(obj, TOOL_INPUT_KEYS, MAX_TOOL_SEGMENT_CHARS);
const outputPreview = firstPreview(obj, TOOL_OUTPUT_KEYS, MAX_TOOL_SEGMENT_CHARS);
const fallbackContent = extractEntryContent(obj, messageObj);
const parts: string[] = [];
if (inputPreview) parts.push(`in=${inputPreview}`);
if (outputPreview) parts.push(`out=${outputPreview}`);
const content = parts.length > 0 ? parts.join(" | ") : fallbackContent;
if (!content) return null;
return buildMessageEntry({
kind: "tool_event",
role,
author,
content,
timestamp,
toolName,
toolStatus,
});
}
function parseExecutionEvent(obj: Record<string, unknown>): SessionHistoryMessage | null {
const eventKind = inferExecutionEventKind(obj);
if (!eventKind) return null;
const messageObj = asObject(obj.message);
const role = extractRole(obj, messageObj) ?? "system";
const author = extractAuthor(obj, messageObj);
const timestamp = extractTimestamp(obj, messageObj);
const toolName = inferToolName(obj);
const toolStatus = firstString(obj, TOOL_STATUS_KEYS);
const content = extractEntryContent(obj, messageObj) || `${eventKind} event`;
const refs = extractExecutionSessionRefs(obj, content);
return buildMessageEntry({
kind: eventKind,
role,
author,
content,
timestamp,
toolName,
toolStatus,
parentSessionKey: refs.parentSessionKey,
childSessionKey: refs.childSessionKey,
});
}
function parseExecutionEventFromText(content: string): SessionHistoryMessage | null {
const kind = inferExecutionEventKindFromText(content);
if (!kind) return null;
const refs = extractExecutionSessionRefs({}, content);
return buildMessageEntry({
kind,
role: "system",
content,
parentSessionKey: refs.parentSessionKey,
childSessionKey: refs.childSessionKey,
inferred: true,
});
}
function buildMessageEntry(input: {
kind: SessionHistoryKind;
role: string;
author?: string;
content: string;
timestamp?: string;
toolName?: string;
toolStatus?: string;
parentSessionKey?: string;
childSessionKey?: string;
inferred?: boolean;
}): SessionHistoryMessage {
const truncatedContent = truncateText(input.content, MAX_ENTRY_CONTENT_CHARS);
return {
kind: input.kind,
role: input.role,
author: input.author,
content: truncatedContent.text,
timestamp: input.timestamp,
toolName: input.toolName,
toolStatus: input.toolStatus,
truncated: truncatedContent.truncated || undefined,
parentSessionKey: input.parentSessionKey,
childSessionKey: input.childSessionKey,
inferred: input.inferred || undefined,
};
}
function looksLikeToolEvent(obj: Record<string, unknown>): boolean {
const roleValue = (extractRole(obj, asObject(obj.message)) ?? "").toLowerCase();
if (roleValue.includes("tool")) return true;
const typeValue = (asString(obj.type) ?? "").toLowerCase();
if (typeValue.includes("tool")) return true;
const hasHint = TOOL_HINT_KEYS.some((key) => key in obj);
if (!hasHint) return false;
const hasName = Boolean(inferToolName(obj));
if (hasName) return true;
return TOOL_OUTPUT_KEYS.some((key) => key in obj) && TOOL_INPUT_KEYS.some((key) => key in obj);
}
function inferToolName(obj: Record<string, unknown>): string | undefined {
const direct = firstString(obj, TOOL_NAME_KEYS);
if (direct) return direct;
const toolObj = asObject(obj.tool);
if (toolObj) {
const nested = firstString(toolObj, ["name", "toolName", "id", "key"]);
if (nested) return nested;
}
const functionObj = asObject(obj.function);
if (functionObj) {
const nested = firstString(functionObj, ["name", "toolName"]);
if (nested) return nested;
}
const callObj = asObject(obj.tool_call) ?? asObject(obj.toolCall);
if (callObj) {
const nested = firstString(callObj, ["name", "toolName", "id"]);
if (nested) return nested;
}
return undefined;
}
function inferExecutionEventKind(obj: Record<string, unknown>): Extract<SessionHistoryKind, "accepted" | "spawn"> | undefined {
const messageObj = asObject(obj.message);
const signals = [
...EXECUTION_EVENT_KEYS.map((key) => firstString(obj, [key])),
inferToolName(obj),
extractEntryContent(obj, messageObj),
]
.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.map((item) => item.toLowerCase());
for (const signal of signals) {
const eventKind = inferExecutionEventKindFromText(signal);
if (eventKind) return eventKind;
}
return undefined;
}
function inferExecutionEventKindFromText(input: string): Extract<SessionHistoryKind, "accepted" | "spawn"> | undefined {
const lower = input.toLowerCase();
if (/\bsessions?_spawn\b/.test(lower) || /\bspawn(?:ed|ing)?\b/.test(lower)) return "spawn";
if (/\baccepted\b/.test(lower) || /\baccept(?:ed|ing)?\b/.test(lower)) return "accepted";
return undefined;
}
function extractExecutionSessionRefs(
obj: Record<string, unknown>,
content: string,
): { parentSessionKey?: string; childSessionKey?: string } {
const parentSessionKey = firstString(obj, [...PARENT_SESSION_KEYS]);
const childSessionKey = firstString(obj, [...CHILD_SESSION_KEYS]);
if (parentSessionKey && childSessionKey) {
return { parentSessionKey, childSessionKey };
}
const contentKeys = extractSessionKeys(content);
if (contentKeys.length >= 2) {
return {
parentSessionKey: parentSessionKey ?? contentKeys[0],
childSessionKey: childSessionKey ?? contentKeys[1],
};
}
return {
parentSessionKey,
childSessionKey,
};
}
function extractSessionKeys(input: string): string[] {
return [...new Set(input.match(SESSION_KEY_REGEX) ?? [])];
}
function firstPreview(
obj: Record<string, unknown>,
keys: string[],
maxLength: number,
): string | undefined {
for (const key of keys) {
if (!(key in obj)) continue;
const preview = previewValue(obj[key], maxLength);
if (preview) return preview;
}
return undefined;
}
function previewValue(input: unknown, maxLength: number): string {
if (input === undefined || input === null) return "";
if (typeof input === "string") return truncateText(normalizeSpace(input), maxLength).text;
if (typeof input === "number" || typeof input === "boolean") return String(input);
try {
return truncateText(normalizeSpace(JSON.stringify(input)), maxLength).text;
} catch {
return truncateText(normalizeSpace(String(input)), maxLength).text;
}
}
function extractContent(obj: Record<string, unknown>): string {
const directKeys = [
"content",
"text",
"message",
"body",
"prompt",
"output",
"value",
"summary",
"response",
];
for (const key of directKeys) {
const text = extractText(obj[key], 0);
if (text) return truncateText(normalizeSpace(text), MAX_ENTRY_CONTENT_CHARS).text;
}
return "";
}
function extractText(input: unknown, depth: number): string {
if (depth > 4 || input === null || input === undefined) return "";
if (typeof input === "string") return input;
if (typeof input === "number" || typeof input === "boolean") return String(input);
if (Array.isArray(input)) {
const textBlocks = input
.map((item) => extractStructuredTextBlock(item, depth + 1))
.filter((item) => item.trim() !== "");
if (textBlocks.length > 0) {
return textBlocks.join(" ");
}
const thinkingBlocks = input
.map((item) => extractStructuredThinkingBlock(item, depth + 1))
.filter((item) => item.trim() !== "");
if (thinkingBlocks.length > 0) {
return thinkingBlocks.join(" ");
}
return input
.map((item) => extractText(item, depth + 1))
.filter((item) => item.trim() !== "")
.join(" ");
}
const obj = asObject(input);
if (!obj) return "";
const structured = extractStructuredContentBlock(obj, depth);
if (structured.trim() !== "") return structured;
for (const key of ["text", "thinking", "content", "message", "body", "value", "summary", "output", "response"]) {
const text = extractText(obj[key], depth + 1);
if (text.trim() !== "") return text;
}
return "";
}
function extractStructuredContentBlock(input: unknown, depth: number): string {
return (
extractStructuredTextBlock(input, depth) ||
extractStructuredThinkingBlock(input, depth)
);
}
function extractStructuredTextBlock(input: unknown, depth: number): string {
const obj = asObject(input);
if (!obj || depth > 4) return "";
const blockType = (firstString(obj, ROLE_TYPE_KEYS) ?? "").toLowerCase();
if (blockType === "text" || blockType === "summary_text") {
return extractText(obj.text ?? obj.content ?? obj.value, depth + 1);
}
return "";
}
function extractStructuredThinkingBlock(input: unknown, depth: number): string {
const obj = asObject(input);
if (!obj || depth > 4) return "";
const blockType = (firstString(obj, ROLE_TYPE_KEYS) ?? "").toLowerCase();
if (blockType === "thinking") {
return extractText(obj.thinking ?? obj.text ?? obj.summary, depth + 1);
}
return "";
}
function extractRole(
obj: Record<string, unknown>,
messageObj?: Record<string, unknown>,
): string | undefined {
for (const candidate of [messageObj, obj]) {
if (!candidate) continue;
const direct = firstString(candidate, ROLE_KEYS);
if (direct) return direct;
const roleLikeType = firstString(candidate, ROLE_TYPE_KEYS)?.toLowerCase();
if (roleLikeType && KNOWN_ROLE_TYPES.has(roleLikeType)) {
return roleLikeType;
}
}
return undefined;
}
function extractAuthor(
obj: Record<string, unknown>,
messageObj?: Record<string, unknown>,
): string | undefined {
return (messageObj ? firstString(messageObj, AUTHOR_KEYS) : undefined) ?? firstString(obj, AUTHOR_KEYS);
}
function extractTimestamp(
obj: Record<string, unknown>,
messageObj?: Record<string, unknown>,
): string | undefined {
return normalizeTimestamp(firstValue(obj, TIME_KEYS) ?? firstValue(messageObj ?? {}, TIME_KEYS));
}
function extractEntryContent(
obj: Record<string, unknown>,
messageObj?: Record<string, unknown>,
): string {
if (messageObj) {
const nested = extractContent(messageObj);
if (nested) return nested;
}
return extractContent(obj);
}
function normalizeHistoryFromText(raw: string): SessionHistoryMessage[] {
if (!raw.trim()) return [];
return raw
.split(/\r?\n/)
.map((line) => normalizeSpace(line))
.filter((line) => line !== "")
.map((line) => parseHistoryEntry(line))
.filter((item): item is SessionHistoryMessage => Boolean(item));
}
function pickLatestMessage(messages: SessionHistoryMessage[]): SessionHistoryMessage | undefined {
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
const candidate = messages[idx];
if (candidate.content.trim() !== "") return candidate;
}
return undefined;
}
function inferSessionExecutionChain(
session: SessionSummary,
messages: SessionHistoryMessage[],
): SessionExecutionChainSummary | undefined {
const fromHistory = inferSessionExecutionChainFromHistory(session, messages);
if (fromHistory) return fromHistory;
return inferSessionExecutionChainFromKey(session);
}
function inferSessionExecutionChainFromHistory(
session: SessionSummary,
messages: SessionHistoryMessage[],
): SessionExecutionChainSummary | undefined {
const acceptedEntries = messages.filter((message) => message.kind === "accepted");
const spawnEntries = messages.filter((message) => message.kind === "spawn");
if (acceptedEntries.length === 0 && spawnEntries.length === 0) {
return undefined;
}
const acceptedEntry = acceptedEntries.at(-1);
const spawnEntry = spawnEntries.at(-1);
const fallbackFromKey = inferSessionExecutionChainFromKey(session);
const accepted = acceptedEntries.length > 0 || spawnEntries.length > 0 || Boolean(fallbackFromKey?.accepted);
const spawned = spawnEntries.length > 0 || Boolean(fallbackFromKey?.spawned);
const acceptedAt = acceptedEntry?.timestamp ?? spawnEntry?.timestamp ?? fallbackFromKey?.acceptedAt;
const spawnedAt = spawnEntry?.timestamp ?? fallbackFromKey?.spawnedAt;
const parentSessionKey =
spawnEntry?.parentSessionKey ??
acceptedEntry?.parentSessionKey ??
fallbackFromKey?.parentSessionKey ??
(spawnEntries.length > 0 ? session.sessionKey : undefined);
const childSessionKey =
spawnEntry?.childSessionKey ??
acceptedEntry?.childSessionKey ??
fallbackFromKey?.childSessionKey ??
(isRunSessionKey(session.sessionKey) ? session.sessionKey : undefined);
const stage = resolveExecutionChainStage(session.state, accepted, spawned);
const inferred =
Boolean(acceptedEntry?.inferred) ||
Boolean(spawnEntry?.inferred) ||
(spawnEntries.length === 0 && Boolean(fallbackFromKey?.spawned)) ||
(acceptedEntries.length === 0 && Boolean(fallbackFromKey?.accepted));
return {
accepted,
spawned,
acceptedAt,
spawnedAt,
parentSessionKey,
childSessionKey,
stage,
source: "history",
inferred,
detail: buildExecutionChainDetail({
session,
accepted,
spawned,
acceptedAt,
spawnedAt,
parentSessionKey,
childSessionKey,
source: "history",
inferred,
}),
};
}
function inferSessionExecutionChainFromKey(
session: SessionSummary,
): SessionExecutionChainSummary | undefined {
const parentSessionKey = inferParentSessionKey(session.sessionKey);
if (!parentSessionKey) return undefined;
const accepted = true;
const spawned = true;
const stage = resolveExecutionChainStage(session.state, accepted, spawned);
return {
accepted,
spawned,
acceptedAt: session.lastMessageAt,
spawnedAt: session.lastMessageAt,
parentSessionKey,
childSessionKey: session.sessionKey,
stage,
source: "session_key",
inferred: true,
detail: buildExecutionChainDetail({
session,
accepted,
spawned,
acceptedAt: session.lastMessageAt,
spawnedAt: session.lastMessageAt,
parentSessionKey,
childSessionKey: session.sessionKey,
source: "session_key",
inferred: true,
}),
};
}
function resolveExecutionChainStage(
sessionState: AgentRunState,
accepted: boolean,
spawned: boolean,
): SessionExecutionChainSummary["stage"] {
if (sessionState === "running" || sessionState === "blocked" || sessionState === "waiting_approval" || sessionState === "error") {
return "running";
}
if (spawned) return "spawned";
if (accepted) return "accepted";
return "idle";
}
function inferParentSessionKey(sessionKey: string): string | undefined {
const marker = ":run:";
const markerIndex = sessionKey.indexOf(marker);
if (markerIndex <= 0) return undefined;
return sessionKey.slice(0, markerIndex);
}
function isRunSessionKey(sessionKey: string): boolean {
return inferParentSessionKey(sessionKey) !== undefined;
}
function buildExecutionChainDetail(input: {
session: SessionSummary;
accepted: boolean;
spawned: boolean;
acceptedAt?: string;
spawnedAt?: string;
parentSessionKey?: string;
childSessionKey?: string;
source: SessionExecutionChainSummary["source"];
inferred: boolean;
}): string {
const parts: string[] = [];
parts.push(`accepted=${input.accepted ? "yes" : "no"}`);
parts.push(`spawned=${input.spawned ? "yes" : "no"}`);
if (input.parentSessionKey) parts.push(`parent=${input.parentSessionKey}`);
if (input.childSessionKey) parts.push(`child=${input.childSessionKey}`);
if (input.acceptedAt) parts.push(`acceptedAt=${input.acceptedAt}`);
if (input.spawnedAt) parts.push(`spawnedAt=${input.spawnedAt}`);
parts.push(`source=${input.source}`);
if (input.inferred) parts.push("inferred=yes");
return parts.join(" | ");
}
function compareSessions(a: SessionSummary, b: SessionSummary): number {
const aTs = toMs(a.lastMessageAt);
const bTs = toMs(b.lastMessageAt);
if (aTs !== bTs) return bTs - aTs;
return a.sessionKey.localeCompare(b.sessionKey);
}
function matchesSession(session: SessionSummary, filters: SessionConversationFilters): boolean {
if (filters.state && session.state !== filters.state) return false;
if (filters.agentId && (session.agentId ?? "").toLowerCase() !== filters.agentId.toLowerCase()) return false;
const q = filters.q?.trim().toLowerCase();
if (!q) return true;
return (
session.sessionKey.toLowerCase().includes(q) ||
(session.label ?? "").toLowerCase().includes(q) ||
(session.agentId ?? "").toLowerCase().includes(q)
);
}
function normalizeTimestamp(value: unknown): string | undefined {
if (typeof value === "string") {
const ms = Date.parse(value);
if (!Number.isNaN(ms)) return new Date(ms).toISOString();
return undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return new Date(value).toISOString();
}
return undefined;
}
function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
const value = firstValue(obj, keys);
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
}
function firstValue(obj: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (key in obj) return obj[key];
}
return undefined;
}
function summarizeSnippet(input: string): string {
const cleaned = normalizeSpace(input);
if (cleaned.length <= MAX_SNIPPET_CHARS) return cleaned;
return `${cleaned.slice(0, MAX_SNIPPET_CHARS - 3)}...`;
}
function normalizeSpace(input: string): string {
return input.replace(/\s+/g, " ").trim();
}
function truncateText(input: string, maxLength: number): { text: string; truncated: boolean } {
if (input.length <= maxLength) {
return { text: input, truncated: false };
}
if (maxLength <= 3) {
return { text: input.slice(0, Math.max(0, maxLength)), truncated: true };
}
return {
text: `${input.slice(0, maxLength - 3)}...`,
truncated: true,
};
}
function toMs(value: string | undefined): number {
if (!value) return 0;
const ms = Date.parse(value);
return Number.isNaN(ms) ? 0 : ms;
}
function normalizePage(input: number): number {
if (!Number.isFinite(input)) return 1;
return Math.max(1, Math.trunc(input));
}
function normalizePageSize(input: number): number {
if (!Number.isFinite(input)) return 20;
return Math.max(1, Math.min(100, Math.trunc(input)));
}
function normalizeHistoryLimit(input: number, fallback = 8): number {
if (!Number.isFinite(input)) return fallback;
return Math.max(1, Math.min(200, Math.trunc(input)));
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
+85
View File
@@ -0,0 +1,85 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ReadModelSnapshot } from "../types";
export interface SnapshotDiff {
sessionsDelta: number;
statusesDelta: number;
cronJobsDelta: number;
approvalsDelta: number;
projectsDelta: number;
tasksDelta: number;
budgetEvaluationsDelta: number;
}
export interface SnapshotStoreResult {
path: string;
diff: SnapshotDiff;
}
const RUNTIME_DIR = join(process.cwd(), "runtime");
const LAST_SNAPSHOT_PATH = join(RUNTIME_DIR, "last-snapshot.json");
function countOf<T>(items: T[] | undefined): number {
return Array.isArray(items) ? items.length : 0;
}
function computeDiff(prev: ReadModelSnapshot | null, next: ReadModelSnapshot): SnapshotDiff {
if (!prev) {
return {
sessionsDelta: countOf(next.sessions),
statusesDelta: countOf(next.statuses),
cronJobsDelta: countOf(next.cronJobs),
approvalsDelta: countOf(next.approvals),
projectsDelta: countOf(next.projects.projects),
tasksDelta: next.tasksSummary.tasks,
budgetEvaluationsDelta: next.budgetSummary.total,
};
}
const prevTasks = (prev as Partial<ReadModelSnapshot>).tasksSummary?.tasks ?? 0;
const prevBudgets = (prev as Partial<ReadModelSnapshot>).budgetSummary?.total ?? 0;
return {
sessionsDelta: countOf(next.sessions) - countOf(prev.sessions),
statusesDelta: countOf(next.statuses) - countOf(prev.statuses),
cronJobsDelta: countOf(next.cronJobs) - countOf(prev.cronJobs),
approvalsDelta: countOf(next.approvals) - countOf(prev.approvals),
projectsDelta:
countOf(next.projects.projects) - countOf((prev as Partial<ReadModelSnapshot>).projects?.projects),
tasksDelta: next.tasksSummary.tasks - prevTasks,
budgetEvaluationsDelta: next.budgetSummary.total - prevBudgets,
};
}
async function readPreviousSnapshot(): Promise<ReadModelSnapshot | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const raw = await readFile(LAST_SNAPSHOT_PATH, "utf8");
return JSON.parse(raw) as ReadModelSnapshot;
} catch {
if (attempt === 2) return null;
await delay(25 * (attempt + 1));
}
}
return null;
}
export async function saveSnapshot(next: ReadModelSnapshot): Promise<SnapshotStoreResult> {
const prev = await readPreviousSnapshot();
const diff = computeDiff(prev, next);
await mkdir(dirname(LAST_SNAPSHOT_PATH), { recursive: true });
const tempPath = `${LAST_SNAPSHOT_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await writeFile(tempPath, JSON.stringify(next, null, 2), "utf8");
await rename(tempPath, LAST_SNAPSHOT_PATH);
return {
path: LAST_SNAPSHOT_PATH,
diff,
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+292
View File
@@ -0,0 +1,292 @@
import { appendFile, mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import {
LOCAL_API_TOKEN,
LOCAL_TOKEN_AUTH_REQUIRED,
TASK_HEARTBEAT_DRY_RUN,
TASK_HEARTBEAT_ENABLED,
TASK_HEARTBEAT_MAX_TASKS_PER_RUN,
} from "../config";
import type { ProjectTask, TaskStoreSnapshot } from "../types";
import { loadTaskStore, saveTaskStore } from "./task-store";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const TASK_HEARTBEAT_LOG_PATH = join(RUNTIME_DIR, "task-heartbeat.log");
const DEFAULT_RECENT_RUN_LIMIT = 20;
const UNASSIGNED_OWNER_VALUES = new Set(["", "unassigned", "none", "n/a", "unknown", "na"]);
export interface TaskHeartbeatGate {
enabled: boolean;
dryRun: boolean;
maxTasksPerRun: number;
localTokenAuthRequired: boolean;
localTokenConfigured: boolean;
}
export interface TaskHeartbeatSelection {
projectId: string;
taskId: string;
title: string;
owner: string;
dueAt?: string;
fromStatus: "todo";
toStatus: "in_progress";
}
export interface TaskHeartbeatResult {
ok: boolean;
mode: "blocked" | "dry_run" | "live";
message: string;
evaluatedAt: string;
gate: TaskHeartbeatGate;
checked: number;
eligible: number;
selected: number;
executed: number;
selections: TaskHeartbeatSelection[];
taskStorePath?: string;
logPath: string;
}
export interface TaskHeartbeatRunsSnapshot {
path: string;
count: number;
runs: TaskHeartbeatResult[];
}
export interface RunTaskHeartbeatOptions {
gate?: TaskHeartbeatGate;
}
export function runtimeTaskHeartbeatGate(): TaskHeartbeatGate {
return {
enabled: TASK_HEARTBEAT_ENABLED,
dryRun: TASK_HEARTBEAT_DRY_RUN,
maxTasksPerRun: TASK_HEARTBEAT_MAX_TASKS_PER_RUN,
localTokenAuthRequired: LOCAL_TOKEN_AUTH_REQUIRED,
localTokenConfigured: LOCAL_API_TOKEN !== "",
};
}
export function selectHeartbeatTasks(
store: Pick<TaskStoreSnapshot, "tasks">,
maxTasksPerRun: number,
): TaskHeartbeatSelection[] {
const safeMax = Number.isFinite(maxTasksPerRun) && maxTasksPerRun > 0 ? Math.floor(maxTasksPerRun) : 0;
if (safeMax === 0) return [];
return store.tasks
.filter((task) => task.status === "todo" && isAssignedOwner(task.owner))
.sort(compareHeartbeatCandidateTasks)
.slice(0, safeMax)
.map((task) => ({
projectId: task.projectId,
taskId: task.taskId,
title: task.title,
owner: task.owner,
dueAt: task.dueAt,
fromStatus: "todo",
toStatus: "in_progress",
}));
}
export async function runTaskHeartbeat(
options: RunTaskHeartbeatOptions = {},
): Promise<TaskHeartbeatResult> {
const gate = options.gate ?? runtimeTaskHeartbeatGate();
const evaluatedAt = new Date().toISOString();
try {
const store = await loadTaskStore();
const selections = selectHeartbeatTasks(store, gate.maxTasksPerRun);
const base = {
evaluatedAt,
gate,
checked: store.tasks.length,
eligible: store.tasks.filter((task) => task.status === "todo" && isAssignedOwner(task.owner)).length,
selected: selections.length,
selections,
logPath: TASK_HEARTBEAT_LOG_PATH,
};
if (!gate.enabled) {
return await writeHeartbeatAudit({
ok: false,
mode: "blocked",
message: "Task heartbeat is disabled by runtime gate.",
executed: 0,
...base,
});
}
if (gate.maxTasksPerRun <= 0) {
return await writeHeartbeatAudit({
ok: false,
mode: "blocked",
message: "Task heartbeat maxTasksPerRun must be > 0.",
executed: 0,
...base,
});
}
if (gate.dryRun) {
return await writeHeartbeatAudit({
ok: true,
mode: "dry_run",
message:
selections.length === 0
? "Heartbeat dry-run found no assigned backlog tasks."
: `Heartbeat dry-run selected ${selections.length} assigned backlog task(s).`,
executed: 0,
...base,
});
}
if (gate.localTokenAuthRequired && !gate.localTokenConfigured) {
return await writeHeartbeatAudit({
ok: false,
mode: "blocked",
message: "Task heartbeat live mode requires LOCAL_API_TOKEN when local token auth gate is enabled.",
executed: 0,
...base,
});
}
if (selections.length === 0) {
return await writeHeartbeatAudit({
ok: true,
mode: "live",
message: "Heartbeat live run found no assigned backlog tasks.",
executed: 0,
...base,
});
}
const selectionKeys = new Set(selections.map((item) => taskSelectionKey(item.projectId, item.taskId)));
const updatedAt = new Date().toISOString();
const nextStore: TaskStoreSnapshot = {
...store,
tasks: store.tasks.map((task) => {
if (!selectionKeys.has(taskSelectionKey(task.projectId, task.taskId))) {
return task;
}
return {
...task,
status: "in_progress",
updatedAt,
};
}),
updatedAt,
};
const taskStorePath = await saveTaskStore(nextStore);
return await writeHeartbeatAudit({
ok: true,
mode: "live",
message: `Heartbeat started ${selections.length} assigned backlog task(s).`,
executed: selections.length,
taskStorePath,
...base,
});
} catch (error) {
return await writeHeartbeatAudit({
ok: false,
mode: "blocked",
message: error instanceof Error ? error.message : "Task heartbeat failed.",
evaluatedAt,
gate,
checked: 0,
eligible: 0,
selected: 0,
executed: 0,
selections: [],
logPath: TASK_HEARTBEAT_LOG_PATH,
});
}
}
export async function readTaskHeartbeatRuns(limit = DEFAULT_RECENT_RUN_LIMIT): Promise<TaskHeartbeatRunsSnapshot> {
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 200) : DEFAULT_RECENT_RUN_LIMIT;
try {
const raw = await readFile(TASK_HEARTBEAT_LOG_PATH, "utf8");
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const runs: TaskHeartbeatResult[] = [];
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (runs.length >= safeLimit) break;
try {
const parsed = JSON.parse(lines[index]) as TaskHeartbeatResult;
if (parsed && typeof parsed === "object" && typeof parsed.evaluatedAt === "string") {
runs.push(parsed);
}
} catch {
continue;
}
}
return {
path: TASK_HEARTBEAT_LOG_PATH,
count: runs.length,
runs,
};
} catch {
return {
path: TASK_HEARTBEAT_LOG_PATH,
count: 0,
runs: [],
};
}
}
async function writeHeartbeatAudit(result: TaskHeartbeatResult): Promise<TaskHeartbeatResult> {
await mkdir(RUNTIME_DIR, { recursive: true });
await appendFile(TASK_HEARTBEAT_LOG_PATH, `${JSON.stringify(result)}\n`, "utf8");
return result;
}
function compareHeartbeatCandidateTasks(a: ProjectTask, b: ProjectTask): number {
const dueDiff = compareOptionalIsoAscending(a.dueAt, b.dueAt);
if (dueDiff !== 0) return dueDiff;
const updatedDiff = compareOptionalIsoAscending(a.updatedAt, b.updatedAt);
if (updatedDiff !== 0) return updatedDiff;
const ownerDiff = a.owner.localeCompare(b.owner);
if (ownerDiff !== 0) return ownerDiff;
const projectDiff = a.projectId.localeCompare(b.projectId);
if (projectDiff !== 0) return projectDiff;
return a.taskId.localeCompare(b.taskId);
}
function compareOptionalIsoAscending(left: string | undefined, right: string | undefined): number {
if (left && right) {
const leftMs = Date.parse(left);
const rightMs = Date.parse(right);
if (Number.isFinite(leftMs) && Number.isFinite(rightMs)) {
return leftMs - rightMs;
}
return left.localeCompare(right);
}
if (left) return -1;
if (right) return 1;
return 0;
}
function isAssignedOwner(owner: string | undefined): boolean {
if (!owner) return false;
const normalized = owner.trim().toLowerCase();
if (normalized === "") return false;
return !UNASSIGNED_OWNER_VALUES.has(normalized);
}
function taskSelectionKey(projectId: string, taskId: string): string {
return `${projectId}::${taskId}`;
}
+692
View File
@@ -0,0 +1,692 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { loadProjectStore } from "./project-store";
import type {
AgentBudgetPlan,
BudgetThresholds,
ProjectTask,
RollbackPlan,
TaskArtifact,
TaskListItem,
TaskState,
TaskStoreSnapshot,
} from "../types";
const RUNTIME_DIR = join(process.cwd(), "runtime");
export const TASKS_PATH = join(RUNTIME_DIR, "tasks.json");
const DEFAULT_WARN_RATIO = 0.8;
const PROJECT_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
const TASK_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
const EMPTY_STORE: TaskStoreSnapshot = {
tasks: [],
agentBudgets: [],
updatedAt: "1970-01-01T00:00:00.000Z",
};
export class TaskStoreValidationError extends Error {
readonly statusCode: number;
readonly issues: string[];
constructor(message: string, issues: string[] = [], statusCode = 400) {
super(message);
this.name = "TaskStoreValidationError";
this.issues = issues;
this.statusCode = statusCode;
}
}
export interface CreateTaskInput {
projectId: string;
taskId: string;
title: string;
status?: TaskState;
owner?: string;
dueAt?: string;
definitionOfDone?: string[];
artifacts?: TaskArtifact[];
rollback?: RollbackPlan;
sessionKeys?: string[];
budget?: BudgetThresholds;
}
export interface UpdateTaskStatusInput {
taskId: string;
status: TaskState;
projectId?: string;
}
export interface TaskMutationResult {
path: string;
projectId: string;
projectTitle: string;
task: ProjectTask;
}
export async function loadTaskStore(): Promise<TaskStoreSnapshot> {
try {
const raw = await readFile(TASKS_PATH, "utf8");
return normalizeTaskStore(JSON.parse(raw));
} catch {
return cloneEmptyStore();
}
}
export async function saveTaskStore(next: TaskStoreSnapshot): Promise<string> {
const normalized = normalizeTaskStore({
...next,
updatedAt: new Date().toISOString(),
});
await mkdir(RUNTIME_DIR, { recursive: true });
await writeFile(TASKS_PATH, JSON.stringify(normalized, null, 2), "utf8");
return TASKS_PATH;
}
export function listTasks(
store: TaskStoreSnapshot,
projectTitleById: Map<string, string> = new Map<string, string>(),
): TaskListItem[] {
return [...store.tasks]
.map((task) => ({
projectId: task.projectId,
projectTitle: projectTitleById.get(task.projectId) ?? task.projectId,
taskId: task.taskId,
title: task.title,
status: task.status,
owner: task.owner,
dueAt: task.dueAt,
sessionKeys: task.sessionKeys,
updatedAt: task.updatedAt,
}))
.sort((a, b) => {
if (a.dueAt && b.dueAt) return a.dueAt.localeCompare(b.dueAt);
if (a.dueAt) return -1;
if (b.dueAt) return 1;
return a.taskId.localeCompare(b.taskId);
});
}
export async function createTask(input: unknown): Promise<TaskMutationResult> {
const payload = validateCreateTaskInput(input);
const [store, projectStore] = await Promise.all([loadTaskStore(), loadProjectStore()]);
const project = projectStore.projects.find((item) => item.projectId === payload.projectId);
if (!project) {
throw new TaskStoreValidationError(`projectId '${payload.projectId}' not found.`, ["projectId"], 404);
}
if (findTaskMatches(store, payload.taskId).length > 0) {
throw new TaskStoreValidationError(`taskId '${payload.taskId}' already exists.`, ["taskId"], 409);
}
const now = new Date().toISOString();
const task: ProjectTask = {
projectId: payload.projectId,
taskId: payload.taskId,
title: payload.title,
status: payload.status ?? "todo",
owner: payload.owner ?? "unassigned",
dueAt: payload.dueAt,
definitionOfDone: payload.definitionOfDone ?? [],
artifacts: payload.artifacts ?? [],
rollback:
payload.rollback ?? {
strategy: "manual-rollback",
steps: [],
},
sessionKeys: payload.sessionKeys ?? [],
budget: payload.budget ?? normalizeThresholds(undefined),
updatedAt: now,
};
store.tasks.push(task);
store.updatedAt = now;
const path = await saveTaskStore(store);
return {
path,
projectId: task.projectId,
projectTitle: project.title,
task,
};
}
export async function updateTaskStatus(input: unknown): Promise<TaskMutationResult> {
const payload = validateUpdateTaskStatusInput(input);
const [store, projectStore] = await Promise.all([loadTaskStore(), loadProjectStore()]);
const matches = findTaskMatches(store, payload.taskId, payload.projectId);
if (matches.length === 0) {
throw new TaskStoreValidationError(
`taskId '${payload.taskId}' was not found${payload.projectId ? ` in project '${payload.projectId}'` : ""}.`,
[],
404,
);
}
if (matches.length > 1) {
throw new TaskStoreValidationError(
`taskId '${payload.taskId}' is ambiguous. Provide projectId.`,
["projectId"],
409,
);
}
const target = matches[0];
const project = projectStore.projects.find((item) => item.projectId === target.task.projectId);
if (!project) {
throw new TaskStoreValidationError(
`projectId '${target.task.projectId}' referenced by task '${target.task.taskId}' was not found.`,
["projectId"],
409,
);
}
const now = new Date().toISOString();
target.task.status = payload.status;
target.task.updatedAt = now;
store.updatedAt = now;
const path = await saveTaskStore(store);
return {
path,
projectId: target.task.projectId,
projectTitle: project.title,
task: target.task,
};
}
function findTaskMatches(
store: TaskStoreSnapshot,
taskId: string,
projectId?: string,
): Array<{ task: ProjectTask }> {
const matches: Array<{ task: ProjectTask }> = [];
for (const task of store.tasks) {
if (projectId && task.projectId !== projectId) continue;
if (task.taskId === taskId) {
matches.push({ task });
}
}
return matches;
}
function validateCreateTaskInput(input: unknown): CreateTaskInput {
const obj = ensureObject(input, "create task payload");
const issues: string[] = [];
const projectId = requiredProjectId(obj.projectId, "projectId", issues);
const taskId = requiredTaskId(obj.taskId, "taskId", issues);
const title = requiredBoundedString(obj.title, "title", 180, issues);
const status = optionalTaskState(obj.status, "status", issues);
const owner = optionalBoundedString(obj.owner, "owner", 80, issues);
const dueAt = optionalIsoString(obj.dueAt, "dueAt", issues);
const definitionOfDone = optionalStringArray(obj.definitionOfDone, "definitionOfDone", issues);
const sessionKeys = optionalStringArray(obj.sessionKeys, "sessionKeys", issues);
const artifacts = optionalArtifacts(obj.artifacts, "artifacts", issues);
const rollback = optionalRollback(obj.rollback, "rollback", issues);
const budget = optionalBudget(obj.budget, "budget", issues);
if (issues.length > 0) {
throw new TaskStoreValidationError("Invalid create task payload.", issues, 400);
}
return {
projectId,
taskId,
title,
status,
owner,
dueAt,
definitionOfDone,
sessionKeys,
artifacts,
rollback,
budget,
};
}
function validateUpdateTaskStatusInput(input: unknown): UpdateTaskStatusInput {
const obj = ensureObject(input, "update task status payload");
const issues: string[] = [];
const taskId = requiredTaskId(obj.taskId, "taskId", issues);
const projectId = optionalProjectId(obj.projectId, "projectId", issues);
const status = requiredTaskState(obj.status, "status", issues);
if (issues.length > 0) {
throw new TaskStoreValidationError("Invalid update status payload.", issues, 400);
}
return { taskId, status, projectId };
}
function normalizeTaskStore(input: unknown): TaskStoreSnapshot {
const obj = asObject(input);
if (!obj) return cloneEmptyStore();
const tasks =
normalizeTasks(asArray(obj.tasks)) ??
normalizeLegacyProjectTasks(asArray(obj.projects));
return {
tasks,
agentBudgets: normalizeAgentBudgets(asArray(obj.agentBudgets)),
updatedAt: asIsoString(obj.updatedAt),
};
}
function normalizeLegacyProjectTasks(projects: unknown[] | undefined): ProjectTask[] {
if (!projects) return [];
const out: ProjectTask[] = [];
for (const project of projects) {
const projectObj = asObject(project);
if (!projectObj) continue;
const projectId = asString(projectObj.projectId);
if (!projectId) continue;
const tasks = asArray(projectObj.tasks);
if (!tasks) continue;
for (const task of tasks) {
const normalized = normalizeTask(task, projectId);
if (normalized) out.push(normalized);
}
}
return out;
}
function normalizeTasks(tasks: unknown[] | undefined): ProjectTask[] | undefined {
if (!tasks) return undefined;
return tasks
.map((task) => normalizeTask(task))
.filter((task): task is ProjectTask => Boolean(task));
}
function normalizeTask(input: unknown, fallbackProjectId?: string): ProjectTask | null {
const obj = asObject(input);
if (!obj) return null;
const taskId = asString(obj.taskId);
if (!taskId) return null;
const projectId = asString(obj.projectId) ?? fallbackProjectId;
if (!projectId) return null;
return {
projectId,
taskId,
title: asString(obj.title) ?? taskId,
status: normalizeTaskState(asString(obj.status)),
owner: asString(obj.owner) ?? "unassigned",
dueAt: asOptionalIsoString(obj.dueAt),
definitionOfDone: toStringArray(obj.definitionOfDone),
artifacts: normalizeArtifacts(asArray(obj.artifacts)),
rollback: normalizeRollback(asObject(obj.rollback)),
sessionKeys: toStringArray(obj.sessionKeys),
budget: normalizeThresholds(asObject(obj.budget)),
updatedAt: asIsoString(obj.updatedAt),
};
}
function normalizeAgentBudgets(agentBudgets: unknown[] | undefined): AgentBudgetPlan[] {
if (!agentBudgets) return [];
return agentBudgets
.map((agentBudget) => normalizeAgentBudget(agentBudget))
.filter((agentBudget): agentBudget is AgentBudgetPlan => Boolean(agentBudget));
}
function normalizeAgentBudget(input: unknown): AgentBudgetPlan | null {
const obj = asObject(input);
if (!obj) return null;
const agentId = asString(obj.agentId);
if (!agentId) return null;
return {
agentId,
label: asString(obj.label),
thresholds: normalizeThresholds(asObject(obj.thresholds)),
};
}
function normalizeArtifacts(artifacts: unknown[] | undefined): TaskArtifact[] {
if (!artifacts) return [];
const normalized: TaskArtifact[] = [];
for (const item of artifacts) {
const obj = asObject(item);
if (!obj) continue;
const artifactId = asString(obj.artifactId);
const label = asString(obj.label);
const location = asString(obj.location);
if (!artifactId || !label || !location) continue;
const type = asString(obj.type);
normalized.push({
artifactId,
type: type === "code" || type === "doc" || type === "link" || type === "other" ? type : "other",
label,
location,
});
}
return normalized;
}
function normalizeRollback(input: Record<string, unknown> | undefined): RollbackPlan {
if (!input) {
return {
strategy: "manual-rollback",
steps: [],
};
}
return {
strategy: asString(input.strategy) ?? "manual-rollback",
steps: toStringArray(input.steps),
verification: asString(input.verification),
};
}
function normalizeTaskState(input: string | undefined): TaskState {
if (input === "todo" || input === "in_progress" || input === "blocked" || input === "done") {
return input;
}
return "todo";
}
function normalizeThresholds(input: Record<string, unknown> | undefined): BudgetThresholds {
const warnRatio = asNumber(input?.warnRatio) ?? DEFAULT_WARN_RATIO;
return {
tokensIn: asPositiveNumber(input?.tokensIn),
tokensOut: asPositiveNumber(input?.tokensOut),
totalTokens: asPositiveNumber(input?.totalTokens),
cost: asPositiveNumber(input?.cost),
warnRatio: warnRatio > 0 && warnRatio < 1 ? warnRatio : DEFAULT_WARN_RATIO,
};
}
function cloneEmptyStore(): TaskStoreSnapshot {
return {
tasks: [],
agentBudgets: [],
updatedAt: EMPTY_STORE.updatedAt,
};
}
function ensureObject(input: unknown, label: string): Record<string, unknown> {
const obj = asObject(input);
if (!obj) throw new TaskStoreValidationError(`${label} must be a JSON object.`, [], 400);
return obj;
}
function requiredProjectId(value: unknown, field: string, issues: string[]): string {
if (typeof value !== "string" || value.trim() === "") {
issues.push(`${field} must be a non-empty string`);
return "";
}
const trimmed = value.trim();
if (!PROJECT_ID_REGEX.test(trimmed)) {
issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`);
}
if (trimmed.length > 100) {
issues.push(`${field} must be <= 100 characters`);
}
return trimmed;
}
function optionalProjectId(value: unknown, field: string, issues: string[]): string | undefined {
if (value === undefined) return undefined;
return requiredProjectId(value, field, issues);
}
function requiredTaskId(value: unknown, field: string, issues: string[]): string {
if (typeof value !== "string" || value.trim() === "") {
issues.push(`${field} must be a non-empty string`);
return "";
}
const trimmed = value.trim();
if (!TASK_ID_REGEX.test(trimmed)) {
issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`);
}
if (trimmed.length > 120) {
issues.push(`${field} must be <= 120 characters`);
}
return trimmed;
}
function requiredBoundedString(
value: unknown,
field: string,
maxLength: number,
issues: string[],
): string {
if (typeof value !== "string" || value.trim() === "") {
issues.push(`${field} must be a non-empty string`);
return "";
}
const trimmed = value.trim();
if (trimmed.length > maxLength) {
issues.push(`${field} must be <= ${maxLength} characters`);
}
return trimmed;
}
function optionalBoundedString(
value: unknown,
field: string,
maxLength: number,
issues: string[],
): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") {
issues.push(`${field} must be a string`);
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
issues.push(`${field} cannot be empty when provided`);
return undefined;
}
if (trimmed.length > maxLength) {
issues.push(`${field} must be <= ${maxLength} characters`);
return undefined;
}
return trimmed;
}
function optionalStringArray(
value: unknown,
field: string,
issues: string[],
): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
issues.push(`${field} must be an array of strings`);
return undefined;
}
const out = [...new Set(value.map((item) => item.trim()).filter((item) => item.length > 0))];
if (out.some((item) => item.length > 200)) {
issues.push(`${field} values must be <= 200 characters`);
}
return out;
}
function optionalIsoString(
value: unknown,
field: string,
issues: string[],
): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
issues.push(`${field} must be an ISO date-time string`);
return undefined;
}
return new Date(value).toISOString();
}
function optionalTaskState(
value: unknown,
field: string,
issues: string[],
): TaskState | undefined {
if (value === undefined) return undefined;
return requiredTaskState(value, field, issues);
}
function requiredTaskState(
value: unknown,
field: string,
issues: string[],
): TaskState {
if (value === "todo" || value === "in_progress" || value === "blocked" || value === "done") {
return value;
}
issues.push(`${field} must be one of: todo, in_progress, blocked, done`);
return "todo";
}
function optionalArtifacts(
value: unknown,
field: string,
issues: string[],
): TaskArtifact[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value)) {
issues.push(`${field} must be an array`);
return undefined;
}
const artifacts: TaskArtifact[] = [];
value.forEach((item, idx) => {
const obj = asObject(item);
if (!obj) {
issues.push(`${field}[${idx}] must be an object`);
return;
}
const artifactId = requiredBoundedString(obj.artifactId, `${field}[${idx}].artifactId`, 120, issues);
const label = requiredBoundedString(obj.label, `${field}[${idx}].label`, 180, issues);
const location = requiredBoundedString(obj.location, `${field}[${idx}].location`, 200, issues);
const rawType = obj.type;
const type =
rawType === "code" || rawType === "doc" || rawType === "link" || rawType === "other"
? rawType
: undefined;
if (!type) {
issues.push(`${field}[${idx}].type must be one of: code, doc, link, other`);
return;
}
artifacts.push({ artifactId, type, label, location });
});
return artifacts;
}
function optionalRollback(
value: unknown,
field: string,
issues: string[],
): RollbackPlan | undefined {
if (value === undefined) return undefined;
const obj = asObject(value);
if (!obj) {
issues.push(`${field} must be an object`);
return undefined;
}
const strategy = requiredBoundedString(obj.strategy, `${field}.strategy`, 120, issues);
const steps = optionalStringArray(obj.steps, `${field}.steps`, issues) ?? [];
const verification = optionalBoundedString(obj.verification, `${field}.verification`, 220, issues);
return { strategy, steps, verification };
}
function optionalBudget(
value: unknown,
field: string,
issues: string[],
): BudgetThresholds | undefined {
if (value === undefined) return undefined;
const obj = asObject(value);
if (!obj) {
issues.push(`${field} must be an object`);
return undefined;
}
const numericFields: Array<keyof BudgetThresholds> = [
"tokensIn",
"tokensOut",
"totalTokens",
"cost",
"warnRatio",
];
for (const key of numericFields) {
const raw = obj[key];
if (raw === undefined) continue;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
issues.push(`${field}.${key} must be a finite number`);
}
}
const warnRatio = obj.warnRatio;
if (typeof warnRatio === "number" && (warnRatio <= 0 || warnRatio >= 1)) {
issues.push(`${field}.warnRatio must be > 0 and < 1`);
}
return normalizeThresholds(obj);
}
function asIsoString(v: unknown): string {
if (typeof v === "string" && !Number.isNaN(Date.parse(v))) return new Date(v).toISOString();
return new Date().toISOString();
}
function asOptionalIsoString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
if (Number.isNaN(Date.parse(v))) return undefined;
return new Date(v).toISOString();
}
function toStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return [...new Set(
v
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter((item) => item.length > 0),
)];
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function asArray(v: unknown): unknown[] | undefined {
return Array.isArray(v) ? v : undefined;
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function asNumber(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function asPositiveNumber(v: unknown): number | undefined {
const parsed = asNumber(v);
if (parsed === undefined || parsed <= 0) return undefined;
return parsed;
}
+35
View File
@@ -0,0 +1,35 @@
import type { TaskStoreSnapshot, TasksSummary } from "../types";
export function computeTasksSummary(tasks: TaskStoreSnapshot, totalProjects?: number): TasksSummary {
const owners = new Set<string>();
const projectIds = new Set<string>();
let taskCount = 0;
let todo = 0;
let inProgress = 0;
let blocked = 0;
let done = 0;
let artifacts = 0;
for (const task of tasks.tasks) {
taskCount += 1;
projectIds.add(task.projectId);
owners.add(task.owner);
artifacts += task.artifacts.length;
if (task.status === "todo") todo += 1;
if (task.status === "in_progress") inProgress += 1;
if (task.status === "blocked") blocked += 1;
if (task.status === "done") done += 1;
}
return {
projects: typeof totalProjects === "number" ? totalProjects : projectIds.size,
tasks: taskCount,
todo,
inProgress,
blocked,
done,
owners: owners.size,
artifacts,
};
}
+242
View File
@@ -0,0 +1,242 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { TaskState } from "../types";
export const UI_PREFERENCES_PATH = join(process.cwd(), "runtime", "ui-preferences.json");
export type UiQuickFilter = "all" | "attention" | TaskState;
export type UiLanguage = "en" | "zh";
export const UI_QUICK_FILTERS: UiQuickFilter[] = [
"all",
"attention",
"todo",
"in_progress",
"blocked",
"done",
];
export interface UiPreferencesTaskFilters {
status?: TaskState;
owner?: string;
project?: string;
}
export interface UiPreferences {
language: UiLanguage;
compactStatusStrip: boolean;
quickFilter: UiQuickFilter;
taskFilters: UiPreferencesTaskFilters;
updatedAt: string;
}
export interface UiPreferencesLoadResult {
path: string;
preferences: UiPreferences;
issues: string[];
}
export function defaultUiPreferences(now = new Date().toISOString()): UiPreferences {
return {
language: "zh",
compactStatusStrip: true,
quickFilter: "all",
taskFilters: {},
updatedAt: now,
};
}
export async function loadUiPreferences(): Promise<UiPreferencesLoadResult> {
let parsed: unknown;
let issues: string[] = [];
try {
const raw = await readFile(UI_PREFERENCES_PATH, "utf8");
parsed = JSON.parse(raw) as unknown;
} catch (error) {
const fallback = defaultUiPreferences();
const reason = error instanceof Error ? error.message : "unable to read preference file";
issues = [`preferences fallback applied: ${reason}`];
await writeUiPreferences(fallback);
return {
path: UI_PREFERENCES_PATH,
preferences: fallback,
issues,
};
}
const normalized = normalizeUiPreferences(parsed);
if (normalized.issues.length > 0) {
await writeUiPreferences(normalized.preferences);
}
return {
path: UI_PREFERENCES_PATH,
preferences: normalized.preferences,
issues: normalized.issues,
};
}
export async function saveUiPreferences(preferences: UiPreferences): Promise<UiPreferencesLoadResult> {
const normalized = normalizeUiPreferences(preferences);
await writeUiPreferences(normalized.preferences);
return {
path: UI_PREFERENCES_PATH,
preferences: normalized.preferences,
issues: normalized.issues,
};
}
export function isUiQuickFilter(input: string): input is UiQuickFilter {
return UI_QUICK_FILTERS.includes(input as UiQuickFilter);
}
export function isUiLanguage(input: string): input is UiLanguage {
return input === "en" || input === "zh";
}
function normalizeUiPreferences(input: unknown): { preferences: UiPreferences; issues: string[] } {
const now = new Date().toISOString();
const base = defaultUiPreferences(now);
const issues: string[] = [];
const obj = asObject(input);
if (!obj) {
issues.push("preferences must be a JSON object");
return { preferences: base, issues };
}
let compactStatusStrip = base.compactStatusStrip;
let language = base.language;
if (typeof obj.language === "string") {
const normalizedLanguage = obj.language.trim().toLowerCase();
if (isUiLanguage(normalizedLanguage)) {
language = normalizedLanguage;
} else {
issues.push("language must be one of: en, zh");
}
} else if (obj.language !== undefined) {
issues.push("language must be a string");
}
if (obj.compactStatusStrip !== undefined) {
if (typeof obj.compactStatusStrip === "boolean") {
compactStatusStrip = obj.compactStatusStrip;
} else {
issues.push("compactStatusStrip must be a boolean");
}
}
let quickFilter = base.quickFilter;
if (typeof obj.quickFilter === "string") {
const trimmed = obj.quickFilter.trim();
if (isUiQuickFilter(trimmed)) {
quickFilter = trimmed;
} else {
issues.push("quickFilter must be one of: all, attention, todo, in_progress, blocked, done");
}
} else if (obj.quickFilter !== undefined) {
issues.push("quickFilter must be a string");
}
const taskFilters = normalizeTaskFilters(obj.taskFilters, issues);
if (taskFilters.status === undefined && isTaskState(quickFilter)) {
taskFilters.status = quickFilter;
}
let updatedAt = now;
if (typeof obj.updatedAt === "string" && !Number.isNaN(Date.parse(obj.updatedAt))) {
updatedAt = new Date(obj.updatedAt).toISOString();
} else if (obj.updatedAt !== undefined) {
issues.push("updatedAt must be an ISO-8601 timestamp");
}
return {
preferences: {
language,
compactStatusStrip,
quickFilter,
taskFilters,
updatedAt,
},
issues,
};
}
function normalizeTaskFilters(
input: unknown,
issues: string[],
): UiPreferencesTaskFilters {
const out: UiPreferencesTaskFilters = {};
if (input === undefined) return out;
const obj = asObject(input);
if (!obj) {
issues.push("taskFilters must be an object");
return out;
}
if (typeof obj.status === "string") {
const status = obj.status.trim();
if (!status) {
out.status = undefined;
} else if (isTaskState(status)) {
out.status = status;
} else {
issues.push("taskFilters.status must be one of: todo, in_progress, blocked, done");
}
} else if (obj.status !== undefined) {
issues.push("taskFilters.status must be a string");
}
const owner = normalizeOptionalString(obj.owner, "taskFilters.owner", 80, issues);
if (owner) out.owner = owner;
const project = normalizeOptionalString(obj.project, "taskFilters.project", 120, issues);
if (project) out.project = project;
return out;
}
function normalizeOptionalString(
input: unknown,
label: string,
maxLength: number,
issues: string[],
): string | undefined {
if (input === undefined) return undefined;
if (typeof input !== "string") {
issues.push(`${label} must be a string`);
return undefined;
}
const trimmed = input.trim();
if (!trimmed) return undefined;
if (/[\u0000-\u001F\u007F]/.test(trimmed)) {
issues.push(`${label} contains control characters`);
return undefined;
}
if (trimmed.length > maxLength) {
issues.push(`${label} must be <= ${maxLength} characters`);
return undefined;
}
return trimmed;
}
function isTaskState(input: string): input is TaskState {
return input === "todo" || input === "in_progress" || input === "blocked" || input === "done";
}
function asObject(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
async function writeUiPreferences(preferences: UiPreferences): Promise<void> {
await mkdir(join(process.cwd(), "runtime"), { recursive: true });
await writeFile(UI_PREFERENCES_PATH, `${JSON.stringify(preferences, null, 2)}\n`, "utf8");
}
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -27,7 +27,7 @@ test("repo includes baseline open-source release metadata", () => {
const ignore = readFileSync(path.join(ROOT, ".gitignore"), "utf8");
assert.match(ignore, /(^|\n)node_modules\/(\n|$)/);
assert.match(ignore, /(^|\n)dist\/(\n|$)/);
assert.match(ignore, /(^|\n)runtime\/(\n|$)/);
assert.match(ignore, /(^|\n)\/?runtime\/(\n|$)/);
const license = readFileSync(path.join(ROOT, "LICENSE"), "utf8");
assert.match(license, /MIT License/);
@@ -66,3 +66,18 @@ test("gateway URL can be overridden from env for non-local installations", () =>
assert.equal(output, "ws://example.invalid:9999");
});
test("core source directories are present and tracked in git", () => {
assert(existsSync(path.join(ROOT, "src", "ui", "server.ts")), "Expected src/ui/server.ts to exist.");
assert(existsSync(path.join(ROOT, "src", "runtime", "usage-cost.ts")), "Expected src/runtime/usage-cost.ts to exist.");
const tracked = execFileSync("git", ["ls-files", "src/ui/server.ts", "src/runtime/usage-cost.ts"], {
cwd: ROOT,
encoding: "utf8",
})
.trim()
.split("\n")
.filter(Boolean);
assert.deepEqual(tracked.sort(), ["src/runtime/usage-cost.ts", "src/ui/server.ts"]);
});