mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(tinyflows): preserve explicit node timeouts, align discover timeout, log cap pins (#4877)
This commit is contained in:
@@ -1,18 +1,30 @@
|
||||
/**
|
||||
* EditableFlowCanvas — validation UX (Phase 3c) + draft/dirty state (Phase 3d).
|
||||
*
|
||||
* Drives the canvas through the public `FlowCanvas editable` entry point with a
|
||||
* mocked `flowsApi` so `validateFlow` is deterministic. Covers:
|
||||
* - an invalid graph shows the inline error banner, rings the offending node,
|
||||
* and blocks Save;
|
||||
* - a valid-with-warnings graph surfaces warnings distinctly and allows Save;
|
||||
* - dirty tracking gates Save/Discard, Discard resets to baseline, and a
|
||||
* successful Save clears the dirty flag.
|
||||
* A canvas-refactor moved the Save / Discard / dirty-badge *buttons* out of
|
||||
* this component and up into `FlowCanvasPage`'s header — the canvas now only
|
||||
* exposes them through the `EditableFlowCanvasHandle` ref (`save()`/
|
||||
* `discard()`) and reports state up via `onSaveMetaChange`
|
||||
* (`{ dirty, hasErrors, saving }`), same as `onDirtyChange`. See
|
||||
* `FlowCanvasPage.test.tsx` for the header-button + confirm-dialog + RPC
|
||||
* integration coverage (clicking `flow-editor-save`/`flow-editor-discard`).
|
||||
*
|
||||
* This file drives the canvas through the public `FlowCanvas editable` entry
|
||||
* point with a mocked `flowsApi` so `validateFlow` is deterministic, and
|
||||
* covers what `FlowCanvas`/`EditableFlowCanvas` itself still owns:
|
||||
* - an invalid graph shows the inline error banner, rings the offending
|
||||
* node, and the imperative `save()` handle refuses to fire `onSave`;
|
||||
* - a valid-with-warnings graph surfaces warnings distinctly and still
|
||||
* lets `save()` fire;
|
||||
* - dirty tracking gates `save()`/`discard()`, `discard()` resets to
|
||||
* baseline, and a successful `save()` clears the dirty flag.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowNode } from '../../../../lib/flows/graphAdapter';
|
||||
import type { EditableFlowCanvasHandle, EditorSaveMeta } from '../EditableFlowCanvas';
|
||||
import FlowCanvas from '../FlowCanvas';
|
||||
|
||||
const validateFlow = vi.hoisted(() => vi.fn());
|
||||
@@ -38,16 +50,27 @@ function triggerNode(): FlowNode {
|
||||
const META = { schema_version: 1, id: 'wf_1', name: 'My flow' } as const;
|
||||
|
||||
function renderCanvas(props: Partial<React.ComponentProps<typeof FlowCanvas>> = {}) {
|
||||
return render(
|
||||
const ref = createRef<EditableFlowCanvasHandle>();
|
||||
const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>();
|
||||
const utils = render(
|
||||
<FlowCanvas
|
||||
ref={ref}
|
||||
editable
|
||||
nodes={[triggerNode()]}
|
||||
edges={[]}
|
||||
meta={META}
|
||||
onSave={vi.fn().mockResolvedValue(undefined)}
|
||||
onSaveMetaChange={onSaveMetaChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return { ...utils, ref, onSaveMetaChange };
|
||||
}
|
||||
|
||||
/** Latest `{ dirty, hasErrors, saving }` the canvas reported to its host. */
|
||||
function lastSaveMeta(onSaveMetaChange: ReturnType<typeof vi.fn<(meta: EditorSaveMeta) => void>>) {
|
||||
const calls = onSaveMetaChange.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
}
|
||||
|
||||
describe('EditableFlowCanvas — validation + dirty state', () => {
|
||||
@@ -57,24 +80,29 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
|
||||
listFlowConnections.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('surfaces hard errors, rings the offending node, and blocks Save', async () => {
|
||||
it('surfaces hard errors, rings the offending node, and blocks save()', async () => {
|
||||
validateFlow.mockResolvedValue({
|
||||
valid: false,
|
||||
errors: ['invalid config for node t: missing schedule'],
|
||||
warnings: [],
|
||||
});
|
||||
const { container } = renderCanvas();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const { container, ref, onSaveMetaChange } = renderCanvas({ onSave });
|
||||
|
||||
// Make an edit so the graph is dirty (Save is only ever enabled when dirty).
|
||||
// Make an edit so the graph is dirty (Save is only ever attempted when
|
||||
// dirty). Validation runs automatically on the debounce after the edit
|
||||
// (the manual Validate button now lives on the selected node card).
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
// Validation runs automatically on the debounce after the edit (the manual
|
||||
// Validate button now lives on the selected node card).
|
||||
|
||||
const errors = await screen.findByTestId('flow-editor-errors');
|
||||
expect(errors).toHaveTextContent('invalid config for node t: missing schedule');
|
||||
|
||||
// Save is blocked while there are hard errors, even though the graph is dirty.
|
||||
expect(screen.getByTestId('flow-editor-save')).toBeDisabled();
|
||||
// The host header reads `hasErrors` off `onSaveMetaChange` to disable its
|
||||
// Save button; the canvas itself also refuses to fire `onSave` through
|
||||
// the imperative handle while hard errors exist, even though dirty.
|
||||
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true });
|
||||
act(() => ref.current?.save());
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
// The named node ('t') is ringed with the error class on its RF wrapper.
|
||||
await waitFor(() =>
|
||||
@@ -84,13 +112,14 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows warnings distinctly from errors and allows Save', async () => {
|
||||
it('shows warnings distinctly from errors and still lets save() fire', async () => {
|
||||
validateFlow.mockResolvedValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: ['this trigger kind does not fire automatically yet'],
|
||||
});
|
||||
renderCanvas();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const { ref, onSaveMetaChange } = renderCanvas({ onSave });
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
// Auto-validation (debounced) surfaces the warning.
|
||||
@@ -99,71 +128,77 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
|
||||
expect(warnings).toHaveTextContent('does not fire automatically');
|
||||
// A valid graph never renders the errors list…
|
||||
expect(screen.queryByTestId('flow-editor-errors')).not.toBeInTheDocument();
|
||||
// …and Save is allowed (warnings don't block).
|
||||
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
|
||||
// …and warnings don't block Save.
|
||||
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: false });
|
||||
|
||||
act(() => ref.current?.save());
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tracks dirty state: Save/Discard gate on it, Discard resets, Save clears it', async () => {
|
||||
it('tracks dirty state: save()/discard() gate on it, discard resets, save clears it', async () => {
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onDirtyChange = vi.fn();
|
||||
renderCanvas({ onSave, onDirtyChange });
|
||||
const { ref, onSaveMetaChange } = renderCanvas({ onSave, onDirtyChange });
|
||||
|
||||
// Pristine: no dirty badge, Save + Discard disabled.
|
||||
expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-editor-save')).toBeDisabled();
|
||||
expect(screen.getByTestId('flow-editor-discard')).toBeDisabled();
|
||||
// Pristine: not dirty; save()/discard() both no-op through the imperative
|
||||
// handle (the host header renders both buttons disabled in this state).
|
||||
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false });
|
||||
act(() => ref.current?.discard());
|
||||
act(() => ref.current?.save());
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
// Edit → dirty.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('flow-editor-discard')).not.toBeDisabled();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true });
|
||||
expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
|
||||
|
||||
// Discard → back to the single trigger, no longer dirty.
|
||||
fireEvent.click(screen.getByTestId('flow-editor-discard'));
|
||||
act(() => ref.current?.discard());
|
||||
expect(screen.getAllByTestId('flow-node')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false });
|
||||
|
||||
// Edit again and Save → onSave called, dirty cleared once it resolves.
|
||||
// Edit again and save() → onSave called, dirty cleared once it resolves.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
act(() => ref.current?.save());
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
const graph = onSave.mock.calls[0][0];
|
||||
expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']);
|
||||
await waitFor(() => expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument());
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
|
||||
await waitFor(() => expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false }));
|
||||
});
|
||||
|
||||
it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', async () => {
|
||||
it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', () => {
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onDirtyChange = vi.fn();
|
||||
// Mirrors `FlowCanvasPage` remounting the canvas (`key={canvasVersion}`)
|
||||
// after accepting a copilot proposal: the incoming nodes/edges ARE the
|
||||
// component's "initial" graph, so without `initialDirty` the canvas would
|
||||
// seed its baseline from them and instantly read as clean even though
|
||||
// nothing was persisted (the P1 this regression test guards against).
|
||||
renderCanvas({ onDirtyChange, initialDirty: true });
|
||||
const { ref, onSaveMetaChange } = renderCanvas({ onSave, onDirtyChange, initialDirty: true });
|
||||
|
||||
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true });
|
||||
act(() => ref.current?.save());
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces a Save failure inline and leaves the graph dirty', async () => {
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
const onSave = vi.fn().mockRejectedValue(new Error('core unreachable'));
|
||||
renderCanvas({ onSave });
|
||||
const onDirtyChange = vi.fn();
|
||||
const { ref } = renderCanvas({ onSave, onDirtyChange });
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
act(() => ref.current?.save());
|
||||
|
||||
const saveError = await screen.findByTestId('flow-editor-save-error');
|
||||
expect(saveError).toHaveTextContent('core unreachable');
|
||||
// Still dirty — nothing persisted.
|
||||
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,7 +340,7 @@ describe('flowsApi', () => {
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.flows_discover',
|
||||
params: {},
|
||||
timeoutMs: 310_000,
|
||||
timeoutMs: 610_000,
|
||||
});
|
||||
expect(result).toEqual([suggestion]);
|
||||
});
|
||||
@@ -353,7 +353,7 @@ describe('flowsApi', () => {
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.flows_discover',
|
||||
params: { thread_id: 'scout-thread-1' },
|
||||
timeoutMs: 310_000,
|
||||
timeoutMs: 610_000,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -799,12 +799,14 @@ export async function promoteDraft(id: string, requireApproval?: boolean): Promi
|
||||
|
||||
/**
|
||||
* `openhuman.flows_discover` runs the read-only Flow Scout agent, which reasons
|
||||
* over the user's memory/threads/connections/flows and can take up to ~300s
|
||||
* server-side (`FLOW_DISCOVER_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`).
|
||||
* Give the client a matching budget so a slow discovery run doesn't time out
|
||||
* client-side while the agent is still thinking.
|
||||
* over the user's memory/threads/connections/flows and can take up to ~600s
|
||||
* server-side (`FLOW_DISCOVER_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`,
|
||||
* raised to match `FLOW_BUILD_TIMEOUT_SECS` for the same iteration cap). Give
|
||||
* the client a matching budget (mirrors {@link FLOW_BUILD_TIMEOUT_MS}) so a
|
||||
* slow discovery run doesn't time out client-side while the agent is still
|
||||
* thinking.
|
||||
*/
|
||||
const FLOW_DISCOVER_TIMEOUT_MS = 310_000;
|
||||
const FLOW_DISCOVER_TIMEOUT_MS = 610_000;
|
||||
|
||||
/**
|
||||
* Run the Flow Scout discovery agent via `openhuman.flows_discover` and return
|
||||
|
||||
Reference in New Issue
Block a user