mirror of
https://github.com/TianyiDataScience/openclaw-control-center.git
synced 2026-08-14 08:52:27 +00:00
Track runtime source tree in public repo
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
runtime/
|
||||
/runtime/
|
||||
coverage/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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", "-");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user