feat(FN-2465): stabilize agent log handling and add OpenClaw adapter

- Make agent log ordering deterministic in core store and dev-server retrieval paths
- Stabilize AgentLogViewer row identity and hook ordering behavior to prevent regressions
- Add targeted tests for store ordering, useAgentLogs hook behavior, and AgentLogViewer rendering
- Introduce executable OpenClaw runtime adapter modules, types, and updated plugin packaging/docs
This commit is contained in:
Fusion
2026-04-24 11:30:27 -07:00
committed by gsxdsm
parent e40430c9bd
commit bafcbd6c39
7 changed files with 201 additions and 67 deletions

View File

@@ -3949,6 +3949,45 @@ Task with acceptance criteria
]);
});
it("preserves insertion order when multiple entries share the same timestamp", async () => {
const task = await createTestTask();
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
const logs = await store.getAgentLogs(task.id);
expect(logs.map((entry) => entry.text)).toEqual([
"first tied",
"second tied",
"third tied",
]);
});
it("applies deterministic ordering for tied timestamps with limit/offset pagination", async () => {
const task = await createTestTask();
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "fourth tied", "text", tiedTimestamp);
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
{ text: "third tied" },
{ text: "fourth tied" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 1 })).resolves.toMatchObject([
{ text: "second tied" },
{ text: "third tied" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
{ text: "first tied" },
{ text: "second tied" },
]);
});
it("preserves long entry fields when returning a bounded tail", async () => {
const task = await createTestTask();
const longText = [

View File

@@ -4667,7 +4667,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const rows = this.db.prepare(`
SELECT * FROM agentLogEntries
WHERE taskId = ?
ORDER BY timestamp DESC
ORDER BY timestamp DESC, id DESC
LIMIT ?
`).all(taskId, readCount) as Array<Record<string, unknown>>;
const entries = rows.map((row) => this.mapAgentLogRow(row)).reverse();
@@ -4680,7 +4680,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const rows = this.db.prepare(`
SELECT * FROM agentLogEntries
WHERE taskId = ?
ORDER BY timestamp ASC
ORDER BY timestamp ASC, id ASC
`).all(taskId) as Array<Record<string, unknown>>;
const entries = rows.map((row) => this.mapAgentLogRow(row));
if (offset > 0) {
@@ -4719,7 +4719,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const rows = this.db.prepare(`
SELECT * FROM agentLogEntries
WHERE taskId = ? AND timestamp >= ? AND timestamp <= ?
ORDER BY timestamp ASC
ORDER BY timestamp ASC, id ASC
`).all(taskId, startIso, end) as Array<Record<string, unknown>>;
return rows.map((row) => this.mapAgentLogRow(row));
}

View File

@@ -1,50 +0,0 @@
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
"task:updated": [task: Task];
"task:deleted": [task: Task];
"task:merged": [result: MergeResult];
"settings:updated": [data: { settings: Settings; previous: Settings }];
"agent:log": [entry: AgentLogEntry];
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private kbDir: string;
private tasksDir: string;
private configPath: string;
private archiveLogPath: string;
private activityLogPath: string;
/** File-system watcher instance */
private watcher: FSWatcher | null = null;
/** In-memory cache of tasks for diffing watcher events */
private taskCache: Map<string, Task> = new Map();
/** Paths recently written by in-process mutations (suppresses duplicate events) */
private recentlyWritten: Set<string> = new Set();
/** Pending debounce timers keyed by task ID */
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
/** Debounce interval in ms */
private debounceMs = 150;
/** Per-task promise chain for serializing writes */
private taskLocks: Map<string, Promise<void>> = new Map();
/** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve();
/** Global settings store (`~/.pi/kb/settings.json`) */
private globalSettingsStore: GlobalSettingsStore;
constructor(private rootDir: string, globalSettingsDir?: string) {
super();
this.setMaxListeners(100);
this.fusionDir = join(rootDir, ".fusion");
this.tasksDir = join(this.fusionDir, "tasks");
this.configPath = join(this.fusionDir, "config.json");
this.archiveLogPath = join(this.fusionDir, "archive.jsonl");