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:
@@ -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 = [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user