## Summary Restores green **Full Suite (non-blocking)** runs on `main`. Recent main merges left i18n key parity, schema baseline bookkeeping (0011→0012), heartbeat tool inventory (FN-8058 `fn_task_logs_read`), and merger whitespace-classification mocks (execFile `git diff -p -w :2: :3:`) out of date, so all four test shards failed. ## Root causes observed on main - **Shard 4 / `@fusion/i18n`**: missing `skipConfirmationDialogs*` + `reviewBudgetExhausted` in non-en locales; orphan `awaitingApprovalPlanReviewReplanCap` - **Shard 3 / `@fusion/core`**: `SCHEMA_BASELINE_VERSION` advanced to `0012` while tests still equated it with `OWNER_PROJECT_ID_SPLIT_VERSION` (`0011`) and omitted `0012` from applied-migration lists - **Shards 1–2 / `@fusion/engine`**: tool count/snapshot drift for `fn_task_logs_read`; merger tests still mocked `git diff-tree` for trivial classification after the execFile `:2:`/`:3:` cutover; mock provider `updateTask` arity drift ## Changes - Locale catalogs: add missing keys, drop orphan key - Schema applier tests: immutable 0011 identity + baseline 0012 lists - Heartbeat + gating snapshots: include `fn_task_logs_read` - Merger unit mocks: recognize `git diff -p -w :2:path :3:path` - Mock provider: accept optional third `updateTask` arg ## Test plan - [x] `pnpm --filter @fusion/i18n exec vitest run` — 23/23 - [x] `pnpm --filter @fusion/core exec vitest run src/__tests__/postgres/schema-applier.test.ts` (immutable + automation upgrade) — pass - [x] `pnpm --filter @fusion/core exec vitest run` project-identity + satellite-fusiondir — pass - [x] Engine suites from failed CI shards (file-scoped, hermes/openclaw/paperclip/grok, reliability post-finalize/mission, heartbeat, gating, merger recovery/prompt, mock-provider, etc.) — pass - [ ] Full Suite workflow green on merge to main <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Improved project data isolation across backend operations. - Added safer optional toast handling when UI components render outside the full application shell. - Added support for reading task logs during agent heartbeat sessions. - **Bug Fixes** - Prevented runtime probes from hanging and avoided scanning large binary files. - Improved path handling for workspaces with missing descendants. - Corrected task retry state resets and GitHub import/issue-close behavior. - **Style** - Improved chat, terminal, and settings spacing. - Added clearer accessibility labeling for the auto-merge control. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
235 lines
9.2 KiB
TypeScript
235 lines
9.2 KiB
TypeScript
/**
|
|
* Async Drizzle ExperimentSessionStore helpers (U6 satellite-db-injected-stores).
|
|
*
|
|
* FNXC:ExperimentSessionStore 2026-06-24-08:05:
|
|
* Async equivalents of the sync SQLite ExperimentSessionStore call sites in
|
|
* experiment-session-store.ts. These helpers target the PostgreSQL
|
|
* `project.experiment_sessions` and `project.experiment_session_records`
|
|
* tables via Drizzle.
|
|
*
|
|
* SQLite → PostgreSQL notes (VAL-SCHEMA-004):
|
|
* All JSON columns (metric, keptRunIds, tags, metadata, payload) are jsonb
|
|
* in PostgreSQL, so Drizzle returns them already-parsed as JS values.
|
|
*
|
|
* Transition context (see library/satellite-store-migration-pattern.md):
|
|
* `getDatabase()` still returns the sync `Database` until the coordinated
|
|
* flip. These helpers are the async target the PostgreSQL integration tests
|
|
* consume.
|
|
*/
|
|
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
|
import * as schema from "./postgres/schema/index.js";
|
|
import { projectOwnershipPartition, type AsyncDataLayer, type DbTransaction } from "./postgres/data-layer.js";
|
|
import type {
|
|
ExperimentSession,
|
|
ExperimentSessionListOptions,
|
|
ExperimentSessionRecord,
|
|
ExperimentSessionStatus,
|
|
ExperimentRecordType,
|
|
} from "./experiment-session-types.js";
|
|
|
|
/** A query-capable handle: either the top-level db or a transaction handle. */
|
|
type QueryHandle = AsyncDataLayer["db"] | DbTransaction;
|
|
|
|
function rowToSession(row: Record<string, unknown>): ExperimentSession {
|
|
const metricRaw = typeof row.metric === "string" ? JSON.parse(row.metric) : row.metric;
|
|
return {
|
|
id: row.id as string,
|
|
name: row.name as string,
|
|
// FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011).
|
|
projectId: (row.ownerProjectId as string | null) ?? undefined,
|
|
status: row.status as ExperimentSessionStatus,
|
|
metric: (metricRaw as ExperimentSession["metric"]) ?? { name: "unknown", direction: "maximize" },
|
|
currentSegment: Number(row.currentSegment ?? 1),
|
|
maxIterations: (row.maxIterations as number | null) ?? undefined,
|
|
workingDir: (row.workingDir as string | null) ?? undefined,
|
|
baselineRunId: (row.baselineRunId as string | null) ?? undefined,
|
|
bestRunId: (row.bestRunId as string | null) ?? undefined,
|
|
keptRunIds: (row.keptRunIds as string[]) ?? [],
|
|
tags: (row.tags as string[]) ?? [],
|
|
metadata: row.metadata as ExperimentSession["metadata"],
|
|
createdAt: row.createdAt as string,
|
|
updatedAt: row.updatedAt as string,
|
|
finalizedAt: (row.finalizedAt as string | null) ?? undefined,
|
|
};
|
|
}
|
|
|
|
function rowToRecord(row: Record<string, unknown>): ExperimentSessionRecord {
|
|
return {
|
|
id: row.id as string,
|
|
sessionId: row.sessionId as string,
|
|
segment: Number(row.segment),
|
|
seq: Number(row.seq),
|
|
type: row.type as ExperimentSessionRecord["type"],
|
|
payload: (row.payload as ExperimentSessionRecord["payload"]) ?? {},
|
|
createdAt: row.createdAt as string,
|
|
} as ExperimentSessionRecord;
|
|
}
|
|
|
|
/**
|
|
* Create an experiment session.
|
|
*/
|
|
export async function createExperimentSession(
|
|
handle: QueryHandle,
|
|
session: ExperimentSession,
|
|
): Promise<ExperimentSession> {
|
|
await handle.insert(schema.project.experimentSessions).values({
|
|
id: session.id,
|
|
name: session.name,
|
|
// FNXC:MultiProjectIsolation 2026-07-15-23:40: domain project goes to owner_project_id; project_id (the RLS partition) is owned by the fusion_assign_project_id trigger/GUC.
|
|
ownerProjectId: session.projectId ?? null,
|
|
status: session.status,
|
|
metric: JSON.stringify(session.metric),
|
|
currentSegment: session.currentSegment,
|
|
maxIterations: session.maxIterations ?? null,
|
|
workingDir: session.workingDir ?? null,
|
|
baselineRunId: session.baselineRunId ?? null,
|
|
bestRunId: session.bestRunId ?? null,
|
|
keptRunIds: session.keptRunIds,
|
|
tags: session.tags,
|
|
metadata: session.metadata ?? null,
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.updatedAt,
|
|
finalizedAt: session.finalizedAt ?? null,
|
|
});
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Get a single experiment session by id.
|
|
*/
|
|
export async function getExperimentSession(handle: QueryHandle, id: string): Promise<ExperimentSession | undefined> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.experimentSessions)
|
|
.where(eq(schema.project.experimentSessions.id, id));
|
|
return rows[0] ? rowToSession(rows[0]) : undefined;
|
|
}
|
|
|
|
/**
|
|
* List experiment sessions with optional filters.
|
|
*/
|
|
export async function listExperimentSessions(handle: QueryHandle, options: ExperimentSessionListOptions = {}): Promise<ExperimentSession[]> {
|
|
const conditions: ReturnType<typeof eq>[] = [];
|
|
if (options.status) conditions.push(eq(schema.project.experimentSessions.status, options.status));
|
|
if (options.projectId) conditions.push(eq(schema.project.experimentSessions.ownerProjectId, options.projectId));
|
|
const query = handle
|
|
.select()
|
|
.from(schema.project.experimentSessions)
|
|
.orderBy(desc(schema.project.experimentSessions.createdAt));
|
|
const rows = conditions.length > 0 ? await query.where(and(...conditions)) : await query;
|
|
return rows.map(rowToSession);
|
|
}
|
|
|
|
/**
|
|
* Persist (update) an experiment session's mutable fields.
|
|
*/
|
|
export async function persistExperimentSession(handle: QueryHandle, session: ExperimentSession): Promise<void> {
|
|
await handle
|
|
.update(schema.project.experimentSessions)
|
|
.set({
|
|
name: session.name,
|
|
ownerProjectId: session.projectId ?? null,
|
|
status: session.status,
|
|
metric: JSON.stringify(session.metric),
|
|
currentSegment: session.currentSegment,
|
|
maxIterations: session.maxIterations ?? null,
|
|
workingDir: session.workingDir ?? null,
|
|
baselineRunId: session.baselineRunId ?? null,
|
|
bestRunId: session.bestRunId ?? null,
|
|
keptRunIds: session.keptRunIds,
|
|
tags: session.tags,
|
|
metadata: session.metadata ?? null,
|
|
updatedAt: session.updatedAt,
|
|
finalizedAt: session.finalizedAt ?? null,
|
|
})
|
|
.where(eq(schema.project.experimentSessions.id, session.id));
|
|
}
|
|
|
|
/**
|
|
* Delete an experiment session by id. Returns true if a row was deleted.
|
|
*/
|
|
export async function deleteExperimentSession(handle: QueryHandle, id: string): Promise<boolean> {
|
|
const result = await handle
|
|
.delete(schema.project.experimentSessions)
|
|
.where(eq(schema.project.experimentSessions.id, id))
|
|
.returning({ id: schema.project.experimentSessions.id });
|
|
return result.length > 0;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ExperimentSessionStore 2026-06-24-08:10:
|
|
* Append a record to a session with an auto-incrementing seq inside a
|
|
* transaction.
|
|
*/
|
|
export async function appendExperimentRecord(
|
|
layer: AsyncDataLayer,
|
|
input: { id: string; sessionId: string; segment: number; type: ExperimentRecordType; payload: Record<string, unknown> },
|
|
): Promise<ExperimentSessionRecord> {
|
|
return layer.transactionImmediate(async (tx) => {
|
|
const parentRows = await tx
|
|
.select({ projectId: schema.project.experimentSessions.projectId })
|
|
.from(schema.project.experimentSessions)
|
|
.where(eq(schema.project.experimentSessions.id, input.sessionId))
|
|
.limit(1);
|
|
const ownership = projectOwnershipPartition(parentRows[0]?.projectId ?? layer.projectId);
|
|
const seqRows = await tx
|
|
.select({ nextSeq: sql<number>`coalesce(max(${schema.project.experimentSessionRecords.seq}), 0) + 1` })
|
|
.from(schema.project.experimentSessionRecords)
|
|
.where(and(
|
|
eq(schema.project.experimentSessionRecords.projectId, ownership),
|
|
eq(schema.project.experimentSessionRecords.sessionId, input.sessionId),
|
|
));
|
|
const seq = seqRows[0]?.nextSeq ?? 1;
|
|
const createdAt = new Date().toISOString();
|
|
await tx.insert(schema.project.experimentSessionRecords).values({
|
|
projectId: ownership,
|
|
id: input.id,
|
|
sessionId: input.sessionId,
|
|
segment: input.segment,
|
|
seq,
|
|
type: input.type,
|
|
payload: input.payload,
|
|
createdAt,
|
|
});
|
|
return {
|
|
id: input.id,
|
|
sessionId: input.sessionId,
|
|
segment: input.segment,
|
|
seq,
|
|
type: input.type,
|
|
payload: input.payload,
|
|
createdAt,
|
|
} as unknown as ExperimentSessionRecord;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a single experiment record by id.
|
|
*/
|
|
export async function getExperimentRecord(handle: QueryHandle, id: string): Promise<ExperimentSessionRecord | undefined> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.experimentSessionRecords)
|
|
.where(eq(schema.project.experimentSessionRecords.id, id));
|
|
return rows[0] ? rowToRecord(rows[0]) : undefined;
|
|
}
|
|
|
|
/**
|
|
* List experiment records for a session.
|
|
*/
|
|
export async function listExperimentRecords(
|
|
handle: QueryHandle,
|
|
sessionId: string,
|
|
opts: { segment?: number; type?: ExperimentRecordType } = {},
|
|
): Promise<ExperimentSessionRecord[]> {
|
|
const conditions = [eq(schema.project.experimentSessionRecords.sessionId, sessionId)];
|
|
if (opts.segment !== undefined) conditions.push(eq(schema.project.experimentSessionRecords.segment, opts.segment));
|
|
if (opts.type) conditions.push(eq(schema.project.experimentSessionRecords.type, opts.type));
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.experimentSessionRecords)
|
|
.where(and(...conditions))
|
|
.orderBy(asc(schema.project.experimentSessionRecords.seq));
|
|
return rows.map(rowToRecord);
|
|
}
|