diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 7e444875aa..c50157c2ba 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -372,6 +372,11 @@ jobs: with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 + # Targeted init (not `submodules: true`) so we skip the large tauri-cef + # fork the core image doesn't need. The Dockerfile COPYs vendor/ because + # [patch.crates-io] resolves tinyagents from vendor/tinyagents. + - name: Init tinyagents submodule + run: git submodule update --init vendor/tinyagents - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 22b26dd93e..8fb162fa6a 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -265,6 +265,11 @@ jobs: with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 + # Targeted init (not `submodules: true`) so we skip the large tauri-cef + # fork the core image doesn't need. The Dockerfile COPYs vendor/ because + # [patch.crates-io] resolves tinyagents from vendor/tinyagents. + - name: Init tinyagents submodule + run: git submodule update --init vendor/tinyagents - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build image (no push) diff --git a/.gitignore b/.gitignore index dc7c864982..1f9f80e7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,4 @@ distribution.cer # Release note previews CHANGELOG.preview.md *.profraw +*.diff \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 72a5f05a08..8f61e06ed6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "app/src-tauri/vendor/tauri-plugin-notification"] path = app/src-tauri/vendor/tauri-plugin-notification url = https://github.com/tinyhumansai/tauri-plugin-notification.git +[submodule "vendor/tinyagents"] + path = vendor/tinyagents + url = https://github.com/tinyhumansai/tinyagents diff --git a/Cargo.lock b/Cargo.lock index 2bfc7fa610..6aec97037a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6676,9 +6676,7 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ebc9790a96fe910c98c60758fad23bd33faeb77889bc2355e6dc3f5aa70e0c" +version = "1.5.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index c1545fdca2..b3dfbe01c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ crate-type = ["rlib"] tinyplace = "1.0.1" # tinyflows — host-agnostic workflow engine (typed node graph → validate → compile → # run on tinyagents). Powers the "Workflows" feature via the seam in -# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.3 transitively +# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.5 transitively # (same version openhuman already uses — no conflict). Published on crates.io. tinyflows = "0.3" # TinyAgents — Rust LLM orchestration framework (LangGraph/LangChain-style): @@ -56,7 +56,7 @@ tinyflows = "0.3" # aligned to 0.40, avoiding duplicate `links = "sqlite3"` native bindings. # Durable graph checkpoints still use `SqlRunLedgerCheckpointer` until the # migration re-points those rows to the crate checkpointer. -tinyagents = { version = "1.3", features = ["sqlite"] } +tinyagents = { version = "1.5.0", features = ["sqlite"] } # TokenJuice code compressor — AST-aware signature extraction. Optional (C build) # behind the default `tokenjuice-treesitter` feature; disabling it falls back to # the language-agnostic brace-depth heuristic. See src/openhuman/tokenjuice/compressors/code.rs. @@ -230,7 +230,7 @@ fantoccini = { version = "0.22.0", optional = true, default-features = false, fe serde-big-array = { version = "0.5", optional = true } pdf-extract = "0.10" # WhatsApp Web — upstream `whatsapp-rust` 0.5. Its Diesel-backed sqlite-storage -# feature links sqlite3 separately from rusqlite 0.40, so the TinyAgents 1.3 +# feature links sqlite3 separately from rusqlite 0.40, so the TinyAgents 1.5 # baseline compiles this provider against wacore's in-memory Backend until a # rusqlite-backed durable store lands. whatsapp-rust = { version = "0.5", optional = true, default-features = false, features = ["tokio-runtime"] } @@ -331,6 +331,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } # See: https://github.com/tinyhumansai/openhuman/issues/273 [patch.crates-io] whisper-rs-sys = { git = "https://github.com/tinyhumansai/whisper-rs-sys.git", branch = "main" } +# TinyAgents is vendored as a git submodule (pinned at the released tag) so +# migration work can change the SDK source in-tree, test it against OpenHuman +# immediately, and PR the diff upstream from the submodule. Keep the submodule +# version in lockstep with the `tinyagents` requirement above. After cloning: +# `git submodule update --init vendor/tinyagents` (worktrees included). +tinyagents = { path = "vendor/tinyagents" } # Emit just enough DWARF in release builds for Sentry to symbolicate Rust # panics + render surrounding source lines. `line-tables-only` keeps the diff --git a/Dockerfile b/Dockerfile index 00be4887c7..477f8e6032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,11 @@ WORKDIR /build # Cache dependencies — copy only manifests first COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +# Vendored TinyAgents SDK (git submodule; [patch.crates-io] points here, so +# the dep-cache build below already resolves it). CI must init the submodule +# before docker build — see the "Init tinyagents submodule" steps in +# release-production.yml / release-staging.yml. +COPY vendor/ vendor/ # Create a dummy src to build deps RUN mkdir -p src && \ echo 'fn main() {}' > src/main.rs && \ diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 83a08db474..12d74d0459 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -9077,9 +9077,7 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ebc9790a96fe910c98c60758fad23bd33faeb77889bc2355e6dc3f5aa70e0c" +version = "1.5.0" dependencies = [ "async-trait", "futures", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8d0d9af8e3..4b7f9fe477 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -207,6 +207,10 @@ e2e-test-support = ["openhuman_core/e2e-test-support"] # whisper.cpp to use MSVC's static runtime (/MT), matching CEF and avoiding # LNK2038/LNK1169 CRT conflicts on Windows. whisper-rs-sys = { git = "https://github.com/tinyhumansai/whisper-rs-sys.git", branch = "main" } +# TinyAgents vendored submodule (repo-root vendor/tinyagents, pinned at the +# released tag) — same patch as the root Cargo world so both resolve the +# in-tree SDK source. `git submodule update --init vendor/tinyagents` first. +tinyagents = { path = "../../vendor/tinyagents" } # CEF support lives on the `feat/cef` branch of tauri-apps/tauri. We carry our # own fork at tinyhumansai/tauri-cef on `feat/cef-notification-intercept` which diff --git a/app/src-tauri/src/cef_stale_reap.rs b/app/src-tauri/src/cef_stale_reap.rs index 8ba1663269..2f2751ac1d 100644 --- a/app/src-tauri/src/cef_stale_reap.rs +++ b/app/src-tauri/src/cef_stale_reap.rs @@ -569,7 +569,10 @@ mod tests { #[test] fn marker_is_fresh_bounds_on_age() { - assert!(marker_is_fresh(Some(Duration::from_secs(0)), MARKER_MAX_AGE)); + assert!(marker_is_fresh( + Some(Duration::from_secs(0)), + MARKER_MAX_AGE + )); assert!(marker_is_fresh(Some(MARKER_MAX_AGE), MARKER_MAX_AGE)); assert!(!marker_is_fresh( Some(MARKER_MAX_AGE + Duration::from_secs(1)), diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index db11a97c8f..d3d19a7557 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -12,6 +12,7 @@ import Accounts from './pages/Accounts'; import Brain from './pages/Brain'; import AgentInsightsPreview from './pages/dev/AgentInsightsPreview'; import Feedback from './pages/Feedback'; +import FlowsPage from './pages/FlowsPage'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; import Onboarding from './pages/onboarding/Onboarding'; @@ -94,6 +95,20 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { } /> + {/* Workflows — the `flows::` domain's discoverable list hub (issue + B5a). Distinct from the legacy SKILL.md `/workflows/*` Skill routes + below (create/run) and their `/workflows` → `/settings/automations` + back-compat redirect, which stay untouched. The canvas (B5b) and + agent-proposal surface (B4) are separate, later work. */} + + + + } + /> + {/* Back-compat: /activity and /intelligence → settings notifications page. */} } /> } /> diff --git a/app/src/components/EmptyStateCard.tsx b/app/src/components/EmptyStateCard.tsx index 28bd71e363..121cac0aa7 100644 --- a/app/src/components/EmptyStateCard.tsx +++ b/app/src/components/EmptyStateCard.tsx @@ -6,6 +6,8 @@ interface EmptyStateCardProps { description: string; actionLabel?: string; onAction?: () => void; + /** `data-testid` for the action button, so callers can target it distinctly from other same-labeled buttons on the page. */ + actionTestId?: string; footer?: ReactNode; className?: string; } @@ -16,6 +18,7 @@ const EmptyStateCard = ({ description, actionLabel, onAction, + actionTestId, footer, className = '', }: EmptyStateCardProps) => { @@ -30,6 +33,7 @@ const EmptyStateCard = ({ {actionLabel && onAction ? ( + + + + + + ); +}; + +export default WorkflowProposalCard; diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx new file mode 100644 index 0000000000..b85637cb5a --- /dev/null +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -0,0 +1,179 @@ +/** + * FlowListRow (issue B5a / B5a.1) — one saved-flow row on the Workflows list + * page. Asserts the name/status rendering, the last-run/never-run text + * (including the localized relative-time strings), and that the + * toggle/Run/View runs controls call back with the row's `Flow`. + */ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Flow } from '../../services/api/flowsApi'; +import { renderWithProviders } from '../../test/test-utils'; +import FlowListRow from './FlowListRow'; + +function makeFlow(overrides: Partial = {}): Flow { + return { + id: 'flow-1', + name: 'Daily digest', + enabled: true, + graph: { nodes: [], edges: [] }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + last_run_at: null, + last_status: null, + require_approval: false, + ...overrides, + }; +} + +describe('FlowListRow', () => { + it('renders the flow name and an Enabled badge when enabled', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Daily digest')).toBeInTheDocument(); + expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled'); + }); + + it('renders a Paused badge when disabled', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused'); + }); + + it('shows "Never run" when the flow has no last_run_at', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Never run')).toBeInTheDocument(); + }); + + it('shows the capitalized status and "Just now" for a run seconds ago', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Completed · Just now')).toBeInTheDocument(); + }); + + it('shows a minutes-ago relative time', () => { + const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + renderWithProviders( + + ); + + expect(screen.getByText('Completed · 5m ago')).toBeInTheDocument(); + }); + + it('shows an hours-ago relative time', () => { + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60_000).toISOString(); + renderWithProviders( + + ); + + expect(screen.getByText('Failed · 3h ago')).toBeInTheDocument(); + }); + + it('shows a days-ago relative time', () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString(); + renderWithProviders( + + ); + + expect(screen.getByText('Pending_approval · 2d ago')).toBeInTheDocument(); + }); + + it('calls onToggle with the flow when the switch is clicked', () => { + const onToggle = vi.fn(); + renderWithProviders( + + ); + + fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); + + expect(onToggle).toHaveBeenCalledWith(makeFlow()); + }); + + it('calls onRun with the flow when the Run button is clicked', () => { + const onRun = vi.fn(); + renderWithProviders( + + ); + + fireEvent.click(screen.getByTestId('flow-run-flow-1')); + + expect(onRun).toHaveBeenCalledWith(makeFlow()); + }); + + it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => { + const onViewRuns = vi.fn(); + renderWithProviders( + + ); + + const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1'); + expect(viewRunsButton).toHaveTextContent('View runs'); + fireEvent.click(viewRunsButton); + + expect(onViewRuns).toHaveBeenCalledWith(makeFlow()); + }); + + it('shows the running label and disables Run while busy', () => { + renderWithProviders( + + ); + + const runButton = screen.getByTestId('flow-run-flow-1'); + expect(runButton).toHaveTextContent('Running…'); + expect(runButton).toBeDisabled(); + }); + + it('disables the toggle while busy=toggle', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled(); + }); +}); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx new file mode 100644 index 0000000000..91df423e7c --- /dev/null +++ b/app/src/components/flows/FlowListRow.tsx @@ -0,0 +1,120 @@ +/** + * FlowListRow — one saved-flow row on the Workflows list page (issue B5a). + * + * Mirrors the row layout of `CoreJobList` + * (`app/src/components/settings/panels/cron/CoreJobList.tsx`): name + status + * badge header, a line of run metadata, then a row of `Button` actions. Swaps + * the cron "pause/resume" text button for a `SettingsSwitch` toggle (the + * canonical boolean control — see `components/settings/controls`) since + * enable/disable here is a persistent setting, not a one-off action. + * + * "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`) + * for this flow's run history — re-added now that B3b's run inspector has + * landed and the drawer has somewhere to send the user. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { Flow } from '../../services/api/flowsApi'; +import SettingsSwitch from '../settings/controls/SettingsSwitch'; +import Button from '../ui/Button'; + +/** Which of this row's actions currently has a request in flight, if any. */ +export type FlowListRowBusy = 'toggle' | 'run' | null; + +/** Matches `useT()`'s `t` signature (`I18nContextValue['t']` isn't exported). */ +type TFn = (key: string, fallback?: string) => string; + +export interface FlowListRowProps { + flow: Flow; + onToggle: (flow: Flow) => void; + onRun: (flow: Flow) => void; + onViewRuns: (flow: Flow) => void; + busy?: FlowListRowBusy; +} + +/** + * Formats the "last run" line. `t()` doesn't interpolate, so counts are + * spliced into the translated template in code (`{count}` placeholder) rather + * than templated through raw string concatenation. + */ +function relativeTime(iso: string, t: TFn): string { + const ms = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(ms / 60000); + if (mins < 1) return t('flows.list.justNow'); + if (mins < 60) return t('flows.list.minutesAgo').replace('{count}', String(mins)); + const hrs = Math.floor(mins / 60); + if (hrs < 24) return t('flows.list.hoursAgo').replace('{count}', String(hrs)); + const days = Math.floor(hrs / 24); + return t('flows.list.daysAgo').replace('{count}', String(days)); +} + +/** + * `last_status` is rendered as-is (capitalized) rather than mapped through + * i18n — the same precedent `CoreJobList` follows for `job.last_status` — + * since it's a raw engine-status word, not prose. + */ +function capitalize(value: string): string { + return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} + +const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowListRowProps) => { + const { t } = useT(); + const toggleBusy = busy === 'toggle'; + const runBusy = busy === 'run'; + + const lastRunLabel = + flow.last_run_at && flow.last_status + ? `${capitalize(flow.last_status)} · ${relativeTime(flow.last_run_at, t)}` + : t('flows.list.neverRun'); + + return ( +
+
+
+
{flow.name}
+
{lastRunLabel}
+
+ + {flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')} + +
+ +
+ onToggle(flow)} + /> + + +
+
+ ); +}; + +export default FlowListRow; diff --git a/app/src/components/flows/FlowRunInspectorDrawer.tsx b/app/src/components/flows/FlowRunInspectorDrawer.tsx new file mode 100644 index 0000000000..dd8dcc54fc --- /dev/null +++ b/app/src/components/flows/FlowRunInspectorDrawer.tsx @@ -0,0 +1,282 @@ +/** + * FlowRunInspectorDrawer (issue B3b) + * ---------------------------------- + * + * Right-side drawer showing a single durable `tinyflows` run's status + step + * timeline, opened from the "View run" action on {@link FlowApprovalCard}. + * Drawer chrome mirrors `pages/conversations/components/SubagentDrawer.tsx` + * (fixed overlay + backdrop-click-to-close + Escape-to-close) so it renders + * as a fixed overlay regardless of where the parent mounts it in the DOM. + * + * Data comes from {@link useFlowRunPoller}, which polls + * `openhuman.flows_get_run` every 2s until the run reaches a terminal status + * (`completed`/`failed`) — `pending_approval` keeps polling since the run can + * still be resumed elsewhere. + * + * `FlowRunStep` is lean by design (`node_id` + `output` + optional `port` + * only — no per-step status/timing), so each step renders as a plain label + * + collapsible output, not a graduated status timeline. Status-dot/pill + * visual language borrows from `components/intelligence/WorkflowRunDetail.tsx` + * (`RUN_STATUS_ACCENT`/`PHASE_STATUS_DOT`) and + * `pages/conversations/components/ToolTimelineBlock.tsx` (`StatusTag`) — + * dots, not progress bars (project rule). + */ +import debug from 'debug'; + +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunPoller } from '../../hooks/useFlowRunPoller'; +import { useT } from '../../lib/i18n/I18nContext'; +import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi'; + +const log = debug('flows:run-inspector-drawer'); + +/** + * Accent classes per run status (semantic palette from tailwind.config.js). + * Exported so {@link FlowRunsDrawer} (issue B5a.1) can reuse the same + * status-pill visual language for its run-history rows instead of + * duplicating the mapping. + */ +export const FLOW_RUN_STATUS_ACCENT: Record = { + running: + 'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300', + completed: + 'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300', + pending_approval: + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300', + failed: + 'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300', +}; + +/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. Exported, see above. */ +export const FLOW_RUN_STATUS_DOT: Record = { + running: 'bg-ocean-500 animate-pulse', + completed: 'bg-sage-500', + pending_approval: 'bg-amber-500 animate-pulse', + failed: 'bg-coral-500', +}; + +/** i18n key per run status. Exported, see above. */ +export const FLOW_RUN_STATUS_KEY: Record = { + running: 'flowRuns.status.running', + completed: 'flowRuns.status.completed', + pending_approval: 'flowRuns.status.pending_approval', + failed: 'flowRuns.status.failed', +}; + +function formatTimestamp(value: string | null | undefined): string | null { + if (!value) return null; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return null; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }).format(new Date(parsed)); +} + +/** Render a step's `output` — pretty-printed JSON for objects/arrays, verbatim for strings. */ +function formatStepOutput(output: unknown): string { + if (output == null) return ''; + if (typeof output === 'string') return output; + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); + } +} + +function StepRow({ step, index }: { step: FlowRunStep; index: number }) { + const { t } = useT(); + const outputText = formatStepOutput(step.output); + + return ( +
  • +
    + + + {step.node_id} + + {step.port !== undefined && ( + + {t('flowRuns.inspector.port')}: {step.port} + + )} +
    + {outputText.length > 0 && ( +
    + + {t('flowRuns.inspector.output')} + +
    +            {outputText}
    +          
    +
    + )} +
  • + ); +} + +interface Props { + /** Run id (== thread_id) to inspect. Renders `null` (nothing) when absent. */ + runId: string | null; + onClose: () => void; +} + +/** + * Renders `null` when `runId` is `null` so the parent can mount this + * unconditionally and just flip `runId` (same convention as + * `SubagentDrawer`). + */ +export function FlowRunInspectorDrawer({ runId, onClose }: Props) { + const { t } = useT(); + const { run, loading, error } = useFlowRunPoller(runId); + + useEscapeKey(() => { + log('escape: closing runId=%s', runId); + onClose(); + }, runId !== null); + + if (!runId) return null; + + const startedAt = formatTimestamp(run?.started_at); + const finishedAt = formatTimestamp(run?.finished_at); + const pendingCount = run?.pending_approvals.length ?? 0; + + return ( +
    + {/* Backdrop */} + + + +
    + {loading && !run && ( +
    +
    + {t('flowRuns.inspector.loading')} +
    + )} + + {error && ( +
    + {t('flowRuns.inspector.loadError')}: {error} +
    + )} + + {run && ( + <> + {/* Timing */} +
    + {startedAt && ( +
    + {t('flowRuns.inspector.startedAt')}: {startedAt} +
    + )} + {finishedAt ? ( +
    + {t('flowRuns.inspector.finishedAt')}: {finishedAt} +
    + ) : run.status === 'running' || run.status === 'pending_approval' ? ( +
    {t('flowRuns.inspector.running')}
    + ) : null} +
    + + {/* Error banner */} + {run.error && ( +
    + {t('flowRuns.inspector.error')}: {run.error} +
    + )} + + {/* Pending approvals banner */} + {run.status === 'pending_approval' && pendingCount > 0 && ( +
    + {t('flowRuns.inspector.pendingApprovalsCount').replace( + '{count}', + String(pendingCount) + )} +
    + )} + + {/* Steps timeline */} +
    +

    + {t('flowRuns.inspector.steps')} +

    + {run.steps.length === 0 ? ( +

    + {t('flowRuns.inspector.noSteps')} +

    + ) : ( +
      + {run.steps.map((step, idx) => ( + + ))} +
    + )} +
    + + )} +
    + +
    + ); +} + +export default FlowRunInspectorDrawer; diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx new file mode 100644 index 0000000000..511a998899 --- /dev/null +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -0,0 +1,196 @@ +/** + * FlowRunsDrawer (issue B5a.1) — rendering contract. + * + * Asserts: renders null when `flowId` is null; loading state; renders fetched + * runs (status pill, started-at, truncated id); empty state; error state; + * clicking a run opens `FlowRunInspectorDrawer` for it, stacked on top; + * closing the inspector returns to the runs list; Escape/backdrop/✕ close the + * runs drawer itself. + * + * Mocks `flowsApi.listFlowRuns` (the one-shot fetch this drawer owns) and + * `FlowRunInspectorDrawer` (its own behavior is covered by + * `FlowRunInspectorDrawer.test.tsx`) so this suite only exercises the + * run-history list + the nesting contract between the two drawers. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { store } from '../../store'; +import { FlowRunsDrawer } from './FlowRunsDrawer'; + +const listFlowRuns = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns })); + +const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn()); +vi.mock('./FlowRunInspectorDrawer', () => ({ + FLOW_RUN_STATUS_ACCENT: { + running: 'accent-running', + completed: 'accent-completed', + pending_approval: 'accent-pending', + failed: 'accent-failed', + }, + FLOW_RUN_STATUS_DOT: { + running: 'dot-running', + completed: 'dot-completed', + pending_approval: 'dot-pending', + failed: 'dot-failed', + }, + FLOW_RUN_STATUS_KEY: { + running: 'flowRuns.status.running', + completed: 'flowRuns.status.completed', + pending_approval: 'flowRuns.status.pending_approval', + failed: 'flowRuns.status.failed', + }, + FlowRunInspectorDrawer: (props: { runId: string | null; onClose: () => void }) => { + FlowRunInspectorDrawer(props); + if (!props.runId) return null; + return ( +
    + {props.runId} + +
    + ); + }, +})); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'completed', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +function renderDrawer(flowId: string | null, onClose: () => void, flowName?: string) { + return render( + + + + ); +} + +describe('FlowRunsDrawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders null when flowId is null', () => { + const { container } = renderDrawer(null, vi.fn()); + expect(container).toBeEmptyDOMElement(); + expect(listFlowRuns).not.toHaveBeenCalled(); + }); + + it('shows a loading state before the fetch resolves', () => { + listFlowRuns.mockReturnValue(new Promise(() => {})); // never resolves + renderDrawer('flow-1', vi.fn()); + expect(screen.getByTestId('flow-runs-loading')).toBeInTheDocument(); + }); + + it('fetches and lists runs for the flow', async () => { + listFlowRuns.mockResolvedValue([ + makeRun({ id: 'run-1', status: 'completed' }), + makeRun({ id: 'run-2', status: 'failed' }), + ]); + renderDrawer('flow-1', vi.fn(), 'Daily digest'); + + expect(await screen.findByTestId('flow-runs-list')).toBeInTheDocument(); + expect(listFlowRuns).toHaveBeenCalledWith('flow-1'); + expect(screen.getByTestId('flow-run-row-run-1')).toBeInTheDocument(); + expect(screen.getByTestId('flow-run-row-run-2')).toBeInTheDocument(); + expect(screen.getByText('Runs for Daily digest')).toBeInTheDocument(); + }); + + it('falls back to a generic title when no flowName is given', async () => { + listFlowRuns.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + expect(screen.getByText('Workflow runs')).toBeInTheDocument(); + }); + + it('shows an empty state when there are no runs', async () => { + listFlowRuns.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + expect(await screen.findByTestId('flow-runs-empty')).toHaveTextContent('No runs yet'); + }); + + it('shows an error state when the fetch fails', async () => { + listFlowRuns.mockRejectedValue(new Error('core unreachable')); + renderDrawer('flow-1', vi.fn()); + expect(await screen.findByTestId('flow-runs-error')).toHaveTextContent('core unreachable'); + }); + + it('opens the run inspector on top when a run row is clicked', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + fireEvent.click(row); + + expect(await screen.findByTestId('mock-inspector')).toHaveTextContent('run-1'); + // The runs list stays mounted underneath. + expect(screen.getByTestId('flow-runs-list')).toBeInTheDocument(); + }); + + it('returns to the run list when the inspector closes', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]); + renderDrawer('flow-1', vi.fn()); + + fireEvent.click(await screen.findByTestId('flow-run-row-run-1')); + expect(await screen.findByTestId('mock-inspector')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('mock-inspector-close')); + expect(screen.queryByTestId('mock-inspector')).not.toBeInTheDocument(); + expect(screen.getByTestId('flow-runs-list')).toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', async () => { + listFlowRuns.mockResolvedValue([]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('flow-runs-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the backdrop is clicked', async () => { + listFlowRuns.mockResolvedValue([]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('flow-runs-backdrop')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Escape is pressed and no run is selected', async () => { + listFlowRuns.mockResolvedValue([]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close the runs drawer on Escape while the inspector is open', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + + fireEvent.click(await screen.findByTestId('flow-run-row-run-1')); + expect(await screen.findByTestId('mock-inspector')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx new file mode 100644 index 0000000000..35cf3282fa --- /dev/null +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -0,0 +1,218 @@ +/** + * FlowRunsDrawer (issue B5a.1) + * ---------------------------- + * + * Right-side drawer listing a flow's run history, opened from the + * "View runs" action on {@link FlowListRow}. Drawer chrome mirrors + * `FlowRunInspectorDrawer`/`SubagentDrawer` (fixed overlay + backdrop-click- + * to-close + Escape-to-close via `useEscapeKey`) so it renders as a fixed + * overlay regardless of where the parent mounts it. + * + * Data is a one-shot fetch via `listFlowRuns` — no polling here. The run + * inspector already polls a single run's live status via `useFlowRunPoller`; + * polling the whole list here would duplicate that logic for no benefit + * (the list only needs to be fresh when the drawer opens). + * + * Clicking a run sets `selectedRunId` and renders the existing + * `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50` + * overlays, and the inspector is rendered *after* this drawer's own overlay + * in the JSX, so it paints above it (same stacking context, later DOM wins) + * and its backdrop naturally intercepts clicks meant for the runs list. + * Closing the inspector clears `selectedRunId` and returns to the run list; + * closing this drawer (✕ / backdrop / Escape) calls `onClose`. While the + * inspector is open, this drawer's own Escape handler is disabled so a + * single Escape press closes only the topmost overlay (the inspector) first. + */ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useT } from '../../lib/i18n/I18nContext'; +import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; +import { + FLOW_RUN_STATUS_ACCENT, + FLOW_RUN_STATUS_DOT, + FLOW_RUN_STATUS_KEY, + FlowRunInspectorDrawer, +} from './FlowRunInspectorDrawer'; + +const log = debug('flows:runs-drawer'); + +function formatTimestamp(value: string | null | undefined): string | null { + if (!value) return null; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return null; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(parsed)); +} + +interface Props { + /** Flow to list runs for. Renders `null` (nothing) when absent. */ + flowId: string | null; + /** Flow name for the drawer title, when known. */ + flowName?: string; + onClose: () => void; +} + +/** + * Renders `null` when `flowId` is `null` so the parent can mount this + * unconditionally and just flip `flowId` (same convention as + * `FlowRunInspectorDrawer`/`SubagentDrawer`). + */ +export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) { + const { t } = useT(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(null); + + useEffect(() => { + // Reset for the new target so a previous flow's runs/error can't linger + // under a different flowId while the new fetch is in flight. + setSelectedRunId(null); + setError(null); + + if (!flowId) { + setRuns([]); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + log('loading runs: flowId=%s', flowId); + listFlowRuns(flowId) + .then(result => { + if (cancelled) return; + setRuns(result); + log('loaded runs: flowId=%s count=%d', flowId, result.length); + }) + .catch(err => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + log('load failed: flowId=%s err=%s', flowId, msg); + setError(msg); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [flowId]); + + useEscapeKey( + () => { + log('escape: closing flowId=%s', flowId); + onClose(); + }, + flowId !== null && selectedRunId === null + ); + + if (!flowId) return null; + + const title = flowName + ? t('flows.runs.title').replace('{name}', flowName) + : t('flows.runs.titleFallback'); + + return ( + <> +
    + {/* Backdrop */} + + + +
    + {loading && ( +
    +
    + {t('flows.runs.loading')} +
    + )} + + {error && ( +
    + {t('flows.runs.loadError')}: {error} +
    + )} + + {!loading && !error && runs.length === 0 && ( +

    + {t('flows.runs.empty')} +

    + )} + + {!loading && !error && runs.length > 0 && ( +
      + {runs.map(run => { + const startedAt = formatTimestamp(run.started_at); + return ( +
    • + +
    • + ); + })} +
    + )} +
    + +
    + + {selectedRunId && ( + setSelectedRunId(null)} /> + )} + + ); +} + +export default FlowRunsDrawer; diff --git a/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx new file mode 100644 index 0000000000..5994df0069 --- /dev/null +++ b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx @@ -0,0 +1,143 @@ +/** + * FlowRunInspectorDrawer (issue B3b) — rendering contract. + * + * Asserts: renders null when `runId` is null; loading state; renders fetched + * run data (status pill, steps, expandable output, port pill); error state; + * pending-approvals banner when `status === 'pending_approval'`; run.error + * banner; Escape and backdrop both close; close button calls `onClose`. + * + * Mocks `useFlowRunPoller` directly rather than the underlying RPC client — + * its own poll-until-terminal contract is covered by + * `hooks/__tests__/useFlowRunPoller.test.ts`. + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../../services/api/flowsApi'; +import { store } from '../../../store'; +import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer'; + +const useFlowRunPoller = vi.hoisted(() => vi.fn()); +vi.mock('../../../hooks/useFlowRunPoller', () => ({ useFlowRunPoller })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'thread-1', + flow_id: 'flow-1', + thread_id: 'thread-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [ + { node_id: 'fetch-data', output: { rows: 3 } }, + { node_id: 'branch', output: 'ok', port: 'true' }, + ], + pending_approvals: [], + ...overrides, + }; +} + +function renderDrawer(runId: string | null, onClose: () => void) { + return render( + + + + ); +} + +describe('FlowRunInspectorDrawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders null when runId is null', () => { + useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: null }); + const { container } = renderDrawer(null, vi.fn()); + expect(container).toBeEmptyDOMElement(); + expect(useFlowRunPoller).toHaveBeenCalledWith(null); + }); + + it('shows a loading state before data resolves', () => { + useFlowRunPoller.mockReturnValue({ run: null, loading: true, error: null }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-inspector-loading')).toBeInTheDocument(); + }); + + it('renders the run status pill and step list once data resolves', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + renderDrawer('thread-1', vi.fn()); + + expect(screen.getByTestId('flow-run-status-pill')).toHaveTextContent('Running'); + expect(screen.getByTestId('flow-run-steps')).toBeInTheDocument(); + expect(screen.getByText('fetch-data')).toBeInTheDocument(); + expect(screen.getByText('branch')).toBeInTheDocument(); + expect(screen.getByTestId('flow-run-step-port-1')).toHaveTextContent('true'); + }); + + it('expands a step to reveal its output', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + renderDrawer('thread-1', vi.fn()); + + const step = screen.getByTestId('flow-run-step-0'); + expect(step.querySelector('pre')).not.toBeVisible(); + fireEvent.click(screen.getAllByText('Output')[0]); + expect(step.querySelector('pre')).toBeVisible(); + expect(step.querySelector('pre')?.textContent).toContain('"rows": 3'); + }); + + it('shows an error state when the poller reports an error', () => { + useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: 'network down' }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down'); + }); + + it('shows the pending-approvals banner when status is pending_approval', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }), + loading: false, + error: null, + }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2'); + }); + + it('does not show the pending-approvals banner for a running run', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + renderDrawer('thread-1', vi.fn()); + expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument(); + }); + + it('shows the run.error banner when present', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'failed', error: 'node crashed' }), + loading: false, + error: null, + }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-error-banner')).toHaveTextContent('node crashed'); + }); + + it('calls onClose when the close button is clicked', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + const onClose = vi.fn(); + renderDrawer('thread-1', onClose); + fireEvent.click(screen.getByTestId('flow-run-inspector-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the backdrop is clicked', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + const onClose = vi.fn(); + renderDrawer('thread-1', onClose); + fireEvent.click(screen.getByTestId('flow-run-inspector-backdrop')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Escape is pressed', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + const onClose = vi.fn(); + renderDrawer('thread-1', onClose); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index fec8daaa54..b9d10b6120 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -28,6 +28,7 @@ describe('CollapsedNavRail', () => { 'nav.chat', 'nav.human', 'nav.brain', + 'nav.flows', 'nav.agentWorld', 'nav.connections', ]) { @@ -83,6 +84,22 @@ describe('CollapsedNavRail', () => { ); }); + it('marks Workflows active on the /flows list route', () => { + renderWithProviders(, { initialEntries: ['/flows'] }); + expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + it('marks Workflows active on a nested /flows/* sub-route', () => { + renderWithProviders(, { initialEntries: ['/flows/some-flow-id'] }); + expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + it('renders a Settings icon that navigates to /settings', () => { renderWithProviders(, { initialEntries: ['/home'] }); const settings = screen.getByRole('button', { name: 'nav.settings' }); diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx index d4ae75cce2..2f045f8ee3 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.tsx @@ -15,6 +15,7 @@ import { useHomeNav } from './useHomeNav'; function matchActive(path: string, pathname: string): boolean { if (path === '/chat') return pathname.startsWith('/chat'); if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/'); + if (path === '/flows') return pathname === '/flows' || pathname.startsWith('/flows/'); if (path === '/home') return pathname === '/home'; return pathname === path; } diff --git a/app/src/components/layout/shell/SidebarNav.test.tsx b/app/src/components/layout/shell/SidebarNav.test.tsx index cab802c0fa..790e8289a3 100644 --- a/app/src/components/layout/shell/SidebarNav.test.tsx +++ b/app/src/components/layout/shell/SidebarNav.test.tsx @@ -35,6 +35,24 @@ describe('SidebarNav active matching', () => { expect(tabButton('Chat')).toHaveAttribute('aria-current', 'page'); }); + it('keeps Workflows active on the /flows list route', () => { + renderWithProviders(, { initialEntries: ['/flows'] }); + + expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page'); + }); + + it('keeps Workflows active on a nested /flows/* sub-route', () => { + renderWithProviders(, { initialEntries: ['/flows/some-flow-id'] }); + + expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark Workflows active on an unrelated route', () => { + renderWithProviders(, { initialEntries: ['/chat'] }); + + expect(tabButton('Workflows')).not.toHaveAttribute('aria-current'); + }); + it('gives the active tab a visible brand-accent fill (not the white sidebar background)', () => { renderWithProviders(, { initialEntries: ['/chat'] }); diff --git a/app/src/components/layout/shell/SidebarNav.tsx b/app/src/components/layout/shell/SidebarNav.tsx index a9e0fcd06f..89d8ec26bd 100644 --- a/app/src/components/layout/shell/SidebarNav.tsx +++ b/app/src/components/layout/shell/SidebarNav.tsx @@ -19,6 +19,8 @@ import { NavIcon } from './navIcons'; * - `/agent-world` → the index and every `/agent-world/*` section (it * redirects to `/agent-world/explore`, so an exact match * would never light up) + * - `/flows` → the list page and any future `/flows/*` sub-route + * (canvas, run detail, …) * - `/home` → exact match (so `/` redirects don't light it up) */ function matchActive(path: string, pathname: string): boolean { @@ -26,6 +28,7 @@ function matchActive(path: string, pathname: string): boolean { if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/'); if (path === '/agent-world') return pathname === '/agent-world' || pathname.startsWith('/agent-world/'); + if (path === '/flows') return pathname === '/flows' || pathname.startsWith('/flows/'); if (path === '/home') return pathname === '/home'; return pathname === path; } diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx index ef8fd25a0c..565b5d9206 100644 --- a/app/src/components/layout/shell/navIcons.tsx +++ b/app/src/components/layout/shell/navIcons.tsx @@ -102,6 +102,22 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) { /> ); + case 'flows': + // Three connected nodes — a saved automation graph, matching the + // Workflows list page's empty-state glyph (FlowsPage.tsx). + return ( + + + + + + + ); case 'agent-world': // Globe/network glyph — represents the A2A agent social network. return ( diff --git a/app/src/components/meetings/__tests__/HistorySection.test.tsx b/app/src/components/meetings/__tests__/HistorySection.test.tsx index 93ed3eb69b..cbf22734b1 100644 --- a/app/src/components/meetings/__tests__/HistorySection.test.tsx +++ b/app/src/components/meetings/__tests__/HistorySection.test.tsx @@ -33,13 +33,26 @@ beforeEach(() => { const NOW = Date.now(); +// Anchor the day-grouped fixtures to noon UTC of their respective days. The +// history rail buckets by UTC calendar day (`utcDayKey`), so a `NOW - 1h` +// timestamp lands in *yesterday's* bucket when the suite runs just after +// 00:00 UTC — CI hit this and the "Today" group vanished. Noon UTC always +// shares its day's key regardless of wall-clock time, making grouping +// deterministic. (No future-filtering in `groupRecords`, so a noon-today +// timestamp that is technically ahead of `now` still groups as Today.) +const noonTodayUtc = (() => { + const d = new Date(); + d.setUTCHours(12, 0, 0, 0); + return d.getTime(); +})(); + const todayCall: MeetCallRecord = { request_id: 'req-today', meet_url: 'https://meet.google.com/abc-def-ghi', bot_display_name: 'OpenHuman', owner_display_name: 'Alice', - started_at_ms: NOW - 3600000, - ended_at_ms: NOW - 3000000, + started_at_ms: noonTodayUtc, + ended_at_ms: noonTodayUtc + 600000, listened_seconds: 300, spoken_seconds: 60, turn_count: 5, @@ -51,8 +64,8 @@ const yesterdayCall: MeetCallRecord = { meet_url: 'https://zoom.us/j/999888', bot_display_name: 'OpenHuman', owner_display_name: 'Bob', - started_at_ms: NOW - 86400000 - 3600000, - ended_at_ms: NOW - 86400000 - 3000000, + started_at_ms: noonTodayUtc - 86400000, + ended_at_ms: noonTodayUtc - 86400000 + 600000, listened_seconds: 120, spoken_seconds: 30, turn_count: 2, diff --git a/app/src/components/meetings/__tests__/UpcomingTable.test.tsx b/app/src/components/meetings/__tests__/UpcomingTable.test.tsx index c56507dea0..1bb499faad 100644 --- a/app/src/components/meetings/__tests__/UpcomingTable.test.tsx +++ b/app/src/components/meetings/__tests__/UpcomingTable.test.tsx @@ -105,7 +105,19 @@ describe('UpcomingTable', () => { }); it('shows a date-group separator (Today)', async () => { - listMock.mockResolvedValueOnce([makeMeeting()]); + // Anchor the meeting to noon *today* rather than `NOW + 1h`. The default + // `NOW + 1h` fixture rolls into tomorrow's date bucket when the suite runs + // within an hour of local midnight (CI hit this at 23:14 UTC), so the + // "Today" separator never rendered. Noon today always shares today's day + // key regardless of wall-clock time, making the grouping deterministic. + const noonToday = new Date(); + noonToday.setHours(12, 0, 0, 0); + listMock.mockResolvedValueOnce([ + makeMeeting({ + start_time_ms: noonToday.getTime(), + end_time_ms: noonToday.getTime() + 30 * 60 * 1000, + }), + ]); renderWithProviders(); await waitFor(() => expect(screen.getByText(/today/i)).toBeInTheDocument()); }); diff --git a/app/src/components/notifications/FlowApprovalCard.test.tsx b/app/src/components/notifications/FlowApprovalCard.test.tsx index dd4d1778b0..eacc8c0b03 100644 --- a/app/src/components/notifications/FlowApprovalCard.test.tsx +++ b/app/src/components/notifications/FlowApprovalCard.test.tsx @@ -1,10 +1,12 @@ /** - * Approve/Dismiss contract for the flow-pending-approval notification card - * (issue B3a). Asserts that Approve reads `{ flow_id, thread_id, node_ids }` - * from the notification's action payload, calls `flowsApi.resumeFlow` with - * those args, clears the notification on success, surfaces a localized error - * on failure, and that Dismiss clears the notification WITHOUT calling any RPC - * (there is no `flows_deny` endpoint yet). + * Approve/Dismiss/View-run contract for the flow-pending-approval + * notification card (issues B3a + B3b). Asserts that Approve reads + * `{ flow_id, thread_id, node_ids }` from the notification's action payload, + * calls `flowsApi.resumeFlow` with those args, clears the notification on + * success, surfaces a localized error on failure (including when `node_ids` + * contains non-string entries — an invalid payload), that Dismiss clears the + * notification WITHOUT calling any RPC (there is no `flows_deny` endpoint + * yet), and that "View run" opens the {@link FlowRunInspectorDrawer}. */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; @@ -17,6 +19,11 @@ import FlowApprovalCard from './FlowApprovalCard'; const resumeFlow = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ resumeFlow })); +vi.mock('../flows/FlowRunInspectorDrawer', () => ({ + FlowRunInspectorDrawer: ({ runId }: { runId: string | null; onClose: () => void }) => + runId ?
    {runId}
    : null, +})); + function makeItem(overrides: Partial = {}): NotificationItem { return { id: 'flow-pending-approval:flow-1:thread-1', @@ -92,7 +99,11 @@ describe('FlowApprovalCard', () => { it('does NOT clear the notification when the run parks again on the next gate', async () => { // Sequential gates: resume returns with pending_approvals still non-empty and // the core re-publishes the same-id prompt — the card must not wipe it. - resumeFlow.mockResolvedValue({ output: null, pending_approvals: ['node-c'], thread_id: 'thread-1' }); + resumeFlow.mockResolvedValue({ + output: null, + pending_approvals: ['node-c'], + thread_id: 'thread-1', + }); store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); renderCard(makeItem()); @@ -105,9 +116,7 @@ describe('FlowApprovalCard', () => { expect(item?.actions).toHaveLength(1); expect(item?.read).toBe(false); // Approve re-enabled so the user can act on the next gate. - await waitFor(() => - expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled() - ); + await waitFor(() => expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled()); }); it('shows a localized error and re-enables the buttons when resumeFlow rejects', async () => { @@ -168,4 +177,54 @@ describe('FlowApprovalCard', () => { }); expect(resumeFlow).not.toHaveBeenCalled(); }); + + it('treats non-string node_ids as an invalid payload (Approve errors, no resumeFlow call)', async () => { + renderCard( + makeItem({ + actions: [ + { + actionId: 'approve', + label: 'Review', + payload: { flow_id: 'flow-1', thread_id: 'thread-1', node_ids: [42, null] }, + }, + ], + }) + ); + + fireEvent.click(screen.getByTestId('flow-approval-approve')); + + await waitFor(() => { + expect( + screen.getByText( + (_content, element) => + element?.tagName.toLowerCase() === 'p' && + (element?.textContent ?? '').includes( + 'Could not resume the workflow. Please try again.' + ) + ) + ).toBeInTheDocument(); + }); + expect(resumeFlow).not.toHaveBeenCalled(); + }); + + it('does not render "View run" when the payload is invalid', () => { + renderCard( + makeItem({ + actions: [{ actionId: 'approve', label: 'Review', payload: { flow_id: 'flow-1' } }], + }) + ); + expect(screen.queryByTestId('flow-approval-view-run')).not.toBeInTheDocument(); + }); + + it('"View run" opens the run inspector drawer for the payload thread_id', () => { + renderCard(makeItem()); + + expect(screen.queryByTestId('flow-run-inspector-drawer-stub')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('flow-approval-view-run')); + + const drawer = screen.getByTestId('flow-run-inspector-drawer-stub'); + expect(drawer).toBeInTheDocument(); + expect(drawer).toHaveTextContent('thread-1'); + }); }); diff --git a/app/src/components/notifications/FlowApprovalCard.tsx b/app/src/components/notifications/FlowApprovalCard.tsx index f9df5f9d67..cb4fb7f849 100644 --- a/app/src/components/notifications/FlowApprovalCard.tsx +++ b/app/src/components/notifications/FlowApprovalCard.tsx @@ -15,12 +15,16 @@ * actions and marks it read. Dismiss is UI-only: there is no `flows_deny` / * cancel-run RPC yet (documented follow-up — the run stays parked * `pending_approval` server-side and can still be approved later from the run - * history once B3b's inspector ships), so Dismiss just clears the prompt from - * the Notification Center without touching the engine. + * history), so Dismiss just clears the prompt from the Notification Center + * without touching the engine. * * Styling mirrors the existing amber approval chrome * (`WorkflowRunApprovalCard`) and the `role="alertdialog"` a11y pattern * (`ApprovalRequestCard`) so this reads as the same affordance family. + * + * "View run" (B3b) opens {@link FlowRunInspectorDrawer} for the run's status + + * step timeline (run id === the payload's `thread_id`) without disturbing the + * Approve/Dismiss flow above. */ import debug from 'debug'; import { useState } from 'react'; @@ -33,6 +37,7 @@ import { markRead, type NotificationItem, } from '../../store/notificationSlice'; +import { FlowRunInspectorDrawer } from '../flows/FlowRunInspectorDrawer'; import Button from '../ui/Button'; const log = debug('notifications:flow-approval-card'); @@ -50,7 +55,8 @@ function isFlowApprovalPayload(value: unknown): value is FlowApprovalPayload { return ( typeof record.flow_id === 'string' && typeof record.thread_id === 'string' && - Array.isArray(record.node_ids) + Array.isArray(record.node_ids) && + record.node_ids.every((x: unknown) => typeof x === 'string') ); } @@ -67,6 +73,7 @@ const FlowApprovalCard = ({ notification: n }: Props) => { const dispatch = useAppDispatch(); const [pending, setPending] = useState<'approve' | null>(null); const [error, setError] = useState(null); + const [inspecting, setInspecting] = useState(false); const payload = n.actions?.[0]?.payload; const parsed = isFlowApprovalPayload(payload) ? payload : null; @@ -174,9 +181,31 @@ const FlowApprovalCard = ({ notification: n }: Props) => { onClick={handleDismiss}> {t('notifications.flow.dismiss')} + {parsed && ( + + )}
    + {parsed && ( + setInspecting(false)} + /> + )} ); }; diff --git a/app/src/config/__tests__/navConfig.test.ts b/app/src/config/__tests__/navConfig.test.ts index 8c7783b7da..0a20263b51 100644 --- a/app/src/config/__tests__/navConfig.test.ts +++ b/app/src/config/__tests__/navConfig.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig'; describe('NAV_TABS', () => { - it('has exactly 5 entries', () => { - expect(NAV_TABS).toHaveLength(5); + it('has exactly 6 entries', () => { + expect(NAV_TABS).toHaveLength(6); }); it('has the correct ids in order', () => { @@ -12,6 +12,7 @@ describe('NAV_TABS', () => { 'chat', 'human', 'brain', + 'flows', 'agent-world', 'connections', ]); @@ -22,6 +23,7 @@ describe('NAV_TABS', () => { '/chat', '/human', '/brain', + '/flows', '/agent-world', '/connections', ]); @@ -32,6 +34,7 @@ describe('NAV_TABS', () => { 'nav.chat', 'nav.human', 'nav.brain', + 'nav.flows', 'nav.agentWorld', 'nav.connections', ]); @@ -42,6 +45,7 @@ describe('NAV_TABS', () => { 'tab-chat', 'tab-human', 'tab-brain', + 'tab-flows', 'tab-agent-world', 'tab-connections', ]); diff --git a/app/src/config/navConfig.ts b/app/src/config/navConfig.ts index 843308926e..ff47ca08e7 100644 --- a/app/src/config/navConfig.ts +++ b/app/src/config/navConfig.ts @@ -36,6 +36,7 @@ export const NAV_TABS: NavTab[] = [ { id: 'chat', labelKey: 'nav.chat', path: '/chat', walkthroughAttr: 'tab-chat' }, { id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' }, { id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' }, + { id: 'flows', labelKey: 'nav.flows', path: '/flows', walkthroughAttr: 'tab-flows' }, { id: 'agent-world', labelKey: 'nav.agentWorld', diff --git a/app/src/hooks/__tests__/useFlowRunPoller.test.ts b/app/src/hooks/__tests__/useFlowRunPoller.test.ts new file mode 100644 index 0000000000..00fb98c4c6 --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunPoller.test.ts @@ -0,0 +1,197 @@ +/** + * useFlowRunPoller (issue B3b) — poll-until-terminal contract. + * + * Asserts: initial loading→resolved, 2s poll cadence while `running` / + * `pending_approval`, stop on `completed`/`failed`, stop when `runId` goes + * `null`, error surfaced (and no further poll) on rejection, and effect + * cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { useFlowRunPoller } from '../useFlowRunPoller'; + +const getFlowRun = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/flowsApi', () => ({ getFlowRun })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'thread-1', + flow_id: 'flow-1', + thread_id: 'thread-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +describe('useFlowRunPoller', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts in loading and resolves with the first fetched run', async () => { + getFlowRun.mockResolvedValue(makeRun()); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + expect(result.current.loading).toBe(true); + expect(result.current.run).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.run?.status).toBe('running'); + expect(result.current.error).toBeNull(); + }); + + it('polls every 2s while the run is running', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(3); + }); + + it('keeps polling while pending_approval (not terminal)', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'pending_approval' })); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run?.status).toBe('pending_approval'); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(2); + }); + + it('stops polling once the run completes', async () => { + getFlowRun.mockResolvedValue( + makeRun({ status: 'completed', finished_at: '2026-01-01T00:01:00Z' }) + ); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run?.status).toBe('completed'); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('stops polling once the run fails', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'failed', error: 'boom' })); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run?.status).toBe('failed'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('stops and clears state when runId becomes null', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + const { result, rerender } = renderHook(({ runId }) => useFlowRunPoller(runId), { + initialProps: { runId: 'thread-1' as string | null }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run).not.toBeNull(); + + rerender({ runId: null }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.run).toBeNull(); + expect(result.current.loading).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('sets error on rejection and does not schedule another poll', async () => { + getFlowRun.mockRejectedValue(new Error('network down')); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.error).toBe('network down'); + expect(result.current.loading).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('cleans up pending timers on unmount', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + const { unmount } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + unmount(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + // No further calls after unmount. + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('does nothing when runId starts null', async () => { + const { result } = renderHook(() => useFlowRunPoller(null)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.run).toBeNull(); + expect(getFlowRun).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useFlowRunPoller.ts b/app/src/hooks/useFlowRunPoller.ts new file mode 100644 index 0000000000..1b4f6fb8c3 --- /dev/null +++ b/app/src/hooks/useFlowRunPoller.ts @@ -0,0 +1,117 @@ +/** + * useFlowRunPoller (issue B3b) + * ---------------------------- + * + * Poll-until-terminal loop for a single durable `tinyflows` run, feeding the + * {@link FlowRunInspectorDrawer}. The flows engine emits no socket events for + * run progress (same situation as the `workflow_run_*` orchestration surface), + * so this mirrors the setTimeout-chained poll loop in + * `components/intelligence/IntelligenceOrchestrationTab.tsx` (~lines 112-143): + * schedule the next poll only after the current one resolves and the run is + * still non-terminal, guard against races with `cancelled`/`inFlight`, and + * never let an unmounted component call `setState`. + * + * `pending_approval` is explicitly NOT terminal — a paused run still needs + * live status so the drawer reflects an approval elsewhere resolving it. + */ +import debug from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { type FlowRun, type FlowRunStatus, getFlowRun } from '../services/api/flowsApi'; + +const log = debug('flows:poller'); + +/** How often to poll a non-terminal run for progress. */ +const POLL_INTERVAL_MS = 2000; + +const TERMINAL = new Set(['completed', 'failed']); + +function isTerminal(run: FlowRun | null): boolean { + return run !== null && TERMINAL.has(run.status); +} + +export interface UseFlowRunPollerResult { + run: FlowRun | null; + loading: boolean; + error: string | null; +} + +/** + * Poll `openhuman.flows_get_run` for `runId` every {@link POLL_INTERVAL_MS}ms + * while the run is `running` or `pending_approval`. Stops polling once the + * run reaches a terminal status, when `runId` becomes `null`, when `runId` + * changes, or on unmount. A failed fetch surfaces `error` and does NOT + * schedule another poll — a broken endpoint shouldn't be hammered. + */ +export function useFlowRunPoller(runId: string | null): UseFlowRunPollerResult { + // Lazy initial state keyed off the `runId` this hook instance first mounts + // with, so the loading spinner is already correct on the very first paint + // without a synchronous `setState` in the effect body below. + const [run, setRun] = useState(null); + const [loading, setLoading] = useState(() => runId !== null); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + // Reset view state for the new target — avoids painting the previous + // runId's data/error under a different runId while the first fetch for + // it is in flight. (On the very first mount this just re-applies the + // lazy-initial values above, so it's a no-op paint-wise.) + setRun(null); + setError(null); + + if (!runId) { + setLoading(false); + return; + } + setLoading(true); + + let cancelled = false; + let inFlight = false; + let pollHandle: number | undefined; + + const tick = async () => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const next = await getFlowRun(runId); + if (cancelled || !mountedRef.current) return; + setRun(next); + setLoading(false); + setError(null); + if (!isTerminal(next)) { + pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS); + } else { + log('tick: runId=%s reached terminal status=%s', runId, next.status); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('tick: error runId=%s err=%s', runId, msg); + if (cancelled || !mountedRef.current) return; + setError(msg); + setLoading(false); + // Do not schedule another poll — leave retrying to the caller (e.g. + // reopening the drawer) rather than hammering a broken endpoint. + } finally { + inFlight = false; + } + }; + + void tick(); + return () => { + cancelled = true; + if (pollHandle !== undefined) window.clearTimeout(pollHandle); + }; + }, [runId]); + + return { run, loading, error }; +} + +export default useFlowRunPoller; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 7e1041ae8a..29c55d5ee8 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -164,6 +164,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'لم يتم العثور على ملفات وكلاء', 'nav.activity': 'النشاط', 'nav.brain': 'الدماغ', + 'nav.flows': 'سير العمل', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'المحفظة', 'agentWorld.description': @@ -3018,6 +3019,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'العميل يريد القيام بعمل يحتاج إلى موافقتك', 'chat.approval.title': 'الموافقة المطلوبة', 'chat.approval.tool': 'Tool:', + 'chat.flowProposal.title': 'اقتراح مسار العمل', + 'chat.flowProposal.subtitle': 'راجع هذه الأتمتة قبل حفظها.', + 'chat.flowProposal.triggerLabel': 'المُشغّل', + 'chat.flowProposal.stepsLabel': 'الخطوات', + 'chat.flowProposal.noSteps': 'لا توجد خطوات إضافية.', + 'chat.flowProposal.requireApprovalHint': 'سيتطلب كل إجراء صادر موافقتك.', + 'chat.flowProposal.save': 'حفظ وتفعيل', + 'chat.flowProposal.saving': 'جارٍ الحفظ…', + 'chat.flowProposal.dismiss': 'إغلاق', + 'chat.flowProposal.error': 'تعذّر حفظ سير العمل. حاول مرة أخرى.', 'channels.authMode.managed_dm': 'قم بتسجيل الدخول باستخدام OpenHuman', 'channels.authMode.oauth': 'OAuth تسجيل الدخول', 'channels.authMode.bot_token': 'استخدم رمز الروبوت الخاص بك', @@ -3547,6 +3558,52 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'في انتظار {count} من بوابات الموافقة', 'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه', 'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل', + 'notifications.flow.viewRun': 'عرض التشغيل', + 'flowRuns.inspector.title': 'تفاصيل التشغيل', + 'flowRuns.inspector.startedAt': 'بدأ', + 'flowRuns.inspector.finishedAt': 'انتهى', + 'flowRuns.inspector.running': 'قيد التشغيل…', + 'flowRuns.inspector.error': 'خطأ', + 'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة', + 'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة', + 'flowRuns.inspector.steps': 'الخطوات', + 'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.', + 'flowRuns.inspector.output': 'المخرجات', + 'flowRuns.inspector.port': 'المنفذ', + 'flowRuns.inspector.loading': 'جارٍ تحميل التشغيل…', + 'flowRuns.inspector.loadError': 'تعذّر تحميل هذا التشغيل', + 'flowRuns.status.running': 'قيد التشغيل', + 'flowRuns.status.completed': 'مكتمل', + 'flowRuns.status.pending_approval': 'بانتظار الموافقة', + 'flowRuns.status.failed': 'فشل', + + 'flows.page.title': 'سير العمل', + 'flows.page.description': 'أتمتة محفوظة يمكنك تفعيلها وتشغيلها ومتابعتها.', + 'flows.page.emptyTitle': 'لا توجد عمليات سير عمل بعد', + 'flows.page.emptyDescription': + 'ستظهر عمليات سير العمل المحفوظة هنا بمجرد إنشاء واحدة من لوحة الرسم.', + 'flows.page.loading': 'جارٍ تحميل عمليات سير العمل…', + 'flows.page.loadError': 'تعذر تحميل عمليات سير العمل. يرجى المحاولة مرة أخرى.', + 'flows.page.newWorkflow': 'سير عمل جديد', + 'flows.list.lastRun': 'آخر تشغيل', + 'flows.list.neverRun': 'لم يتم التشغيل بعد', + 'flows.list.justNow': 'الآن', + 'flows.list.minutesAgo': 'منذ {count} دقيقة', + 'flows.list.hoursAgo': 'منذ {count} ساعة', + 'flows.list.daysAgo': 'منذ {count} يوم', + 'flows.list.runNow': 'تشغيل', + 'flows.list.running': 'جارٍ التشغيل…', + 'flows.list.viewRuns': 'عرض التشغيلات', + 'flows.list.toggleEnabled': 'تفعيل سير العمل', + 'flows.list.enabled': 'مفعّل', + 'flows.list.paused': 'متوقف مؤقتًا', + 'flows.list.runStarted': 'بدأ تشغيل سير العمل', + 'flows.runs.title': 'عمليات التشغيل لـ {name}', + 'flows.runs.titleFallback': 'عمليات تشغيل سير العمل', + 'flows.runs.loading': 'جارٍ تحميل عمليات التشغيل…', + 'flows.runs.loadError': 'تعذّر تحميل عمليات التشغيل', + 'flows.runs.empty': 'لا توجد عمليات تشغيل بعد', + 'oauth.button.connecting': 'جارٍ الاتصال...', 'oauth.button.loopbackTimeout': 'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 31d77ad063..0748e483e7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -169,6 +169,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'কোনো এজেন্ট প্রোফাইল পাওয়া যায়নি', 'nav.activity': 'কার্যকলাপ', 'nav.brain': 'ব্রেইন', + 'nav.flows': 'ওয়ার্কফ্লো', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'ওয়ালেট', 'agentWorld.description': @@ -3083,6 +3084,17 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'এজেন্ট এমন কাজ করতে চায় যা আপনার অনুমোদন প্রয়োজন.', 'chat.approval.title': 'অনুমোদন প্রয়োজন', 'chat.approval.tool': 'টুল:', + 'chat.flowProposal.title': 'ওয়ার্কফ্লো প্রস্তাব', + 'chat.flowProposal.subtitle': 'সংরক্ষণ করার আগে এই অটোমেশনটি পর্যালোচনা করুন।', + 'chat.flowProposal.triggerLabel': 'ট্রিগার', + 'chat.flowProposal.stepsLabel': 'ধাপসমূহ', + 'chat.flowProposal.noSteps': 'কোনো অতিরিক্ত ধাপ নেই।', + 'chat.flowProposal.requireApprovalHint': + 'প্রতিটি বহির্গামী কাজের জন্য আপনার অনুমোদন প্রয়োজন হবে।', + 'chat.flowProposal.save': 'সংরক্ষণ ও সক্রিয় করুন', + 'chat.flowProposal.saving': 'সংরক্ষণ করা হচ্ছে…', + 'chat.flowProposal.dismiss': 'খারিজ করুন', + 'chat.flowProposal.error': 'ওয়ার্কফ্লো সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।', 'channels.authMode.managed_dm': 'OpenHuman দিয়ে লগইন করুন', 'channels.authMode.oauth': 'OAuth সাইন-ইন করুন', 'channels.authMode.bot_token': 'আপনার নিজের বট টোকেন ব্যবহার করুন', @@ -3628,6 +3640,52 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '{count}টি অনুমোদন গেট মুলতুবি আছে', 'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন', 'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান', + 'notifications.flow.viewRun': 'রান দেখুন', + 'flowRuns.inspector.title': 'রান বিবরণ', + 'flowRuns.inspector.startedAt': 'শুরু হয়েছে', + 'flowRuns.inspector.finishedAt': 'শেষ হয়েছে', + 'flowRuns.inspector.running': 'চলছে…', + 'flowRuns.inspector.error': 'ত্রুটি', + 'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন', + 'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়', + 'flowRuns.inspector.steps': 'ধাপ', + 'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।', + 'flowRuns.inspector.output': 'আউটপুট', + 'flowRuns.inspector.port': 'পোর্ট', + 'flowRuns.inspector.loading': 'রান লোড হচ্ছে…', + 'flowRuns.inspector.loadError': 'এই রানটি লোড করা যায়নি', + 'flowRuns.status.running': 'চলছে', + 'flowRuns.status.completed': 'সম্পন্ন', + 'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়', + 'flowRuns.status.failed': 'ব্যর্থ', + + 'flows.page.title': 'ওয়ার্কফ্লো', + 'flows.page.description': 'সংরক্ষিত অটোমেশন যা আপনি সক্ষম, চালাতে এবং পর্যবেক্ষণ করতে পারেন।', + 'flows.page.emptyTitle': 'এখনো কোনো ওয়ার্কফ্লো নেই', + 'flows.page.emptyDescription': + 'ক্যানভাস থেকে একটি তৈরি করলে সংরক্ষিত ওয়ার্কফ্লোগুলো এখানে দেখা যাবে।', + 'flows.page.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…', + 'flows.page.loadError': 'ওয়ার্কফ্লো লোড করা যায়নি। আবার চেষ্টা করুন।', + 'flows.page.newWorkflow': 'নতুন ওয়ার্কফ্লো', + 'flows.list.lastRun': 'সর্বশেষ চালানো', + 'flows.list.neverRun': 'কখনো চালানো হয়নি', + 'flows.list.justNow': 'এইমাত্র', + 'flows.list.minutesAgo': '{count} মিনিট আগে', + 'flows.list.hoursAgo': '{count} ঘণ্টা আগে', + 'flows.list.daysAgo': '{count} দিন আগে', + 'flows.list.runNow': 'চালান', + 'flows.list.running': 'চলছে…', + 'flows.list.viewRuns': 'রান দেখুন', + 'flows.list.toggleEnabled': 'ওয়ার্কফ্লো সক্ষম করুন', + 'flows.list.enabled': 'সক্ষম', + 'flows.list.paused': 'বিরতি দেওয়া', + 'flows.list.runStarted': 'ওয়ার্কফ্লো শুরু হয়েছে', + 'flows.runs.title': '{name}-এর জন্য রান', + 'flows.runs.titleFallback': 'ওয়ার্কফ্লো রান', + 'flows.runs.loading': 'রান লোড হচ্ছে…', + 'flows.runs.loadError': 'রান লোড করা যায়নি', + 'flows.runs.empty': 'এখনো কোনো রান নেই', + 'oauth.button.connecting': 'সংযোগ হচ্ছে...', 'oauth.button.loopbackTimeout': 'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 72e1d7d618..aa04f3e277 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -174,6 +174,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'Keine Agentenprofile gefunden', 'nav.activity': 'Aktivität', 'nav.brain': 'Gehirn', + 'nav.flows': 'Workflows', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Wallet', 'agentWorld.description': @@ -3160,6 +3161,17 @@ const messages: TranslationMap = { 'Der Agent möchte eine Aktion ausführen, die Ihre Zustimmung erfordert.', 'chat.approval.title': 'Genehmigung erforderlich', 'chat.approval.tool': 'Werkzeug:', + 'chat.flowProposal.title': 'Workflow-Vorschlag', + 'chat.flowProposal.subtitle': 'Prüfen Sie diese Automatisierung, bevor Sie sie speichern.', + 'chat.flowProposal.triggerLabel': 'Auslöser', + 'chat.flowProposal.stepsLabel': 'Schritte', + 'chat.flowProposal.noSteps': 'Keine weiteren Schritte.', + 'chat.flowProposal.requireApprovalHint': 'Jede ausgehende Aktion benötigt Ihre Genehmigung.', + 'chat.flowProposal.save': 'Speichern & aktivieren', + 'chat.flowProposal.saving': 'Wird gespeichert…', + 'chat.flowProposal.dismiss': 'Verwerfen', + 'chat.flowProposal.error': + 'Der Workflow konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', 'channels.authMode.managed_dm': 'Mit OpenHuman anmelden', 'channels.authMode.oauth': 'OAuth-Anmeldung', 'channels.authMode.bot_token': 'Eigenen Bot-Token verwenden', @@ -3717,6 +3729,53 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Wartet auf {count} Genehmigungsschritt(e)', 'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen', 'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen', + 'notifications.flow.viewRun': 'Lauf anzeigen', + 'flowRuns.inspector.title': 'Laufdetails', + 'flowRuns.inspector.startedAt': 'Gestartet', + 'flowRuns.inspector.finishedAt': 'Beendet', + 'flowRuns.inspector.running': 'Läuft…', + 'flowRuns.inspector.error': 'Fehler', + 'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen', + 'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung', + 'flowRuns.inspector.steps': 'Schritte', + 'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.', + 'flowRuns.inspector.output': 'Ausgabe', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Lauf wird geladen…', + 'flowRuns.inspector.loadError': 'Dieser Lauf konnte nicht geladen werden', + 'flowRuns.status.running': 'Läuft', + 'flowRuns.status.completed': 'Abgeschlossen', + 'flowRuns.status.pending_approval': 'Wartet auf Genehmigung', + 'flowRuns.status.failed': 'Fehlgeschlagen', + + 'flows.page.title': 'Workflows', + 'flows.page.description': + 'Gespeicherte Automatisierungen, die du aktivieren, ausführen und überwachen kannst.', + 'flows.page.emptyTitle': 'Noch keine Workflows', + 'flows.page.emptyDescription': + 'Gespeicherte Workflows erscheinen hier, sobald du einen im Canvas erstellst.', + 'flows.page.loading': 'Workflows werden geladen…', + 'flows.page.loadError': 'Workflows konnten nicht geladen werden. Bitte versuche es erneut.', + 'flows.page.newWorkflow': 'Neuer Workflow', + 'flows.list.lastRun': 'Letzter Lauf', + 'flows.list.neverRun': 'Noch nie ausgeführt', + 'flows.list.justNow': 'Gerade eben', + 'flows.list.minutesAgo': 'vor {count} Min.', + 'flows.list.hoursAgo': 'vor {count} Std.', + 'flows.list.daysAgo': 'vor {count} Tagen', + 'flows.list.runNow': 'Ausführen', + 'flows.list.running': 'Läuft…', + 'flows.list.viewRuns': 'Läufe anzeigen', + 'flows.list.toggleEnabled': 'Workflow aktivieren', + 'flows.list.enabled': 'Aktiviert', + 'flows.list.paused': 'Pausiert', + 'flows.list.runStarted': 'Workflow gestartet', + 'flows.runs.title': 'Ausführungen für {name}', + 'flows.runs.titleFallback': 'Workflow-Ausführungen', + 'flows.runs.loading': 'Ausführungen werden geladen…', + 'flows.runs.loadError': 'Ausführungen konnten nicht geladen werden', + 'flows.runs.empty': 'Noch keine Ausführungen', + 'oauth.button.connecting': 'Verbinden...', 'oauth.button.loopbackTimeout': 'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d280d2a51a..e91e413159 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -25,6 +25,7 @@ const en: TranslationMap = { 'nav.noAgentProfiles': 'No agent profiles found', 'nav.activity': 'Activity', 'nav.brain': 'Brain', + 'nav.flows': 'Workflows', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Wallet', // Agent World section sub-navigation labels @@ -3543,6 +3544,16 @@ const en: TranslationMap = { 'chat.approval.fallback': 'The agent wants to run an action that needs your approval.', 'chat.approval.title': 'Approval needed', 'chat.approval.tool': 'Tool:', + 'chat.flowProposal.title': 'Workflow proposal', + 'chat.flowProposal.subtitle': 'Review this automation before saving it.', + 'chat.flowProposal.triggerLabel': 'Trigger', + 'chat.flowProposal.stepsLabel': 'Steps', + 'chat.flowProposal.noSteps': 'No additional steps.', + 'chat.flowProposal.requireApprovalHint': 'Every outbound action will need your approval.', + 'chat.flowProposal.save': 'Save & enable', + 'chat.flowProposal.saving': 'Saving…', + 'chat.flowProposal.dismiss': 'Dismiss', + 'chat.flowProposal.error': 'Could not save the workflow. Please try again.', // Auth mode labels 'channels.authMode.managed_dm': 'Login with OpenHuman', @@ -4267,6 +4278,57 @@ const en: TranslationMap = { 'notifications.flow.gateCount': 'Waiting on {count} approval gate(s)', 'notifications.flow.approveHint': 'Resume the workflow past this checkpoint', 'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow', + 'notifications.flow.viewRun': 'View run', + 'flowRuns.inspector.title': 'Run details', + 'flowRuns.inspector.startedAt': 'Started', + 'flowRuns.inspector.finishedAt': 'Finished', + 'flowRuns.inspector.running': 'Running…', + 'flowRuns.inspector.error': 'Error', + 'flowRuns.inspector.pendingApprovals': 'Pending approvals', + 'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval', + 'flowRuns.inspector.steps': 'Steps', + 'flowRuns.inspector.noSteps': 'No steps recorded yet.', + 'flowRuns.inspector.output': 'Output', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Loading run…', + 'flowRuns.inspector.loadError': 'Could not load this run', + 'flowRuns.status.running': 'Running', + 'flowRuns.status.completed': 'Completed', + 'flowRuns.status.pending_approval': 'Awaiting approval', + 'flowRuns.status.failed': 'Failed', + + // ── Workflows list page + nav tab (B5a) — the `flows::` domain's + // discoverable hub at /flows. Distinct from the legacy SKILL.md + // "workflows.*" namespace above and from B3b's "flowRuns.*" / B4's + // "chat.flowProposal.*" namespaces (kept apart here to avoid merge + // conflicts with those in-flight branches). + 'flows.page.title': 'Workflows', + 'flows.page.description': 'Saved automations you can enable, run, and monitor.', + 'flows.page.emptyTitle': 'No workflows yet', + 'flows.page.emptyDescription': + 'Saved workflows will show up here once you create one from the canvas.', + 'flows.page.loading': 'Loading workflows…', + 'flows.page.loadError': 'Could not load workflows. Please try again.', + 'flows.page.newWorkflow': 'New workflow', + 'flows.list.lastRun': 'Last run', + 'flows.list.neverRun': 'Never run', + 'flows.list.justNow': 'Just now', + 'flows.list.minutesAgo': '{count}m ago', + 'flows.list.hoursAgo': '{count}h ago', + 'flows.list.daysAgo': '{count}d ago', + 'flows.list.runNow': 'Run', + 'flows.list.running': 'Running…', + 'flows.list.viewRuns': 'View runs', + 'flows.list.toggleEnabled': 'Enable workflow', + 'flows.list.enabled': 'Enabled', + 'flows.list.paused': 'Paused', + 'flows.list.runStarted': 'Workflow started', + 'flows.runs.title': 'Runs for {name}', + 'flows.runs.titleFallback': 'Workflow runs', + 'flows.runs.loading': 'Loading runs…', + 'flows.runs.loadError': 'Could not load runs', + 'flows.runs.empty': 'No runs yet', + 'oauth.button.connecting': 'Connecting...', 'oauth.button.loopbackTimeout': 'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f5902382ac..300c9f0c0a 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -171,6 +171,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'No se encontraron perfiles de agente', 'nav.activity': 'Actividad', 'nav.brain': 'Cerebro', + 'nav.flows': 'Flujos de trabajo', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Cartera', 'agentWorld.description': @@ -3138,6 +3139,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'El agente quiere ejecutar una acción que necesita su aprobación.', 'chat.approval.title': 'Aprobación necesaria', 'chat.approval.tool': 'Herramienta:', + 'chat.flowProposal.title': 'Propuesta de flujo de trabajo', + 'chat.flowProposal.subtitle': 'Revisa esta automatización antes de guardarla.', + 'chat.flowProposal.triggerLabel': 'Disparador', + 'chat.flowProposal.stepsLabel': 'Pasos', + 'chat.flowProposal.noSteps': 'No hay pasos adicionales.', + 'chat.flowProposal.requireApprovalHint': 'Cada acción saliente necesitará tu aprobación.', + 'chat.flowProposal.save': 'Guardar y activar', + 'chat.flowProposal.saving': 'Guardando…', + 'chat.flowProposal.dismiss': 'Descartar', + 'chat.flowProposal.error': 'No se pudo guardar el flujo de trabajo. Inténtalo de nuevo.', 'channels.authMode.managed_dm': 'Iniciar sesión con OpenHuman', 'channels.authMode.oauth': 'OAuth Iniciar sesión', 'channels.authMode.bot_token': 'Utilice su propio token de bot', @@ -3691,6 +3702,53 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Esperando {count} puerta(s) de aprobación', 'notifications.flow.approveHint': 'Reanudar el flujo de trabajo después de este punto de control', 'notifications.flow.dismissHint': 'Ocultar este aviso sin reanudar el flujo de trabajo', + 'notifications.flow.viewRun': 'Ver ejecución', + 'flowRuns.inspector.title': 'Detalles de la ejecución', + 'flowRuns.inspector.startedAt': 'Iniciado', + 'flowRuns.inspector.finishedAt': 'Finalizado', + 'flowRuns.inspector.running': 'En ejecución…', + 'flowRuns.inspector.error': 'Error', + 'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes', + 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación', + 'flowRuns.inspector.steps': 'Pasos', + 'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.', + 'flowRuns.inspector.output': 'Salida', + 'flowRuns.inspector.port': 'Puerto', + 'flowRuns.inspector.loading': 'Cargando ejecución…', + 'flowRuns.inspector.loadError': 'No se pudo cargar esta ejecución', + 'flowRuns.status.running': 'En ejecución', + 'flowRuns.status.completed': 'Completado', + 'flowRuns.status.pending_approval': 'Esperando aprobación', + 'flowRuns.status.failed': 'Fallido', + + 'flows.page.title': 'Flujos de trabajo', + 'flows.page.description': + 'Automatizaciones guardadas que puedes habilitar, ejecutar y supervisar.', + 'flows.page.emptyTitle': 'Aún no hay flujos de trabajo', + 'flows.page.emptyDescription': + 'Los flujos de trabajo guardados aparecerán aquí en cuanto crees uno desde el lienzo.', + 'flows.page.loading': 'Cargando flujos de trabajo…', + 'flows.page.loadError': 'No se pudieron cargar los flujos de trabajo. Inténtalo de nuevo.', + 'flows.page.newWorkflow': 'Nuevo flujo de trabajo', + 'flows.list.lastRun': 'Última ejecución', + 'flows.list.neverRun': 'Nunca ejecutado', + 'flows.list.justNow': 'Justo ahora', + 'flows.list.minutesAgo': 'hace {count} min', + 'flows.list.hoursAgo': 'hace {count} h', + 'flows.list.daysAgo': 'hace {count} d', + 'flows.list.runNow': 'Ejecutar', + 'flows.list.running': 'Ejecutando…', + 'flows.list.viewRuns': 'Ver ejecuciones', + 'flows.list.toggleEnabled': 'Habilitar flujo de trabajo', + 'flows.list.enabled': 'Habilitado', + 'flows.list.paused': 'Pausado', + 'flows.list.runStarted': 'Flujo de trabajo iniciado', + 'flows.runs.title': 'Ejecuciones de {name}', + 'flows.runs.titleFallback': 'Ejecuciones del flujo de trabajo', + 'flows.runs.loading': 'Cargando ejecuciones…', + 'flows.runs.loadError': 'No se pudieron cargar las ejecuciones', + 'flows.runs.empty': 'Aún no hay ejecuciones', + 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': 'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7589d55afe..58e067cee8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -171,6 +171,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': "Aucun profil d'agent trouvé", 'nav.activity': 'Activité', 'nav.brain': 'Cerveau', + 'nav.flows': 'Workflows', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Portefeuille', 'agentWorld.description': @@ -3152,6 +3153,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': "L'agent veut exécuter une action qui nécessite votre approbation.", 'chat.approval.title': 'Approbation requise', 'chat.approval.tool': 'Outil:', + 'chat.flowProposal.title': 'Proposition de workflow', + 'chat.flowProposal.subtitle': "Vérifiez cette automatisation avant de l'enregistrer.", + 'chat.flowProposal.triggerLabel': 'Déclencheur', + 'chat.flowProposal.stepsLabel': 'Étapes', + 'chat.flowProposal.noSteps': 'Aucune étape supplémentaire.', + 'chat.flowProposal.requireApprovalHint': 'Chaque action sortante nécessitera votre approbation.', + 'chat.flowProposal.save': 'Enregistrer et activer', + 'chat.flowProposal.saving': 'Enregistrement…', + 'chat.flowProposal.dismiss': 'Ignorer', + 'chat.flowProposal.error': "Impossible d'enregistrer le workflow. Veuillez réessayer.", 'channels.authMode.managed_dm': 'Connectez-vous avec OpenHuman', 'channels.authMode.oauth': 'OAuth Connectez-vous', 'channels.authMode.bot_token': 'Utiliser votre propre jeton de robot', @@ -3706,6 +3717,53 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'En attente de {count} validation(s)', 'notifications.flow.approveHint': 'Reprendre le workflow après ce point de contrôle', 'notifications.flow.dismissHint': 'Masquer cette invite sans reprendre le workflow', + 'notifications.flow.viewRun': "Voir l'exécution", + 'flowRuns.inspector.title': "Détails de l'exécution", + 'flowRuns.inspector.startedAt': 'Démarré', + 'flowRuns.inspector.finishedAt': 'Terminé', + 'flowRuns.inspector.running': 'En cours…', + 'flowRuns.inspector.error': 'Erreur', + 'flowRuns.inspector.pendingApprovals': 'Approbations en attente', + 'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation", + 'flowRuns.inspector.steps': 'Étapes', + 'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.', + 'flowRuns.inspector.output': 'Sortie', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': "Chargement de l'exécution…", + 'flowRuns.inspector.loadError': 'Impossible de charger cette exécution', + 'flowRuns.status.running': 'En cours', + 'flowRuns.status.completed': 'Terminé', + 'flowRuns.status.pending_approval': "En attente d'approbation", + 'flowRuns.status.failed': 'Échoué', + + 'flows.page.title': 'Workflows', + 'flows.page.description': + 'Automatisations enregistrées que vous pouvez activer, exécuter et surveiller.', + 'flows.page.emptyTitle': "Aucun workflow pour l'instant", + 'flows.page.emptyDescription': + 'Les workflows enregistrés apparaîtront ici dès que vous en créerez un depuis le canevas.', + 'flows.page.loading': 'Chargement des workflows…', + 'flows.page.loadError': 'Impossible de charger les workflows. Veuillez réessayer.', + 'flows.page.newWorkflow': 'Nouveau workflow', + 'flows.list.lastRun': 'Dernière exécution', + 'flows.list.neverRun': 'Jamais exécuté', + 'flows.list.justNow': "À l'instant", + 'flows.list.minutesAgo': 'il y a {count} min', + 'flows.list.hoursAgo': 'il y a {count} h', + 'flows.list.daysAgo': 'il y a {count} j', + 'flows.list.runNow': 'Exécuter', + 'flows.list.running': 'Exécution…', + 'flows.list.viewRuns': 'Voir les exécutions', + 'flows.list.toggleEnabled': 'Activer le workflow', + 'flows.list.enabled': 'Activé', + 'flows.list.paused': 'En pause', + 'flows.list.runStarted': 'Workflow démarré', + 'flows.runs.title': 'Exécutions de {name}', + 'flows.runs.titleFallback': 'Exécutions du workflow', + 'flows.runs.loading': 'Chargement des exécutions…', + 'flows.runs.loadError': 'Impossible de charger les exécutions', + 'flows.runs.empty': 'Aucune exécution pour le moment', + 'oauth.button.connecting': 'Connexion en cours…', 'oauth.button.loopbackTimeout': "La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0b430e1d73..8d264b383c 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -168,6 +168,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'कोई एजेंट प्रोफाइल नहीं मिला', 'nav.activity': 'गतिविधि', 'nav.brain': 'ब्रेन', + 'nav.flows': 'वर्कफ़्लो', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'वॉलेट', 'agentWorld.description': @@ -3083,6 +3084,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'एजेंट अपने अनुमोदन की जरूरत है कि एक कार्रवाई चलाने के लिए चाहता है।', 'chat.approval.title': 'आवश्यक अनुमोदन', 'chat.approval.tool': 'उपकरण:', + 'chat.flowProposal.title': 'वर्कफ़्लो प्रस्ताव', + 'chat.flowProposal.subtitle': 'सहेजने से पहले इस स्वचालन की समीक्षा करें।', + 'chat.flowProposal.triggerLabel': 'ट्रिगर', + 'chat.flowProposal.stepsLabel': 'चरण', + 'chat.flowProposal.noSteps': 'कोई अतिरिक्त चरण नहीं।', + 'chat.flowProposal.requireApprovalHint': 'हर बाहरी कार्रवाई के लिए आपकी स्वीकृति आवश्यक होगी।', + 'chat.flowProposal.save': 'सहेजें और सक्षम करें', + 'chat.flowProposal.saving': 'सहेजा जा रहा है…', + 'chat.flowProposal.dismiss': 'खारिज करें', + 'chat.flowProposal.error': 'वर्कफ़्लो सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।', 'channels.authMode.managed_dm': 'OpenHuman से लॉगिन करें', 'channels.authMode.oauth': 'OAuth साइन-इन करें', 'channels.authMode.bot_token': 'अपने स्वयं के बॉट टोकन का उपयोग करें', @@ -3629,6 +3640,51 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '{count} अनुमोदन गेट लंबित हैं', 'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें', 'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं', + 'notifications.flow.viewRun': 'रन देखें', + 'flowRuns.inspector.title': 'रन विवरण', + 'flowRuns.inspector.startedAt': 'शुरू हुआ', + 'flowRuns.inspector.finishedAt': 'समाप्त हुआ', + 'flowRuns.inspector.running': 'चल रहा है…', + 'flowRuns.inspector.error': 'त्रुटि', + 'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन', + 'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में', + 'flowRuns.inspector.steps': 'चरण', + 'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।', + 'flowRuns.inspector.output': 'आउटपुट', + 'flowRuns.inspector.port': 'पोर्ट', + 'flowRuns.inspector.loading': 'रन लोड हो रहा है…', + 'flowRuns.inspector.loadError': 'यह रन लोड नहीं हो सका', + 'flowRuns.status.running': 'चल रहा है', + 'flowRuns.status.completed': 'पूर्ण', + 'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में', + 'flowRuns.status.failed': 'विफल', + + 'flows.page.title': 'वर्कफ़्लो', + 'flows.page.description': 'सहेजे गए ऑटोमेशन जिन्हें आप सक्षम, चला और मॉनिटर कर सकते हैं।', + 'flows.page.emptyTitle': 'अभी तक कोई वर्कफ़्लो नहीं', + 'flows.page.emptyDescription': 'कैनवास से एक बनाने के बाद सहेजे गए वर्कफ़्लो यहां दिखाई देंगे।', + 'flows.page.loading': 'वर्कफ़्लो लोड हो रहे हैं…', + 'flows.page.loadError': 'वर्कफ़्लो लोड नहीं हो सके। कृपया फिर से प्रयास करें।', + 'flows.page.newWorkflow': 'नया वर्कफ़्लो', + 'flows.list.lastRun': 'अंतिम रन', + 'flows.list.neverRun': 'कभी नहीं चला', + 'flows.list.justNow': 'अभी अभी', + 'flows.list.minutesAgo': '{count} मिनट पहले', + 'flows.list.hoursAgo': '{count} घंटे पहले', + 'flows.list.daysAgo': '{count} दिन पहले', + 'flows.list.runNow': 'चलाएं', + 'flows.list.running': 'चल रहा है…', + 'flows.list.viewRuns': 'रन देखें', + 'flows.list.toggleEnabled': 'वर्कफ़्लो सक्षम करें', + 'flows.list.enabled': 'सक्षम', + 'flows.list.paused': 'रोका गया', + 'flows.list.runStarted': 'वर्कफ़्लो शुरू हुआ', + 'flows.runs.title': '{name} के लिए रन', + 'flows.runs.titleFallback': 'वर्कफ़्लो रन', + 'flows.runs.loading': 'रन लोड हो रहे हैं…', + 'flows.runs.loadError': 'रन लोड नहीं हो सके', + 'flows.runs.empty': 'अभी तक कोई रन नहीं', + 'oauth.button.connecting': 'कनेक्ट हो रहा है...', 'oauth.button.loopbackTimeout': 'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index cd16746be9..767198b510 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -169,6 +169,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'Profil agen tidak ditemukan', 'nav.activity': 'Aktivitas', 'nav.brain': 'Otak', + 'nav.flows': 'Alur Kerja', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Dompet', 'agentWorld.description': @@ -3093,6 +3094,17 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Agen ingin melakukan tindakan yang membutuhkan persetujuanmu.', 'chat.approval.title': 'Perlu persetujuan', 'chat.approval.tool': 'Alat:', + 'chat.flowProposal.title': 'Proposal alur kerja', + 'chat.flowProposal.subtitle': 'Tinjau otomatisasi ini sebelum menyimpannya.', + 'chat.flowProposal.triggerLabel': 'Pemicu', + 'chat.flowProposal.stepsLabel': 'Langkah', + 'chat.flowProposal.noSteps': 'Tidak ada langkah tambahan.', + 'chat.flowProposal.requireApprovalHint': + 'Setiap tindakan keluar akan memerlukan persetujuan Anda.', + 'chat.flowProposal.save': 'Simpan & aktifkan', + 'chat.flowProposal.saving': 'Menyimpan…', + 'chat.flowProposal.dismiss': 'Abaikan', + 'chat.flowProposal.error': 'Alur kerja tidak dapat disimpan. Silakan coba lagi.', 'channels.authMode.managed_dm': 'Masuk dengan OpenHuman', 'channels.authMode.oauth': 'OAuth Masuk', 'channels.authMode.bot_token': 'Gunakan Token Bot Anda sendiri', @@ -3636,6 +3648,52 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Menunggu {count} gerbang persetujuan', 'notifications.flow.approveHint': 'Lanjutkan alur kerja setelah titik pemeriksaan ini', 'notifications.flow.dismissHint': 'Sembunyikan prompt ini tanpa melanjutkan alur kerja', + 'notifications.flow.viewRun': 'Lihat proses', + 'flowRuns.inspector.title': 'Detail proses', + 'flowRuns.inspector.startedAt': 'Dimulai', + 'flowRuns.inspector.finishedAt': 'Selesai', + 'flowRuns.inspector.running': 'Berjalan…', + 'flowRuns.inspector.error': 'Kesalahan', + 'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda', + 'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan', + 'flowRuns.inspector.steps': 'Langkah', + 'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.', + 'flowRuns.inspector.output': 'Keluaran', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Memuat proses…', + 'flowRuns.inspector.loadError': 'Tidak dapat memuat proses ini', + 'flowRuns.status.running': 'Berjalan', + 'flowRuns.status.completed': 'Selesai', + 'flowRuns.status.pending_approval': 'Menunggu persetujuan', + 'flowRuns.status.failed': 'Gagal', + + 'flows.page.title': 'Alur Kerja', + 'flows.page.description': 'Otomatisasi tersimpan yang dapat Anda aktifkan, jalankan, dan pantau.', + 'flows.page.emptyTitle': 'Belum ada alur kerja', + 'flows.page.emptyDescription': + 'Alur kerja tersimpan akan muncul di sini setelah Anda membuat satu dari kanvas.', + 'flows.page.loading': 'Memuat alur kerja…', + 'flows.page.loadError': 'Alur kerja gagal dimuat. Silakan coba lagi.', + 'flows.page.newWorkflow': 'Alur Kerja Baru', + 'flows.list.lastRun': 'Terakhir dijalankan', + 'flows.list.neverRun': 'Belum pernah dijalankan', + 'flows.list.justNow': 'Baru saja', + 'flows.list.minutesAgo': '{count} menit lalu', + 'flows.list.hoursAgo': '{count} jam lalu', + 'flows.list.daysAgo': '{count} hari lalu', + 'flows.list.runNow': 'Jalankan', + 'flows.list.running': 'Sedang berjalan…', + 'flows.list.viewRuns': 'Lihat riwayat', + 'flows.list.toggleEnabled': 'Aktifkan alur kerja', + 'flows.list.enabled': 'Aktif', + 'flows.list.paused': 'Dijeda', + 'flows.list.runStarted': 'Alur kerja dimulai', + 'flows.runs.title': 'Proses untuk {name}', + 'flows.runs.titleFallback': 'Proses alur kerja', + 'flows.runs.loading': 'Memuat proses…', + 'flows.runs.loadError': 'Tidak dapat memuat proses', + 'flows.runs.empty': 'Belum ada proses', + 'oauth.button.connecting': 'Menghubungkan...', 'oauth.button.loopbackTimeout': 'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 4fc856d0a5..cfb3869ab0 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -171,6 +171,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'Nessun profilo agente trovato', 'nav.activity': 'Attività', 'nav.brain': 'Cervello', + 'nav.flows': 'Flussi di lavoro', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Portafoglio', 'agentWorld.description': @@ -3134,6 +3135,16 @@ const messages: TranslationMap = { "L'agente vuole eseguire un'azione che necessita della tua approvazione.", 'chat.approval.title': 'Approvazione necessaria', 'chat.approval.tool': 'Strumento:', + 'chat.flowProposal.title': 'Proposta di workflow', + 'chat.flowProposal.subtitle': 'Rivedi questa automazione prima di salvarla.', + 'chat.flowProposal.triggerLabel': 'Trigger', + 'chat.flowProposal.stepsLabel': 'Passaggi', + 'chat.flowProposal.noSteps': 'Nessun passaggio aggiuntivo.', + 'chat.flowProposal.requireApprovalHint': 'Ogni azione in uscita richiederà la tua approvazione.', + 'chat.flowProposal.save': 'Salva e attiva', + 'chat.flowProposal.saving': 'Salvataggio…', + 'chat.flowProposal.dismiss': 'Ignora', + 'chat.flowProposal.error': 'Impossibile salvare il workflow. Riprova.', 'channels.authMode.managed_dm': 'Accedi con OpenHuman', 'channels.authMode.oauth': 'OAuth Accedi', 'channels.authMode.bot_token': 'Usa il tuo token Bot', @@ -3686,6 +3697,52 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'In attesa di {count} punto/i di approvazione', 'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint', 'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow', + 'notifications.flow.viewRun': 'Visualizza esecuzione', + 'flowRuns.inspector.title': 'Dettagli esecuzione', + 'flowRuns.inspector.startedAt': 'Avviato', + 'flowRuns.inspector.finishedAt': 'Terminato', + 'flowRuns.inspector.running': 'In esecuzione…', + 'flowRuns.inspector.error': 'Errore', + 'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso', + 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione', + 'flowRuns.inspector.steps': 'Passaggi', + 'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.', + 'flowRuns.inspector.output': 'Output', + 'flowRuns.inspector.port': 'Porta', + 'flowRuns.inspector.loading': 'Caricamento esecuzione…', + 'flowRuns.inspector.loadError': 'Impossibile caricare questa esecuzione', + 'flowRuns.status.running': 'In esecuzione', + 'flowRuns.status.completed': 'Completato', + 'flowRuns.status.pending_approval': 'In attesa di approvazione', + 'flowRuns.status.failed': 'Non riuscito', + + 'flows.page.title': 'Flussi di lavoro', + 'flows.page.description': 'Automazioni salvate che puoi abilitare, eseguire e monitorare.', + 'flows.page.emptyTitle': 'Ancora nessun flusso di lavoro', + 'flows.page.emptyDescription': + 'I flussi di lavoro salvati appariranno qui non appena ne crei uno dalla lavagna.', + 'flows.page.loading': 'Caricamento dei flussi di lavoro…', + 'flows.page.loadError': 'Impossibile caricare i flussi di lavoro. Riprova.', + 'flows.page.newWorkflow': 'Nuovo flusso di lavoro', + 'flows.list.lastRun': 'Ultima esecuzione', + 'flows.list.neverRun': 'Mai eseguito', + 'flows.list.justNow': 'Proprio ora', + 'flows.list.minutesAgo': '{count} min fa', + 'flows.list.hoursAgo': '{count} h fa', + 'flows.list.daysAgo': '{count} g fa', + 'flows.list.runNow': 'Esegui', + 'flows.list.running': 'In esecuzione…', + 'flows.list.viewRuns': 'Visualizza esecuzioni', + 'flows.list.toggleEnabled': 'Abilita flusso di lavoro', + 'flows.list.enabled': 'Abilitato', + 'flows.list.paused': 'In pausa', + 'flows.list.runStarted': 'Flusso di lavoro avviato', + 'flows.runs.title': 'Esecuzioni di {name}', + 'flows.runs.titleFallback': 'Esecuzioni del flusso di lavoro', + 'flows.runs.loading': 'Caricamento esecuzioni…', + 'flows.runs.loadError': 'Impossibile caricare le esecuzioni', + 'flows.runs.empty': 'Ancora nessuna esecuzione', + 'oauth.button.connecting': 'Connessione...', 'oauth.button.loopbackTimeout': 'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 60e43df205..d428898ce4 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -165,6 +165,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': '에이전트 프로필을 찾을 수 없습니다', 'nav.activity': '활동', 'nav.brain': '브레인', + 'nav.flows': '워크플로', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': '지갑', 'agentWorld.description': @@ -3052,6 +3053,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': '에이전트가 승인이 필요한 작업을 실행하려고 합니다.', 'chat.approval.title': '승인 필요', 'chat.approval.tool': '도구:', + 'chat.flowProposal.title': '워크플로 제안', + 'chat.flowProposal.subtitle': '저장하기 전에 이 자동화를 검토하세요.', + 'chat.flowProposal.triggerLabel': '트리거', + 'chat.flowProposal.stepsLabel': '단계', + 'chat.flowProposal.noSteps': '추가 단계가 없습니다.', + 'chat.flowProposal.requireApprovalHint': '모든 외부 작업에는 승인이 필요합니다.', + 'chat.flowProposal.save': '저장 및 활성화', + 'chat.flowProposal.saving': '저장 중…', + 'chat.flowProposal.dismiss': '닫기', + 'chat.flowProposal.error': '워크플로를 저장할 수 없습니다. 다시 시도하세요.', 'channels.authMode.managed_dm': 'OpenHuman로 로그인', 'channels.authMode.oauth': 'OAuth 로그인', 'channels.authMode.bot_token': '자체 봇 토큰 사용', @@ -3592,6 +3603,51 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '승인 게이트 {count}개 대기 중', 'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개', 'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기', + 'notifications.flow.viewRun': '실행 보기', + 'flowRuns.inspector.title': '실행 세부정보', + 'flowRuns.inspector.startedAt': '시작됨', + 'flowRuns.inspector.finishedAt': '종료됨', + 'flowRuns.inspector.running': '실행 중…', + 'flowRuns.inspector.error': '오류', + 'flowRuns.inspector.pendingApprovals': '대기 중인 승인', + 'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중', + 'flowRuns.inspector.steps': '단계', + 'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.', + 'flowRuns.inspector.output': '출력', + 'flowRuns.inspector.port': '포트', + 'flowRuns.inspector.loading': '실행 로드 중…', + 'flowRuns.inspector.loadError': '이 실행을 로드할 수 없습니다', + 'flowRuns.status.running': '실행 중', + 'flowRuns.status.completed': '완료됨', + 'flowRuns.status.pending_approval': '승인 대기 중', + 'flowRuns.status.failed': '실패', + + 'flows.page.title': '워크플로', + 'flows.page.description': '활성화, 실행, 모니터링할 수 있는 저장된 자동화입니다.', + 'flows.page.emptyTitle': '아직 워크플로가 없습니다', + 'flows.page.emptyDescription': '캔버스에서 워크플로를 만들면 여기에 표시됩니다.', + 'flows.page.loading': '워크플로 로드 중…', + 'flows.page.loadError': '워크플로를 불러올 수 없습니다. 다시 시도해 주세요.', + 'flows.page.newWorkflow': '새 워크플로', + 'flows.list.lastRun': '마지막 실행', + 'flows.list.neverRun': '실행된 적 없음', + 'flows.list.justNow': '방금', + 'flows.list.minutesAgo': '{count}분 전', + 'flows.list.hoursAgo': '{count}시간 전', + 'flows.list.daysAgo': '{count}일 전', + 'flows.list.runNow': '실행', + 'flows.list.running': '실행 중…', + 'flows.list.viewRuns': '실행 내역 보기', + 'flows.list.toggleEnabled': '워크플로 활성화', + 'flows.list.enabled': '활성화됨', + 'flows.list.paused': '일시 중지됨', + 'flows.list.runStarted': '워크플로가 시작되었습니다', + 'flows.runs.title': '{name}의 실행 기록', + 'flows.runs.titleFallback': '워크플로 실행 기록', + 'flows.runs.loading': '실행 기록을 불러오는 중…', + 'flows.runs.loadError': '실행 기록을 불러올 수 없습니다', + 'flows.runs.empty': '아직 실행 기록이 없습니다', + 'oauth.button.connecting': '연결 중...', 'oauth.button.loopbackTimeout': '로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 7219c74348..a1e606f7f1 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -173,6 +173,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'Nie znaleziono profili agentów', 'nav.activity': 'Aktywność', 'nav.brain': 'Mózg', + 'nav.flows': 'Przepływy pracy', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Portfel', 'agentWorld.description': @@ -3118,6 +3119,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Agent chce wykonać akcję wymagającą Twojej zgody.', 'chat.approval.title': 'Wymagana zgoda', 'chat.approval.tool': 'Narzędzie:', + 'chat.flowProposal.title': 'Propozycja przepływu pracy', + 'chat.flowProposal.subtitle': 'Sprawdź tę automatyzację przed zapisaniem.', + 'chat.flowProposal.triggerLabel': 'Wyzwalacz', + 'chat.flowProposal.stepsLabel': 'Kroki', + 'chat.flowProposal.noSteps': 'Brak dodatkowych kroków.', + 'chat.flowProposal.requireApprovalHint': 'Każda wychodząca akcja będzie wymagać Twojej zgody.', + 'chat.flowProposal.save': 'Zapisz i włącz', + 'chat.flowProposal.saving': 'Zapisywanie…', + 'chat.flowProposal.dismiss': 'Odrzuć', + 'chat.flowProposal.error': 'Nie udało się zapisać przepływu pracy. Spróbuj ponownie.', 'channels.authMode.managed_dm': 'Zaloguj się z OpenHuman', 'channels.authMode.oauth': 'Logowanie OAuth', 'channels.authMode.bot_token': 'Użyj własnego tokena bota', @@ -3672,6 +3683,53 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Oczekiwanie na {count} bramkę/bramki zatwierdzenia', 'notifications.flow.approveHint': 'Wznów przepływ pracy po tym punkcie kontrolnym', 'notifications.flow.dismissHint': 'Ukryj ten monit bez wznawiania przepływu pracy', + 'notifications.flow.viewRun': 'Zobacz przebieg', + 'flowRuns.inspector.title': 'Szczegóły przebiegu', + 'flowRuns.inspector.startedAt': 'Rozpoczęto', + 'flowRuns.inspector.finishedAt': 'Zakończono', + 'flowRuns.inspector.running': 'W trakcie…', + 'flowRuns.inspector.error': 'Błąd', + 'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia', + 'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie', + 'flowRuns.inspector.steps': 'Kroki', + 'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.', + 'flowRuns.inspector.output': 'Dane wyjściowe', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Ładowanie przebiegu…', + 'flowRuns.inspector.loadError': 'Nie można załadować tego przebiegu', + 'flowRuns.status.running': 'W trakcie', + 'flowRuns.status.completed': 'Zakończono', + 'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie', + 'flowRuns.status.failed': 'Niepowodzenie', + + 'flows.page.title': 'Przepływy pracy', + 'flows.page.description': + 'Zapisane automatyzacje, które możesz włączyć, uruchomić i monitorować.', + 'flows.page.emptyTitle': 'Brak przepływów pracy', + 'flows.page.emptyDescription': + 'Zapisane przepływy pracy pojawią się tutaj, gdy utworzysz jeden na płótnie.', + 'flows.page.loading': 'Ładowanie przepływów pracy…', + 'flows.page.loadError': 'Nie udało się załadować przepływów pracy. Spróbuj ponownie.', + 'flows.page.newWorkflow': 'Nowy przepływ pracy', + 'flows.list.lastRun': 'Ostatnie uruchomienie', + 'flows.list.neverRun': 'Nigdy nie uruchomiono', + 'flows.list.justNow': 'Przed chwilą', + 'flows.list.minutesAgo': '{count} min temu', + 'flows.list.hoursAgo': '{count} godz. temu', + 'flows.list.daysAgo': '{count} dni temu', + 'flows.list.runNow': 'Uruchom', + 'flows.list.running': 'Uruchamianie…', + 'flows.list.viewRuns': 'Zobacz uruchomienia', + 'flows.list.toggleEnabled': 'Włącz przepływ pracy', + 'flows.list.enabled': 'Włączony', + 'flows.list.paused': 'Wstrzymany', + 'flows.list.runStarted': 'Przepływ pracy uruchomiony', + 'flows.runs.title': 'Przebiegi dla {name}', + 'flows.runs.titleFallback': 'Przebiegi przepływu pracy', + 'flows.runs.loading': 'Ładowanie przebiegów…', + 'flows.runs.loadError': 'Nie udało się załadować przebiegów', + 'flows.runs.empty': 'Brak przebiegów', + 'oauth.button.connecting': 'Łączenie...', 'oauth.button.loopbackTimeout': 'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8f3c24af0e..2dbe353d8e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -170,6 +170,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'Nenhum perfil de agente encontrado', 'nav.activity': 'Atividade', 'nav.brain': 'Cérebro', + 'nav.flows': 'Fluxos de trabalho', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Carteira', 'agentWorld.description': @@ -3136,6 +3137,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'O agente quer executar uma ação que precisa da sua aprovação.', 'chat.approval.title': 'Aprovação necessária', 'chat.approval.tool': 'Ferramenta:', + 'chat.flowProposal.title': 'Proposta de fluxo de trabalho', + 'chat.flowProposal.subtitle': 'Revise esta automação antes de salvá-la.', + 'chat.flowProposal.triggerLabel': 'Gatilho', + 'chat.flowProposal.stepsLabel': 'Etapas', + 'chat.flowProposal.noSteps': 'Nenhuma etapa adicional.', + 'chat.flowProposal.requireApprovalHint': 'Cada ação de saída exigirá sua aprovação.', + 'chat.flowProposal.save': 'Salvar e ativar', + 'chat.flowProposal.saving': 'Salvando…', + 'chat.flowProposal.dismiss': 'Dispensar', + 'chat.flowProposal.error': 'Não foi possível salvar o fluxo de trabalho. Tente novamente.', 'channels.authMode.managed_dm': 'Faça login com OpenHuman', 'channels.authMode.oauth': 'OAuth Faça login', 'channels.authMode.bot_token': 'Use seu próprio token de bot', @@ -3687,6 +3698,52 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Aguardando {count} porta(s) de aprovação', 'notifications.flow.approveHint': 'Retomar o fluxo de trabalho após este ponto de verificação', 'notifications.flow.dismissHint': 'Ocultar este aviso sem retomar o fluxo de trabalho', + 'notifications.flow.viewRun': 'Ver execução', + 'flowRuns.inspector.title': 'Detalhes da execução', + 'flowRuns.inspector.startedAt': 'Iniciado', + 'flowRuns.inspector.finishedAt': 'Concluído', + 'flowRuns.inspector.running': 'Em execução…', + 'flowRuns.inspector.error': 'Erro', + 'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes', + 'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação', + 'flowRuns.inspector.steps': 'Etapas', + 'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.', + 'flowRuns.inspector.output': 'Saída', + 'flowRuns.inspector.port': 'Porta', + 'flowRuns.inspector.loading': 'Carregando execução…', + 'flowRuns.inspector.loadError': 'Não foi possível carregar esta execução', + 'flowRuns.status.running': 'Em execução', + 'flowRuns.status.completed': 'Concluído', + 'flowRuns.status.pending_approval': 'Aguardando aprovação', + 'flowRuns.status.failed': 'Falhou', + + 'flows.page.title': 'Fluxos de trabalho', + 'flows.page.description': 'Automações salvas que você pode habilitar, executar e monitorar.', + 'flows.page.emptyTitle': 'Ainda não há fluxos de trabalho', + 'flows.page.emptyDescription': + 'Os fluxos de trabalho salvos aparecerão aqui assim que você criar um a partir do canvas.', + 'flows.page.loading': 'Carregando fluxos de trabalho…', + 'flows.page.loadError': 'Não foi possível carregar os fluxos de trabalho. Tente novamente.', + 'flows.page.newWorkflow': 'Novo fluxo de trabalho', + 'flows.list.lastRun': 'Última execução', + 'flows.list.neverRun': 'Nunca executado', + 'flows.list.justNow': 'Agora mesmo', + 'flows.list.minutesAgo': 'há {count} min', + 'flows.list.hoursAgo': 'há {count} h', + 'flows.list.daysAgo': 'há {count} d', + 'flows.list.runNow': 'Executar', + 'flows.list.running': 'Executando…', + 'flows.list.viewRuns': 'Ver execuções', + 'flows.list.toggleEnabled': 'Habilitar fluxo de trabalho', + 'flows.list.enabled': 'Habilitado', + 'flows.list.paused': 'Pausado', + 'flows.list.runStarted': 'Fluxo de trabalho iniciado', + 'flows.runs.title': 'Execuções de {name}', + 'flows.runs.titleFallback': 'Execuções do fluxo de trabalho', + 'flows.runs.loading': 'Carregando execuções…', + 'flows.runs.loadError': 'Não foi possível carregar as execuções', + 'flows.runs.empty': 'Ainda não há execuções', + 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': 'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8cd8c71966..aca00920f5 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -173,6 +173,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': 'Профили агентов не найдены', 'nav.activity': 'Активность', 'nav.brain': 'Мозг', + 'nav.flows': 'Рабочие процессы', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': 'Кошелёк', 'agentWorld.description': @@ -3109,6 +3110,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Агент хочет выполнить действие, требующее вашего одобрения.', 'chat.approval.title': 'Требуется одобрение', 'chat.approval.tool': 'Инструмент:', + 'chat.flowProposal.title': 'Предложение рабочего процесса', + 'chat.flowProposal.subtitle': 'Проверьте эту автоматизацию перед сохранением.', + 'chat.flowProposal.triggerLabel': 'Триггер', + 'chat.flowProposal.stepsLabel': 'Шаги', + 'chat.flowProposal.noSteps': 'Дополнительных шагов нет.', + 'chat.flowProposal.requireApprovalHint': 'Каждое исходящее действие потребует вашего одобрения.', + 'chat.flowProposal.save': 'Сохранить и включить', + 'chat.flowProposal.saving': 'Сохранение…', + 'chat.flowProposal.dismiss': 'Скрыть', + 'chat.flowProposal.error': 'Не удалось сохранить рабочий процесс. Попробуйте еще раз.', 'channels.authMode.managed_dm': 'Войдите с помощью OpenHuman', 'channels.authMode.oauth': 'OAuth Вход в систему', 'channels.authMode.bot_token': 'Используйте свой собственный токен бота', @@ -3661,6 +3672,53 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Ожидание {count} шлюз(ов) одобрения', 'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки', 'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса', + 'notifications.flow.viewRun': 'Просмотреть запуск', + 'flowRuns.inspector.title': 'Детали запуска', + 'flowRuns.inspector.startedAt': 'Начато', + 'flowRuns.inspector.finishedAt': 'Завершено', + 'flowRuns.inspector.running': 'Выполняется…', + 'flowRuns.inspector.error': 'Ошибка', + 'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения', + 'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения', + 'flowRuns.inspector.steps': 'Шаги', + 'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.', + 'flowRuns.inspector.output': 'Вывод', + 'flowRuns.inspector.port': 'Порт', + 'flowRuns.inspector.loading': 'Загрузка запуска…', + 'flowRuns.inspector.loadError': 'Не удалось загрузить этот запуск', + 'flowRuns.status.running': 'Выполняется', + 'flowRuns.status.completed': 'Завершено', + 'flowRuns.status.pending_approval': 'Ожидает подтверждения', + 'flowRuns.status.failed': 'Не удалось', + + 'flows.page.title': 'Рабочие процессы', + 'flows.page.description': + 'Сохранённые автоматизации, которые можно включать, запускать и отслеживать.', + 'flows.page.emptyTitle': 'Пока нет рабочих процессов', + 'flows.page.emptyDescription': + 'Сохранённые рабочие процессы появятся здесь, как только вы создадите один на холсте.', + 'flows.page.loading': 'Загрузка рабочих процессов…', + 'flows.page.loadError': 'Не удалось загрузить рабочие процессы. Попробуйте снова.', + 'flows.page.newWorkflow': 'Новый рабочий процесс', + 'flows.list.lastRun': 'Последний запуск', + 'flows.list.neverRun': 'Ещё не запускался', + 'flows.list.justNow': 'Только что', + 'flows.list.minutesAgo': '{count} мин назад', + 'flows.list.hoursAgo': '{count} ч назад', + 'flows.list.daysAgo': '{count} дн назад', + 'flows.list.runNow': 'Запустить', + 'flows.list.running': 'Выполняется…', + 'flows.list.viewRuns': 'Просмотреть запуски', + 'flows.list.toggleEnabled': 'Включить рабочий процесс', + 'flows.list.enabled': 'Включён', + 'flows.list.paused': 'Приостановлен', + 'flows.list.runStarted': 'Рабочий процесс запущен', + 'flows.runs.title': 'Запуски для {name}', + 'flows.runs.titleFallback': 'Запуски рабочего процесса', + 'flows.runs.loading': 'Загрузка запусков…', + 'flows.runs.loadError': 'Не удалось загрузить запуски', + 'flows.runs.empty': 'Пока нет запусков', + 'oauth.button.connecting': 'Подключение...', 'oauth.button.loopbackTimeout': 'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 4edbf7f581..701567c352 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -153,6 +153,7 @@ const messages: TranslationMap = { 'nav.noAgentProfiles': '未找到代理档案', 'nav.activity': '动态', 'nav.brain': '大脑', + 'nav.flows': '工作流', 'nav.agentWorld': 'Tiny Place', 'nav.wallet': '钱包', 'agentWorld.description': @@ -2923,6 +2924,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': '智能体想要运行一项需要你批准的操作。', 'chat.approval.title': '需要批准', 'chat.approval.tool': '工具:', + 'chat.flowProposal.title': '工作流建议', + 'chat.flowProposal.subtitle': '保存前请先查看此自动化流程。', + 'chat.flowProposal.triggerLabel': '触发器', + 'chat.flowProposal.stepsLabel': '步骤', + 'chat.flowProposal.noSteps': '没有其他步骤。', + 'chat.flowProposal.requireApprovalHint': '每个外发操作都需要你的批准。', + 'chat.flowProposal.save': '保存并启用', + 'chat.flowProposal.saving': '保存中…', + 'chat.flowProposal.dismiss': '忽略', + 'chat.flowProposal.error': '无法保存该工作流。请重试。', 'channels.authMode.managed_dm': '使用 OpenHuman 登录', 'channels.authMode.oauth': 'OAuth 登录', 'channels.authMode.bot_token': '使用你自己的 Bot Token', @@ -3438,6 +3449,51 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '正在等待 {count} 个批准节点', 'notifications.flow.approveHint': '在此检查点之后恢复工作流', 'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流', + 'notifications.flow.viewRun': '查看运行', + 'flowRuns.inspector.title': '运行详情', + 'flowRuns.inspector.startedAt': '开始时间', + 'flowRuns.inspector.finishedAt': '结束时间', + 'flowRuns.inspector.running': '运行中…', + 'flowRuns.inspector.error': '错误', + 'flowRuns.inspector.pendingApprovals': '待批准', + 'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准', + 'flowRuns.inspector.steps': '步骤', + 'flowRuns.inspector.noSteps': '尚未记录任何步骤。', + 'flowRuns.inspector.output': '输出', + 'flowRuns.inspector.port': '端口', + 'flowRuns.inspector.loading': '正在加载运行…', + 'flowRuns.inspector.loadError': '无法加载此运行', + 'flowRuns.status.running': '运行中', + 'flowRuns.status.completed': '已完成', + 'flowRuns.status.pending_approval': '等待批准', + 'flowRuns.status.failed': '失败', + + 'flows.page.title': '工作流', + 'flows.page.description': '已保存的自动化流程,可启用、运行并监控。', + 'flows.page.emptyTitle': '还没有工作流', + 'flows.page.emptyDescription': '在画布中创建工作流后,将显示在此处。', + 'flows.page.loading': '正在加载工作流…', + 'flows.page.loadError': '无法加载工作流,请重试。', + 'flows.page.newWorkflow': '新建工作流', + 'flows.list.lastRun': '上次运行', + 'flows.list.neverRun': '从未运行', + 'flows.list.justNow': '刚刚', + 'flows.list.minutesAgo': '{count}分钟前', + 'flows.list.hoursAgo': '{count}小时前', + 'flows.list.daysAgo': '{count}天前', + 'flows.list.runNow': '运行', + 'flows.list.running': '运行中…', + 'flows.list.viewRuns': '查看运行记录', + 'flows.list.toggleEnabled': '启用工作流', + 'flows.list.enabled': '已启用', + 'flows.list.paused': '已暂停', + 'flows.list.runStarted': '工作流已启动', + 'flows.runs.title': '{name} 的运行记录', + 'flows.runs.titleFallback': '工作流运行记录', + 'flows.runs.loading': '正在加载运行记录…', + 'flows.runs.loadError': '无法加载运行记录', + 'flows.runs.empty': '暂无运行记录', + 'oauth.button.connecting': '连接中...', 'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。', 'oauth.login.continueWith': '继续使用', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 43d08c13c5..7794451970 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -15,6 +15,7 @@ import IntegrationConnectCard from '../components/chat/IntegrationConnectCard'; import QueuedFollowups from '../components/chat/QueuedFollowups'; import SuperContextToggle from '../components/chat/SuperContextToggle'; import { whenSuperContextWriteSettled } from '../components/chat/superContextWrite'; +import WorkflowProposalCard from '../components/chat/WorkflowProposalCard'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import { settingsNavState } from '../components/settings/modal/settingsOverlay'; @@ -352,6 +353,9 @@ const Conversations = ({ const pendingPlanReviewByThread = useAppSelector( state => state.chatRuntime.pendingPlanReviewByThread ); + const pendingWorkflowProposalsByThread = useAppSelector( + state => state.chatRuntime.pendingWorkflowProposalsByThread + ); const streamingAssistantByThread = useAppSelector( state => state.chatRuntime.streamingAssistantByThread ); @@ -1611,6 +1615,13 @@ const Conversations = ({ const pendingPlanReview = selectedThreadId ? (pendingPlanReviewByThread[selectedThreadId] ?? null) : null; + // A candidate automation the agent drafted via `propose_workflow` (issue B4), + // awaiting the user's Save/Dismiss decision on `WorkflowProposalCard`. Unlike + // `pendingPlanReview`, the underlying tool call already completed — this + // just controls whether the card is still showing. + const pendingWorkflowProposal = selectedThreadId + ? (pendingWorkflowProposalsByThread[selectedThreadId] ?? null) + : null; const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); const hasVisibleMessages = visibleMessages.length > 0; const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; @@ -2882,6 +2893,22 @@ const Conversations = ({ /> )} + {/* Agent-first Workflow authoring (issue B4): the agent drafted a + candidate automation via `propose_workflow`. The tool only + validates — it never creates the flow — so this card is the ONLY + path from proposal to saved automation via "Save & enable" + (`flows_create`), or the user can Dismiss it outright. */} + {selectedThreadId && pendingWorkflowProposal && ( + // Keyed by name so a second proposal in the same thread (before the + // first is resolved) remounts the card and resets its local + // saving/error state, matching the PlanReviewCard pattern above. + + )} + {selectedThreadId && ( vi.fn()); +const setFlowEnabled = vi.hoisted(() => vi.fn()); +const runFlow = vi.hoisted(() => vi.fn()); +const listFlowRuns = vi.hoisted(() => vi.fn()); +vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow, listFlowRuns })); + +const mockNavigate = vi.hoisted(() => vi.fn()); +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +function makeFlow(overrides: Partial = {}): Flow { + return { + id: 'flow-1', + name: 'Daily digest', + enabled: true, + graph: { nodes: [], edges: [] }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + last_run_at: null, + last_status: null, + require_approval: false, + ...overrides, + }; +} + +describe('FlowsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows a loading state while flows are being fetched', () => { + listFlows.mockReturnValue(new Promise(() => {})); // never resolves + renderWithProviders(); + + expect(screen.getByText('Loading workflows…')).toBeInTheDocument(); + }); + + it('shows the empty state when there are no saved flows, with a "New workflow" action', async () => { + listFlows.mockResolvedValue([]); + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('No workflows yet')).toBeInTheDocument()); + // There's no canvas builder yet (B5b) — the empty state's action bridges + // to Chat/B4 instead, same as the header button. + expect(screen.getByTestId('flows-empty-new-workflow')).toHaveTextContent('New workflow'); + }); + + it('shows an error banner when the fetch fails', async () => { + listFlows.mockRejectedValue(new Error('core unreachable')); + renderWithProviders(); + + await waitFor(() => + expect(screen.getByText('Could not load workflows. Please try again.')).toBeInTheDocument() + ); + }); + + it('renders one row per saved flow', async () => { + listFlows.mockResolvedValue([makeFlow(), makeFlow({ id: 'flow-2', name: 'Weekly report' })]); + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('Daily digest')).toBeInTheDocument()); + expect(screen.getByText('Weekly report')).toBeInTheDocument(); + }); + + it('toggles a flow via setFlowEnabled and reflects the updated state', async () => { + listFlows.mockResolvedValue([makeFlow({ enabled: true })]); + setFlowEnabled.mockResolvedValue(makeFlow({ enabled: false })); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('flow-toggle-flow-1')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); + + expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false); + await waitFor(() => + expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused') + ); + }); + + it('runs a flow, shows a "Workflow started" toast, and refetches the list', async () => { + listFlows.mockResolvedValue([makeFlow()]); + runFlow.mockResolvedValue({ output: null, pending_approvals: [], thread_id: 't1' }); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('flow-run-flow-1')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('flow-run-flow-1')); + + expect(runFlow).toHaveBeenCalledWith('flow-1'); + await waitFor(() => expect(screen.getByText('Workflow started')).toBeInTheDocument()); + // Loaded once on mount, once more on refetch after the run kicks off. + await waitFor(() => expect(listFlows).toHaveBeenCalledTimes(2)); + }); + + it('shows an error banner (without a toast) when runFlow rejects', async () => { + listFlows.mockResolvedValue([makeFlow()]); + runFlow.mockRejectedValue(new Error('flow disabled')); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('flow-run-flow-1')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('flow-run-flow-1')); + + await waitFor(() => expect(screen.getByText('flow disabled')).toBeInTheDocument()); + expect(screen.queryByText('Workflow started')).not.toBeInTheDocument(); + }); + + it('opens the run-history drawer for the clicked flow when "View runs" is clicked', async () => { + listFlows.mockResolvedValue([makeFlow()]); + listFlowRuns.mockResolvedValue([]); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('flow-view-runs-flow-1')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('flow-view-runs-flow-1')); + + expect(await screen.findByTestId('flow-runs-drawer')).toBeInTheDocument(); + expect(screen.getByText('Runs for Daily digest')).toBeInTheDocument(); + expect(listFlowRuns).toHaveBeenCalledWith('flow-1'); + + fireEvent.click(screen.getByTestId('flow-runs-close')); + expect(screen.queryByTestId('flow-runs-drawer')).not.toBeInTheDocument(); + }); + + it('renders a "New workflow" header button and navigates to /chat when clicked', async () => { + listFlows.mockResolvedValue([makeFlow()]); + renderWithProviders(); + + const newWorkflowButton = await screen.findByTestId('flows-new-workflow'); + expect(newWorkflowButton).toHaveTextContent('New workflow'); + fireEvent.click(newWorkflowButton); + + expect(mockNavigate).toHaveBeenCalledWith('/chat'); + }); + + it('navigates to /chat when the empty-state "New workflow" action is clicked', async () => { + listFlows.mockResolvedValue([]); + renderWithProviders(); + + const emptyStateButton = await screen.findByTestId('flows-empty-new-workflow'); + fireEvent.click(emptyStateButton); + + expect(mockNavigate).toHaveBeenCalledWith('/chat'); + }); +}); diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx new file mode 100644 index 0000000000..013794ecdf --- /dev/null +++ b/app/src/pages/FlowsPage.tsx @@ -0,0 +1,227 @@ +/** + * FlowsPage — the Workflows list page (issue B5a). + * + * The discoverable hub for the `flows::` domain: lists every saved + * `Flow` (name, enabled toggle, last-run status, Run button). This is NOT the + * canvas (B5b ships flow authoring/editing) — until it lands, "New workflow" + * (header + empty-state) bridges to the B4 agent-proposal flow in Chat + * instead, since that's the only way to author a flow today. + */ +import createDebug from 'debug'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import EmptyStateCard from '../components/EmptyStateCard'; +import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow'; +import FlowRunsDrawer from '../components/flows/FlowRunsDrawer'; +import { ToastContainer } from '../components/intelligence/Toast'; +import PanelPage from '../components/layout/PanelPage'; +import Button from '../components/ui/Button'; +import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { useT } from '../lib/i18n/I18nContext'; +import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi'; +import type { ToastNotification } from '../types/intelligence'; + +const log = createDebug('app:flows'); + +/** Which single row + action currently has a request in flight, if any. */ +type BusyKey = `toggle:${string}` | `run:${string}`; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export default function FlowsPage() { + const { t } = useT(); + const navigate = useNavigate(); + const [flows, setFlows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [toasts, setToasts] = useState([]); + // Flow whose run history is open in `FlowRunsDrawer` (B3b's run inspector + // then stacks on top of that when a specific run is picked). `null` keeps + // the drawer unmounted. + const [selectedFlowId, setSelectedFlowId] = useState(null); + + const addToast = useCallback((toast: Omit) => { + setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]); + }, []); + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(item => item.id !== id)); + }, []); + + const loadFlows = useCallback(async () => { + log('loading flows'); + setLoading(true); + setError(null); + try { + const result = await listFlows(); + setFlows(result); + log('loaded %d flows', result.length); + } catch (err) { + log('load failed: %o', err); + setError(t('flows.page.loadError')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadFlows(); + }, [loadFlows]); + + const handleToggle = useCallback( + async (flow: Flow) => { + if (busyKey) return; + const key: BusyKey = `toggle:${flow.id}`; + setBusyKey(key); + setError(null); + log('toggle: id=%s next=%s', flow.id, !flow.enabled); + try { + const updated = await setFlowEnabled(flow.id, !flow.enabled); + setFlows(prev => prev.map(f => (f.id === updated.id ? updated : f))); + } catch (err) { + log('toggle failed: id=%s err=%o', flow.id, err); + setError(errorMessage(err)); + } finally { + setBusyKey(null); + } + }, + [busyKey] + ); + + const handleRun = useCallback( + async (flow: Flow) => { + if (busyKey) return; + const key: BusyKey = `run:${flow.id}`; + setBusyKey(key); + setError(null); + log('run: id=%s', flow.id); + try { + // Fire-and-forget: the caller doesn't wait for the run to finish, + // just that it kicked off. The refetch below picks up the refreshed + // `last_run_at` / `last_status` once the engine settles (or, for a + // still-running flow, on the next manual refresh). Only refetch on + // success — `loadFlows()` clears `error`, which would otherwise wipe + // the failure banner set in the `catch` below. + await runFlow(flow.id); + addToast({ type: 'success', title: t('flows.list.runStarted') }); + await loadFlows(); + } catch (err) { + log('run failed: id=%s err=%o', flow.id, err); + setError(errorMessage(err)); + } finally { + setBusyKey(null); + } + }, + [busyKey, addToast, loadFlows, t] + ); + + const busyFor = (flow: Flow): FlowListRowBusy => { + if (busyKey === `toggle:${flow.id}`) return 'toggle'; + if (busyKey === `run:${flow.id}`) return 'run'; + return null; + }; + + const handleViewRuns = useCallback((flow: Flow) => { + log('view runs: id=%s', flow.id); + setSelectedFlowId(flow.id); + }, []); + + const selectedFlow = flows.find(f => f.id === selectedFlowId) ?? null; + + /** + * "New workflow" (there's no canvas builder yet — B5b) bridges to Chat so + * the user can kick off B4's agent-proposal flow instead. There's no + * existing mechanism to prefill or auto-send an initial composer message + * from outside the Chat page — `Conversations.tsx` only reads + * `location.state.openThreadId` (to reopen a thread), and the composer's + * text is local `useState` with no Redux draft slice. This is the same gap + * `ActionItemChecklist.tsx`'s "Run with OpenHuman" button already hit, so + * we follow its precedent: navigate to `/chat` with no prefill rather than + * build new prefill plumbing from scratch. + */ + const handleNewWorkflow = useCallback(() => { + log('new workflow: navigating to chat'); + // TODO: prefill the chat composer with a workflow-building prompt once a + // draft/initial-message API exists (see ActionItemChecklist.tsx's + // identical TODO for the same gap). + navigate('/chat'); + }, [navigate]); + + return ( + + {t('flows.page.newWorkflow')} + + }> +
    + {error && ( +
    + +
    + )} + + {loading && } + + {!loading && flows.length === 0 && !error && ( + + + + + + + } + title={t('flows.page.emptyTitle')} + description={t('flows.page.emptyDescription')} + actionLabel={t('flows.page.newWorkflow')} + actionTestId="flows-empty-new-workflow" + onAction={handleNewWorkflow} + /> + )} + + {!loading && flows.length > 0 && ( +
    + {flows.map(flow => ( + void handleToggle(f)} + onRun={f => void handleRun(f)} + onViewRuns={handleViewRuns} + /> + ))} +
    + )} +
    + + setSelectedFlowId(null)} + /> + + +
    + ); +} diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index 0143c405d7..f7177c39ba 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -51,12 +51,14 @@ import { setStreamingAssistantForThread, setTaskBoardForThread, setToolTimelineForThread, + setWorkflowProposalForThread, type StreamingAssistantState, type ToolTimelineEntry, type ToolTimelineEntryStatus, upsertArtifactFailedForThread, upsertArtifactInProgressForThread, upsertArtifactReadyForThread, + type WorkflowProposal, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { selectSocketStatus } from '../store/socketSelectors'; @@ -251,6 +253,51 @@ function chatTurnUsagePayload(event: ChatDoneEvent): { }; } +/** + * Parses a completed `propose_workflow` tool call's JSON `output` into a + * `WorkflowProposal` for `WorkflowProposalCard` (issue B4 — agent-first + * Workflow authoring). The tool's `execute()` + * (`src/openhuman/flows/tools.rs`) returns + * `{ type: "workflow_proposal", name, graph, require_approval, summary }` as + * its `ToolResult` body; this maps that wire shape onto the store's camelCase + * `WorkflowProposal`. Returns `null` for anything that fails to parse or + * doesn't match the expected shape — defensive, since a malformed proposal + * must never crash the chat runtime, it should just silently not render a + * card. + */ +function parseWorkflowProposal(output: string): WorkflowProposal | null { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + const obj = parsed as Record; + if (obj.type !== 'workflow_proposal') return null; + if (typeof obj.name !== 'string' || obj.graph == null) return null; + + const summary = (obj.summary ?? {}) as Record; + const rawSteps = Array.isArray(summary.steps) ? summary.steps : []; + const steps = rawSteps + .filter((s): s is Record => !!s && typeof s === 'object') + .map(s => ({ + kind: typeof s.kind === 'string' ? s.kind : 'unknown', + name: typeof s.name === 'string' ? s.name : '', + config_hint: typeof s.config_hint === 'string' ? s.config_hint : undefined, + })); + + return { + name: obj.name, + graph: obj.graph, + // The Rust tool defaults `require_approval` to `true` when the caller + // omits it, so treat anything other than an explicit `false` as `true` + // here too — keeps the client's fallback in lockstep with the server's. + requireApproval: obj.require_approval !== false, + summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps }, + }; +} + export function findPendingDelegationContext( entries: ToolTimelineEntry[], round: number @@ -629,6 +676,27 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { } } + // Agent-first Workflow authoring (issue B4): a completed + // `propose_workflow` call carries a `workflow_proposal` JSON payload + // in `output` — surface it as a `WorkflowProposalCard` above the + // composer. The tool only validates; only the card's "Save & enable" + // action ever calls `flows_create`, so this dispatch alone can never + // create a flow. + if (event.tool_name === 'propose_workflow' && event.success) { + const proposal = parseWorkflowProposal(event.output); + if (proposal) { + rtLog('propose_workflow proposal parsed', { + thread: event.thread_id, + name: proposal.name, + }); + dispatch(setWorkflowProposalForThread({ threadId: event.thread_id, proposal })); + } else { + rtLog('propose_workflow result did not parse as a workflow_proposal', { + thread: event.thread_id, + }); + } + } + const current = store.getState().chatRuntime.inferenceStatusByThread[event.thread_id]; if (!current) return; dispatch( diff --git a/app/src/services/api/flowsApi.test.ts b/app/src/services/api/flowsApi.test.ts index fc856b4aca..73a16d4bed 100644 --- a/app/src/services/api/flowsApi.test.ts +++ b/app/src/services/api/flowsApi.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getFlowRun, listFlowRuns, resumeFlow } from './flowsApi'; +import { + getFlowRun, + listFlowRuns, + listFlows, + resumeFlow, + runFlow, + setFlowEnabled, +} from './flowsApi'; const mockCallCoreRpc = vi.fn(); vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) })); @@ -143,4 +150,117 @@ describe('flowsApi', () => { await expect(getFlowRun('missing')).rejects.toThrow('flow run not found'); }); }); + + describe('listFlows', () => { + const flow = { + id: 'flow-1', + name: 'Demo flow', + enabled: true, + graph: { nodes: [], edges: [] }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + last_run_at: null, + last_status: null, + require_approval: false, + }; + + it('calls openhuman.flows_list with no params', async () => { + mockCallCoreRpc.mockResolvedValue(cliEnvelope([flow])); + + await listFlows(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_list', params: {} }); + }); + + it('unwraps the { result, logs } envelope into the flow array', async () => { + mockCallCoreRpc.mockResolvedValue(cliEnvelope([flow])); + + const result = await listFlows(); + + expect(result).toEqual([flow]); + }); + + it('propagates rejection from callCoreRpc', async () => { + mockCallCoreRpc.mockRejectedValue(new Error('boom')); + + await expect(listFlows()).rejects.toThrow('boom'); + }); + }); + + describe('setFlowEnabled', () => { + it('calls openhuman.flows_set_enabled with id and enabled', async () => { + const flow = { + id: 'flow-1', + name: 'Demo flow', + enabled: false, + graph: {}, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + last_run_at: null, + last_status: null, + require_approval: false, + }; + mockCallCoreRpc.mockResolvedValue(cliEnvelope(flow)); + + const result = await setFlowEnabled('flow-1', false); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.flows_set_enabled', + params: { id: 'flow-1', enabled: false }, + }); + expect(result).toEqual(flow); + }); + + it('propagates rejection from callCoreRpc', async () => { + mockCallCoreRpc.mockRejectedValue(new Error('flow not found')); + + await expect(setFlowEnabled('missing', true)).rejects.toThrow('flow not found'); + }); + }); + + describe('runFlow', () => { + it('calls openhuman.flows_run with id, input, and the extended timeout', async () => { + mockCallCoreRpc.mockResolvedValue( + cliEnvelope({ output: { nodes: {} }, pending_approvals: [], thread_id: 't1' }) + ); + + const result = await runFlow('flow-1'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.flows_run', + params: { id: 'flow-1', input: null }, + timeoutMs: 610_000, + }); + expect(result).toEqual({ output: { nodes: {} }, pending_approvals: [], thread_id: 't1' }); + }); + + it('passes a supplied input payload through', async () => { + mockCallCoreRpc.mockResolvedValue( + cliEnvelope({ output: null, pending_approvals: [], thread_id: 't2' }) + ); + + await runFlow('flow-1', { trigger: 'manual' }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.flows_run', + params: { id: 'flow-1', input: { trigger: 'manual' } }, + timeoutMs: 610_000, + }); + }); + + it('unwraps the { result, logs } envelope', async () => { + const payload = { output: null, pending_approvals: ['node-a'], thread_id: 't3' }; + mockCallCoreRpc.mockResolvedValue(cliEnvelope(payload)); + + const result = await runFlow('flow-1'); + + expect(result).toEqual(payload); + }); + + it('propagates rejection from callCoreRpc', async () => { + mockCallCoreRpc.mockRejectedValue(new Error('flow disabled')); + + await expect(runFlow('flow-1')).rejects.toThrow('flow disabled'); + }); + }); }); diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 57338206c2..d428eb9a83 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -1,7 +1,10 @@ /** * Frontend client for the durable `openhuman.flows_*` run surface (issue B2 / - * B3). Wraps the subset of controllers the B3a approval card and the B3b run - * inspector need: + * B3 / B4). Wraps the subset of controllers the B3a approval card, the B3b + * run inspector, and the B4 agent-proposal card need: + * - `flows_create` — persist a new flow (B4 — only ever called from the + * user's own "Save & enable" click on `WorkflowProposalCard`; the agent's + * `propose_workflow` tool only validates and never reaches this RPC) * - `flows_resume` — resume a `pending_approval` run past its checkpoint * - `flows_list_runs` — recent runs for a flow, newest first (B3b) * - `flows_get_run` — a single run record by id (B3b) @@ -28,10 +31,11 @@ import { callCoreRpc } from '../coreRpcClient'; const log = debug('flowsApi'); /** - * `openhuman.flows_resume` drives the engine and can run up to ~600s server-side - * (`FLOW_RUN_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`). Give the client a - * slightly larger budget than the default 30s so a slow resume doesn't fail - * client-side while the engine is still running. + * `openhuman.flows_resume` and `openhuman.flows_run` both drive the tinyflows + * engine and can run up to ~600s server-side (`FLOW_RUN_TIMEOUT_SECS` in + * `src/openhuman/flows/ops.rs`). Give the client a slightly larger budget than + * the default 30s so a slow run/resume doesn't fail client-side while the + * engine is still running. */ const FLOW_RESUME_TIMEOUT_MS = 610_000; @@ -78,6 +82,33 @@ export interface FlowResumeResult { thread_id: string; } +/** + * A saved automation workflow (`src/openhuman/flows/types.rs::Flow`) — the + * Workflows list page (B5a) row shape. `graph` is the raw tinyflows + * `WorkflowGraph`; the list page doesn't need to interpret it, only the + * canvas (B5b) does, so it's kept as `unknown` here. + */ +export interface Flow { + /** Stable identifier (UUID) for this flow. */ + id: string; + /** Human-readable name shown in the Workflows UI. */ + name: string; + /** Whether this flow may currently be triggered/run. */ + enabled: boolean; + /** The validated, migrated workflow graph — opaque to this client. */ + graph: unknown; + /** RFC3339 creation timestamp. */ + created_at: string; + /** RFC3339 last-update timestamp. */ + updated_at: string; + /** RFC3339 timestamp of the most recent run, if any. */ + last_run_at: string | null; + /** Outcome of the most recent run: `"completed"` | `"pending_approval"` | `"failed"`. */ + last_status: string | null; + /** "Require approval for outbound actions" toggle (issue B2). */ + require_approval: boolean; +} + // --------------------------------------------------------------------------- // CLI-compatible envelope unwrapping. // --------------------------------------------------------------------------- @@ -108,6 +139,35 @@ function unwrapCliEnvelope(payload: unknown): T { // RPC client. // --------------------------------------------------------------------------- +/** + * Create (and, by default, enable) a new saved flow via `openhuman.flows_create` + * (issue B4). This is the ONLY path that persists a flow — the agent's + * `propose_workflow` tool (`src/openhuman/flows/tools.rs`) only validates a + * candidate graph and returns a summary; `WorkflowProposalCard`'s "Save & + * enable" button is what calls this function, directly from the client, on + * the user's explicit action. `requireApproval` defaults server-side to + * `false` when omitted, but the B4 proposal flow always passes it explicitly + * (defaulting to `true` on the Rust tool side) so a saved agent-proposed flow + * starts with its outbound-action approval gate on. + */ +export async function createFlow( + name: string, + graph: unknown, + requireApproval?: boolean +): Promise { + log('createFlow: request name=%s requireApproval=%s', name, requireApproval ?? 'default'); + const response = await callCoreRpc({ + method: 'openhuman.flows_create', + params: + requireApproval === undefined + ? { name, graph } + : { name, graph, require_approval: requireApproval }, + }); + const flow = unwrapCliEnvelope(response); + log('createFlow: response id=%s name=%s enabled=%s', flow.id, flow.name, flow.enabled); + return flow; +} + /** * Resume a `pending_approval` flow run past its checkpoint via * `openhuman.flows_resume`. `approvals` should name the node ids from the @@ -166,6 +226,70 @@ export async function getFlowRun(runId: string): Promise { return run; } -export const flowsApi = { resumeFlow, listFlowRuns, getFlowRun }; +/** + * List all saved flows via `openhuman.flows_list` (the Workflows list page, + * B5a). No params. Unlike the run-surface calls above, the payload IS the + * `Flow[]` array directly — there is no outer `{ flows: [...] }` wrapper (see + * `src/openhuman/flows/ops.rs::flows_list`, which returns `Vec` + * straight through `RpcOutcome::single_log`). + */ +export async function listFlows(): Promise { + log('listFlows: request'); + const response = await callCoreRpc({ method: 'openhuman.flows_list', params: {} }); + const flows = unwrapCliEnvelope(response); + log('listFlows: response count=%d', flows.length); + return flows; +} + +/** + * Enable or disable a saved flow via `openhuman.flows_set_enabled`. Returns + * the updated `Flow` row directly (same no-wrapper shape as `flows_list`'s + * elements). + */ +export async function setFlowEnabled(id: string, enabled: boolean): Promise { + log('setFlowEnabled: request id=%s enabled=%s', id, enabled); + const response = await callCoreRpc({ + method: 'openhuman.flows_set_enabled', + params: { id, enabled }, + }); + const flow = unwrapCliEnvelope(response); + log('setFlowEnabled: response id=%s enabled=%s', flow.id, flow.enabled); + return flow; +} + +/** + * Run a saved flow to completion (or until it pauses on a human-approval + * gate) via `openhuman.flows_run`. This is the call that actually drives the + * tinyflows engine, so it shares `flows_resume`'s ~600s server-side budget + * (see {@link FLOW_RESUME_TIMEOUT_MS}). The Workflows list page's Run button + * uses this fire-and-forget: it awaits the call just long enough to know the + * run kicked off, shows a toast, and refetches `listFlows()` to pick up the + * refreshed `last_run_at`/`last_status`. + */ +export async function runFlow(id: string, input?: unknown): Promise { + log('runFlow: request id=%s', id); + const response = await callCoreRpc({ + method: 'openhuman.flows_run', + params: { id, input: input ?? null }, + timeoutMs: FLOW_RESUME_TIMEOUT_MS, + }); + const result = unwrapCliEnvelope(response); + log( + 'runFlow: response threadId=%s pendingApprovals=%d', + result.thread_id, + result.pending_approvals?.length ?? 0 + ); + return result; +} + +export const flowsApi = { + createFlow, + resumeFlow, + listFlowRuns, + getFlowRun, + listFlows, + setFlowEnabled, + runFlow, +}; export default flowsApi; diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index f921108092..26b01d77b8 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -432,6 +432,40 @@ export interface PendingPlanReview { steps: string[]; } +/** One step in a `WorkflowProposal`'s summary — a non-trigger node. */ +export interface WorkflowProposalStep { + /** tinyflows node kind (e.g. `"agent"`, `"tool_call"`, `"http_request"`). */ + kind: string; + /** Human-readable node name. */ + name: string; + /** Optional short description of the node's config (e.g. a tool slug, prompt). */ + config_hint?: string; +} + +/** + * A candidate automation workflow the agent proposed via the `propose_workflow` + * tool (issue B4 — agent-first Workflow authoring). VALIDATED but never + * created — the agent's tool can only validate and summarize a graph; the + * user must click "Save & enable" on `WorkflowProposalCard` to actually + * persist it via `openhuman.flows_create`. Parsed from the `propose_workflow` + * tool call's completed-result JSON (`tool_result` socket event) in + * `ChatRuntimeProvider`. + */ +export interface WorkflowProposal { + /** Proposed flow name. */ + name: string; + /** The validated tinyflows WorkflowGraph, ready to hand to `flows_create` as-is. */ + graph: unknown; + /** Whether the flow should require approval on every outbound action once saved. */ + requireApproval: boolean; + summary: { + /** One-line description of the trigger (e.g. `"schedule: 0 9 * * *"`). */ + trigger: string; + /** Ordered non-trigger steps. */ + steps: WorkflowProposalStep[]; + }; +} + /** * Lifecycle status of a single agent-generated artifact, as projected * onto the chat runtime per thread. @@ -529,6 +563,16 @@ interface ChatRuntimeState { inferenceTurnLifecycleByThread: Record; pendingApprovalByThread: Record; pendingPlanReviewByThread: Record; + /** + * Thread-scoped candidate workflow proposed by the `propose_workflow` agent + * tool (issue B4), awaiting the user's "Save & enable" / "Dismiss" decision + * on `WorkflowProposalCard`. Unlike `pendingApprovalByThread` / + * `pendingPlanReviewByThread`, this is NOT parked on a server-side gate — + * the underlying tool call already completed; this is purely a + * client-side "should the card render" flag, cleared on Save, Dismiss, or + * thread reset. + */ + pendingWorkflowProposalsByThread: Record; /** * Per-thread artifact ledger. Snapshots are upserted on * `artifact_ready` / `artifact_failed` socket events keyed on @@ -595,6 +639,7 @@ const initialState: ChatRuntimeState = { inferenceTurnLifecycleByThread: {}, pendingApprovalByThread: {}, pendingPlanReviewByThread: {}, + pendingWorkflowProposalsByThread: {}, artifactsByThread: {}, sessionTokenUsage: emptySessionTokenUsage(), usageByThread: {}, @@ -1097,6 +1142,15 @@ const chatRuntimeSlice = createSlice({ clearPendingPlanReviewForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.pendingPlanReviewByThread[action.payload.threadId]; }, + setWorkflowProposalForThread: ( + state, + action: PayloadAction<{ threadId: string; proposal: WorkflowProposal }> + ) => { + state.pendingWorkflowProposalsByThread[action.payload.threadId] = action.payload.proposal; + }, + clearWorkflowProposalForThread: (state, action: PayloadAction<{ threadId: string }>) => { + delete state.pendingWorkflowProposalsByThread[action.payload.threadId]; + }, /** * Mark a producer-tool call as in-flight so the `ArtifactCard` can * render a spinner before any ready/failed event arrives. Caller @@ -1292,6 +1346,7 @@ const chatRuntimeSlice = createSlice({ delete state.inferenceTurnLifecycleByThread[action.payload.threadId]; delete state.pendingApprovalByThread[action.payload.threadId]; delete state.pendingPlanReviewByThread[action.payload.threadId]; + delete state.pendingWorkflowProposalsByThread[action.payload.threadId]; delete state.queueStatusByThread[action.payload.threadId]; delete state.queuedFollowupsByThread[action.payload.threadId]; delete state.pendingSendThreadIds[action.payload.threadId]; @@ -1313,6 +1368,7 @@ const chatRuntimeSlice = createSlice({ state.inferenceTurnLifecycleByThread = {}; state.pendingApprovalByThread = {}; state.pendingPlanReviewByThread = {}; + state.pendingWorkflowProposalsByThread = {}; state.artifactsByThread = {}; state.queueStatusByThread = {}; state.queuedFollowupsByThread = {}; @@ -1416,6 +1472,10 @@ const chatRuntimeSlice = createSlice({ // Likewise drop any stale parked plan review — its gate future cannot // survive a rehydrate, so the card must not linger. delete state.pendingPlanReviewByThread[threadId]; + // Same for a workflow proposal (B4) — it's a client-only "should the + // card render" flag with no server-side record, so a rehydrate must + // not resurrect one left over from a previous session. + delete state.pendingWorkflowProposalsByThread[threadId]; if (snapshot.taskBoard) { state.taskBoardByThread[threadId] = snapshot.taskBoard; } @@ -1518,6 +1578,8 @@ export const { clearPendingApprovalForThread, setPendingPlanReviewForThread, clearPendingPlanReviewForThread, + setWorkflowProposalForThread, + clearWorkflowProposalForThread, upsertArtifactInProgressForThread, upsertArtifactReadyForThread, upsertArtifactFailedForThread, diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 527308651b..3998b5549a 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -72,6 +72,7 @@ const TOOL_DISPLAY_NAMES: Record = { audio_generate_and_email_podcast: 'Generating & emailing podcast', composio_list_connections: 'Viewing your Connections', agent_prepare_context: 'Preparing context', + propose_workflow: 'Proposing workflow', }; /** diff --git a/app/test/e2e/specs/navigation.spec.ts b/app/test/e2e/specs/navigation.spec.ts index 27f20827cf..5448f6c8b3 100644 --- a/app/test/e2e/specs/navigation.spec.ts +++ b/app/test/e2e/specs/navigation.spec.ts @@ -44,6 +44,7 @@ const ROUTES: Route[] = [ { hash: '/rewards' }, { hash: '/settings' }, { hash: '/agent-world' }, + { hash: '/flows' }, ]; async function rootTextLength(): Promise { diff --git a/docs/tinyagents-full-migration-plan/00-baseline.md b/docs/tinyagents-full-migration-plan/00-baseline.md index 10d7fff0a8..b41bcb0491 100644 --- a/docs/tinyagents-full-migration-plan/00-baseline.md +++ b/docs/tinyagents-full-migration-plan/00-baseline.md @@ -1,21 +1,21 @@ # 00 — Baseline: crate, features, native links -Current status (2026-07-02): baseline dependency alignment is complete in both -Cargo worlds. `tinyagents 1.3.0` is resolved with the `sqlite` feature, +Current status (2026-07-03): baseline dependency alignment is complete in both +Cargo worlds. `tinyagents 1.5.0` is resolved with the `sqlite` feature, OpenHuman pins `rusqlite = "=0.40.0"`, both worlds patch through `vendor/rusqlite-0.40.0` and `vendor/libsqlite3-sys-0.38.0`, and the SDK-gaps inventory has been refreshed against the published 1.3.0 crate source. ## Steps -1. **Bump `tinyagents` to `"1.3"`** (done in both Cargo worlds — root and +1. **Bump `tinyagents` to `"1.5.0"`** (done in both Cargo worlds — root and `app/src-tauri/`). Known 1.1→1.2 break already handled (`MessageDelta::text` ctor). Note: the `openai` crate feature was removed after 1.2.0 (1.2.1+ features are only `sqlite`/`repl`) — we never enabled it, so no impact. See "1.3.0 delta" below for new API this plan uses. 2. **Align rusqlite to 0.40** in both worlds (`Cargo.toml` root and `app/src-tauri/Cargo.toml`). OpenHuman pins `rusqlite = "=0.40.0"` and - enables `tinyagents = { version = "1.3", features = ["sqlite"] }`. + enables `tinyagents = { version = "1.5.0", features = ["sqlite"] }`. Compatibility notes: - `rusqlite 0.40` and `libsqlite3-sys 0.38` are consumed directly from crates.io. Their build scripts use the `cfg_select!` macro (stable from diff --git a/docs/tinyagents-full-migration-plan/01-tooling/README.md b/docs/tinyagents-full-migration-plan/01-tooling/README.md index 6202508e38..e120098b58 100644 --- a/docs/tinyagents-full-migration-plan/01-tooling/README.md +++ b/docs/tinyagents-full-migration-plan/01-tooling/README.md @@ -5,7 +5,7 @@ exposure, and output budgeting onto SDK primitives; delete the OpenHuman side-lookup pattern and legacy tool plumbing. Target SDK surface (available across tinyagents 1.2.x–1.3.0; current repo lock -is 1.3.0): +is 1.5.0): - `Tool::policy() -> ToolPolicy { side_effects, runtime, access }` — serializable safety metadata (read_only/writes_files/network/destructive/ diff --git a/docs/tinyagents-full-migration-plan/04-sessions/03-checkpointer.md b/docs/tinyagents-full-migration-plan/04-sessions/03-checkpointer.md index 20cd7f9bfc..cb0800418c 100644 --- a/docs/tinyagents-full-migration-plan/04-sessions/03-checkpointer.md +++ b/docs/tinyagents-full-migration-plan/04-sessions/03-checkpointer.md @@ -1,6 +1,6 @@ # 04.3 — SqliteCheckpointer swap -Baseline is complete: OpenHuman is on tinyagents 1.3.0 with the `sqlite` +Baseline is complete: OpenHuman is on tinyagents 1.5.0 with the `sqlite` feature and the compatible `rusqlite` pin. The remaining work is the OpenHuman-row migration/expiry decision. diff --git a/docs/tinyagents-full-migration-plan/10-registry.md b/docs/tinyagents-full-migration-plan/10-registry.md index 32d04c3000..e49c830716 100644 --- a/docs/tinyagents-full-migration-plan/10-registry.md +++ b/docs/tinyagents-full-migration-plan/10-registry.md @@ -37,7 +37,7 @@ aliases }`, `RegistrySnapshot` (`to_dot()`), native/MCP/Composio/generated tools, unsafe aliases → registry diagnostic errors (today: duplicate handling is scattered across generated tools, MCP, and native registration instead of one SDK diagnostic stream). - TinyAgents 1.3.0 is pinned and exposes `AliasBinding`, alias diagnostics, + TinyAgents 1.5.0 is pinned and exposes `AliasBinding`, alias diagnostics, cross-kind name-reuse detection, and `ComponentKind::{Middleware, Checkpointer, TaskStore, Listener}`; OpenHuman still needs to project those SDK diagnostics into its runtime. diff --git a/docs/tinyagents-full-migration-plan/99-deletion-ledger.md b/docs/tinyagents-full-migration-plan/99-deletion-ledger.md index 80adf8b5a9..e615cb1bce 100644 --- a/docs/tinyagents-full-migration-plan/99-deletion-ledger.md +++ b/docs/tinyagents-full-migration-plan/99-deletion-ledger.md @@ -18,6 +18,15 @@ they land. - [x] `context/pipeline.rs` (454) + `context/guard.rs` (236, keep stats structs) — 03.1 - [x] `context/tool_result_budget.rs` (172) — 03.1 - [x] `harness/payload_summarizer.rs` (490) — 01.4 +- [x] `tinyagents/middleware.rs::MicrocompactMiddleware` struct + impl (~46) — + W2-microcompact (2026-07-03): upstreamed into the vendored crate as + `tinyagents::harness::middleware::MicrocompactMiddleware` + (`tinyhumansai/tinyagents@feat/microcompact-middleware`, gitlink bumped); + OpenHuman now constructs the crate type with `CLEARED_PLACEHOLDER` and + events off, so behavior is byte-identical. The in-house struct + impl are + deleted; the retained OpenHuman tests assert parity against the crate type. + Was the C3-corrected/C5 "extract-then-delete" item (the local microcompact + was NOT 1.5.0-superseded; this PR did the extraction). ## Deletable after SDK-surface adoption @@ -84,6 +93,19 @@ they land. crate-internal `agent/harness/turn_subagent_usage.rs` (176) task-local — 06 (live until crate budget/run-tree accounting avoids duplicate `UsageRecorded` and covers parent-turn rollups) + W2-budget-dedupe (2026-07-03): dedupe guard landed — the event bridge now + records a model call's `UsageRecorded` exactly once, keyed on the run-scoped + iteration, so the observe-only crate `BudgetMiddleware`'s re-emit can't + double-count (`observability::OpenhumanEventBridge::record_usage`, `[budget]`). + Crate `BudgetMiddleware` installed OBSERVE-ONLY (empty `BudgetLimits`) at + `tinyagents/mod.rs`; local `CostBudgetMiddleware` demoted to a + divergence-logging shadow (`[budget_shadow]`, `after_agent`) but STILL + authoritative for enforcement. Flip criteria (must ALL hold before deleting + this row): (1) ≥ 500 parent+subagent turns with zero `[budget_shadow]` + divergence; (2) crate pricing table wired for money budgets; (3) run-tree + rollup via a shared `BudgetTracker` replacing the `turn_subagent_usage` + task-local. See the flip-criteria comment at the `tinyagents/mod.rs` + registration site. - [ ] `agent/dispatcher.rs` (609) + `harness/parse.rs` (833) legacy tool-call parsing — after XML/P-format transcripts read from the store and no live path parses provider text (04.2 + verify) @@ -94,6 +116,15 @@ they land. ## Deletable after session-store cutover (04.2 phase 4) +> **04.2 phase 2 landed (W2-shadow-reads, 2026-07-03):** a store-backed shadow +> reader runs beside the legacy transcript reader, normalizes both sides via +> `session_import/convert.rs`, and logs `[session_shadow_read]` divergence +> (compact, no-PII). Legacy stays authoritative; gated by +> `agent.session_shadow_reads` (default OFF) + `OPENHUMAN_SESSION_SHADOW_READS` +> kill switch. The rows below stay `[ ]` until the shadow logs are +> divergence-clean across the fixture matrix and reads are flipped to the store +> (phase 3), which is the precondition for these deletions. + - [ ] `session/transcript.rs` (1347) + tests (978) - [ ] `session/migration.rs` (373) + tests - [ ] `session/turn/session_io.rs` (391) diff --git a/docs/tinyagents-full-migration-plan/C2b-todos-parity.md b/docs/tinyagents-full-migration-plan/C2b-todos-parity.md new file mode 100644 index 0000000000..fa5010881e --- /dev/null +++ b/docs/tinyagents-full-migration-plan/C2b-todos-parity.md @@ -0,0 +1,103 @@ +# C2b — Task board / todos onto `graph::todos` (parity note) + +Status: **first slice landed** (branch `feat/tinyagents-c2-todos`). Adapter-first, +shadow-only. Legacy stays authoritative; nothing here changes product behavior. + +This note pairs with the CONTINUATION plan §C2 (step 3) and records what the +crate `tinyagents::graph::todos` surface maps onto in OpenHuman, and what it does +**not** — the residue that must stay in the product host after a future cutover. + +## What landed in this slice + +- `src/openhuman/todos/graph_shadow.rs` — the adapter: + - Total, lossless status mapping OpenHuman ↔ crate (`map_status_to_crate` / + `map_status_from_crate`) — the two `TaskCardStatus` enums share the same + seven variants (`Todo`, `AwaitingApproval`, `Ready`, `InProgress`, + `Blocked`, `Done`, `Rejected`). + - `TaskBoardCard` field-by-field conversion (`to_crate_card`) — all metadata + (objective, plan, assignedAgent, allowedTools, approvalMode, + acceptanceCriteria, evidence, notes, blocker, sessionThreadId, + sourceMetadata, order, updatedAt) preserved. + - `spawn_mirror` — after every authoritative `todos::ops::save_cards` write of + a `Thread` board, mirrors the persisted cards into a crate `FileStore` + (`/tinyagents_graph_store`) under namespace `graph.todos`. Fire- + and-forget, log-only; a crate rejection (e.g. the single-`InProgress` + invariant) is warn-logged as a **DIVERGENCE**. + - `spawn_shadow_claim` — wired into `todos::ops::claim_card` (which is the + single claim entry-point the dispatcher's two claim sites in + `task_dispatcher/dispatch.rs` funnel through, plus RPC/reclaim callers). It + seeds the crate board with the pre-claim snapshot and replays the crate + `claim_card` CAS, warn-logging when the crate ok/err verdict disagrees with + the authoritative legacy claim. + +The legacy claim/save path is byte-for-byte preserved: `claim_card` was +refactored to compute one ok/err verdict via an extracted `apply_claim` helper +(so the shadow sees the same not-found / wrong-status / invariant outcomes), but +the persisted result and all existing tests are unchanged. + +## Crate `TodoTool` vs `agent/tools/todo.rs` — what maps + +The crate ships a single multiplexer `TodoTool` (`op`-dispatched) that is a near +drop-in for the OpenHuman `todo` tool. Shared surface: + +| Concern | Crate `TodoTool` | OpenHuman `tools/todo.rs` | Match? | +| --- | --- | --- | --- | +| Dispatch style | single tool, `op` field | single tool, `op` field | yes | +| Thread binding | `ToolExecutionContext::thread_id` (never an arg) | `thread_context::current_thread_id()` + `fork_context` parent | yes (both bind to current thread, never an arg) | +| `add`/`edit`/`update_status`/`remove`/`replace`/`clear`/`list` | present | present | yes | +| Optional card fields | objective/plan/assignedAgent/allowedTools/approvalMode/acceptanceCriteria/evidence/notes/blocker | same | yes | +| Return shape | `{ threadId, cards, markdown }` | `{ threadId, cards, markdown }` | yes | +| Status aliases | `parse_status` (pending→todo, approved→ready, …) | `ops::parse_status` (identical alias table) | yes | +| Single-`InProgress` invariant | `enforce_single_in_progress` (hard error) | `enforce_single_in_progress` (identical) | yes | +| `claim_card` CAS | `store::claim_card(expected, target)` | `ops::claim_card(expected, target)` | yes (identical semantics; proven by shadow tests) | + +## What does NOT map (product residue — must stay in the host) + +1. **Approval-gate coupling.** OpenHuman's `todo` tool stamps a default + `approvalMode` by reading `config.autonomy.require_task_plan_approval` + (`default_task_approval_mode`), and the dispatcher's + `requires_plan_approval` + `TaskPlanAwaitingApproval` `DomainEvent` drive the + interactive plan-review gate. The crate `TodoTool` has **no** config read and + **no** approval-gate wiring — it exposes `decide_plan`/`revise_plan` state + transitions only. The gate policy stays product. +2. **`DomainEvent` emissions.** `ops::claim_card`/mutations emit + `AgentProgress::TaskBoardUpdated` (via `fork_context` `on_progress`) and the + dispatcher publishes `TaskPlanAwaitingApproval`. The crate store emits + nothing. All event vocabulary stays product (ledger: keep). +3. **RPC projection shapes.** `threads.task_board_*` and `openhuman.todos_*` + (see `todos/schemas.rs`) are the wire contracts the kanban UI binds to + (`app/src/services/api/todosApi.ts`, `USER_TASKS_THREAD_ID = "user-tasks"`). + These are **unchanged** by this slice and, per §C2, become read-side + projections over the crate store only at cutover — not now. +4. **Scratch board.** OpenHuman has a thread-less in-memory `BoardLocation::Scratch` + fallback (tool calls outside a chat thread). The crate board is always + `(Store, thread_id)`, so scratch mutations have **no** crate mirror target — + the shadow skips them (trace-logged). +5. **Persistence substrate + timestamps.** Product persists RFC3339 + `updated_at` to `/agent_task_boards/.json`; the + crate uses epoch-millis strings in a `Store` namespace. The mirror does not + reconcile timestamps (cosmetic). Card-id minting also differs + (`task-` product vs `task-` crate) but ids are passed through, so a + persisted board round-trips. +6. **Run lifecycle.** `todos/runs.rs` (run records, heartbeats, stale-reclaim) + and `task_dispatcher/` executor mechanics (executor resolution, autonomous + run, board write-back) are **not** part of the crate todos surface — they + stay product and are the §C2 step-3 "runner node" work, tracked separately. + +## Single-writer constraint + +The crate `Store` has no compare-and-set, so ns `graph.todos` assumes a single +writer. The core process is that single writer (both the mirror and the +shadow-claim run in-core). Documented in the module header; honoured because all +mutations funnel through `todos::ops`. + +## Next (not in this slice) + +- Flip the mirror from shadow to authoritative (crate store becomes the source + of truth; legacy JSON becomes a projection or is retired). +- Reimplement `threads.task_board_*` / `openhuman.todos_*` as projections over + the crate store. +- Replace the dispatcher claim/poll loop with the crate `claim_card` CAS + a + graph runner node, keeping `DomainEvent` emission + channel bindings product. +- Delete `task_board.rs` + todo CRUD mechanics + dispatcher executor mechanics + (~3.2k + tests) once parity logs are clean. diff --git a/docs/tinyagents-full-migration-plan/CONTINUATION-2026-07.md b/docs/tinyagents-full-migration-plan/CONTINUATION-2026-07.md new file mode 100644 index 0000000000..cada56cfee --- /dev/null +++ b/docs/tinyagents-full-migration-plan/CONTINUATION-2026-07.md @@ -0,0 +1,265 @@ +# TinyAgents Migration — Continuation Plan (2026-07-03) + +Status: supersedes the ordering in `README.md` for remaining work. Written +after a ground-truth audit of `main` (post-#4249), a re-inventory of the +TinyAgents crate (1.4.0 published, 1.5.0 tagged), and a critical +re-evaluation of the `99-deletion-ledger.md` "Never delete" list. + +## 1. Where we actually are (ground truth, main @ 2026-07-03) + +The "finish TinyAgents harness migration" PR (#4249 → #4399) has landed. +Per-workstream state: + +| Workstream | State | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 00 baseline | Done (1.3.0 + sqlite, rusqlite 0.40) — **needs re-bump to 1.4/1.5** | +| 01 tooling | Mostly done. Live: crate-internal tool side-lookup (01.1), `tool_filter.rs` 299 + `tool_prep.rs` 344 (01.3) | +| 02 models | Mostly done. Live: `reliable.rs` 900 (gated on re-architecture), `ThinkingForwarder` residual seams | +| 03 context/cache | Done. `context/` is 1.3k of product prompt/stats state | +| 04 sessions | **Primary unfinished work.** Live path is 100% legacy: `transcript.rs` 1347, `migration.rs` 373, `session_io.rs` 463, `session_db/` 4.5k, `subagent_sessions/` 653, `session_import/` 1.9k. Crate `Store`/`AppendStore` used only by the write-only importer. 04.3 checkpointer swap IS done in code (`tinyagents/checkpoint.rs` deleted) — the 04.3 doc text is stale | +| 05 events | 05.2 engine deletion done. `progress_tracing` (817 + 477 langfuse + 719 tests) still the live web-progress exporter; journal projection not at parity | +| 06 cost | Not wired: no `BudgetMiddleware`/`BudgetLimits`/`RunTree` in the shared runner. Blocker: `UsageRecorded` de-duplication | +| 07 subagents | Diagnostic-skeleton graph only; procedural runner still live. `running_subagents.rs` has **grown to 1931 lines** (target ≤300); `JsonlTaskStore` adopted for detached lifecycle records; `run_queue/` + `spawn_depth_context.rs` still live | +| 08 orchestration | 08.1/08.2/08.4 done. Live: 08.3 durable interrupts for approvals, 08.5 `worktree_context.rs` fallback thread | +| 09 embeddings | Done | +| 10 registry | Pending — `CapabilityRegistry` unused in src/ | +| 11 testing | Conformance pass green on 2026-07-02 baseline | + +Crate APIs available but **unused** in src/: `JsonlTaskStore` (now partially +adopted), `BudgetMiddleware`, `ContextualToolSelectionMiddleware` (only its +shadow), `UnknownToolPolicy`, `CapabilityRegistry`. + +## 2. Crate delta: 1.4.0 / 1.5.0 (the upgrade this plan targets) + +- **1.4.0 (published 2026-07-02)** + - `graph::goals` — durable per-thread `ThreadGoal`: completion contract, + token budget, Active/Paused/BudgetLimited/Complete, `goal_gate_node` + self-driving loop, `run_continuation_tick`, `note_user_turn`, model tools + `goal_get/goal_set/goal_complete`, host `goal_pause/resume/clear`. + Persists on harness `Store` ns `graph.goals`. + - `graph::todos` — `TaskBoard` kanban (Todo→Ready→InProgress→Done, Blocked, + AwaitingApproval), single-`InProgress` invariant, `claim_card` CAS, + single multiplexer `TodoTool`. Persists ns `graph.todos`. + - Graph resilience: `CompiledGraph::with_node_retry(RetryPolicy)`, + opt-in backoff sleeping, failure-boundary checkpoints + + `CompiledGraph::retry(thread)` resumable failures, + `GraphEvent::NodeRetryScheduled`. +- **1.5.0 (tagged 2026-07-03, not yet on crates.io)** + - `harness::no_progress::NoProgressTracker` — extracted from OpenHuman + PR #4389. Pure state machine: `record(step, &ToolAttempt) -> +Continue/Nudge/Halt`; identical-failure ladder, varied-failure backstop, + fast-trip on hard policy rejects. +- Known crate limitation relevant here: `Store` has no compare-and-set, so + goals/todos mutations must funnel through one process (fine — the core is + the single writer). + +## 3. Re-evaluated component verdicts (revises `99-deletion-ledger.md`) + +The old "Never delete" list over-protects. Revised verdicts, ordered by +reclaimable lines (non-test; tests roughly double each figure): + +### Migrate to crate, then delete locally (upstream-extraction candidates) + +Precedent: `NoProgressTracker` was extracted upstream from #4389 and shipped +in 1.5.0. Same play for: + +| Component | Lines (~generic) | Notes | +| ------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pformat.rs` + `dispatcher.rs` + `harness/parse.rs` | 1941 (~1900) | Tool-call dialect machinery (P-format encoder, permissive XML/JSON parser with key-drift recovery). Zero DomainEvent coupling. Delete gate: 04.2 read-cutover (no live path parses provider text) | +| `multimodal.rs` | 1690 (~1550) | `[IMAGE:]`/`[FILE:]` marker → provider content blocks, mime allowlist, PDF extraction, fetch gating, truncation budget. Only the marker convention is product | +| `progress_tracing.rs` + `langfuse.rs` | 1294 (~1200) | Crate already ships Langfuse exporters + journals. Delete gate: 05.3 journal-backed web-progress parity | +| `tool_result_artifacts/` | 588 (~500) | Already built on crate `Store`; overflow-to-artifact is a generic harness concern. Product residue: PII scrub hook | +| `hooks.rs` + `stop_hooks.rs` trait machinery | 543 (~450) | PostTurnHook/StopHook traits are pure harness; product hook bodies stay | +| `host_runtime.rs` adapter core | 456 (~350) | Native/Docker `RuntimeAdapter` overlaps crate workspace isolation | +| `ArgRecoveryMiddleware`, `RepeatedToolFailureMiddleware` core | ~300 | Latter becomes a thin driver over crate `NoProgressTracker` (1.5.0) | +| `tool_filter.rs` fuzzy ranker | 299 (~250) | Generic ranking; Composio input types stay product | +| `triage/` evaluator+routing+decision core | ~800 of 3779 | Generic "LLM triage node" (tiered fallback, cache, verdict parse). `envelope.rs`/`escalation.rs`/`events.rs` stay product (Composio, DomainEvents, agent ids) | + +### Replace with crate features, then delete (no upstreaming needed) + +| Component | Lines | Replaced by | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `thread_goals/` | 2185 | `graph::goals` (1.4.0). OpenHuman keeps RPC schemas + UI projection over the crate store | +| `agent/task_board.rs` + `tools/todo.rs` mechanics | 604 + ~430 | `graph::todos` `TaskBoard` + `TodoTool` (1.4.0). RPC shapes (`threads.task_board_*`, `openhuman.todos_*`) become projections | +| `task_dispatcher/` executor mechanics | ~450 of 1494 | `claim_card` CAS + graph runner node; DomainEvent emission + channel bindings stay | +| 6 duplicate middlewares in `tinyagents/middleware.rs` (2448 total): `OpenHumanToolExposureShadow`, `CacheAlign`, `Microcompact`, `CostBudget`, `PromptCacheSegment` (partial), `ToolPolicyMiddleware` (local) | ~900 | Crate `ContextualToolSelectionMiddleware`, cache guard, compaction, `BudgetMiddleware`, `ToolAllowlistMiddleware`. All are self-acknowledged shadows/parity holds | +| `spawn_depth_context.rs` | 92 | Crate `RecursionPolicy`/`RecursionStack` | +| legacy session stack (04.2 phase 4) | ~9000 incl. tests | Crate `Store`/`AppendStore`/`StoreChatHistory` | + +### Confirmed product — keep (ledger was right) + +`archivist/` (memory/learning policy — only the hook-scheduling shell ~200 +lines is generic), `bus.rs`, `schemas.rs`, `error.rs`, `turn_origin.rs`, +`memory_context.rs`, `debug/`, `library/`, `progress.rs` event vocabulary, +prompts **content** (the section/render machinery ~600 lines is generic but +decoupling is low-value), preference/`run_workflow`/`delegate_to_personality` +tools, approval/`MemoryProtocol`/`CliRpcOnly`/`ToolOutcomeCapture` +middlewares. + +Cross-cutting gate for the coupled middle tier (session turn/builder, triage +escalation, task dispatch): **DomainEvent subscribers and JSON-RPC shapes** — +these must become projections before their hosts can shrink. + +### Coverage sweep: harness files previously unreferenced by any plan doc (2026-07-03) + +A basename grep of `agent/harness/**` against this plan folder found these +non-test files with no verdict anywhere. Assigned now (tests follow parents): + +| File | Lines | Verdict | +| --- | --- | --- | +| `subagent_runner/extract_tool.rs` | 612 | **[migrate/delete]** — generic progressive-disclosure Q&A over handoff-cached payloads; pairs with `handoff.rs`. Goes with the C5 overflow-to-artifact/handoff extraction | +| `subagent_runner/handoff.rs` | 287 | **[migrate/delete]** — generic oversized-result handoff cache (crate tool-output/artifact overlap). C5 | +| `subagent_runner/types.rs` | 232 | **[shrink]** — spawn options/outcome/error taxonomy largely maps to crate `SubAgentPolicy`/`OrchestrationTaskKind`. C6 | +| `subagent_runner/autonomous.rs` | 29 | **[keep]** — approval-gating override policy for unattended skill runs | +| `fork_context.rs` | 226 | **[delete-via-crate]** — task-local parent-context carrier working around the `Tool` trait; replace with `ToolExecutionContext`/`RunContext` fields (same play that deleted `worktree_context.rs`). C6 | +| `sandbox_context.rs` | 94 | **[delete-via-crate]** — same task-local pattern (sandbox_mode carrier). C6 | +| `task_recency_context.rs` | 106 | **[delete-via-crate]** — same pattern (Composio recency window). C6 | +| `turn_attachments_context.rs` | 71 | **[delete-via-crate]** — same pattern (vision-attachment forwarding). C6 | +| `session/turn_checkpoint.rs` | 105 | **[delete]** with the C1 session cutover (turn checkpointing rides crate checkpoints) | +| `memory_protocol.rs` | 386 | **[keep]** — product memory-protocol state machine (#4116) behind `MemoryProtocolMiddleware` | +| `builtin_definitions.rs` | 273 | **[keep]** — product agent-definition data facade | +| `definition_loader.rs` | 295 | **[keep]** — product TOML definition loader | +| `archivist/{hook_impl,recap,tree_ingest}.rs` | 592 | **[keep]** — archivist internals, verdict inherited from §3 | + +The four task-local context carriers (`fork_context`, `sandbox_context`, +`task_recency_context`, `turn_attachments_context`, ~500 lines) are one +C6 work item: extend the crate execution context instead of task-locals. + +## 4. Continuation workstreams (execution order) + +Sized for `/goal` execution like the original folders; each step lands code + +tests + deletions + a ledger tick. + +### C0 — Crate bump to 1.4 (then 1.5 when published) + +1. Bump both Cargo worlds to `tinyagents = "1.4"`; re-verify sqlite chain. +2. When 1.5.0 publishes: bump again; rewrite `RepeatedToolFailureMiddleware` + as a driver over `harness::no_progress::NoProgressTracker`; delete the + in-house identical-failure ladder. +3. Adopt `with_node_retry` + `CompiledGraph::retry` on the delegation and + spawn-parallel graphs (replaces bespoke retry glue; complements 08.3). +4. Update `00-baseline.md` "1.3.0 delta" → 1.4/1.5 delta; refresh + `docs/tinyagents-sdk-gaps.md` (goals/todos and no-progress close two + OpenHuman-convergence items). + +### C1 — Sessions cutover (04.2 phases 2–4) — biggest single unlock + +The importer (`session_import/`) already proves shape parity. Execute: + +1. 04.1 live dual-writes (turns append `session.{stem}.messages` + descriptor + upsert alongside legacy JSONL). +2. Shadow reads + parity fixtures (11-fixture matrix), then flip reads. +3. Retire: `transcript.rs` (1347+978), `migration.rs` (373+170), + `session_io.rs` (463), `session_db/` generic parts (~1.6k), + `subagent_sessions/` (653); `session_import/` one release later. +4. Then (unblocked by "no live path parses provider text"): delete + `dispatcher.rs` + `parse.rs` + `pformat.rs` (~1.9k + tests), after + upstreaming the dialect machinery (C5) or accepting native-only. + ~11k lines total. +5. Fix the stale 04.3 doc (checkpointer swap already landed; `checkpoint.rs` + is gone). + +### C2 — Thread goals + thread tasks onto `graph::goals`/`graph::todos` + +1. Adapter: `thread_goals/store.rs`+`runtime.rs`+`continuation.rs` → + crate `ThreadGoal` + `goal_gate_node` + `run_continuation_tick`; keep + `thread_goals/schemas.rs` RPC shapes as projections; map + `goal_get/set/complete` model tools + host pause/resume/clear. +2. One-time migration of existing goal rows into ns `graph.goals`. +3. `task_board.rs` → crate `TaskBoard`; `tools/todo.rs` → crate `TodoTool`; + `task_dispatcher/` claim/poll loop → `claim_card` CAS + runner node. + DomainEvent emissions + `threads.task_board_*`/`openhuman.todos_*` RPC + become read-side projections of the crate store. +4. Single-writer constraint honoured (core is the only mutator; document it). +5. Delete: `thread_goals/{store,runtime,continuation}.rs` mechanics, + `task_board.rs`, todo CRUD mechanics, dispatcher executor mechanics + (~3.2k + tests). + +### C3 — Middleware de-duplication (01.1/01.3/06 finish) + +1. Flip `ContextualToolSelectionMiddleware` from shadow to owner (parity logs + are already accumulating); delete `OpenHumanToolExposureShadowMiddleware`, + then `tool_filter.rs` mechanics + `tool_prep.rs` selection half. +2. De-duplicate `UsageRecorded` (single owner: crate event → bridge records + once), then install crate `BudgetMiddleware`/`BudgetLimits`; delete local + `CostBudgetMiddleware` + `turn_subagent_usage.rs` task-local (206) in + favour of `RunTree` rollup. +3. ~~Delete `CacheAlign` + `Microcompact`~~ CORRECTED by C3 execution + (2026-07-03): `CacheAlign` deleted (warn-only, crate cache guard already + installed — commit on `feat/tinyagents-c3-middleware-dedupe`). + `Microcompact` is NOT crate-superseded — tinyagents 1.5.0 has no + tool-result body-clearing equivalent and the local one is live on the + session turn path — moved to the C5 upstream-extraction batch (extract a + crate microcompact first, then delete locally). +4. ~~Adopt `UnknownToolPolicy::Rewrite`~~ CORRECTED by C3 execution: the + sentinel + `UnknownToolRewriteMiddleware` were already deleted in 01.2; + live policy is `UnknownToolPolicy::ReturnToolError`, which preserves the + attempted tool name + args (#4419 UX). Rewrite mode would regress (needs a + catch-all target tool); rationale comment added at `run_policy_for`. Done — + no further action. + Net: `tinyagents/middleware.rs` 2448 → ~1500 (Microcompact stays until C5). + +### C4 — Events/progress projection (05.3) then delete progress_tracing + +1. Journal-backed web-progress projection (`HarnessEventJournal` + + `HarnessStatusStore` + late-attach `replay_from`). +2. Parity vs `SpanCollector` spans; then delete `progress_tracing.rs` + + `progress_tracing/` (~2k incl. tests) — Langfuse rides crate exporters. +3. DomainEvent/AgentProgress become projections (ledger final clause). + +### C5 — Upstream extraction batch (tinyagents PRs, then local deletes) + +In crate-repo PRs, mirroring the NoProgressTracker precedent, in value order: + +1. `multimodal` attachment resolver (~1550) — marker convention stays here. +2. Tool-call dialect layer (`pformat`/parser) (~1900) — enables C1 step 4 to + be a pure delete. +3. Overflow-to-artifact tool-result store (~500). +4. PostTurnHook/StopHook trait machinery (~450); host runtime adapter (~350); + fuzzy tool ranker (~250); ArgRecovery (~150); triage evaluator core + (~800) as a generic "LLM triage node". + Each: crate PR → bump → local adapter shrinks to product residue → delete. + +### C6 — Subagents finish (07) + +1. Absorb `ops/runner.rs` (1212) + `ops/graph.rs` (1039) into named pipeline + nodes (07.1); procedural runner retired. +2. `running_subagents.rs` 1931 → ≤300: status/tombstone persistence fully on + `JsonlTaskStore` + `orchestrate_*` tools (07.2). +3. Steering: crate `SteeringRegistry` owns lanes; split then delete + `run_queue/` (317); `spawn_depth_context.rs` → `RecursionPolicy` (07.3). +4. Replace the four task-local context carriers (`fork_context`, + `sandbox_context`, `task_recency_context`, `turn_attachments_context`, + ~500 lines) with typed fields on the crate execution context (see §3 + coverage sweep); shrink `subagent_runner/types.rs` onto crate + `SubAgentPolicy`/task types. + +### C7 — Remaining gated items + +- 08.3 durable approval interrupts (now easier with 1.4.0 resumable + failures); 08.5 drop the `worktree_action_dir` fallback thread, delete + `worktree_context.rs` remnant wiring. +- 10 CapabilityRegistry projection + fail-closed diagnostics. +- 02.2 `reliable.rs`: unchanged verdict — gated on routing non-turn provider + calls through the crate harness; do not force. +- `ThinkingForwarder`: delete when crate `ModelDelta` grows reasoning + + tool-name-on-start (file upstream issue; sdk-gaps §3). + +## 5. Expected reclaim + +| Phase | Local lines deleted (incl. tests, rough) | +| --------------------- | ---------------------------------------------------------------------------- | +| C1 sessions + dialect | ~11,000 | +| C2 goals/todos | ~4,000 | +| C3 middleware dedupe | ~1,500 | +| C4 progress tracing | ~2,000 | +| C5 upstream batch | ~6,000 | +| C6 subagents | ~4,500 | +| Total | **~29,000** (of ~57k in `agent/` + ~11k adapter + ~9k session/goals modules) | + +## 6. Rules (unchanged) + +Approval/security/sandbox/credential boundaries inviolate; JSON-RPC contracts +stable unless a migration note lands; adapter → proven parity → delete; +explicit `git add `; verify branch before commit. Tests deferred per +workstream slice with a final conformance pass (11). diff --git a/docs/tinyagents-full-migration-plan/HANDOFF-2026-07-03.md b/docs/tinyagents-full-migration-plan/HANDOFF-2026-07-03.md new file mode 100644 index 0000000000..136df4bf2d --- /dev/null +++ b/docs/tinyagents-full-migration-plan/HANDOFF-2026-07-03.md @@ -0,0 +1,170 @@ +# Handoff — TinyAgents migration wave 1 (2026-07-03) + +Context file for picking this work up in a fresh session. Read together with +[`CONTINUATION-2026-07.md`](CONTINUATION-2026-07.md) (the plan this wave +executes) and [`99-deletion-ledger.md`](99-deletion-ledger.md). + +## What this session did + +1. **Audit** — ground-truth audit of post-#4249 main, TinyAgents crate + re-inventory (1.4.0 published: `graph::goals`/`graph::todos`/graph + resilience; 1.5.0: `NoProgressTracker` extracted from our #4389), and a + critical re-review of the deletion ledger's "never delete" list. Product of + that: `CONTINUATION-2026-07.md` (workstreams C0–C7, ~29k-line reclaim) with + a coverage-sweep appendix for previously unreferenced harness files. +2. **Vendored the SDK** — `vendor/tinyagents` git submodule pinned at tag + `v1.5.0`; `[patch.crates-io] tinyagents = { path = ... }` in BOTH Cargo + worlds (root `Cargo.toml`, `app/src-tauri/Cargo.toml`); Dockerfile now + `COPY vendor/ vendor/`; release-production/staging docker jobs run a + targeted `git submodule update --init vendor/tinyagents`. All other + cargo-running CI jobs already checkout `submodules: recursive`; the mobile + crates (`app/src-tauri-mobile`) do NOT depend on the core crate, so their + `submodules: false` stays. Agents can now edit SDK source in-tree and PR + upstream from the submodule (do this in C5). +3. **Executed wave 1** via a 6-agent workflow (run id `wf_df08984c-139`, + scripts under the session dir; all agents completed). +4. `agent-diff-v0.58.7-to-HEAD.diff` at repo root (gitignored) — full diff of + `src/openhuman/agent/` since the last release, for review. + +## Branch map (local; base → children) + +- **`feat/tinyagents-c0-15-baseline`** ← base for everything below; branched + from `docs/tinyagents-migration-continuation` (which holds the plan docs and + branched from upstream/main). + - `c7f287380` bump tinyagents 1.5.0 (+ .diff gitignore) + - `e2f010cba` vendor submodule + [patch.crates-io] both worlds + - `4c2895801` RepeatedToolFailureMiddleware → crate `NoProgressTracker` + driver (in-house identical/varied/hard-reject ladder deleted; Nudge→ + `SteeringCommand::Redirect`, Halt→`HaltSummarySlot`+Pause+reset; 25 + middleware tests green) + - `fbafad2d3` CI docker submodule fixes + - `dd63b3a66` `with_node_retry(RetryPolicy::default().with_max_attempts(1))` + on delegation + spawn-parallel graphs (behavior-preserving seam; raise + attempts later; backoff sleeping off) + - `02be67b06` plan corrections from C3 (below) +- **`feat/tinyagents-c1-dual-writes`** — 04.1 done. Live turns dual-write to + `{workspace}/tinyagents_store/{kv,journal}` (`session.{stem}.messages` + + descriptor upsert, via `session_import/convert.rs` normalization). New + config flag `agent.session_dual_write` (serde default **ON**); + `OPENHUMAN_SESSION_DUAL_WRITE` is a pure **kill switch** (can force off, + never on), read per-turn. Parity test: + `session_import::live_tests` (2 pass). Read path untouched. +- **`feat/tinyagents-c2-goals`** — `thread_goals/crate_adapter.rs` dual-write + mirror into ns `graph.goals` (keys byte-identical to the crate reader, + proven by reading back via `tinyagents::graph::goals::store::get`); + idempotent `migrate_legacy_goals_into_crate_store` (callable, NOT wired to + boot); shadow tool surface flag-gated **OFF**. Legacy store authoritative. +- **`feat/tinyagents-c2-todos`** — `todos/graph_shadow.rs` mirrors boards into + ns `graph.todos` at `/tinyagents_graph_store`; `claim_card` CAS + shadow through the single refactored `todos::ops::claim_card` chokepoint + (`apply_claim` helper), divergence warn-logged; parity note + `C2b-todos-parity.md` (what maps vs product residue). Legacy authoritative. +- **`feat/tinyagents-c3-middleware-dedupe`** — PARTIAL by design: + `CacheAlignMiddleware` deleted (−147 lines; crate cache guard already + installed). See "plan corrections" for the two refusals. +- **`feat/tinyagents-c4-journals`** — restart-stable event ids + (`EventSink::with_stream_id(run_id)` → `{run_id}-evt-{offset}`), run id + minted once via `journal::mint_run_id`; `FileStatusStore` records thread_id + (`HarnessRunStatus::with_thread`) so `list_by_thread` answers; late-attach + replay test reconstructs from the store alone. + +All slice branches were created FROM `feat/tinyagents-c0-15-baseline` +(worktree HEADs were stale; agents rebased themselves correctly). + +## Plan corrections discovered during execution (already committed, 02be67b06) + +- **MicrocompactMiddleware is NOT crate-superseded** — tinyagents 1.5.0 has no + tool-result body-clearing; local one is live at `turn/core.rs` (~:885, + keep_recent=5). Moved to C5: extract into the crate first, then delete. +- **Unknown-tool is already done** — sentinel + rewrite middleware deleted in + 01.2; live policy `UnknownToolPolicy::ReturnToolError` deliberately kept + over `Rewrite` (preserves #4419 attempted-name UX; Rewrite needs a catch-all + target tool). Rationale comment at `run_policy_for`. +- C3's shadow-parity investigation: exposure-shadow parity logs exist but are + not yet asserted divergence-free — flipping + `ContextualToolSelectionMiddleware` to owner still needs a parity-log audit. + +## Known debt / gotchas for the next session + +- **Worktrees + submodule**: fresh worktrees leave `vendor/tinyagents` empty → + cargo fails on the patch path. Always + `git submodule update --init vendor/tinyagents` first. +- Test invocation: `RUST_MIN_STACK=16777216 … --test-threads=1` + (deep sub-agent futures overflow default stacks); `GGML_NATIVE=OFF` for + cargo on Apple Silicon. Full suites were deliberately NOT run — targeted + module tests only (user directive). Integration debt is listed per-slice in + the workflow reports and belongs to the final conformance pass (plan §11). +- C1's `RunContext.stores` session-KV registration has no in-run consumer + until 04.2 (`StoreChatHistory` adoption was deferred as read-path work). +- C4 follow-ups: replay RPC (`agent.run_events`) unexposed; + parent/root run-id lineage still 05.2/05.3. +- The five slice branches will conflict lightly in `tinyagents/middleware.rs` + / `mod.rs` when merged together — merge C0 first, then slices one at a + time, resolving against C0's tree. + +## Wave 1 (merged) + +All five wave-1 slice branches were MERGED into +`feat/tinyagents-c0-15-baseline` and pushed to PR **#4473**, which landed the +entire wave-1 scope into `tinyhumansai/openhuman:main`. + +## Wave 2 status (2026-07-04) — DONE, on `feat/tinyagents-wave2` + +All four wave-2 slices are implemented and integrated onto ONE branch +`feat/tinyagents-wave2` (off `upstream/main`), one focused commit each, and +opened as a single combined PR. Adapter-first throughout: legacy authoritative, +crate features shadow + log divergence, deletions gated on proven parity. + +1. `W2-microcompact-upstream` — **done.** The generic + `MicrocompactMiddleware` (caller-supplied placeholder, opt-in `Compressed` + event, idempotent tool-body clearing) was implemented IN the vendored crate + and pushed to `tinyhumansai/tinyagents@feat/microcompact-middleware` + (commit `ac73382`) BEFORE the gitlink bump. OpenHuman swapped to the crate + type (constructed with `CLEARED_PLACEHOLDER`, events off → byte-identical); + local struct+impl deleted, OpenHuman tests retargeted as the parity contract. + Crate tests: 5 green. Ledger row ticked. +2. `W2-shadow-reads` — **done.** 04.2 phase 2 store-backed shadow reader beside + the legacy transcript reader; `[session_shadow_read]` divergence logging; + flag `agent.session_shadow_reads` (default OFF) + `OPENHUMAN_SESSION_SHADOW_READS` + kill switch. Legacy authoritative. (Flip → reads-from-store is phase 3, the + ~9k-line deletion unlock.) +3. `W2-budget-dedupe` — **done.** Event bridge records each model call's usage + exactly once (dedupe guard keyed on the run-scoped iteration cursor); crate + `BudgetMiddleware` installed observe-only (empty `BudgetLimits`); local + `CostBudgetMiddleware` demoted to a `[budget_shadow]` divergence logger, still + authoritative for enforcement. Three flip criteria documented at the + `tinyagents/mod.rs` registration site + in the ledger. +4. `W2-replay-rpc` — **done.** Read-only `openhuman.agent_run_events` (paged, + `next_offset`, capped limit), `agent_run_status`, `agent_runs_active` + controllers over the C4 journal/status seams; direct `AgentObservation`/ + `HarnessRunStatus` serde projection (no PII); registered via + `src/core/all.rs` per the controller-migration checklist. (Resolves the + "replay RPC unexposed" C4 follow-up.) + +**Test note:** targeted crate tests (microcompact) ran green locally; the full +core-crate test binary would not link locally under this box's memory ceiling +(single-rustc codegen thrash), so `cargo check --lib --tests` is the local +compile gate and CI runs the actual targeted + full suites + coverage. + +## Merge / PR state + +- `feat/tinyagents-wave2` pushed to `origin` (senamakel fork); ONE combined PR + vs `upstream` (`--head senamakel:feat/tinyagents-wave2`): **PR #4483** + (`tinyhumansai/openhuman#4483`). +- Submodule branch `feat/microcompact-middleware` pushed to + `tinyhumansai/tinyagents`; gitlink bumped to `ac73382`. Fresh worktrees must + `git submodule update --init vendor/tinyagents`. +- Git etiquette (user rules): push to `origin`, PR against `upstream` with + `--head senamakel:`; explicit `git add `; never commit on main. + +## Suggested next steps (wave 3) + +1. Flip `session_shadow_reads` → reads-from-store once the fixture-matrix + divergence logs are clean (biggest deletion unlock, ~9k incl. + dispatcher/parse/pformat). +2. Flip crate `BudgetMiddleware` → enforcing owner once the 3 documented + criteria hold; delete local `CostBudgetMiddleware` + `turn_subagent_usage.rs`. +3. Open a tinyagents PR for `feat/microcompact-middleware` (currently a pushed + branch, not merged) so the gitlink can later track a tagged release. +4. Continue C5 upstream extractions (multimodal resolver, dialect layer, + overflow-to-artifact, hooks traits) + C2 shadow → authoritative flips. diff --git a/docs/tinyagents-full-migration-plan/README.md b/docs/tinyagents-full-migration-plan/README.md index bb0ce36154..dca9d3d835 100644 --- a/docs/tinyagents-full-migration-plan/README.md +++ b/docs/tinyagents-full-migration-plan/README.md @@ -2,6 +2,12 @@ Status: active plan (2026-07-02). Branch: `issue/4249-finish-tinyagents-migration`. +> **2026-07-03 update:** #4249 has landed on main. Remaining work is +> re-planned in [`CONTINUATION-2026-07.md`](CONTINUATION-2026-07.md), which +> supersedes the workstream ordering below and targets tinyagents 1.4/1.5 +> (`graph::goals`, `graph::todos`, `NoProgressTracker`, resumable graph +> failures). + Goal: **hard-migrate** OpenHuman's agent harness onto the `tinyagents` crate as the library for orchestration, caching, tooling, observability, model providers, context management, embeddings, sub-agents, steering, summarization, @@ -15,9 +21,10 @@ goals. Execute a step file end-to-end (code + tests + deletions + commit). ## Key facts superseding older docs -- Current crate is **1.3.0** (published 2026-07-02); the plan targets it — - see the "1.3.0 delta" in `00-baseline.md`. -- `docs/tinyagents-sdk-gaps.md` was refreshed against TinyAgents 1.3.0 and now +- Current crate is **1.5.0**; the plan still records the earlier "1.3.0 delta" + in `00-baseline.md` where those primitives first became available. +- `docs/tinyagents-sdk-gaps.md` was refreshed against TinyAgents 1.3.0 and + should be re-audited after the 1.5.0 bump; it currently tracks only residual gaps. tinyagents 1.2.0-1.3.0 ships `UnknownToolPolicy`, `ToolPolicy` safety metadata + `ToolPolicyMiddleware`, reasoning deltas (`MessageDelta.reasoning`), durable `JsonlTaskStore` + orchestration tools, diff --git a/docs/tinyagents-migration-spec.md b/docs/tinyagents-migration-spec.md index cb09ed5912..74230768c7 100644 --- a/docs/tinyagents-migration-spec.md +++ b/docs/tinyagents-migration-spec.md @@ -6,7 +6,7 @@ TinyAgents source reviewed: `tinyhumansai/tinyagents` `origin/main` at `8f226f1`, crate version `1.1.0`. Refreshed against `tinyhumansai/tinyagents` `main` at `348a0e7dc71a1f9039f3d523a2a384661a7a9acd` after the SDK/docs update. Current OpenHuman dependency in this checkout is -`tinyagents = { version = "1.3", features = ["sqlite"] }`. +`tinyagents = { version = "1.5.0", features = ["sqlite"] }`. OpenHuman already depends on TinyAgents and already routes the live agent turn through `src/openhuman/tinyagents/`. This spec is not a proposal to add @@ -80,7 +80,7 @@ OpenHuman Rust core: Already done or partially done: -- `Cargo.toml` pins `tinyagents = { version = "1.3", features = ["sqlite"] }`. +- `Cargo.toml` pins `tinyagents = { version = "1.5.0", features = ["sqlite"] }`. - `src/openhuman/tinyagents/mod.rs` registers OpenHuman `Provider` and `Tool` adapters on `tinyagents::harness::runtime::AgentHarness`. - `ProviderModel` maps OpenHuman `ChatRequest`/`ChatResponse` into diff --git a/docs/tinyagents-session-migration-design.md b/docs/tinyagents-session-migration-design.md index 0905c862f0..020cf7cd15 100644 --- a/docs/tinyagents-session-migration-design.md +++ b/docs/tinyagents-session-migration-design.md @@ -16,7 +16,7 @@ OpenHuman session key. ## Source inventory (what exists on disk today) -All facts verified against the current checkout (TinyAgents 1.3 pinned with the +All facts verified against the current checkout (TinyAgents 1.5.0 pinned with the `sqlite` feature enabled). ### 1. Transcript JSONL (source of truth) @@ -73,7 +73,7 @@ All facts verified against the current checkout (TinyAgents 1.3 pinned with the toolkit/model/sandbox/action-root selector fields, `status`, `reusable`, inline `latestHistory` message mirror, timestamps. -## Target shape (TinyAgents 1.3 primitives) +## Target shape (TinyAgents 1.3+ primitives) Use the crate's `harness::store` as the substrate — no new storage layer: diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index 67b11591d1..224be4e932 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -49,7 +49,7 @@ icon: layer-group ## TinyAgents crate: features & compatibility -OpenHuman pins `tinyagents = { version = "1.3", features = ["sqlite"] }` (see [`Cargo.toml`](../../../Cargo.toml)). The rationale, so future upgrades don't silently regress it: +OpenHuman pins `tinyagents = { version = "1.5.0", features = ["sqlite"] }` (see [`Cargo.toml`](../../../Cargo.toml)). The rationale, so future upgrades don't silently regress it: - **OpenHuman-owned providers only.** We do **not** enable any bundled provider feature. OpenHuman owns provider transport, credentials, OAuth, and billing classification, so the live model is always OpenHuman's `Provider` wrapped as [`ProviderModel`](../../../src/openhuman/tinyagents/model.rs) — never an SDK-owned provider client. The `ChatModel` adapter is the seam that replaces feature-gated SDK providers. - **`sqlite` feature enabled with one native sqlite chain.** OpenHuman's root and Tauri Cargo worlds pin `rusqlite = "=0.40.0"` and patch `rusqlite` / `libsqlite3-sys` locally to avoid the upstream `cfg_select!` build break on the current toolchain. Both worlds resolve to a single `libsqlite3-sys v0.38.0` chain. Durable graph checkpoints still run through [`SqlRunLedgerCheckpointer`](../../../src/openhuman/tinyagents/checkpoint.rs) until the migration re-points those rows to the crate checkpointer. @@ -443,7 +443,7 @@ Every agent turn — chat (`harness/session/turn/core.rs`), channel/CLI (`harnes | `mod.rs` / `model.rs` / `tools.rs` / `convert.rs` | `RunPolicy` / `ChatModel` / `Tool` / message adapters (incl. unknown-tool policy and out-of-band reasoning forwarding). | | `observability.rs` | Harness `AgentEvent` → `AgentProgress` + cost; `GraphTracingSink` for graph events. | | `orchestration.rs` | Re-exported `graph::orchestration` task-store types; map-reduce fanout now uses the TinyAgents SDK surface directly. | -| `checkpoint.rs` | `SqlRunLedgerCheckpointer` — a `Checkpointer` over openhuman's SQLite (`graph_checkpoints` table). TinyAgents 1.3 now ships `SqliteCheckpointer`; OpenHuman keeps this adapter until existing checkpoint rows are migrated or expired and schema ownership is settled. | +| `checkpoint.rs` | `SqlRunLedgerCheckpointer` — a `Checkpointer` over openhuman's SQLite (`graph_checkpoints` table). TinyAgents 1.3+ ships `SqliteCheckpointer`; OpenHuman keeps this adapter until existing checkpoint rows are migrated or expired and schema ownership is settled. | | `delegation.rs` | The durable `plan → execute ⇄ review → finalize` delegation graph (production worker wired in `agent_orchestration::delegation`). | **Orchestration on graphs** (`src/openhuman/agent_orchestration/`): @@ -458,7 +458,7 @@ Every agent turn — chat (`harness/session/turn/core.rs`), channel/CLI (`harnes - **Sub-agent build pipeline** (`subagent_runner/`) — definition resolution, archetype tool filtering, provider resolution, narrow prompt building, memory context, worker-thread mirror, handoff cache, checkpoint/resume — stays openhuman-owned. Sub-agents already *execute* on the harness; the crate's generic `SubAgentTool` would discard this pipeline for marginal crate-native depth tracking (openhuman's `spawn_depth_context` already bounds recursion). - **Durable run ledgers** (`workflow_runs`, `agent_teams`, `command_center`, `subagent_sessions`) stay on openhuman SQLite/JSON until their controller projections and restart semantics are mapped onto TinyAgents task/status/journal records. The `agent_teams` race-safe SQL compare-and-swap task claim remains OpenHuman-owned. -> **Note:** TinyAgents 1.3 ships harness store/cache/session primitives (`harness::store` with JSONL append stores, `harness::cache`, `harness::subagent`, lineage-aware status) plus graph task stores and conformance contracts. The plan for migrating the session shell, sub-agent pipeline, and detached-task lifecycle onto those primitives lives in [`docs/tinyagents-harness-migration-audit.md`](../../../docs/tinyagents-harness-migration-audit.md). +> **Note:** TinyAgents 1.3+ ships harness store/cache/session primitives (`harness::store` with JSONL append stores, `harness::cache`, `harness::subagent`, lineage-aware status) plus graph task stores and conformance contracts. The plan for migrating the session shell, sub-agent pipeline, and detached-task lifecycle onto those primitives lives in [`docs/tinyagents-harness-migration-audit.md`](../../../docs/tinyagents-harness-migration-audit.md). ## See also diff --git a/src/api/config.rs b/src/api/config.rs index a88feffb5d..29b96b4a09 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -1202,9 +1202,10 @@ mod tests { } // Our own hosted backend still passes through (is_openhuman short-circuit), - // and an UNKNOWN custom backend at a bare `/v1` keeps its pass-through so - // we don't reroute real self-hosted backends (the deliberate non-match - // documented on `looks_like_local_ai_endpoint`). + // but an UNKNOWN custom backend at a bare `/v1` base is now classified as + // an OpenAI-compatible inference base (#4153, Signal 2) and falls back so + // control-plane calls are not misrouted. A self-hosted backend must use a + // non-`/v1` base (see the `my-openhuman.example.com` case) to keep routing. assert_eq!( effective_backend_api_url(&Some("https://api.tinyhumans.ai/v1".to_string())), "https://api.tinyhumans.ai", @@ -1212,8 +1213,8 @@ mod tests { ); assert_eq!( effective_backend_api_url(&Some("https://my-backend.example/v1".to_string())), - "https://my-backend.example", - "unknown custom backend must keep pass-through" + fallback, + "unknown bare-/v1 base is an inference base and must fall back" ); } diff --git a/src/core/all.rs b/src/core/all.rs index ab5eece79a..1ed2d06e64 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -131,6 +131,10 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::webview_apis::all_webview_apis_registered_controllers()); // Agent definition and prompt inspection controllers.extend(crate::openhuman::agent::all_agent_registered_controllers()); + // Read-only agent run replay + status over the durable journal/status seams + // (agent_run_events / agent_run_status / agent_runs_active). + controllers + .extend(crate::openhuman::tinyagents::replay::all_agent_replay_registered_controllers()); // Persistent agent profiles (flavours): name, soul, memory sources, skills, MCP, connectors. controllers.extend(crate::openhuman::profiles::all_profiles_registered_controllers()); // User-facing agent registry: defaults, enablement, custom agents, tool policy. @@ -383,6 +387,8 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::mcp_registry::all_mcp_registry_controller_schemas()); schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas()); schemas.extend(crate::openhuman::agent::all_agent_controller_schemas()); + // Read-only agent run replay + status controllers (workstream 05.x). + schemas.extend(crate::openhuman::tinyagents::replay::all_agent_replay_controller_schemas()); schemas.extend(crate::openhuman::profiles::all_profiles_controller_schemas()); schemas.extend(crate::openhuman::agent_registry::all_agent_registry_controller_schemas()); schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas()); diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 2652981793..a8dedb3aea 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -873,15 +873,17 @@ impl Agent { // arguments (no child scope, no early-exit tools, graceful cap pause, // per-turn output cap) and runs the context-window summarization step. // Context middlewares sourced from this session's ContextManager: the - // per-tool-result byte cap + payload summarizer (after_tool), the - // cache-align warning and microcompact tool-body clearing (before_model). + // per-tool-result byte cap + payload summarizer (after_tool) and + // microcompact tool-body clearing (before_model). KV-cache-prefix drift + // detection is owned by the crate `PromptCacheGuardMiddleware` (fed by + // `PromptCacheSegmentMiddleware`); the warn-only `CacheAlignMiddleware` + // was deleted in C3. let context_mw = crate::openhuman::tinyagents::TurnContextMiddleware { tool_result_budget_bytes: self.context.tool_result_budget_bytes(), payload_summarizer: self.payload_summarizer.clone(), artifact_store, tokenjuice_compaction_enabled: self.context.compaction_enabled(), tokenjuice_compression: self.tokenjuice_compression, - cache_align: self.context.compaction_enabled(), microcompact_keep_recent: self.context.microcompact_keep_recent(), // Honor the [context].enabled / autocompact_enabled opt-outs: when off, // the summarization middleware is not installed (no summarizer tokens, diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 4a6f1e9f68..41acb8c385 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -34,6 +34,11 @@ impl Agent { } let loaded_count = session.messages.len(); log::info!("[transcript] loaded {} messages for resume", loaded_count); + // Best-effort store-backed shadow read (issue #4249, + // 04.2 phase 2). Observes + logs divergence only; the + // legacy transcript just loaded stays authoritative and + // is what feeds the resume below. Gated OFF by default. + self.maybe_shadow_read_session_store(&path, &session); let bounded = self.bound_cached_transcript_messages(session.messages); if bounded.len() < loaded_count { log::warn!( @@ -240,8 +245,9 @@ impl Agent { match transcript::write_transcript(path, messages, &meta, turn_usage) { Ok(()) => { - // Best-effort, non-fatal dual-write into the TinyAgents store, - // behind `OPENHUMAN_SESSION_DUAL_WRITE` (default OFF). Only runs + // Best-effort, non-fatal dual-write into the TinyAgents store. + // Gated by the default-ON session dual-write flag + // (`OPENHUMAN_SESSION_DUAL_WRITE` is a kill switch). Only runs // after the legacy JSONL append above succeeds; the legacy path // is primary and untouched (issue #4249, 04.1). self.maybe_dual_write_session_store(path, messages, &meta, turn_usage); @@ -257,9 +263,10 @@ impl Agent { /// Mirror the just-persisted turn into the TinyAgents session store. /// - /// Additive and gated on the `OPENHUMAN_SESSION_DUAL_WRITE` flag (default - /// OFF): when the flag is off this is a cheap early return — no store handle - /// is constructed and behavior is byte-identical to today. When on, the + /// Additive and gated on the default-ON session dual-write flag + /// (`OPENHUMAN_SESSION_DUAL_WRITE` is a kill switch): when killed this is a + /// cheap early return — no store handle is constructed and behavior is + /// byte-identical to the legacy-only path. When on (the default), the /// store write is fired best-effort on a background task and any error is /// logged (`[session-store]`) and swallowed, so it can never fail or alter a /// chat turn. Records reuse the importer's normalization @@ -274,7 +281,9 @@ impl Agent { ) { use crate::openhuman::session_import::live; - if !live::dual_write_enabled() { + // Config flag (default ON) gates the mirror; the env kill switch can + // still force it off. `self.config` is the effective per-agent config. + if !live::dual_write_enabled(self.config.session_dual_write) { return; } @@ -318,6 +327,59 @@ impl Agent { }); } + /// Store-backed **shadow read** of a just-loaded session transcript. + /// + /// Beside the legacy authoritative reader (`try_load_session_transcript`), + /// read the same session back from the TinyAgents journal store, normalize + /// both sides through the importer's `session_import::convert` machinery, + /// compare, and log any divergence (`[session_shadow_read]`, issue #4249, + /// 04.2 phase 2). Additive and gated on the default-**OFF** + /// `AgentConfig::session_shadow_reads` flag + /// (`OPENHUMAN_SESSION_SHADOW_READS` is a kill switch): when disabled this + /// is a cheap early return. + /// + /// The legacy transcript stays authoritative — this only observes. The + /// comparison runs on a spawned background task so it never slows the + /// authoritative read, and every store-read error is treated as "no shadow + /// available" (logged at debug), never propagated. + fn maybe_shadow_read_session_store( + &self, + path: &std::path::Path, + session: &transcript::SessionTranscript, + ) { + use crate::openhuman::session_import::live; + + // Config flag (default OFF) gates the shadow read; the env kill switch + // can still force it off. `self.config` is the effective per-agent config. + if !live::shadow_reads_enabled(self.config.session_shadow_reads) { + return; + } + + // Same session key the write side / importer use: the transcript stem. + let Some(stem) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + log::debug!( + "[session_shadow_read] skipped: no file stem for {}", + path.display() + ); + return; + }; + + let workspace = self.workspace_dir.clone(); + let transcript = session.clone(); + log::debug!( + "[session_shadow_read] scheduled stem={stem} workspace={} legacy_messages={}", + workspace.display(), + transcript.messages.len() + ); + tokio::spawn(async move { + let _ = live::shadow_read_compare(&workspace, &stem, &transcript).await; + }); + } + // ───────────────────────────────────────────────────────────────── // Session-memory extraction. // ───────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs index b37139c1a4..94b44a24f3 100644 --- a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs +++ b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs @@ -18,6 +18,7 @@ use tinyagents::graph::parallel::{map_reduce, FailurePolicy, ParallelOptions}; use tinyagents::graph::{ ClosureStateReducer, CompiledGraph, GraphBuilder, NodeContext, NodeResult, }; +use tinyagents::harness::retry::RetryPolicy; use tinyagents::harness::workspace::{WorkspaceDescriptor, WorkspaceIsolation}; use tinyagents::{CancellationToken, TinyAgentsError}; @@ -1718,7 +1719,16 @@ async fn run_spawn_parallel_execution_graph( .map_err(|e| format!("spawn_parallel_agents graph compile failed: {e}"))? .with_event_sink(Arc::new( crate::openhuman::tinyagents::observability::GraphTracingSink::new(label), - )); + )) + // Adapter-first landing of the crate-native per-node RetryPolicy + // (tinyagents 1.5.0 `CompiledGraph::with_node_retry`). Conservative: + // `max_attempts(1)` preserves today's single-attempt phase semantics + // exactly (no bespoke retry glue existed on these phases) and backoff + // sleeping stays off (the default). Per-worker fanout resilience is + // owned inside the worker/collect phases, not the phase-graph node loop; + // this wires the crate seam so a future slice can raise the attempt cap + // without re-plumbing. + .with_node_retry(RetryPolicy::default().with_max_attempts(1)); tracing::debug!( parent_session = %parent_session, diff --git a/src/openhuman/config/schema/agent.rs b/src/openhuman/config/schema/agent.rs index 713682e800..6d163fa9e0 100644 --- a/src/openhuman/config/schema/agent.rs +++ b/src/openhuman/config/schema/agent.rs @@ -243,6 +243,50 @@ pub struct AgentConfig { /// `OPENHUMAN_TOOL_TIMEOUT_SECS` env var still overrides it when set. #[serde(default = "default_agent_timeout_secs")] pub agent_timeout_secs: u64, + + /// Dual-write each completed session turn into the TinyAgents session + /// store (`{workspace}/tinyagents_store/{kv,journal}`) alongside the + /// legacy `session_raw/*.jsonl` transcript (issue #4249, sessions 04.1). + /// + /// Defaults **ON**: the store has to be populated by live turns so the + /// 04.2 read cutover inherits a complete corpus. The write is additive, + /// best-effort, and non-fatal — a store-write failure never affects the + /// chat turn or the authoritative legacy JSONL. The + /// `OPENHUMAN_SESSION_DUAL_WRITE` env var is a kill switch that overrides + /// this flag in either direction: a falsy value (`0`/`false`/`no`/`off`) + /// forces the dual-write OFF regardless of config; a truthy value forces + /// it ON. See + /// [`crate::openhuman::session_import::live::dual_write_enabled`]. + #[serde(default = "default_session_dual_write")] + pub session_dual_write: bool, + + /// Store-backed **shadow read** of a resumed session's messages: on the + /// legacy transcript read path (`session/turn/session_io.rs` → + /// `try_load_session_transcript`), also read the same session back from the + /// TinyAgents journal (`{workspace}/tinyagents_store/journal`), normalize + /// both sides through the importer's `session_import::convert` machinery, + /// compare, and log any divergence (`[session_shadow_read]`, issue #4249, + /// sessions 04.2 phase 2). + /// + /// Defaults **OFF** (unlike `session_dual_write`, which defaults ON): this + /// is an observation-only parity probe with no product effect. The legacy + /// JSONL read stays authoritative — the shadow read only observes and logs + /// on a background task; a store-read failure is treated as "no shadow + /// available" and never breaks or slows the authoritative read. The + /// `OPENHUMAN_SESSION_SHADOW_READS` env var is a pure **kill switch**: a + /// falsy value (`0`/`false`/`no`/`off`/`disable`) forces the shadow read + /// OFF regardless of config; it can never force it ON. See + /// [`crate::openhuman::session_import::live::shadow_reads_enabled`]. + #[serde(default = "default_session_shadow_reads")] + pub session_shadow_reads: bool, +} + +fn default_session_dual_write() -> bool { + true +} + +fn default_session_shadow_reads() -> bool { + false } fn default_tool_result_budget_bytes() -> usize { @@ -374,6 +418,8 @@ impl Default for AgentConfig { channel_permissions: std::collections::HashMap::new(), tool_result_budget_bytes: default_tool_result_budget_bytes(), agent_timeout_secs: default_agent_timeout_secs(), + session_dual_write: default_session_dual_write(), + session_shadow_reads: default_session_shadow_reads(), } } } diff --git a/src/openhuman/flows/mod.rs b/src/openhuman/flows/mod.rs index 73fee8dd50..c11253ffac 100644 --- a/src/openhuman/flows/mod.rs +++ b/src/openhuman/flows/mod.rs @@ -11,6 +11,7 @@ pub mod bus; pub mod ops; mod schemas; mod store; +pub mod tools; mod types; pub use schemas::{ diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 645be60075..f4f577f08c 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -26,7 +26,14 @@ const FLOW_RUN_TIMEOUT_SECS: u64 = 600; /// an older-schema definition to current), deserializes it, and rejects a /// structurally invalid graph via `tinyflows::validate::validate` — so a bad /// graph is caught at the door, before it's ever persisted. -fn validate_and_migrate_graph(graph_json: Value) -> Result { +/// +/// `pub(crate)` (not private) so `flows::tools::ProposeWorkflowTool` (issue +/// B4 — agent-first workflow authoring) can run a candidate graph through the +/// exact same validate/migrate path `flows_create` uses below, without +/// duplicating it. The tool only calls this — never `flows_create` itself — +/// which is what keeps the "the agent can never create a flow" invariant +/// intact: this function validates and returns, it has no persistence effect. +pub(crate) fn validate_and_migrate_graph(graph_json: Value) -> Result { let migrated = tinyflows::migrate::migrate(graph_json).map_err(|e| e.to_string())?; let graph: WorkflowGraph = serde_json::from_value(migrated).map_err(|e| e.to_string())?; tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?; diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs new file mode 100644 index 0000000000..fd3569fc9e --- /dev/null +++ b/src/openhuman/flows/tools.rs @@ -0,0 +1,337 @@ +//! Agent-facing tool for the `flows::` domain (issue B4 — agent-first +//! Workflow authoring): [`ProposeWorkflowTool`] ("propose_workflow"). +//! +//! The user asks the assistant in chat to build an automation; the agent +//! calls this tool with a candidate `tinyflows::model::WorkflowGraph`. The +//! tool runs the graph through the exact same +//! [`crate::openhuman::flows::ops::validate_and_migrate_graph`] path +//! `flows_create` uses, and returns a `workflow_proposal` summary for the +//! chat UI's `WorkflowProposalCard` — it never persists anything itself. +//! +//! **Human-in-the-loop invariant:** this tool must NEVER call +//! [`crate::openhuman::flows::ops::flows_create`] (or any other persistence +//! path). Only the user's "Save & enable" click in `WorkflowProposalCard` +//! creates the flow, via the `openhuman.flows_create` RPC directly from the +//! client. `permission_level() == PermissionLevel::None` and +//! `external_effect() == false` reflect that this call has no side effect — +//! it is pure validation. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tinyflows::model::{Node, NodeKind, WorkflowGraph}; + +use crate::openhuman::config::Config; +use crate::openhuman::flows::ops::validate_and_migrate_graph; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +/// Max characters kept for a `config_hint` before truncation, so a long +/// prompt/expression doesn't blow up the proposal summary sent to the LLM +/// and rendered in the chat card. +const MAX_CONFIG_HINT_CHARS: usize = 80; + +pub struct ProposeWorkflowTool { + config: Arc, +} + +impl ProposeWorkflowTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ProposeWorkflowTool { + fn name(&self) -> &str { + "propose_workflow" + } + + fn description(&self) -> &str { + "Propose a candidate automation workflow for the user to review and save. This tool \ + ONLY VALIDATES the graph and returns a summary — it NEVER creates or enables the flow; \ + the user must click \"Save & enable\" in the UI before anything is persisted or can \ + run. Build a tinyflows WorkflowGraph: nodes[] ({id, kind, name, config}) + edges[] \ + ({from_node, to_node, from_port?, to_port?}; ports default \"main\"). Exactly ONE \ + trigger node is required. The 12 node kinds: trigger (config.trigger_kind: manual | \ + schedule | webhook | app_event | form | chat_message | evaluation | system | \ + execute_by_workflow; schedule needs config.schedule = {kind:\"cron\",expr,tz?} | \ + {kind:\"at\",at} | {kind:\"every\",every_ms}; app_event needs config.toolkit + \ + config.trigger_slug), agent (config.prompt), tool_call (config.slug REQUIRED + \ + config.args), http_request (config.method/url, optional headers/body), code \ + (config.language: \"javascript\"|\"python\" + config.source), condition (config.field; \ + routes ports \"true\"/\"false\"), switch (config.expression or config.field; routes to \ + the matching case port, or \"default\"), transform (config.set: {key: \"=expr\"} \ + merged onto each item), split_out (config.path to an array field; fans out one item per \ + element), merge (fan-in passthrough, no config), output_parser (passthrough today; no \ + config required), sub_workflow (config.workflow: an embedded child WorkflowGraph). If \ + validation fails, fix the graph and call this tool again." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for the proposed flow." + }, + "graph": { + "type": "object", + "description": "A tinyflows WorkflowGraph: { name?, nodes: [...], edges: [...] }. See the tool description for node kinds and their config shapes.", + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Unique id within the graph." }, + "kind": { + "type": "string", + "enum": [ + "trigger", "agent", "tool_call", "http_request", + "code", "condition", "switch", "merge", "split_out", + "transform", "output_parser", "sub_workflow" + ] + }, + "name": { "type": "string", "description": "Human-readable node name." }, + "config": { "description": "Kind-specific configuration; see tool description." } + }, + "required": ["id", "kind", "name"] + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from_node": { "type": "string" }, + "to_node": { "type": "string" }, + "from_port": { "type": "string", "description": "Defaults to \"main\"." }, + "to_port": { "type": "string", "description": "Defaults to \"main\"." } + }, + "required": ["from_node", "to_node"] + } + } + }, + "required": ["nodes", "edges"] + }, + "require_approval": { + "type": "boolean", + "description": "Force a human-approval gate on every outbound tool/HTTP action this flow takes once saved. Defaults to true for agent-proposed flows." + } + }, + "required": ["name", "graph"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + // Pure validation with no side effect — see module doc. + PermissionLevel::None + } + + fn external_effect(&self) -> bool { + // Never persists or executes anything; only `flows_create` (invoked + // from the client by the user's own "Save & enable" click) does. + false + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let name = match args.get("name").and_then(Value::as_str).map(str::trim) { + Some(name) if !name.is_empty() => name.to_string(), + _ => return Ok(ToolResult::error("Missing 'name' parameter".to_string())), + }; + + let graph_json = match args.get("graph") { + Some(v) if !v.is_null() => v.clone(), + _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())), + }; + + let require_approval = args + .get("require_approval") + .and_then(Value::as_bool) + .unwrap_or(true); + + tracing::debug!( + target: "flows", + %name, + require_approval, + workspace = %self.config.workspace_dir.display(), + "[flows] propose_workflow: validating candidate graph" + ); + + let graph = match validate_and_migrate_graph(graph_json) { + Ok(graph) => graph, + Err(e) => { + tracing::debug!( + target: "flows", + %name, + error = %e, + "[flows] propose_workflow: validation failed" + ); + return Ok(ToolResult::error(format!( + "Workflow graph is invalid: {e}. Fix the graph and call propose_workflow \ + again." + ))); + } + }; + + let summary = build_summary(&graph); + let graph_value = serde_json::to_value(&graph)?; + + tracing::info!( + target: "flows", + %name, + node_count = graph.nodes.len(), + require_approval, + "[flows] propose_workflow: proposal ready for user review" + ); + + Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ + "type": "workflow_proposal", + "name": name, + "graph": graph_value, + "require_approval": require_approval, + "summary": summary, + }))?)) + } +} + +/// Builds the `{ trigger, steps }` summary surfaced to both the LLM (in the +/// tool result) and the chat UI's `WorkflowProposalCard`. +fn build_summary(graph: &WorkflowGraph) -> Value { + let trigger = graph + .trigger() + .map(describe_trigger) + .unwrap_or_else(|| "no trigger".to_string()); + + let steps: Vec = graph + .nodes + .iter() + .filter(|n| n.kind != NodeKind::Trigger) + .map(|n| { + let mut step = json!({ + "kind": node_kind_str(&n.kind), + "name": n.name, + }); + if let Some(hint) = config_hint(n) { + step["config_hint"] = json!(hint); + } + step + }) + .collect(); + + json!({ "trigger": trigger, "steps": steps }) +} + +/// The `snake_case` wire string for a [`NodeKind`] (its `Serialize` impl), +/// for the summary/step JSON. Falls back to `"unknown"` only if serializing +/// ever somehow fails — `NodeKind`'s derive is infallible in practice. +fn node_kind_str(kind: &NodeKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// One-line human description of a trigger node, for the summary's +/// `"trigger"` field — e.g. `"schedule: 0 9 * * *"`, `"app event: +/// gmail/GMAIL_NEW_GMAIL_MESSAGE"`, `"manual"`. +fn describe_trigger(node: &Node) -> String { + let trigger_kind = node + .config + .get("trigger_kind") + .and_then(Value::as_str) + .unwrap_or("manual"); + + match trigger_kind { + "schedule" => { + let schedule = node.config.get("schedule"); + if let Some(expr) = schedule.and_then(|s| s.get("expr")).and_then(Value::as_str) { + format!("schedule: {expr}") + } else if let Some(ms) = schedule + .and_then(|s| s.get("every_ms")) + .and_then(Value::as_u64) + { + format!("schedule: every {ms}ms") + } else if let Some(at) = schedule.and_then(|s| s.get("at")).and_then(Value::as_str) { + format!("schedule: once at {at}") + } else { + "schedule (unspecified)".to_string() + } + } + "app_event" => { + let toolkit = node + .config + .get("toolkit") + .and_then(Value::as_str) + .unwrap_or("?"); + let slug = node + .config + .get("trigger_slug") + .and_then(Value::as_str) + .unwrap_or("?"); + format!("app event: {toolkit}/{slug}") + } + other => other.to_string(), + } +} + +/// Short, human-readable hint for a non-trigger node's config, for the +/// step's optional `"config_hint"` field. `None` when the kind has nothing +/// worth surfacing (e.g. `merge`, `output_parser`). +fn config_hint(node: &Node) -> Option { + let cfg = &node.config; + match &node.kind { + NodeKind::Agent => cfg.get("prompt").and_then(Value::as_str).map(truncate_hint), + NodeKind::ToolCall => cfg.get("slug").and_then(Value::as_str).map(str::to_string), + NodeKind::HttpRequest => { + let method = cfg.get("method").and_then(Value::as_str).unwrap_or("GET"); + let url = cfg.get("url").and_then(Value::as_str).unwrap_or("?"); + Some(truncate_hint(&format!("{method} {url}"))) + } + NodeKind::Code => cfg + .get("language") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| Some("javascript".to_string())), + NodeKind::Condition => cfg + .get("field") + .and_then(Value::as_str) + .map(|f| format!("field: {f}")), + NodeKind::Switch => cfg + .get("expression") + .and_then(Value::as_str) + .or_else(|| cfg.get("field").and_then(Value::as_str)) + .map(truncate_hint), + NodeKind::Transform => cfg.get("set").and_then(Value::as_object).map(|set| { + let keys: Vec<&str> = set.keys().map(String::as_str).collect(); + truncate_hint(&format!("sets: {}", keys.join(", "))) + }), + NodeKind::SplitOut => cfg + .get("path") + .and_then(Value::as_str) + .map(|p| format!("path: {p}")), + NodeKind::SubWorkflow => Some("embedded sub-workflow".to_string()), + NodeKind::Merge | NodeKind::OutputParser | NodeKind::Trigger => None, + } +} + +/// Truncates a hint string to [`MAX_CONFIG_HINT_CHARS`], appending an +/// ellipsis when it was cut — mirrors +/// `crate::openhuman::tools::traits::render_context_value`'s truncation +/// behavior for tool-call timeline details. +fn truncate_hint(s: &str) -> String { + if s.chars().count() <= MAX_CONFIG_HINT_CHARS { + return s.to_string(); + } + let truncated: String = s + .chars() + .take(MAX_CONFIG_HINT_CHARS.saturating_sub(1)) + .collect(); + format!("{truncated}…") +} + +#[cfg(test)] +#[path = "tools_tests.rs"] +mod tests; diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs new file mode 100644 index 0000000000..650f47bda9 --- /dev/null +++ b/src/openhuman/flows/tools_tests.rs @@ -0,0 +1,264 @@ +use super::*; +use crate::openhuman::config::Config; +use serde_json::json; +use tempfile::TempDir; + +fn test_config(tmp: &TempDir) -> Arc { + let config = Config { + workspace_dir: tmp.path().join("workspace"), + action_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + Arc::new(config) +} + +fn valid_graph() -> Value { + json!({ + "nodes": [ + { + "id": "t", + "kind": "trigger", + "name": "Every morning", + "config": { "trigger_kind": "schedule", "schedule": { "kind": "cron", "expr": "0 9 * * *" } } + }, + { + "id": "a", + "kind": "agent", + "name": "Summarize", + "config": { "prompt": "Summarize yesterday's messages" } + }, + { + "id": "s", + "kind": "tool_call", + "name": "Post to Slack", + "config": { "slug": "slack.post_message", "args": { "channel": "#general" } } + } + ], + "edges": [ + { "from_node": "t", "to_node": "a" }, + { "from_node": "a", "to_node": "s" } + ] + }) +} + +#[tokio::test] +async fn valid_graph_returns_workflow_proposal_success() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "Daily standup summary", "graph": valid_graph() })) + .await + .unwrap(); + + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).expect("valid JSON output"); + assert_eq!(parsed["type"], "workflow_proposal"); + assert_eq!(parsed["name"], "Daily standup summary"); + assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn no_trigger_graph_is_an_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let graph_without_trigger = json!({ + "nodes": [ { "id": "a", "kind": "output_parser", "name": "A" } ], + "edges": [] + }); + + let result = tool + .execute(json!({ "name": "bad", "graph": graph_without_trigger })) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result.output().to_lowercase().contains("trigger"), + "expected a trigger-related validation error, got: {}", + result.output() + ); +} + +#[tokio::test] +async fn missing_name_is_an_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "graph": valid_graph() })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("Missing 'name'")); +} + +#[tokio::test] +async fn missing_graph_is_an_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "no graph here" })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("Missing 'graph'")); +} + +#[tokio::test] +async fn omitted_require_approval_defaults_true_in_result() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph() })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["require_approval"], true); +} + +#[tokio::test] +async fn explicit_require_approval_false_is_respected() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph(), "require_approval": false })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["require_approval"], false); +} + +#[tokio::test] +async fn summary_step_count_and_kinds_are_correct() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph() })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + let steps = parsed["summary"]["steps"].as_array().unwrap(); + // 3 nodes total, minus the 1 trigger = 2 steps. + assert_eq!(steps.len(), 2); + assert_eq!(steps[0]["kind"], "agent"); + assert_eq!(steps[0]["name"], "Summarize"); + assert_eq!(steps[0]["config_hint"], "Summarize yesterday's messages"); + assert_eq!(steps[1]["kind"], "tool_call"); + assert_eq!(steps[1]["name"], "Post to Slack"); + assert_eq!(steps[1]["config_hint"], "slack.post_message"); +} + +#[tokio::test] +async fn summary_trigger_describes_schedule() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph() })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["summary"]["trigger"], "schedule: 0 9 * * *"); +} + +#[tokio::test] +async fn summary_trigger_describes_manual_default() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let graph = json!({ + "nodes": [ { "id": "t", "kind": "trigger", "name": "Manual start" } ], + "edges": [] + }); + + let result = tool + .execute(json!({ "name": "demo", "graph": graph })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["summary"]["trigger"], "manual"); + assert!(parsed["summary"]["steps"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn summary_trigger_describes_app_event() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let graph = json!({ + "nodes": [ + { + "id": "t", + "kind": "trigger", + "name": "On new email", + "config": { + "trigger_kind": "app_event", + "toolkit": "gmail", + "trigger_slug": "GMAIL_NEW_GMAIL_MESSAGE" + } + } + ], + "edges": [] + }); + + let result = tool + .execute(json!({ "name": "demo", "graph": graph })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!( + parsed["summary"]["trigger"], + "app event: gmail/GMAIL_NEW_GMAIL_MESSAGE" + ); +} + +#[test] +fn propose_workflow_never_creates_a_flow() { + // The tool must have no way to persist a flow — the human-in-the-loop + // invariant (issue B4) rests entirely on `external_effect() == false` and + // `permission_level() == None` (no gate would even fire if this ever + // regressed to true, but a saved flow must still only ever be created by + // the user's own `flows_create` click). + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + assert_eq!(tool.permission_level(), PermissionLevel::None); + assert!(!tool.external_effect()); +} + +#[test] +fn tool_name_and_schema_are_stable() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + assert_eq!(tool.name(), "propose_workflow"); + + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v.as_str() == Some("name"))); + assert!(required.iter().any(|v| v.as_str() == Some("graph"))); +} + +#[test] +fn display_label_humanizes_the_tool_name() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + assert_eq!( + tool.display_label(&Value::Null).as_deref(), + Some("Propose Workflow") + ); +} diff --git a/src/openhuman/memory_sync/sources/rebuild.rs b/src/openhuman/memory_sync/sources/rebuild.rs index 456073efd2..5b5fb9b86f 100644 --- a/src/openhuman/memory_sync/sources/rebuild.rs +++ b/src/openhuman/memory_sync/sources/rebuild.rs @@ -272,7 +272,6 @@ pub async fn rebuild_tree_from_raw( archive_source_id: &str, ) -> Result { let start = std::time::Instant::now(); - let content_root = config.memory_tree_content_root(); let coverage = raw_coverage(config, tree_scope, archive_source_id)?; tracing::info!( diff --git a/src/openhuman/session_import/live.rs b/src/openhuman/session_import/live.rs index e27aa01253..b35798e423 100644 --- a/src/openhuman/session_import/live.rs +++ b/src/openhuman/session_import/live.rs @@ -1,7 +1,11 @@ //! Live dual-write of new session turns into the TinyAgents store. //! -//! Additive, best-effort, and behind the `OPENHUMAN_SESSION_DUAL_WRITE` -//! environment flag (default **OFF**). The legacy `session_raw/*.jsonl` +//! Additive, best-effort, and gated by the `AgentConfig::session_dual_write` +//! **config flag** which **defaults ON** ([`dual_write_enabled`]); the +//! `OPENHUMAN_SESSION_DUAL_WRITE` env var is a **kill switch** — set it to a +//! falsey value (`0`/`false`/`no`/`off`/`disable`) to force the mirror off +//! regardless of config. This mirrors the `OPENHUMAN_APPROVAL_GATE` +//! default-on-with-kill-switch idiom. The legacy `session_raw/*.jsonl` //! transcript (`session/turn/session_io.rs` → `transcript::write_transcript`) //! stays the primary and authoritative writer; this module mirrors each //! *already-persisted* turn into the same store layout the Phase-1 importer @@ -15,7 +19,7 @@ //! nothing in this module touches the legacy transcript path. use std::path::Path; -use std::sync::OnceLock; +use std::sync::Arc; use anyhow::{Context, Result}; use tinyagents::harness::store::{AppendStore, Store}; @@ -26,31 +30,99 @@ use super::convert::{ build_descriptor, effective_thread_id, journal_messages, sanitize_store_name, stream_name, }; use super::ops::{open_session_stores, SessionStores}; -use super::types::{DescriptorSource, NS_SESSIONS}; +use super::types::{DescriptorSource, JournalMessage, NS_SESSIONS}; -/// Environment flag gating the live session-store dual-write. Default OFF. +/// Kill-switch env var for the live session-store dual-write. The config flag +/// (`AgentConfig::session_dual_write`) defaults ON; setting this env var to a +/// falsey value forces the mirror OFF regardless of config. See +/// [`dual_write_enabled`]. const DUAL_WRITE_ENV: &str = "OPENHUMAN_SESSION_DUAL_WRITE"; -/// Whether the live session-store dual-write is enabled. +/// Kill-switch env var for the store-backed session shadow read. The config +/// flag (`AgentConfig::session_shadow_reads`) defaults OFF; setting this env +/// var to a falsey value forces the shadow read OFF even when the flag is ON. +/// It can never force the shadow read ON. See [`shadow_reads_enabled`]. +const SHADOW_READ_ENV: &str = "OPENHUMAN_SESSION_SHADOW_READS"; + +/// Whether `var` is set to a case-insensitive falsey value +/// (`0`/`false`/`no`/`off`/`disable`/`disabled`). Unset — or any non-falsey +/// value — is not a kill. Read live (not cached) so a config reload / env +/// change is honored on the next turn/read. +fn env_kill_switch_engaged(var: &str) -> bool { + match std::env::var(var) { + Ok(v) => matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" | "disable" | "disabled" + ), + Err(_) => false, + } +} + +/// Whether the `OPENHUMAN_SESSION_DUAL_WRITE` kill switch is engaged (set to a +/// falsey value). Unset — or any non-falsey value — leaves the mirror driven by +/// the config flag. Read live (not cached) so a config reload / env change is +/// honored on the next turn. +fn kill_switch_engaged() -> bool { + env_kill_switch_engaged(DUAL_WRITE_ENV) +} + +/// Store-registry name under which the session KV store is registered on each +/// turn's `RunContext.stores` (issue #4249, 04.1). Slash-free so it round-trips +/// the crate `FileStore` name sanitizer. This is a forward-looking, +/// harness-visible handle to the same `tinyagents_store` KV tree the live +/// dual-write mirrors into; readers stay legacy until 04.2. +pub const TINYAGENTS_SESSION_KV_STORE: &str = "openhuman_sessions"; + +/// Whether the live session-store dual-write is enabled for this turn. +/// +/// `config_enabled` is the `AgentConfig::session_dual_write` flag, which +/// **defaults ON**. The `OPENHUMAN_SESSION_DUAL_WRITE` env var is a pure kill +/// switch: an explicit falsey value (case-insensitive +/// `0`/`false`/`no`/`off`/`disable`/`disabled`) forces the mirror OFF regardless +/// of config; otherwise the config flag wins. Read live (never cached) so a +/// config reload / env change is honored on the next turn. This keeps a clean +/// 04.2 seam (reads can flip independently) while making the mirror the default +/// so new turns land in the store without opt-in. +pub fn dual_write_enabled(config_enabled: bool) -> bool { + let killed = kill_switch_engaged(); + let enabled = config_enabled && !killed; + log::debug!( + "[session-store] dual-write decision config_enabled={config_enabled} kill_switch={killed} enabled={enabled}" + ); + enabled +} + +/// Open the session KV store as an `Arc` for registration on the +/// per-turn `RunContext.stores` under [`TINYAGENTS_SESSION_KV_STORE`], honoring +/// the dual-write flag (config default ON + env kill switch). /// -/// Read **once** from the environment and cached for the process lifetime. -/// Truthy values (case-insensitive): `1`, `true`, `yes`, `on`. Anything else — -/// including an unset variable — is OFF, so default behavior is byte-identical -/// to today (no store handle constructed, no extra writes). -pub fn dual_write_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| { - let enabled = std::env::var(DUAL_WRITE_ENV) - .map(|v| { - matches!( - v.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(false); - log::debug!("[session-store] dual-write flag {DUAL_WRITE_ENV} resolved to {enabled}"); - enabled - }) +/// Best-effort: `None` when the dual-write is disabled **or** the config (hence +/// workspace) cannot be resolved. When present it is the exact same +/// `{workspace}/tinyagents_store/kv` `FileStore` the importer and the live +/// dual-write use, so a harness-side reader (04.2+) sees identical records. The +/// journal (`JsonlAppendStore`, an `AppendStore` rather than a `Store`) is not +/// registrable on the `StoreRegistry`; the dual-write opens it directly. +pub async fn session_kv_store() -> Option> { + let cfg = match crate::openhuman::config::Config::load_or_init().await { + Ok(cfg) => cfg, + Err(err) => { + log::warn!("[session-store] cannot resolve config for store registration: {err:#}"); + return None; + } + }; + if !dual_write_enabled(cfg.agent.session_dual_write) { + log::debug!( + "[session-store] dual-write disabled; skipping RunContext session-store registration" + ); + return None; + } + let workspace = cfg.workspace_dir; + let SessionStores { kv, .. } = open_session_stores(&workspace); + log::debug!( + "[session-store] opened session kv store for RunContext.stores workspace={}", + workspace.display() + ); + Some(Arc::new(kv)) } /// Mirror one completed turn's transcript into the TinyAgents store. @@ -133,3 +205,147 @@ pub async fn write_live_turn( ); Ok(()) } + +// ───────────────────────────────────────────────────────────────────────────── +// Store-backed SHADOW READ (issue #4249, sessions 04.2 phase 2) +// +// Beside the legacy authoritative transcript reader +// (`session/turn/session_io.rs` → `try_load_session_transcript`), read the +// same session's messages back from the crate journal store, normalize both +// sides through the same `convert` machinery the dual-write uses, compare, and +// log divergence. Legacy stays authoritative: this observes + logs only and +// never affects, fails, or slows the authoritative read. +// ───────────────────────────────────────────────────────────────────────────── + +/// Whether the store-backed session **shadow read** is enabled for this read. +/// +/// `config_enabled` is the `AgentConfig::session_shadow_reads` flag, which +/// **defaults OFF** (unlike `session_dual_write`). The +/// `OPENHUMAN_SESSION_SHADOW_READS` env var is a pure kill switch: an explicit +/// falsey value (case-insensitive `0`/`false`/`no`/`off`/`disable`/`disabled`) +/// forces the shadow read OFF regardless of config; it can never force it ON. +/// Read live (never cached) so a config reload / env change is honored on the +/// next read. Mirrors the [`dual_write_enabled`] flag/env idiom exactly, only +/// with the default flipped and no default-on behavior. +pub fn shadow_reads_enabled(config_enabled: bool) -> bool { + let killed = env_kill_switch_engaged(SHADOW_READ_ENV); + let enabled = config_enabled && !killed; + log::debug!( + "[session_shadow_read] decision config_enabled={config_enabled} kill_switch={killed} enabled={enabled}" + ); + enabled +} + +/// Outcome of one shadow-read comparison. Returned for tests/observability; +/// the compact divergence summary is also logged (`[session_shadow_read]`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShadowReadOutcome { + /// The store stream rendered exactly the legacy transcript's messages. + Match { messages: usize }, + /// The store stream diverged from the legacy render. Carries only compact + /// counts + the first differing index — never message bodies (PII). + Divergence { + legacy: usize, + shadow: usize, + first_diff: Option, + }, + /// No shadow available: the store read errored, or the stream is + /// empty/absent for a non-empty legacy transcript (e.g. dual-write was off + /// when this session was written). Treated as non-divergent — the legacy + /// read is authoritative regardless. + Unavailable, +} + +/// Read a session's messages back from the crate journal store +/// (`{workspace}/tinyagents_store/journal`, stream `session.{stem}.messages`) +/// as normalized [`JournalMessage`]s — the same shape the importer and live +/// dual-write write. A missing stream yields an empty vec (not an error). +async fn read_shadow_messages(workspace: &Path, session_key: &str) -> Result> { + let SessionStores { journal, .. } = open_session_stores(workspace); + let stream = stream_name(session_key); + let records = journal + .read_from(&stream, 0) + .await + .with_context(|| format!("shadow read of stream {stream}"))?; + let mut out = Vec::with_capacity(records.len()); + for (offset, value) in records { + let msg: JournalMessage = serde_json::from_value(value) + .with_context(|| format!("shadow record shape at offset {offset}"))?; + out.push(msg); + } + Ok(out) +} + +/// Shadow-read the given session back from the store and compare it against the +/// legacy transcript, logging divergence. Legacy stays authoritative — the +/// caller ignores the returned outcome for control flow (it exists for tests / +/// observability). Best-effort: any store-read error is logged at debug and +/// reported as [`ShadowReadOutcome::Unavailable`]; it never breaks or slows the +/// authoritative read. +/// +/// Both sides are normalized through the importer's `convert` machinery +/// ([`journal_messages`]) so live/legacy renders are directly comparable, then +/// compared by message count and normalized content. On mismatch a **compact** +/// summary (counts + first differing index) is warn-logged; message bodies are +/// never emitted (PII). +pub async fn shadow_read_compare( + workspace: &Path, + session_key: &str, + legacy: &SessionTranscript, +) -> ShadowReadOutcome { + let expected = journal_messages(legacy); + log::debug!( + "[session_shadow_read] enter stem={session_key} workspace={} legacy_messages={}", + workspace.display(), + expected.len() + ); + + let shadow = match read_shadow_messages(workspace, session_key).await { + Ok(v) => v, + Err(err) => { + log::debug!( + "[session_shadow_read] store read error stem={session_key}: {err:#} — no shadow available" + ); + return ShadowReadOutcome::Unavailable; + } + }; + + // Empty/absent store stream against a non-empty legacy transcript: the + // session simply was not mirrored (dual-write off when it was written). + // Treat as "no shadow" rather than a spurious divergence. + if shadow.is_empty() && !expected.is_empty() { + log::debug!( + "[session_shadow_read] no store stream stem={session_key} legacy_messages={} — no shadow available", + expected.len() + ); + return ShadowReadOutcome::Unavailable; + } + + if shadow == expected { + log::debug!( + "[session_shadow_read] parity OK stem={session_key} messages={}", + expected.len() + ); + return ShadowReadOutcome::Match { + messages: expected.len(), + }; + } + + // Divergence: first index where the two normalized renders differ, or the + // shorter length when one is a strict prefix of the other. Compact only. + let first_diff = expected + .iter() + .zip(shadow.iter()) + .position(|(a, b)| a != b) + .or_else(|| (expected.len() != shadow.len()).then(|| expected.len().min(shadow.len()))); + log::warn!( + "[session_shadow_read] DIVERGENCE stem={session_key} legacy_count={} shadow_count={} first_diff={first_diff:?}", + expected.len(), + shadow.len() + ); + ShadowReadOutcome::Divergence { + legacy: expected.len(), + shadow: shadow.len(), + first_diff, + } +} diff --git a/src/openhuman/session_import/live_tests.rs b/src/openhuman/session_import/live_tests.rs new file mode 100644 index 0000000000..5d9073dadc --- /dev/null +++ b/src/openhuman/session_import/live_tests.rs @@ -0,0 +1,354 @@ +//! Write-side parity for the live session-store dual-write (issue #4249, 04.1). +//! +//! Drives the two persistence paths — the legacy authoritative JSONL writer +//! (`transcript::write_transcript`) and the live store mirror +//! ([`super::live::write_live_turn`]) — with the *same* completed turn, then +//! asserts the store journal renders byte-for-byte the same +//! [`JournalMessage`]s the importer's parity helper reads back off the legacy +//! JSONL. This proves the two writers stay shape-identical for new turns +//! without depending on the read path (04.2). + +use std::path::Path; + +use tempfile::TempDir; +use tinyagents::harness::store::{AppendStore, FileStore, JsonlAppendStore, Store}; + +use super::convert::{sanitize_store_name, stream_name}; +use super::live::{ + dual_write_enabled, shadow_read_compare, shadow_reads_enabled, write_live_turn, + ShadowReadOutcome, +}; +use super::ops::store_root; +use super::types::{JournalMessage, SessionDescriptor, NS_SESSIONS}; +use crate::openhuman::agent::harness::session::transcript::{ + attach_turn_usage_metadata, read_transcript, write_transcript, MessageUsage, SessionTranscript, + TranscriptMeta, TurnUsage, +}; +use crate::openhuman::inference::provider::{ChatMessage, ToolCall}; + +/// A transcript meta header matching the importer's `native` fixture shape. +fn meta(thread_id: &str) -> TranscriptMeta { + TranscriptMeta { + agent_name: "orchestrator".to_string(), + agent_id: Some("orchestrator".to_string()), + agent_type: Some("root".to_string()), + dispatcher: "native".to_string(), + provider: Some("anthropic".to_string()), + model: Some("claude".to_string()), + created: "2024-01-01T00:00:00Z".to_string(), + updated: "2024-01-01T00:05:00Z".to_string(), + turn_count: 1, + input_tokens: 100, + output_tokens: 50, + cached_input_tokens: 20, + charged_amount_usd: 0.05, + thread_id: Some(thread_id.to_string()), + task_id: None, + } +} + +/// Per-turn usage carrying a native tool call, so tool-call ids are exercised +/// on the parity path (acceptance: "tool-call ids"). +fn turn_usage() -> TurnUsage { + TurnUsage { + provider: "anthropic".to_string(), + model: "claude".to_string(), + usage: MessageUsage { + input: 100, + output: 50, + cached_input: 20, + context_window: 200_000, + cost_usd: 0.05, + }, + ts: "2024-01-01T00:00:01Z".to_string(), + reasoning_content: None, + tool_calls: vec![ToolCall { + id: "tc1".to_string(), + name: "read_file".to_string(), + arguments: "{\"path\":\"x\"}".to_string(), + extra_content: None, + }], + iteration: 1, + } +} + +/// Read the store journal stream back into `JournalMessage`s, mirroring the +/// importer's `journal_readback` helper. +async fn journal_readback(ws: &Path, stream: &str) -> Vec { + let journal = JsonlAppendStore::new(store_root(ws).join("journal")); + journal + .read_from(stream, 0) + .await + .expect("journal read") + .into_iter() + .map(|(_, v)| serde_json::from_value(v).expect("journal record shape")) + .collect() +} + +#[tokio::test] +async fn live_dual_write_matches_legacy_jsonl_render() { + let ws = TempDir::new().expect("tempdir"); + let stem = "1719_orchestrator"; + let jsonl_path = ws.path().join("session_raw").join(format!("{stem}.jsonl")); + + // A user turn + an assistant turn. The base messages carry no usage + // metadata: the legacy writer embeds it from its `turn_usage` argument, + // exactly as `persist_session_transcript` does in production. + let base_messages = vec![ChatMessage::user("hi"), ChatMessage::assistant("done")]; + let meta = meta("t-root"); + let usage = turn_usage(); + + // (1) Legacy authoritative write — the primary persistence path. + write_transcript(&jsonl_path, &base_messages, &meta, Some(&usage)).expect("legacy write"); + + // (2) Live dual-write — replicate `session_io`'s construction: attach the + // turn usage to the last assistant message, then mirror into the store. + let mut live_messages = base_messages.clone(); + let last_assistant = live_messages + .iter() + .rposition(|m| m.role == "assistant") + .expect("assistant message present"); + attach_turn_usage_metadata(&mut live_messages[last_assistant], &usage); + let transcript = SessionTranscript { + meta: meta.clone(), + messages: live_messages, + }; + write_live_turn(ws.path(), stem, &transcript) + .await + .expect("live dual-write"); + + // Parity: the store journal must equal the importer's read-back of the + // legacy JSONL, field for field (including reconstructed + // `openhuman_turn_usage` metadata and the tool-call id). + let expected: Vec = read_transcript(&jsonl_path) + .expect("read legacy transcript") + .messages + .iter() + .map(JournalMessage::from) + .collect(); + let actual = journal_readback(ws.path(), &stream_name(stem)).await; + assert_eq!( + actual, expected, + "live store stream diverges from the legacy JSONL render" + ); + + // The assistant record must carry the tool-call id via reconstructed usage. + let assistant = actual + .iter() + .find(|m| m.role == "assistant") + .expect("assistant record"); + let tool_id = assistant + .extra_metadata + .as_ref() + .and_then(|m| m.get("openhuman_turn_usage")) + .and_then(|u| u.get("tool_calls")) + .and_then(|t| t.get(0)) + .and_then(|c| c.get("id")) + .and_then(|id| id.as_str()); + assert_eq!(tool_id, Some("tc1"), "tool-call id lost on the store path"); + + // The session descriptor is upserted under the sanitized stem with the + // stem's thread id and journal stream, matching the importer's projection. + let kv = FileStore::new(store_root(ws.path()).join("kv")); + let desc_value = kv + .get(NS_SESSIONS, &sanitize_store_name(stem)) + .await + .expect("kv get") + .expect("descriptor present after live write"); + let desc: SessionDescriptor = serde_json::from_value(desc_value).expect("descriptor shape"); + assert_eq!(desc.session_key, stem); + assert_eq!(desc.thread_id, "t-root"); + assert!(!desc.thread_id_synthesized); + assert_eq!(desc.stream, stream_name(stem)); + assert_eq!(desc.dispatcher, "native"); + assert_eq!(desc.provider.as_deref(), Some("anthropic")); + assert_eq!(desc.model.as_deref(), Some("claude")); +} + +/// The dual-write is driven by the `AgentConfig::session_dual_write` config +/// flag (default ON) with the `OPENHUMAN_SESSION_DUAL_WRITE` env var as a pure +/// kill switch. This exercises the decision matrix directly. Env mutation is +/// process-global, so all assertions live in one serial test and the var is +/// restored on exit; no other test reads this var. +#[test] +fn config_flag_and_env_kill_switch() { + const ENV: &str = "OPENHUMAN_SESSION_DUAL_WRITE"; + let prior = std::env::var(ENV).ok(); + + // Config OFF disables regardless of env. + std::env::remove_var(ENV); + assert!(!dual_write_enabled(false), "config off disables"); + + // Config ON (the default) enables when the env is unset. + assert!(dual_write_enabled(true), "config on + no env enables"); + + // A falsey env value is the kill switch: forces OFF even with config ON. + for killed in ["0", "false", "no", "off", "disable", "disabled", "OFF"] { + std::env::set_var(ENV, killed); + assert!( + !dual_write_enabled(true), + "kill switch value {killed:?} must force off" + ); + } + + // A non-falsey env value does not force on: config still governs. + std::env::set_var(ENV, "1"); + assert!( + dual_write_enabled(true), + "non-falsey env leaves config ON on" + ); + assert!( + !dual_write_enabled(false), + "non-falsey env does not force config-off on" + ); + + match prior { + Some(v) => std::env::set_var(ENV, v), + None => std::env::remove_var(ENV), + } +} + +// ── Store-backed shadow read (issue #4249, 04.2 phase 2) ──────────────────── + +/// A session written by the dual-write and then read back through the shadow +/// reader must render byte-for-byte the messages the legacy JSONL reader +/// produces — no divergence. This is the read-path twin of +/// [`live_dual_write_matches_legacy_jsonl_render`]: it drives the *same* +/// writers, then compares via the actual `shadow_read_compare` path and asserts +/// a clean [`ShadowReadOutcome::Match`]. +#[tokio::test] +async fn shadow_read_roundtrip_matches_legacy() { + let ws = TempDir::new().expect("tempdir"); + let stem = "1719_orchestrator"; + let jsonl_path = ws.path().join("session_raw").join(format!("{stem}.jsonl")); + + let base_messages = vec![ChatMessage::user("hi"), ChatMessage::assistant("done")]; + let meta = meta("t-root"); + let usage = turn_usage(); + + // (1) Legacy authoritative write. (2) Live dual-write into the store. + write_transcript(&jsonl_path, &base_messages, &meta, Some(&usage)).expect("legacy write"); + let mut live_messages = base_messages.clone(); + let last_assistant = live_messages + .iter() + .rposition(|m| m.role == "assistant") + .expect("assistant message present"); + attach_turn_usage_metadata(&mut live_messages[last_assistant], &usage); + let store_transcript = SessionTranscript { + meta: meta.clone(), + messages: live_messages, + }; + write_live_turn(ws.path(), stem, &store_transcript) + .await + .expect("live dual-write"); + + // The legacy reader materializes this SessionTranscript for a resume; the + // shadow reader compares it against the store stream. + let legacy = read_transcript(&jsonl_path).expect("read legacy transcript"); + let outcome = shadow_read_compare(ws.path(), stem, &legacy).await; + assert_eq!( + outcome, + ShadowReadOutcome::Match { + messages: legacy.messages.len() + }, + "round-tripped shadow read must match the legacy render with no divergence" + ); +} + +/// When the store has no stream for the session (dual-write never ran), the +/// shadow read reports [`ShadowReadOutcome::Unavailable`] rather than a spurious +/// divergence, and a content mismatch is reported as +/// [`ShadowReadOutcome::Divergence`] with a compact first-diff index. +#[tokio::test] +async fn shadow_read_unavailable_and_divergence() { + let ws = TempDir::new().expect("tempdir"); + let stem = "1719_orchestrator"; + let meta = meta("t-root"); + + // No store write yet: empty/absent stream against a non-empty legacy + // transcript → Unavailable (no shadow), never a divergence. + let legacy = SessionTranscript { + meta: meta.clone(), + messages: vec![ChatMessage::user("hi"), ChatMessage::assistant("done")], + }; + assert_eq!( + shadow_read_compare(ws.path(), stem, &legacy).await, + ShadowReadOutcome::Unavailable, + "absent store stream must be reported as no shadow available" + ); + + // Now mirror the two-message transcript, then compare against a legacy + // transcript that has an extra trailing message: divergence at index 2. + write_live_turn(ws.path(), stem, &legacy) + .await + .expect("live dual-write"); + let diverging = SessionTranscript { + meta, + messages: vec![ + ChatMessage::user("hi"), + ChatMessage::assistant("done"), + ChatMessage::user("more"), + ], + }; + assert_eq!( + shadow_read_compare(ws.path(), stem, &diverging).await, + ShadowReadOutcome::Divergence { + legacy: 3, + shadow: 2, + first_diff: Some(2), + }, + "a trailing legacy-only message must be reported as a compact divergence" + ); +} + +/// The shadow read is driven by the `AgentConfig::session_shadow_reads` config +/// flag (default **OFF**) with the `OPENHUMAN_SESSION_SHADOW_READS` env var as a +/// pure kill switch (can only force OFF, never ON). This exercises the decision +/// matrix directly — the gate `maybe_shadow_read_session_store` early-returns +/// (never invoking the reader) whenever this returns `false`. Env mutation is +/// process-global, so all assertions live in one serial test and the var is +/// restored on exit. +#[test] +fn shadow_read_flag_and_env_kill_switch() { + const ENV: &str = "OPENHUMAN_SESSION_SHADOW_READS"; + let prior = std::env::var(ENV).ok(); + + // Config OFF (the default) disables regardless of env — reader not invoked. + std::env::remove_var(ENV); + assert!( + !shadow_reads_enabled(false), + "config off (default) disables the shadow read" + ); + + // Config ON enables when the env is unset. + assert!( + shadow_reads_enabled(true), + "config on + no env enables the shadow read" + ); + + // A falsey env value is the kill switch: forces OFF even with config ON. + for killed in ["0", "false", "no", "off", "disable", "disabled", "OFF"] { + std::env::set_var(ENV, killed); + assert!( + !shadow_reads_enabled(true), + "kill switch value {killed:?} must force off even with flag on" + ); + } + + // A non-falsey env value does not force on: config still governs, and it + // can never turn a default-off flag on. + std::env::set_var(ENV, "1"); + assert!( + shadow_reads_enabled(true), + "non-falsey env leaves config ON on" + ); + assert!( + !shadow_reads_enabled(false), + "non-falsey env cannot force a default-off flag on" + ); + + match prior { + Some(v) => std::env::set_var(ENV, v), + None => std::env::remove_var(ENV), + } +} diff --git a/src/openhuman/session_import/mod.rs b/src/openhuman/session_import/mod.rs index 4a295036ea..94a693abca 100644 --- a/src/openhuman/session_import/mod.rs +++ b/src/openhuman/session_import/mod.rs @@ -21,5 +21,7 @@ pub use schemas::{ }; pub use types::{ImportOptions, ImportSummary}; +#[cfg(test)] +mod live_tests; #[cfg(test)] mod ops_tests; diff --git a/src/openhuman/thread_goals/crate_adapter.rs b/src/openhuman/thread_goals/crate_adapter.rs new file mode 100644 index 0000000000..66cbbe6fcb --- /dev/null +++ b/src/openhuman/thread_goals/crate_adapter.rs @@ -0,0 +1,510 @@ +//! Adapter seam: mirror OpenHuman `thread_goals` onto the tinyagents +//! `graph::goals` crate store (issue #4249, plan §C2). +//! +//! **Adapter-first, dual-write.** The legacy per-thread file-JSON store +//! ([`super::store`]) stays **authoritative for reads**; this module *also* +//! mirrors every goal mutation into the crate's `graph::goals` store so a later +//! slice can flip reads over to the crate with zero data migration. The mirror +//! is a **faithful copy**: the crate row carries the *same* `goal_id`, +//! timestamps, and counters as the legacy row (we `put` the converted value +//! directly rather than calling the crate's `store::set`, which would re-mint a +//! `goal-` id and reset counters). +//! +//! Persistence target: the crate [`Store`] rooted at the same workspace KV tree +//! as the 04-sessions journal (`{workspace}/tinyagents_store/kv`), namespace +//! [`GOALS_NAMESPACE`] (`graph.goals`), keyed by `hex(thread_id)` — byte-for-byte +//! the key the crate's own `graph::goals::store` computes, so the crate reader +//! finds exactly what we wrote. +//! +//! # Single-writer constraint +//! +//! The crate `Store` has **no compare-and-set and no cross-key transaction** +//! (see the crate `graph::goals::store` docs). Its per-thread atomicity is a +//! *process-local* async mutex, and the legacy store uses a process-wide mutex. +//! Neither is safe across processes. This is acceptable here because **the +//! OpenHuman core is the single writer** of thread goals — RPC handlers, agent +//! tools, and the heartbeat continuation runtime all run inside one core +//! process. Do not add a second mutating writer (a sidecar, a second core, a +//! cron in another process) without introducing a real CAS first. +//! +//! # Shadow mode +//! +//! The tool/host surface mirror is gated OFF by default behind +//! [`crate_goals_shadow_enabled`] (`OPENHUMAN_THREAD_GOALS_CRATE_SHADOW`). When +//! ON it acts on the legacy result and merely *logs* any crate-vs-legacy +//! divergence — it never changes what a caller observes. + +use std::path::Path; +use std::sync::Arc; + +use tinyagents::graph::goals::store::GOALS_NAMESPACE; +use tinyagents::graph::goals::{ThreadGoal as CrateThreadGoal, ThreadGoalStatus as CrateStatus}; +use tinyagents::harness::store::Store; + +use super::types::{ThreadGoal, ThreadGoalStatus}; +use crate::openhuman::session_import::ops::open_session_stores; + +/// Env flag gating the crate-goals **shadow** mirror on the tool/host surface. +/// Defaults **OFF**; any of `1`/`true`/`yes`/`on` (case-insensitive) enables it. +const SHADOW_ENV: &str = "OPENHUMAN_THREAD_GOALS_CRATE_SHADOW"; + +/// Whether the crate-goals shadow mirror is enabled (defaults OFF). +/// +/// Shadow mode mirrors legacy mutations into the crate store and logs any +/// divergence; it never changes the caller-observed (legacy) result. +pub fn crate_goals_shadow_enabled() -> bool { + std::env::var(SHADOW_ENV) + .map(|v| { + let v = v.trim().to_ascii_lowercase(); + matches!(v.as_str(), "1" | "true" | "yes" | "on") + }) + .unwrap_or(false) +} + +/// Open the crate [`Store`] handle used for the goals mirror, rooted at the +/// shared workspace KV tree (`{workspace}/tinyagents_store/kv`). Same layout the +/// 04-sessions journal + status store use, so everything lives under one tree. +pub(crate) fn crate_goals_store(workspace_dir: &Path) -> Arc { + Arc::new(open_session_stores(workspace_dir).kv) +} + +/// The crate store key for a thread's goal: lowercase hex of the (trimmed) +/// thread-id bytes. This MUST match the crate's private `graph::goals::store` +/// key function exactly so the crate reader resolves our mirrored value. +fn goal_key(thread_id: &str) -> String { + thread_id + .trim() + .as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// Map a legacy [`ThreadGoalStatus`] onto the crate [`CrateStatus`]. The two +/// enums are 1:1 (Active/Paused/BudgetLimited/Complete) — this is the mapping +/// the parity tests pin. +pub(crate) fn to_crate_status(status: ThreadGoalStatus) -> CrateStatus { + match status { + ThreadGoalStatus::Active => CrateStatus::Active, + ThreadGoalStatus::Paused => CrateStatus::Paused, + ThreadGoalStatus::BudgetLimited => CrateStatus::BudgetLimited, + ThreadGoalStatus::Complete => CrateStatus::Complete, + } +} + +/// Map a crate [`CrateStatus`] back onto the legacy [`ThreadGoalStatus`] (the +/// inverse of [`to_crate_status`]). +pub(crate) fn from_crate_status(status: CrateStatus) -> ThreadGoalStatus { + match status { + CrateStatus::Active => ThreadGoalStatus::Active, + CrateStatus::Paused => ThreadGoalStatus::Paused, + CrateStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + CrateStatus::Complete => ThreadGoalStatus::Complete, + } +} + +/// Convert a legacy [`ThreadGoal`] into the crate [`CrateThreadGoal`], +/// preserving every field verbatim (id, objective, status, budget/usage +/// counters, timestamps, continuation flag). A **faithful** projection — no +/// re-minting, no counter reset. +pub(crate) fn to_crate_goal(goal: &ThreadGoal) -> CrateThreadGoal { + CrateThreadGoal { + thread_id: goal.thread_id.clone(), + goal_id: goal.goal_id.clone(), + objective: goal.objective.clone(), + status: to_crate_status(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at_ms: goal.created_at_ms, + updated_at_ms: goal.updated_at_ms, + continuation_suppressed: goal.continuation_suppressed, + } +} + +/// Convert a crate [`CrateThreadGoal`] back into a legacy [`ThreadGoal`] (the +/// inverse of [`to_crate_goal`]), used by the shadow-divergence comparison. +pub(crate) fn from_crate_goal(goal: &CrateThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.clone(), + goal_id: goal.goal_id.clone(), + objective: goal.objective.clone(), + status: from_crate_status(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at_ms: goal.created_at_ms, + updated_at_ms: goal.updated_at_ms, + continuation_suppressed: goal.continuation_suppressed, + } +} + +/// Write the faithful crate mirror of `goal` into `store` (ns `graph.goals`, +/// key `hex(thread_id)`). Overwrites any prior mirror; idempotent for an +/// unchanged value. +pub(crate) async fn put_mirror(store: &Arc, goal: &ThreadGoal) -> Result<(), String> { + let crate_goal = to_crate_goal(goal); + let value = + serde_json::to_value(&crate_goal).map_err(|e| format!("serialize crate goal: {e}"))?; + store + .put(GOALS_NAMESPACE, &goal_key(&goal.thread_id), value) + .await + .map_err(|e| format!("mirror thread goal into {GOALS_NAMESPACE}: {e}")) +} + +/// Read the current crate mirror for `thread_id`, or `None`. Skips a mirror that +/// fails to decode (treated as absent) so a legacy/corrupt row can't wedge the +/// shadow path. +pub(crate) async fn get_mirror( + store: &Arc, + thread_id: &str, +) -> Result, String> { + let value = store + .get(GOALS_NAMESPACE, &goal_key(thread_id)) + .await + .map_err(|e| format!("read crate goal mirror: {e}"))?; + match value { + Some(v) => match serde_json::from_value::(v) { + Ok(crate_goal) => Ok(Some(from_crate_goal(&crate_goal))), + Err(e) => { + tracing::debug!( + thread_id = %thread_id, + error = %e, + "[thread_goals][crate-shadow] undecodable crate mirror; treating as absent" + ); + Ok(None) + } + }, + None => Ok(None), + } +} + +/// Delete the crate mirror for `thread_id`. No-op when absent (matches the +/// crate/legacy clear contract). +pub(crate) async fn delete_mirror(store: &Arc, thread_id: &str) -> Result<(), String> { + store + .delete(GOALS_NAMESPACE, &goal_key(thread_id)) + .await + .map_err(|e| format!("delete crate goal mirror: {e}")) +} + +// ── Shadow-mode surface (flag-gated; acts on legacy, logs divergence) ───────── + +/// Shadow-mirror a legacy mutation result into the crate store, logging any +/// crate-vs-legacy divergence. No-op (and no store I/O) when the shadow flag is +/// OFF. Best-effort: a mirror error is logged, never propagated — the shadow +/// path must never change caller-observed behavior. +pub async fn shadow_mirror_goal(workspace_dir: &Path, legacy_goal: &ThreadGoal) { + if !crate_goals_shadow_enabled() { + return; + } + let store = crate_goals_store(workspace_dir); + // Log divergence against the pre-write crate state (status/counter drift). + match get_mirror(&store, &legacy_goal.thread_id).await { + Ok(Some(prior)) if prior != *legacy_goal => { + tracing::debug!( + thread_id = %legacy_goal.thread_id, + goal_id = %legacy_goal.goal_id, + crate_status = prior.status.as_str(), + legacy_status = legacy_goal.status.as_str(), + crate_tokens = prior.tokens_used, + legacy_tokens = legacy_goal.tokens_used, + "[thread_goals][crate-shadow] mirror diverges from prior crate row; overwriting with legacy" + ); + } + Ok(_) => {} + Err(e) => { + tracing::debug!(error = %e, "[thread_goals][crate-shadow] prior-read failed"); + } + } + if let Err(e) = put_mirror(&store, legacy_goal).await { + tracing::debug!( + thread_id = %legacy_goal.thread_id, + error = %e, + "[thread_goals][crate-shadow] mirror write failed (ignored)" + ); + } else { + tracing::debug!( + thread_id = %legacy_goal.thread_id, + goal_id = %legacy_goal.goal_id, + status = legacy_goal.status.as_str(), + "[thread_goals][crate-shadow] mirrored goal into graph.goals" + ); + } +} + +/// Shadow-mirror a legacy clear into the crate store. No-op when the shadow flag +/// is OFF. Best-effort (errors logged, never propagated). +pub async fn shadow_mirror_clear(workspace_dir: &Path, thread_id: &str) { + if !crate_goals_shadow_enabled() { + return; + } + let store = crate_goals_store(workspace_dir); + if let Err(e) = delete_mirror(&store, thread_id).await { + tracing::debug!( + thread_id = %thread_id, + error = %e, + "[thread_goals][crate-shadow] mirror clear failed (ignored)" + ); + } else { + tracing::debug!( + thread_id = %thread_id, + "[thread_goals][crate-shadow] cleared goal mirror in graph.goals" + ); + } +} + +// ── One-time migration helper (callable, logged, NOT wired to boot) ─────────── + +/// Outcome of a [`migrate_legacy_goals_into_crate_store`] run. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct GoalMigrationReport { + /// Legacy goal rows examined. + pub total: usize, + /// Rows written into the crate store (absent or divergent mirror). + pub copied: usize, + /// Rows already present in the crate store with an identical value. + pub skipped: usize, +} + +/// Copy every existing legacy thread-goal row into the crate `graph.goals` +/// store. **Idempotent**: a row whose crate mirror already equals the legacy +/// value is skipped, so re-running does no writes and reports everything under +/// `skipped`. +/// +/// Callable + logged; deliberately **not wired into boot** in this slice (a +/// later slice schedules it behind a one-shot marker, mirroring the +/// session-import global marker). Honors the single-writer constraint: run it +/// only inside the core process. +pub async fn migrate_legacy_goals_into_crate_store( + workspace_dir: &Path, +) -> Result { + let legacy = super::store::list_all(workspace_dir).await?; + let store = crate_goals_store(workspace_dir); + let mut report = GoalMigrationReport { + total: legacy.len(), + ..Default::default() + }; + tracing::info!( + workspace = %workspace_dir.display(), + total = report.total, + "[thread_goals][crate-migrate] start copy legacy goals → graph.goals" + ); + for goal in &legacy { + match get_mirror(&store, &goal.thread_id).await { + Ok(Some(existing)) if existing == *goal => { + report.skipped += 1; + tracing::debug!( + thread_id = %goal.thread_id, + goal_id = %goal.goal_id, + "[thread_goals][crate-migrate] skip (already mirrored)" + ); + continue; + } + Ok(_) => {} + Err(e) => { + // Read failure → attempt the write anyway (fail-forward copy). + tracing::debug!( + thread_id = %goal.thread_id, + error = %e, + "[thread_goals][crate-migrate] mirror pre-read failed; copying anyway" + ); + } + } + put_mirror(&store, goal).await?; + report.copied += 1; + tracing::debug!( + thread_id = %goal.thread_id, + goal_id = %goal.goal_id, + "[thread_goals][crate-migrate] copied" + ); + } + tracing::info!( + total = report.total, + copied = report.copied, + skipped = report.skipped, + "[thread_goals][crate-migrate] done" + ); + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::thread_goals::store as legacy_store; + + fn sample_goal(status: ThreadGoalStatus) -> ThreadGoal { + ThreadGoal { + thread_id: "thread-α".into(), + goal_id: "goal-uuid-1".into(), + objective: "ship the migration".into(), + status, + token_budget: Some(5_000), + tokens_used: 1_234, + time_used_seconds: 42, + created_at_ms: 1_000, + updated_at_ms: 2_000, + continuation_suppressed: true, + } + } + + #[test] + fn status_mapping_is_bijective_across_all_variants() { + for status in [ + ThreadGoalStatus::Active, + ThreadGoalStatus::Paused, + ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete, + ] { + let round = from_crate_status(to_crate_status(status)); + assert_eq!(round, status, "status round-trip must be identity"); + } + // Pin the exact crate labels the mapping produces. + assert_eq!(to_crate_status(ThreadGoalStatus::Active).as_str(), "active"); + assert_eq!(to_crate_status(ThreadGoalStatus::Paused).as_str(), "paused"); + assert_eq!( + to_crate_status(ThreadGoalStatus::BudgetLimited).as_str(), + "budget_limited" + ); + assert_eq!( + to_crate_status(ThreadGoalStatus::Complete).as_str(), + "complete" + ); + } + + #[test] + fn goal_mapping_preserves_every_field_and_completion_contract() { + // Completion contract: Complete + continuation_suppressed carries through. + let g = sample_goal(ThreadGoalStatus::Complete); + let crate_goal = to_crate_goal(&g); + assert_eq!(crate_goal.thread_id, g.thread_id); + assert_eq!( + crate_goal.goal_id, g.goal_id, + "goal_id preserved (no re-mint)" + ); + assert_eq!(crate_goal.objective, g.objective); + assert_eq!(crate_goal.status, CrateStatus::Complete); + assert_eq!(crate_goal.token_budget, g.token_budget, "budget preserved"); + assert_eq!(crate_goal.tokens_used, g.tokens_used, "usage preserved"); + assert_eq!(crate_goal.time_used_seconds, g.time_used_seconds); + assert_eq!(crate_goal.created_at_ms, g.created_at_ms); + assert_eq!(crate_goal.updated_at_ms, g.updated_at_ms); + assert!( + crate_goal.continuation_suppressed, + "completion suppresses continuation" + ); + // Full round-trip identity. + assert_eq!(from_crate_goal(&crate_goal), g); + } + + #[test] + fn budget_limited_maps_and_over_budget_carries() { + let mut g = sample_goal(ThreadGoalStatus::BudgetLimited); + g.tokens_used = 6_000; // over the 5_000 budget + let crate_goal = to_crate_goal(&g); + assert_eq!(crate_goal.status, CrateStatus::BudgetLimited); + assert!(crate_goal.over_budget(), "over-budget invariant carries"); + assert_eq!(crate_goal.budget_remaining(), Some(0)); + } + + #[tokio::test] + async fn put_get_delete_mirror_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let store = crate_goals_store(tmp.path()); + let g = sample_goal(ThreadGoalStatus::Active); + + assert!(get_mirror(&store, &g.thread_id).await.unwrap().is_none()); + put_mirror(&store, &g).await.unwrap(); + let read = get_mirror(&store, &g.thread_id).await.unwrap().unwrap(); + assert_eq!(read, g, "mirror round-trips the exact legacy value"); + + delete_mirror(&store, &g.thread_id).await.unwrap(); + assert!(get_mirror(&store, &g.thread_id).await.unwrap().is_none()); + // Delete is idempotent (no-op when absent). + delete_mirror(&store, &g.thread_id).await.unwrap(); + } + + #[tokio::test] + async fn crate_reader_resolves_the_mirrored_key() { + // Proves the key/namespace we write matches what the crate's own + // `graph::goals::store` reader computes — the whole point of the mirror. + let tmp = tempfile::tempdir().unwrap(); + let store = crate_goals_store(tmp.path()); + let g = sample_goal(ThreadGoalStatus::Paused); + put_mirror(&store, &g).await.unwrap(); + + let via_crate = tinyagents::graph::goals::store::get(&store, &g.thread_id) + .await + .unwrap() + .expect("crate reader finds the mirrored row"); + assert_eq!(via_crate.goal_id, g.goal_id); + assert_eq!(via_crate.status, CrateStatus::Paused); + assert_eq!(via_crate.tokens_used, g.tokens_used); + } + + #[tokio::test] + async fn migration_copies_then_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + // Seed two legacy goals via the authoritative legacy store. + legacy_store::set(dir, "t1", "objective one", Some(1_000)) + .await + .unwrap(); + let g2 = legacy_store::set(dir, "t2", "objective two", None) + .await + .unwrap(); + legacy_store::account_usage(dir, "t2", &g2.goal_id, 50, 3) + .await + .unwrap(); + + // First run copies both. + let r1 = migrate_legacy_goals_into_crate_store(dir).await.unwrap(); + assert_eq!(r1.total, 2); + assert_eq!(r1.copied, 2); + assert_eq!(r1.skipped, 0); + + // Crate rows now match legacy rows exactly. + let store = crate_goals_store(dir); + let m2 = get_mirror(&store, "t2").await.unwrap().unwrap(); + assert_eq!(m2.tokens_used, 50); + assert_eq!(m2.objective, "objective two"); + + // Second run is a no-op (idempotent) — everything already mirrored. + let r2 = migrate_legacy_goals_into_crate_store(dir).await.unwrap(); + assert_eq!(r2.total, 2); + assert_eq!(r2.copied, 0, "idempotent: nothing re-copied"); + assert_eq!(r2.skipped, 2); + } + + #[tokio::test] + async fn migration_recopies_a_diverged_row() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let g = legacy_store::set(dir, "t", "obj", None).await.unwrap(); + migrate_legacy_goals_into_crate_store(dir).await.unwrap(); + + // Legacy advances (usage accounted) → crate mirror is now stale. + legacy_store::account_usage(dir, "t", &g.goal_id, 99, 1) + .await + .unwrap(); + + let r = migrate_legacy_goals_into_crate_store(dir).await.unwrap(); + assert_eq!(r.copied, 1, "diverged row re-copied"); + assert_eq!(r.skipped, 0); + let store = crate_goals_store(dir); + assert_eq!( + get_mirror(&store, "t").await.unwrap().unwrap().tokens_used, + 99 + ); + } + + #[test] + fn shadow_flag_defaults_off() { + // Not asserting env mutation (process-global); just the default parse. + // An unset/empty value must read as OFF. + assert!(!matches!( + "".trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + )); + } +} diff --git a/src/openhuman/thread_goals/mod.rs b/src/openhuman/thread_goals/mod.rs index 5ec66b92f6..15b1cf335f 100644 --- a/src/openhuman/thread_goals/mod.rs +++ b/src/openhuman/thread_goals/mod.rs @@ -21,6 +21,7 @@ //! [`store::set_if_absent`]). pub mod continuation; +pub mod crate_adapter; pub mod ops; pub mod runtime; mod schemas; diff --git a/src/openhuman/thread_goals/ops.rs b/src/openhuman/thread_goals/ops.rs index 9a2b0bba77..9784d76d5a 100644 --- a/src/openhuman/thread_goals/ops.rs +++ b/src/openhuman/thread_goals/ops.rs @@ -57,6 +57,7 @@ pub async fn set( log::debug!("[thread_goals] rpc=set thread_id={thread_id}"); let goal = store::set(workspace_dir, thread_id, objective, token_budget).await?; emit_updated(&goal); + super::crate_adapter::shadow_mirror_goal(workspace_dir, &goal).await; Ok(RpcOutcome::single_log( GoalEnvelope { goal: Some(goal.clone()), @@ -77,6 +78,7 @@ pub async fn complete( log::debug!("[thread_goals] rpc=complete thread_id={thread_id}"); let goal = store::complete(workspace_dir, thread_id).await?; emit_updated(&goal); + super::crate_adapter::shadow_mirror_goal(workspace_dir, &goal).await; Ok(RpcOutcome::single_log( GoalEnvelope { goal: Some(goal.clone()), @@ -93,6 +95,7 @@ pub async fn pause( log::debug!("[thread_goals] rpc=pause thread_id={thread_id}"); let goal = store::pause(workspace_dir, thread_id).await?; emit_updated(&goal); + super::crate_adapter::shadow_mirror_goal(workspace_dir, &goal).await; Ok(RpcOutcome::single_log( GoalEnvelope { goal: Some(goal.clone()), @@ -109,6 +112,7 @@ pub async fn resume( log::debug!("[thread_goals] rpc=resume thread_id={thread_id}"); let goal = store::resume(workspace_dir, thread_id).await?; emit_updated(&goal); + super::crate_adapter::shadow_mirror_goal(workspace_dir, &goal).await; Ok(RpcOutcome::single_log( GoalEnvelope { goal: Some(goal.clone()), @@ -129,6 +133,8 @@ pub async fn clear( thread_id: thread_id.to_string(), }); } + // Shadow: mirror the clear into the crate graph.goals store (flag-gated OFF). + super::crate_adapter::shadow_mirror_clear(workspace_dir, thread_id).await; Ok(RpcOutcome::single_log( ClearResult { removed }, format!("cleared thread goal (removed={removed})"), diff --git a/src/openhuman/thread_goals/tools.rs b/src/openhuman/thread_goals/tools.rs index 73fc4149c1..a1ad843d8f 100644 --- a/src/openhuman/thread_goals/tools.rs +++ b/src/openhuman/thread_goals/tools.rs @@ -156,6 +156,9 @@ impl Tool for GoalSetTool { status: goal.status.as_str().to_string(), }, ); + // Shadow: mirror into the crate graph.goals store (flag-gated OFF; + // acts on legacy, logs divergence). Best-effort, never fatal. + super::crate_adapter::shadow_mirror_goal(&self.workspace_dir, &goal).await; Ok(ToolResult::success(format!( "Goal set.\n{}", render_goal(&goal) @@ -212,6 +215,8 @@ impl Tool for GoalCompleteTool { status: goal.status.as_str().to_string(), }, ); + // Shadow: mirror into the crate graph.goals store (flag-gated OFF). + super::crate_adapter::shadow_mirror_goal(&self.workspace_dir, &goal).await; Ok(ToolResult::success(format!( "Goal marked complete.\n{}", render_goal(&goal) diff --git a/src/openhuman/tinyagents/delegation.rs b/src/openhuman/tinyagents/delegation.rs index a3feefb85d..9654e93278 100644 --- a/src/openhuman/tinyagents/delegation.rs +++ b/src/openhuman/tinyagents/delegation.rs @@ -39,6 +39,7 @@ use tinyagents::graph::ClosureStateReducer; use tinyagents::graph::{ Command, CompiledGraph, GraphBuilder, Interrupt, NodeContext, NodeResult, END, }; +use tinyagents::harness::retry::RetryPolicy; use tinyagents::CancellationToken; /// Which stage a delegation node is asking the injected worker to run. @@ -605,7 +606,15 @@ where max_visits_per_node: Some(max_revisions + 2), max_total_steps: (max_revisions + 1) * 4 + 8, ..RecursionPolicy::default() - }); + }) + // Adapter-first landing of the crate-native per-node RetryPolicy + // (tinyagents 1.5.0 `CompiledGraph::with_node_retry`). Conservative: + // `max_attempts(1)` preserves today's single-attempt semantics exactly + // (no bespoke retry glue existed here) and backoff sleeping stays off + // (the default), so a transient node-handler failure surfaces as it does + // today. This wires the seam so raising the attempt cap / enabling + // backoff is a one-line, gated follow-up rather than a rewrite. + .with_node_retry(RetryPolicy::default().with_max_attempts(1)); Ok(graph) } diff --git a/src/openhuman/tinyagents/journal.rs b/src/openhuman/tinyagents/journal.rs index 41def43ee9..c7b1552ec7 100644 --- a/src/openhuman/tinyagents/journal.rs +++ b/src/openhuman/tinyagents/journal.rs @@ -25,14 +25,25 @@ //! sinks here) and its records pass through a [`RedactingSink`] so process //! credentials are masked before anything is persisted. //! +//! ## Stable event ids (05.1) +//! +//! The run [`EventSink`] is seeded by the caller with +//! [`EventSink::with_stream_id`]`(run_id)` (see [`mint_run_id`]), so every +//! persisted observation carries a restart-stable `event_id` of the form +//! `{run_id}-evt-{offset}`. That is the id a late-attaching replay reader +//! reconstructs the timeline from — the same `(stream_id, offset)` always mints +//! the same id, and two runs never collide even if both restart their offset +//! counter at zero. +//! //! ## Follow-ups (not in this slice) //! //! - A replay RPC (`agent.run_events`?) that surfaces [`read_run_events`] / //! [`read_run_status`] to the desktop for mid-run reconnect (05.x). -//! - Sub-agent / graph run lineage (`parent_run_id` / `root_run_id` threading) -//! and per-thread status (`thread_id`) — wired in 05.2/05.3. -//! - Seeding the run [`EventSink`] with `with_stream_id(run_id)` for -//! restart-stable event ids. +//! - Full sub-agent / graph run lineage (`parent_run_id` / `root_run_id` +//! threading) — wired in 05.2/05.3. This slice threads `thread_id` (from the +//! sub-agent task scope) so [`FileStatusStore::list_by_thread`] answers. +//! +//! [`EventSink::with_stream_id`]: tinyagents::harness::events::EventSink::with_stream_id use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -41,7 +52,7 @@ use async_trait::async_trait; use tinyagents::error::Result as TaResult; use tinyagents::harness::events::{EventSink, HarnessRunStatus}; -use tinyagents::harness::ids::{ComponentId, HarnessPhase, RunId}; +use tinyagents::harness::ids::{ComponentId, HarnessPhase, RunId, ThreadId}; use tinyagents::harness::observability::{ AgentObservation, FanOutSink, HarnessEventJournal, HarnessStatusStore, JournalSink, RedactingSink, StoreEventJournal, @@ -55,10 +66,18 @@ use crate::openhuman::session_import::ops::open_session_stores; /// it round-trips the crate [`FileStore`] name sanitizer. const STATUS_NS: &str = "run_status"; -/// Mints a fresh, slash-free, process-unique run id (`run.<32-hex>`), used both -/// as the journal stream key and the status-store key. The `simple()` uuid form -/// (no hyphens) keeps the id inside the crate store's allowed-character set. -fn new_run_id() -> RunId { +/// Mints a fresh, slash-free, process-unique run id (`run.<32-hex>`), used three +/// ways for one turn: the [`EventSink::with_stream_id`] prefix (so persisted +/// `event_id`s are the restart-stable `{run_id}-evt-{offset}`), the journal +/// stream key, and the status-store key. The `simple()` uuid form (no hyphens) +/// keeps the id inside the crate store's allowed-character set. +/// +/// The caller mints this *before* creating the run [`EventSink`] so the same id +/// seeds the sink stream prefix and the durable journal/status — see +/// [`attach_turn_journal`]. +/// +/// [`EventSink::with_stream_id`]: tinyagents::harness::events::EventSink::with_stream_id +pub(crate) fn mint_run_id() -> RunId { RunId::new(format!("run.{}", uuid::Uuid::new_v4().simple())) } @@ -268,13 +287,26 @@ impl TurnJournal { /// Attach a durable event journal + status writer to `events`, *in addition to* /// the existing (untouched) [`OpenhumanEventBridge`] subscription. /// +/// `run_id` MUST be the same id the caller passed to +/// [`EventSink::with_stream_id`] when it created `events` (mint it once via +/// [`mint_run_id`]). That shared id is what makes the persisted `event_id`s the +/// restart-stable `{run_id}-evt-{offset}` a late-attach replay reconstructs the +/// timeline from. `thread_id` (when known — e.g. the sub-agent task scope) +/// records the run under a thread so [`FileStatusStore::list_by_thread`] answers. +/// /// Returns a [`TurnJournal`] handle the caller uses to stamp the terminal /// status after the run, or `None` when the store could not be opened (the run /// proceeds unaffected — journaling is best-effort). Safe to call for observed /// and unobserved turns alike: it does not depend on `on_progress`. /// /// [`OpenhumanEventBridge`]: crate::openhuman::tinyagents::observability::OpenhumanEventBridge -pub(crate) async fn attach_turn_journal(events: &EventSink, model: &str) -> Option { +/// [`EventSink::with_stream_id`]: tinyagents::harness::events::EventSink::with_stream_id +pub(crate) async fn attach_turn_journal( + events: &EventSink, + model: &str, + run_id: RunId, + thread_id: Option, +) -> Option { let workspace = match resolve_workspace().await { Ok(dir) => dir, Err(err) => { @@ -284,11 +316,12 @@ pub(crate) async fn attach_turn_journal(events: &EventSink, model: &str) -> Opti }; let stores = open_session_stores(&workspace); - let run_id = new_run_id(); // Event journal: crate StoreEventJournal over the 04-sessions JsonlAppendStore // (stream key = run id). Wrapped in a JournalSink (stamps run lineage) and a - // RedactingSink (masks process credentials) before persisting. + // RedactingSink (masks process credentials) before persisting. Because + // `events` was seeded with `with_stream_id(run_id)`, every persisted + // observation's `event_id` is the stable `{run_id}-evt-{offset}`. let journal: Arc = Arc::new(StoreEventJournal::new(stores.journal)); let journal_sink = JournalSink::new(journal, run_id.clone()); let redacting = RedactingSink::new(Arc::new(journal_sink), openhuman_redaction_secrets()); @@ -299,9 +332,13 @@ pub(crate) async fn attach_turn_journal(events: &EventSink, model: &str) -> Opti let fanout = FanOutSink::new().with(Arc::new(redacting)); events.subscribe(Arc::new(fanout)); - // Status store: durable, Store-backed. Seed an initial `running` snapshot. + // Status store: durable, Store-backed. Seed an initial `running` snapshot, + // recording the thread (when known) so list_by_thread answers at run start. let status_store = Arc::new(FileStatusStore::new(stores.kv)); let mut status = HarnessRunStatus::new(run_id.clone(), ComponentId::new(model.to_string())); + if let Some(thread_id) = thread_id { + status = status.with_thread(thread_id); + } status.mark_running(HarnessPhase::Model); if let Err(err) = status_store.put_status(status.clone()).await { log::debug!( @@ -311,8 +348,9 @@ pub(crate) async fn attach_turn_journal(events: &EventSink, model: &str) -> Opti } log::debug!( - "[journal] attached durable event journal run_id={} model={model}", - run_id.as_str() + "[journal] attached durable event journal run_id={} thread={:?} model={model}", + run_id.as_str(), + status.thread_id.as_ref().map(|t| t.as_str()) ); Some(TurnJournal { run_id, @@ -372,12 +410,15 @@ mod tests { async fn journal_persists_and_replays_run() { let tmp = std::env::temp_dir().join(format!("oh-journal-test-{}", uuid::Uuid::new_v4())); let stores = open_session_stores(&tmp); - let run_id = new_run_id(); + let run_id = mint_run_id(); // Attach a journal sink directly (bypassing config resolution) and emit. + // Seed the sink with the run id so persisted `event_id`s are the + // restart-stable `{run_id}-evt-{offset}` — mirrors the caller in + // `run_turn_via_tinyagents_shared`. let journal: Arc = Arc::new(StoreEventJournal::new(stores.journal)); - let sink = EventSink::new(); + let sink = EventSink::with_stream_id(run_id.as_str()); let journal_sink = JournalSink::new(journal, run_id.clone()); let redacting = RedactingSink::new(Arc::new(journal_sink), vec!["sk-super-secret".into()]); sink.subscribe(Arc::new(FanOutSink::new().with(Arc::new(redacting)))); @@ -394,6 +435,16 @@ mod tests { // Reconstruct from the durable store alone. let replayed = read_run_events_at(&tmp, run_id.as_str(), 0).await; assert_eq!(replayed.len(), 2); + // Records come back fully ordered with restart-stable ids of the form + // `{run_id}-evt-{offset}`. + for (offset, obs) in replayed.iter().enumerate() { + assert_eq!(obs.offset, offset as u64, "offset should be monotonic"); + assert_eq!( + obs.event_id.as_str(), + format!("{}-evt-{offset}", run_id.as_str()), + "event id should be the stable {{stream_id}}-evt-{{offset}}" + ); + } // The seeded secret was masked before persistence. if let AgentEvent::ModelStarted { model, .. } = &replayed[0].event { assert!( @@ -405,15 +456,42 @@ mod tests { panic!("expected ModelStarted first"); } - // Status store round-trips a running → completed transition + list_by_root. + // Late attach at a non-zero offset: a reader that reconnects after the + // first event reconstructs only the tail (offset >= 1), still ordered and + // still with stable ids — the mid-run reconnect/backfill path. + let tail = read_run_events_at(&tmp, run_id.as_str(), 1).await; + assert_eq!(tail.len(), 1); + assert_eq!(tail[0].offset, 1); + assert_eq!( + tail[0].event_id.as_str(), + format!("{}-evt-1", run_id.as_str()) + ); + assert!(matches!(tail[0].event, AgentEvent::ToolStarted { .. })); + + // Status store round-trips a running → completed transition and answers + // list_active / list_by_root / list_by_thread. let status_store = FileStatusStore::new(open_session_stores(&tmp).kv); let mut status = - HarnessRunStatus::new(run_id.clone(), ComponentId::new("mock-model".to_string())); + HarnessRunStatus::new(run_id.clone(), ComponentId::new("mock-model".to_string())) + .with_thread(ThreadId::new("thread-42")); status.mark_running(HarnessPhase::Model); status_store.put_status(status.clone()).await.unwrap(); let active = status_store.list_active().await.unwrap(); assert_eq!(active.len(), 1); assert_eq!(active[0].status, ExecutionStatus::Running); + assert_eq!( + status_store + .list_by_thread("thread-42") + .await + .unwrap() + .len(), + 1 + ); + assert!(status_store + .list_by_thread("nope") + .await + .unwrap() + .is_empty()); status.mark_completed(); status_store.put_status(status).await.unwrap(); diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index b0a0537067..bdbba8ed22 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -7,11 +7,12 @@ //! [`Middleware`] hooks restores the behaviour and makes the graph the single //! place cross-cutting context concerns live: //! -//! - [`CacheAlignMiddleware`] (`before_model`) — warn on volatile tokens in the -//! system prompt that would bust the provider KV-cache prefix. Warn-only. //! - [`MicrocompactMiddleware`] (`before_model`) — clear the bodies of older //! tool-result messages (keeping the N most recent) so a long tool-heavy -//! thread stays cheap without dropping chat history. +//! thread stays cheap without dropping chat history. This is now the crate +//! [`tinyagents::harness::middleware::MicrocompactMiddleware`], constructed +//! with OpenHuman's [`CLEARED_PLACEHOLDER`] wording; the in-house copy was +//! upstreamed (see `99-deletion-ledger.md`). //! - [`ToolOutputMiddleware`] (`after_tool`) — apply the per-tool-result byte //! cap and (optionally) the semantic payload summarizer to each tool result //! as it returns, before it enters the transcript. @@ -20,7 +21,7 @@ //! enabled onto a harness. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use async_trait::async_trait; @@ -30,10 +31,11 @@ use tinyagents::harness::context::RunContext; use tinyagents::harness::events::AgentEvent; use tinyagents::harness::message::{ContentBlock, Message as TaMessage}; use tinyagents::harness::middleware::{ - AgentRun, ContextualToolSelectionMiddleware, Middleware, MiddlewareToolOutcome, - ToolAllowlistMiddleware, ToolHandler, ToolMiddleware, + AgentRun, BudgetTracker, ContextualToolSelectionMiddleware, MicrocompactMiddleware, Middleware, + MiddlewareToolOutcome, ToolAllowlistMiddleware, ToolHandler, ToolMiddleware, }; use tinyagents::harness::model::{ModelRequest, PromptSegment, SegmentRole}; +use tinyagents::harness::no_progress::{NoProgress, NoProgressTracker, ToolAttempt}; use tinyagents::harness::runtime::AgentHarness; use tinyagents::harness::steering::{SteeringCommand, SteeringHandle}; use tinyagents::harness::tool::{ @@ -74,8 +76,6 @@ pub(crate) struct TurnContextMiddleware { pub(crate) tokenjuice_compaction_enabled: bool, /// Agent-level TokenJuice profile for tool-result compaction. pub(crate) tokenjuice_compression: AgentTokenjuiceCompression, - /// Warn on volatile tokens in the system prompt (KV-cache diagnostic). - pub(crate) cache_align: bool, /// Keep-recent count for microcompact tool-body clearing. `0` disables it. pub(crate) microcompact_keep_recent: usize, /// Whether the LLM summarization step (`ContextCompressionMiddleware`) may be @@ -224,8 +224,8 @@ pub(crate) struct SuperContextConfig { impl TurnContextMiddleware { /// A sensible default for turn paths without a session `ContextManager` - /// (channel / sub-agent): cache-align warnings on and the default tool-result - /// byte cap, no summarizer or microcompact. + /// (channel / sub-agent): the default tool-result byte cap, no summarizer or + /// microcompact. pub(crate) fn defaults() -> Self { Self { tool_result_budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES, @@ -233,7 +233,6 @@ impl TurnContextMiddleware { artifact_store: None, tokenjuice_compaction_enabled: false, tokenjuice_compression: AgentTokenjuiceCompression::Off, - cache_align: true, microcompact_keep_recent: 0, autocompact_enabled: true, super_context: None, @@ -246,7 +245,6 @@ impl TurnContextMiddleware { self.tool_result_budget_bytes == 0 && self.payload_summarizer.is_none() && !self.tokenjuice_compaction_enabled - && !self.cache_align && self.microcompact_keep_recent == 0 && self.super_context.is_none() && self.handoff.is_none() @@ -254,10 +252,10 @@ impl TurnContextMiddleware { /// Push the enabled middlewares onto `harness`. /// - /// `before_model` hooks run in registration order, so cache-align (warn) and - /// microcompact (clear tool bodies) are installed **before** the caller's - /// summarization / trim middlewares — microcompact frees cheap tokens first, - /// then summarization/trim handle the rest. + /// `before_model` hooks run in registration order, so microcompact (clear + /// tool bodies) is installed **before** the caller's summarization / trim + /// middlewares — microcompact frees cheap tokens first, then + /// summarization/trim handle the rest. pub(crate) fn install( self, harness: &mut AgentHarness<()>, @@ -272,13 +270,15 @@ impl TurnContextMiddleware { ran: AtomicBool::new(false), })); } - if self.cache_align { - harness.push_middleware(Arc::new(CacheAlignMiddleware)); - } if self.microcompact_keep_recent > 0 { - harness.push_middleware(Arc::new(MicrocompactMiddleware { - keep_recent: self.microcompact_keep_recent, - })); + // Crate middleware (upstreamed from the in-house copy). Constructed + // with OpenHuman's model-facing placeholder so behavior is + // byte-identical to the deleted local version. Events stay off (the + // default) to preserve the prior silent-rewrite behavior. + harness.push_middleware(Arc::new(MicrocompactMiddleware::new( + self.microcompact_keep_recent, + CLEARED_PLACEHOLDER, + ))); } // Handoff runs BEFORE the tool-output budget so an oversized payload is // stashed + replaced with a short placeholder first; the byte cap would @@ -551,163 +551,6 @@ fn parse_context_bundle_has_enough_context(bundle: &str) -> Option { } } -/// `before_model`: flag volatile tokens (UUIDs, timestamps, JWTs, …) in the -/// system prompt that silently break the provider KV-cache prefix. Warn-only — -/// never mutates the request. Replaces the deleted context cache-align reducer. -struct CacheAlignMiddleware; - -/// One detected volatile token in the cache-hot system prompt. -#[derive(Debug, Clone, PartialEq, Eq)] -struct VolatileFinding { - kind: &'static str, - sample: String, -} - -fn detect_volatile_prompt_tokens(system_prompt: &str) -> Vec { - let mut findings = Vec::new(); - for tok in system_prompt - .split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | ':' | '_'))) - { - if tok.len() < 8 { - continue; - } - if is_uuid(tok) { - findings.push(VolatileFinding { - kind: "uuid", - sample: redact_volatile_token(tok), - }); - } else if is_jwt(tok) { - findings.push(VolatileFinding { - kind: "jwt", - sample: redact_volatile_token(tok), - }); - } else if is_iso8601(tok) { - findings.push(VolatileFinding { - kind: "iso8601", - sample: redact_volatile_token(tok), - }); - } else if is_hex_hash(tok) { - findings.push(VolatileFinding { - kind: "hex_hash", - sample: redact_volatile_token(tok), - }); - } - } - findings -} - -fn warn_if_cache_prompt_volatile(system_prompt: &str) -> usize { - let findings = detect_volatile_prompt_tokens(system_prompt); - if !findings.is_empty() { - let mut kinds: Vec<&str> = findings.iter().map(|finding| finding.kind).collect(); - kinds.sort_unstable(); - kinds.dedup(); - let samples = findings - .iter() - .take(5) - .map(|finding| finding.sample.as_str()) - .collect::>() - .join(", "); - ::log::warn!( - "[tinyagents::cache-align] system prompt contains {} volatile token(s) ({}) samples={} -- KV-cache prefix may not hit; keep dynamic content out of the system prompt", - findings.len(), - kinds.join(", "), - samples, - ); - } - findings.len() -} - -fn redact_volatile_token(tok: &str) -> String { - let head: String = tok.chars().take(4).collect(); - format!("{head}...") -} - -fn is_uuid(tok: &str) -> bool { - if tok.len() != 36 { - return false; - } - let bytes = tok.as_bytes(); - for (i, b) in bytes.iter().enumerate() { - let expect_dash = matches!(i, 8 | 13 | 18 | 23); - if expect_dash { - if *b != b'-' { - return false; - } - } else if !b.is_ascii_hexdigit() { - return false; - } - } - true -} - -fn is_jwt(tok: &str) -> bool { - let segs: Vec<&str> = tok.split('.').collect(); - if segs.len() != 3 { - return false; - } - segs.iter().all(|segment| { - segment.len() >= 4 - && segment - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') - }) && tok.starts_with("ey") -} - -fn is_hex_hash(tok: &str) -> bool { - matches!(tok.len(), 32 | 40 | 64) && tok.bytes().all(|b| b.is_ascii_hexdigit()) -} - -fn is_iso8601(tok: &str) -> bool { - let b = tok.as_bytes(); - if tok.len() < 19 { - return false; - } - let digit = |i: usize| b[i].is_ascii_digit(); - digit(0) - && digit(1) - && digit(2) - && digit(3) - && b[4] == b'-' - && digit(5) - && digit(6) - && b[7] == b'-' - && digit(8) - && digit(9) - && (b[10] == b'T' || b[10] == b' ') - && digit(11) - && digit(12) - && b[13] == b':' - && digit(14) - && digit(15) - && b[16] == b':' - && digit(17) - && digit(18) -} - -#[async_trait] -impl Middleware<()> for CacheAlignMiddleware { - fn name(&self) -> &str { - "cache_align" - } - - async fn before_model( - &self, - _ctx: &mut RunContext<()>, - _state: &(), - request: &mut ModelRequest, - ) -> TaResult<()> { - if let Some(sys) = request - .messages - .iter() - .find(|m| matches!(m, TaMessage::System(_))) - { - warn_if_cache_prompt_volatile(&sys.text()); - } - Ok(()) - } -} - /// Seed-free FNV-1a fingerprint (matches the crate's own prompt-layout hash /// approach) so a segment id is stable across process restarts — unlike Rust's /// randomly-seeded `SipHash`. Used to build content-fingerprinted prompt-cache @@ -734,11 +577,12 @@ fn stable_prefix_fingerprint(data: &str) -> String { /// have no prefix to protect. This stamps the segments with **content-fingerprint /// ids**: an unchanged system prompt + tool set yields a stable prefix, while an /// injected timestamp/uuid/etc. changes the fingerprint and the guard records a -/// [`CacheLayoutEvent`](tinyagents::harness::cache::CacheLayoutEvent). The -/// structured successor to [`CacheAlignMiddleware`]'s warn-only volatile-token -/// scan (kept installed in parallel until parity is shown; deletion is a gated -/// follow-up). Read-only w.r.t. the transcript — only sets `cache_segments` / -/// `prompt_fingerprint`. +/// [`CacheLayoutEvent`](tinyagents::harness::cache::CacheLayoutEvent). This is +/// the structured, crate-native replacement for the deleted warn-only +/// `CacheAlignMiddleware` volatile-token scan (C3): the crate +/// `PromptCacheGuardMiddleware` now owns KV-cache-prefix drift detection via +/// recorded `CacheLayoutEvent`s. Read-only w.r.t. the transcript — only sets +/// `cache_segments` / `prompt_fingerprint`. pub(crate) struct PromptCacheSegmentMiddleware; #[async_trait] @@ -802,53 +646,6 @@ impl Middleware<()> for PromptCacheSegmentMiddleware { } } -/// `before_model`: clear the bodies of older tool-result messages, keeping the -/// `keep_recent` most recent verbatim. The graph analogue of -/// `context::microcompact` — bounds a tool-heavy thread's cost without dropping -/// any chat turns. Idempotent: an already-cleared body is left as the -/// placeholder. -struct MicrocompactMiddleware { - keep_recent: usize, -} - -#[async_trait] -impl Middleware<()> for MicrocompactMiddleware { - fn name(&self) -> &str { - "microcompact" - } - - async fn before_model( - &self, - _ctx: &mut RunContext<()>, - _state: &(), - request: &mut ModelRequest, - ) -> TaResult<()> { - let tool_idxs: Vec = request - .messages - .iter() - .enumerate() - .filter(|(_, m)| matches!(m, TaMessage::Tool(_))) - .map(|(i, _)| i) - .collect(); - if tool_idxs.len() <= self.keep_recent { - return Ok(()); - } - let cut = tool_idxs.len() - self.keep_recent; - for &i in &tool_idxs[..cut] { - // Skip messages already reduced to the placeholder; otherwise swap the - // body for it (idempotent, preserves the tool_call_id). - if request.messages[i].text() == CLEARED_PLACEHOLDER { - continue; - } - if let TaMessage::Tool(t) = &request.messages[i] { - let id = t.tool_call_id.clone(); - request.messages[i] = TaMessage::tool(id, CLEARED_PLACEHOLDER); - } - } - Ok(()) - } -} - /// `after_tool`: apply the semantic payload summarizer (when configured) and /// then the hard per-tool-result byte cap to each tool result's model-facing /// content, before it enters the transcript. The graph analogue of the byte cap @@ -1612,14 +1409,54 @@ impl Middleware<()> for MemoryProtocolMiddleware { /// model call spends (issue #4249, Phase 5). Reads the global /// [`CostTracker`](crate::openhuman::cost) and, when cost budgets are configured /// and already exceeded, fails the run before the provider call; a warning -/// threshold logs but proceeds. +/// threshold logs but proceeds. This enforcement path stays **authoritative**. /// /// Self-gating: a no-op unless a global tracker exists and `config.enabled` with /// a limit is set (`check_budget` returns `Allowed` otherwise). Complements the /// post-call `StopHookMiddleware` per-turn USD cap. Projecting the *next* call's /// cost pre-spend (vs the already-exceeded check here) needs an input-token /// estimate — a follow-up. -pub(crate) struct CostBudgetMiddleware; +/// +/// # Shadow role (W2-budget-dedupe) +/// +/// When built with [`with_shadow`](Self::with_shadow), this middleware is ALSO a +/// divergence-logging shadow over the observe-only crate +/// [`BudgetMiddleware`](tinyagents::harness::middleware::BudgetMiddleware). It +/// keeps enforcing exactly as before, but at `after_agent` it compares the +/// crate `BudgetMiddleware`'s shared [`BudgetTracker`] accumulation against the +/// authoritative runtime [`AgentRun::usage`] and logs `[budget_shadow]` parity +/// or divergence (compact numeric summary; no PII). Both accumulate the same +/// per-call `response.usage`, so token totals must match once the crate +/// middleware is on the path — this is the parity signal that must be clean +/// before enforcement can flip to the crate owner (see the flip-criteria comment +/// at the registration site in `tinyagents/mod.rs`). Cost is intentionally NOT +/// compared: the observe-only crate middleware has no pricing table, so its cost +/// stays zero while the local path prices via `cost::catalog` — cost parity is a +/// flip-criteria follow-up. +pub(crate) struct CostBudgetMiddleware { + /// Observe-only crate `BudgetMiddleware`'s shared tracker handle, for the + /// end-of-run `[budget_shadow]` comparison. `None` when the shadow is not + /// installed (isolated unit tests of the enforcement gate). + shadow_tracker: Option, +} + +impl CostBudgetMiddleware { + /// Enforcement-only gate with no shadow comparison (isolated unit tests). + pub(crate) fn new() -> Self { + Self { + shadow_tracker: None, + } + } + + /// Enforcement gate that ALSO compares its per-run token accounting against + /// the observe-only crate `BudgetMiddleware`'s shared `tracker` at end of run + /// and logs `[budget_shadow]` parity/divergence. + pub(crate) fn with_shadow(tracker: BudgetTracker) -> Self { + Self { + shadow_tracker: Some(tracker), + } + } +} #[async_trait] impl Middleware<()> for CostBudgetMiddleware { @@ -1668,41 +1505,95 @@ impl Middleware<()> for CostBudgetMiddleware { _ => Ok(()), } } + + /// Shadow parity check (W2-budget-dedupe). Enforcement already happened per + /// call in `before_model`; here we only observe. Compares the observe-only + /// crate `BudgetMiddleware`'s accumulated token spend against the runtime's + /// authoritative `AgentRun::usage` and logs `[budget_shadow]` divergence. + /// Never fails the run. + async fn after_agent( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + run: &mut AgentRun, + ) -> TaResult<()> { + let Some(tracker) = &self.shadow_tracker else { + return Ok(()); + }; + let crate_usage = tracker.snapshot().usage; // UsageTotals (crate shadow) + let local = run.usage; // UsageTotals (runtime authoritative) + let l = &local.usage; + let c = &crate_usage.usage; + let diverged = l.input_tokens != c.input_tokens + || l.output_tokens != c.output_tokens + || l.cache_read_tokens != c.cache_read_tokens + || l.total_tokens != c.total_tokens + || local.calls != crate_usage.calls; + if diverged { + tracing::warn!( + local_calls = local.calls, + crate_calls = crate_usage.calls, + local_in = l.input_tokens, + crate_in = c.input_tokens, + local_out = l.output_tokens, + crate_out = c.output_tokens, + local_cached = l.cache_read_tokens, + crate_cached = c.cache_read_tokens, + local_total = l.total_tokens, + crate_total = c.total_tokens, + "[budget_shadow] divergence: crate BudgetMiddleware token accounting differs from authoritative AgentRun.usage" + ); + } else { + tracing::debug!( + calls = local.calls, + input = l.input_tokens, + output = l.output_tokens, + cached = l.cache_read_tokens, + total = l.total_tokens, + "[budget_shadow] parity: crate BudgetMiddleware token accounting matches AgentRun.usage" + ); + } + Ok(()) + } } -/// Consecutive **any**-failure no-progress backstop: different commands all -/// failing means the goal is unreachable here. Matches the legacy -/// `NO_PROGRESS_FAILURE_THRESHOLD`. -const NO_PROGRESS_FAILURE_THRESHOLD: usize = 6; -/// Consecutive **identical** hard-policy-rejection repeats before halting — a -/// blocked call re-issued unchanged can never succeed. Legacy -/// `HARD_REJECT_REPEAT_THRESHOLD`. -const HARD_REJECT_REPEAT_THRESHOLD: usize = 2; - -/// `after_tool`: stop the run when tool calls keep failing with no progress -/// (issue #4249). The legacy tool loop's progress guard surfaced a root-cause -/// halt summary — a security/approval denial re-issued unchanged, an identical -/// error retried, or *different* commands all failing — instead of burning the -/// whole iteration budget and ending on a generic cap error. The tinyagents path -/// kept only the model/tool call caps, so this reinstates the guard as a graph -/// middleware. Three halt conditions, checked per failure (any success resets -/// every counter — progress was made): +/// `after_tool`: stop (or nudge) the run when tool calls keep failing with no +/// progress (issue #4249). The legacy tool loop's progress guard surfaced a +/// root-cause halt summary — a security/approval denial re-issued unchanged, an +/// identical error retried, or *different* commands all failing — instead of +/// burning the whole iteration budget and ending on a generic cap error. The +/// tinyagents path kept only the model/tool call caps, so this reinstates the +/// guard as a graph middleware. /// -/// 1. **Hard policy rejection** (`[policy-blocked]`) repeated `HARD_REJECT_REPEAT_THRESHOLD` -/// times with an identical signature — "blocked by the security policy … re-issued". -/// 2. **Identical** error signature repeated `identical_threshold` times — -/// "retried N times with identical arguments". -/// 3. **Any** failure `NO_PROGRESS_FAILURE_THRESHOLD` times in a row (even with -/// varied errors) — "N tool calls in a row failed". +/// As of tinyagents 1.5.0 the escalation ladder itself lives in the crate +/// ([`NoProgressTracker`], extracted upstream from OpenHuman #4389). This +/// middleware is now a **thin driver**: it captures the per-call argument +/// fingerprint (the tool result carries no arguments), feeds each outcome into +/// [`NoProgressTracker::record`], and lowers the returned [`NoProgress`] verdict +/// into OpenHuman steering. It owns only the OpenHuman-side policy: /// -/// On trip it records a root-cause summary into the shared [`HaltSummarySlot`] -/// (the turn overrides its final text with it) and pauses the run via the shared -/// steering handle (same mechanism as the stop-hook / cap pausers). +/// - [`NoProgress::Continue`] — do nothing. +/// - [`NoProgress::Nudge`] — inject the crate's structured "no progress since +/// step X" corrective into the working transcript via +/// [`SteeringCommand::InjectMessage`] so the next model call sees it and +/// changes strategy *before* the same-strategy retry cap trips. (Not +/// `Redirect`: that verb is outside the Interactive steering allowlist and +/// would abort the turn — see the nudge call site.) +/// - [`NoProgress::Halt`] — record the crate's root-cause summary into the shared +/// [`HaltSummarySlot`](super::HaltSummarySlot) (the turn overrides its final +/// text with it) and pause the run via the shared steering handle (same +/// mechanism as the stop-hook / cap pausers), then [`reset`](NoProgressTracker::reset) +/// so a resumed run does not immediately re-pause on the latched state. pub(crate) struct RepeatedToolFailureMiddleware { handle: SteeringHandle, - identical_threshold: usize, halt_summary: super::HaltSummarySlot, - state: std::sync::Mutex, + /// Crate no-progress escalation ladder — the single source of the + /// identical-failure / varied-failure / hard-reject logic (tinyagents 1.5.0). + tracker: NoProgressTracker, + /// Monotonic tool-outcome counter, used only for the crate's "no progress + /// since step X" nudge wording. Not the model-call count, but a stable, + /// increasing marker is all the wording needs. + step: AtomicUsize, /// call_id → argument fingerprint, captured in `before_tool` (the tool result /// carries no arguments). Folded into the identical-repeat signature so the /// "identical arguments" halt only trips on the *same* args — two different @@ -1711,16 +1602,10 @@ pub(crate) struct RepeatedToolFailureMiddleware { arg_sigs: std::sync::Mutex>, } -#[derive(Default)] -struct FailureState { - last_sig: Option, - same_count: usize, - consecutive: usize, -} - impl RepeatedToolFailureMiddleware { /// Build the breaker. `identical_threshold` (the identical-signature retry - /// ceiling) is clamped to at least 2 — a single failure is never a loop. + /// ceiling) is handed straight to [`NoProgressTracker::new`], which clamps it + /// so a nudge always precedes a halt (a single failure is never a loop). pub(crate) fn new( handle: SteeringHandle, identical_threshold: usize, @@ -1728,9 +1613,9 @@ impl RepeatedToolFailureMiddleware { ) -> Self { Self { handle, - identical_threshold: identical_threshold.max(2), halt_summary, - state: std::sync::Mutex::new(FailureState::default()), + tracker: NoProgressTracker::new(identical_threshold), + step: AtomicUsize::new(0), arg_sigs: std::sync::Mutex::new(std::collections::HashMap::new()), } } @@ -1745,13 +1630,6 @@ fn args_fingerprint(arguments: &serde_json::Value) -> String { format!("{:x}", hasher.finish()) } -/// Trim a tool error for inclusion in a halt summary (keep it bounded but retain -/// the deterministic leading detail the model/user needs). -fn truncate_for_halt(text: &str) -> String { - const MAX: usize = 600; - crate::openhuman::util::truncate_with_ellipsis(text, MAX) -} - #[async_trait] impl Middleware<()> for RepeatedToolFailureMiddleware { fn name(&self) -> &str { @@ -1778,90 +1656,75 @@ impl Middleware<()> for RepeatedToolFailureMiddleware { _state: &(), result: &mut TaToolResult, ) -> TaResult<()> { - let mut state = self.state.lock().unwrap(); let arg_fp = self .arg_sigs .lock() .ok() .and_then(|mut sigs| sigs.remove(&result.call_id)) .unwrap_or_default(); - let Some(err) = result.error.as_deref() else { - // Success → progress was made; reset every counter. - *state = FailureState::default(); - return Ok(()); - }; - - // Signature: tool name + argument fingerprint + first error line (the - // deterministic parts; a huge payload tail must not dominate the - // identical-repeat comparison). Including the args means the "identical - // arguments" halt only fires when the args truly repeat. - let err_line = err.lines().next().unwrap_or(err); - let sig = format!("{}\u{1f}{arg_fp}\u{1f}{err_line}", result.name); - state.consecutive += 1; - let same_count = match &state.last_sig { - Some(prev) if *prev == sig => { - state.same_count += 1; - state.same_count - } - _ => { - state.last_sig = Some(sig); - state.same_count = 1; - 1 - } - }; + let step = self.step.fetch_add(1, Ordering::SeqCst) + 1; // A hard policy rejection is marked in the tool output; it can never - // succeed when re-issued unchanged, so it trips faster. - let is_hard_reject = result + // succeed when re-issued unchanged, so the crate ladder trips it faster. + let hard_reject = result .content .contains(crate::openhuman::security::POLICY_BLOCKED_MARKER) - || err.contains(crate::openhuman::security::POLICY_BLOCKED_MARKER); - - let summary = if is_hard_reject && same_count >= HARD_REJECT_REPEAT_THRESHOLD { - Some(format!( - "Stopping: the `{}` call is blocked by the security policy and was re-issued with \ - identical arguments — it can never succeed this way. Reason:\n{}\n\nDo not repeat \ - this call; use an allowed alternative or report that it can't be done here.", - result.name, - truncate_for_halt(err), - )) - } else if same_count >= self.identical_threshold { - Some(format!( - "Stopping: the `{}` call was retried {same_count} times with identical arguments \ - and kept failing — repeating it will not help. Last error:\n{}\n\nThis looks \ - unrecoverable in the current environment. Report this back instead of retrying.", - result.name, - truncate_for_halt(err), - )) - } else if state.consecutive >= NO_PROGRESS_FAILURE_THRESHOLD { - Some(format!( - "Stopping: {} tool calls in a row failed with no progress. Last error (from \ - `{}`):\n{}\n\nDifferent commands are all failing — the goal looks unreachable in \ - this environment. Report this back instead of retrying.", - state.consecutive, - result.name, - truncate_for_halt(err), - )) - } else { - None + || result + .error + .as_deref() + .is_some_and(|err| err.contains(crate::openhuman::security::POLICY_BLOCKED_MARKER)); + + let attempt = ToolAttempt { + tool: &result.name, + arg_fingerprint: &arg_fp, + error: result.error.as_deref(), + hard_reject, + // The unknown-tool recovery sentinel is a C3 concern; today every + // failure feeds the generic backstop exactly as the legacy ladder did. + recoverable_miss: false, }; - if let Some(summary) = summary { - tracing::warn!( - tool = %result.name, - consecutive = state.consecutive, - same_count, - is_hard_reject, - "[tinyagents::mw] repeated tool failure — halting run so the root cause surfaces" - ); - if let Ok(mut slot) = self.halt_summary.lock() { - *slot = Some(summary); + match self.tracker.record(step, &attempt) { + NoProgress::Continue => {} + NoProgress::Nudge(instruction) => { + tracing::warn!( + tool = %result.name, + step, + hard_reject, + "[tinyagents::mw] no-progress nudge — steering the model to change strategy before the retry cap" + ); + // Inject the crate's structured corrective as a system message via + // the `InjectMessage` steering lane. This runs on *every* turn, + // including the user's live interactive turn, whose steering policy + // permits only `InjectMessage`/`Pause` — `Redirect` is Background + // (sub-agent) only, so sending it here aborted every interactive + // turn that hit the nudge with `steering command redirect is not + // permitted by the run policy` (a #4473 migration regression). The + // corrective is trusted, system-generated advisory text, so the + // `InjectMessage` lane is both permitted and semantically correct. + self.handle + .send(SteeringCommand::InjectMessage(TaMessage::system( + instruction, + ))); + } + NoProgress::Halt(summary) => { + tracing::warn!( + tool = %result.name, + step, + hard_reject, + "[tinyagents::mw] repeated tool failure — halting run so the root cause surfaces" + ); + if let Ok(mut slot) = self.halt_summary.lock() { + *slot = Some(summary); + } + // Pause at the top of the next iteration (before the next model + // call), matching the stop-hook / cap pause path. Reset so a + // resumed run does not immediately re-pause on the latched state + // (the crate also resets internally on a halt; this is explicit + // and idempotent). + self.handle.send(SteeringCommand::Pause); + self.tracker.reset(); } - // Pause at the top of the next iteration (before the next model call), - // matching the stop-hook / cap pause path. Reset so a resumed run does - // not immediately re-pause on the same latched state. - self.handle.send(SteeringCommand::Pause); - *state = FailureState::default(); } Ok(()) } @@ -1926,9 +1789,8 @@ mod tests { // ── TurnContextMiddleware config ──────────────────────────────────────── #[test] - fn defaults_enable_cache_align_and_the_byte_cap_only() { + fn defaults_enable_the_byte_cap_only() { let mw = TurnContextMiddleware::defaults(); - assert!(mw.cache_align); assert_eq!( mw.tool_result_budget_bytes, DEFAULT_TOOL_RESULT_BUDGET_BYTES @@ -1938,6 +1800,8 @@ mod tests { // Autocompaction defaults on (channel/sub-agent); the chat path overrides // it from config. assert!(mw.autocompact_enabled); + // The byte cap alone is enough to make the bundle non-empty (CacheAlign + // was deleted in C3, so it no longer contributes here). assert!(!mw.is_empty()); } @@ -2030,11 +1894,15 @@ mod tests { assert_eq!(msgs[0].text(), "only system"); } - // ── MicrocompactMiddleware ────────────────────────────────────────────── + // ── MicrocompactMiddleware (crate) ────────────────────────────────────── + // + // These assert the crate `MicrocompactMiddleware`, constructed with + // OpenHuman's `CLEARED_PLACEHOLDER`, reproduces the deleted in-house + // middleware byte-for-byte — the parity contract for the upstream swap. #[tokio::test] async fn microcompact_clears_older_tool_bodies_and_keeps_recent() { - let mw = MicrocompactMiddleware { keep_recent: 1 }; + let mw = MicrocompactMiddleware::new(1, CLEARED_PLACEHOLDER); let mut req = ModelRequest::new(vec![ TaMessage::system("sys"), TaMessage::user("hello"), @@ -2058,7 +1926,7 @@ mod tests { #[tokio::test] async fn microcompact_is_a_noop_when_within_keep_recent() { - let mw = MicrocompactMiddleware { keep_recent: 5 }; + let mw = MicrocompactMiddleware::new(5, CLEARED_PLACEHOLDER); let mut req = ModelRequest::new(vec![TaMessage::tool("t1", "A"), TaMessage::tool("t2", "B")]); mw.before_model(&mut ctx(), &(), &mut req).await.unwrap(); @@ -2068,7 +1936,7 @@ mod tests { #[tokio::test] async fn microcompact_is_idempotent() { - let mw = MicrocompactMiddleware { keep_recent: 1 }; + let mw = MicrocompactMiddleware::new(1, CLEARED_PLACEHOLDER); let mut req = ModelRequest::new(vec![ TaMessage::tool("t1", "FIRST"), TaMessage::tool("t2", "SECOND"), @@ -2188,11 +2056,46 @@ mod tests { async fn cost_budget_is_a_noop_without_a_global_tracker() { // No global CostTracker is installed in the unit-test process, so the // gate self-disables and the model call proceeds. - let mw = CostBudgetMiddleware; + let mw = CostBudgetMiddleware::new(); let mut req = ModelRequest::new(vec![TaMessage::user("hi")]); assert!(mw.before_model(&mut ctx(), &(), &mut req).await.is_ok()); } + // ── CostBudgetMiddleware shadow (W2-budget-dedupe) ────────────────────── + + /// The shadow comparison at `after_agent` logs parity when the crate + /// `BudgetMiddleware`'s tracker matches the runtime `AgentRun.usage`, and + /// never fails the run — in both the matching and diverging cases. It also + /// must be inert (no panic, `Ok`) when no shadow tracker is installed. + #[tokio::test] + async fn cost_budget_shadow_after_agent_never_fails_the_run() { + use tinyagents::harness::usage::Usage; + + // No shadow tracker: after_agent is a silent no-op. + let plain = CostBudgetMiddleware::new(); + let mut run = AgentRun::new(); + run.usage.record(Usage::new(100, 40)); + assert!(plain.after_agent(&mut ctx(), &(), &mut run).await.is_ok()); + + // Matching tracker (parity): the crate tracker accumulated the same + // single call's usage the runtime recorded into `run.usage`. + let tracker = BudgetTracker::new(); + tracker.record(Usage::new(100, 40), Default::default()); + let shadow = CostBudgetMiddleware::with_shadow(tracker.clone()); + let mut run = AgentRun::new(); + run.usage.record(Usage::new(100, 40)); + assert!(shadow.after_agent(&mut ctx(), &(), &mut run).await.is_ok()); + + // Diverging tracker (crate missed a call): still only logs, never fails. + let mut diverged_run = AgentRun::new(); + diverged_run.usage.record(Usage::new(100, 40)); + diverged_run.usage.record(Usage::new(10, 5)); + assert!(shadow + .after_agent(&mut ctx(), &(), &mut diverged_run) + .await + .is_ok()); + } + // ── RepeatedToolFailureMiddleware ─────────────────────────────────────── fn failing_result(name: &str, err: &str) -> TaToolResult { @@ -2201,6 +2104,18 @@ mod tests { r } + /// Count how many of the steering commands drained from `handle` are + /// `Pause` (the halt signal). The tracker-driven breaker now also emits a + /// `Redirect` **nudge** below the retry cap, so a raw `pending()` count no + /// longer isolates the halt — the tests classify by command kind instead. + fn drain_pause_count(handle: &SteeringHandle) -> usize { + handle + .drain() + .into_iter() + .filter(|c| matches!(c, SteeringCommand::Pause)) + .count() + } + #[tokio::test] async fn repeated_tool_failure_pauses_only_after_the_threshold() { let handle = SteeringHandle::allow_all(); @@ -2209,18 +2124,24 @@ mod tests { 3, std::sync::Arc::new(std::sync::Mutex::new(None)), ); - // Two identical failures: below the threshold, no pause. + // Two identical failures: below the halt threshold. The crate ladder + // nudges (Redirect) on the second, but must NOT pause (halt) yet. for _ in 0..2 { let mut r = failing_result("flaky", "boom"); mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); } - assert_eq!(handle.pending(), 0, "no pause before the threshold"); - // Third identical failure trips the breaker. + assert_eq!( + drain_pause_count(&handle), + 0, + "no halt before the threshold" + ); + // Third identical failure exhausts the same-strategy retries → halt. let mut r = failing_result("flaky", "boom"); mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); - assert!( - handle.pending() >= 1, - "the third identical failure should pause the run" + assert_eq!( + drain_pause_count(&handle), + 1, + "the third identical failure should pause (halt) the run" ); } @@ -2239,12 +2160,17 @@ mod tests { } let mut ok = tool_result("t", "fine"); // error = None mw.after_tool(&mut ctx(), &(), &mut ok).await.unwrap(); - // Two more failures — still below the threshold because the counter reset. + // Two more failures — still below the halt threshold because the counter + // reset, so the ladder never reaches the third identical repeat. for _ in 0..2 { let mut r = failing_result("t", "boom"); mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); } - assert_eq!(handle.pending(), 0, "a success should reset the breaker"); + assert_eq!( + drain_pause_count(&handle), + 0, + "a success should reset the breaker so it never halts" + ); } #[tokio::test] @@ -2256,7 +2182,8 @@ mod tests { std::sync::Arc::new(std::sync::Mutex::new(None)), ); // Three *different* errors never trip the breaker — only an identical, - // deterministic failure loop does. + // deterministic failure loop does (and the varied-failure backstop nudges + // at 4 / halts at 6, both above this count). for err in ["e1", "e2", "e3"] { let mut r = failing_result("t", err); mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); @@ -2264,7 +2191,83 @@ mod tests { assert_eq!( handle.pending(), 0, - "distinct errors must not trip the breaker" + "distinct errors below the backstop must not steer the run" + ); + } + + /// Collect the nudge system-message texts drained from `handle`. The nudge + /// rides the `InjectMessage` lane (not `Redirect`) so it is permitted on the + /// user's interactive turn — see the test below. + fn drain_nudge_messages(handle: &SteeringHandle) -> Vec { + handle + .drain() + .into_iter() + .filter_map(|c| match c { + SteeringCommand::InjectMessage(message) => Some(message.text()), + _ => None, + }) + .collect() + } + + #[tokio::test] + async fn repeated_tool_failure_nudges_change_of_strategy_before_the_halt() { + use crate::openhuman::tinyagents::orchestration::{ + openhuman_steering_handle, SteeringRunClass, + }; + use tinyagents::harness::steering::SteeringCommandKind; + + // #4089: before the same-strategy retry cap, the breaker must feed a + // structured "no progress since step X" corrective back into the loop so + // the model changes approach rather than retrying the identical failing + // call — and it must do so *without* pausing yet. + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new( + handle.clone(), + 3, + std::sync::Arc::new(std::sync::Mutex::new(None)), + ); + // First identical failure: not a loop yet — no steering. + let mut r = failing_result("read_file", "file not found"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + assert!( + handle.drain().is_empty(), + "a single failure is never a loop" + ); + // Second identical failure: the nudge fires, still no halt. + let mut r = failing_result("read_file", "file not found"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + let nudges = drain_nudge_messages(&handle); + assert_eq!( + nudges.len(), + 1, + "the repeat should steer the model to change strategy before the retry cap" + ); + let nudge = &nudges[0]; + assert!( + nudge.contains("no progress"), + "the nudge carries the structured no-progress signal: {nudge}" + ); + assert!( + nudge.to_lowercase().contains("read_file"), + "the nudge names the failing call so the model knows what not to repeat: {nudge}" + ); + + // Regression for the #4473 crash: the nudge must ride a steering lane the + // user's *interactive* turn permits. `Redirect` is Background-only, so a + // Redirect nudge aborted interactive turns; `InjectMessage` is permitted + // on both classes. Assert the interactive policy accepts the lane we use. + let interactive = openhuman_steering_handle(SteeringRunClass::Interactive); + assert!( + interactive + .policy() + .is_allowed(SteeringCommandKind::InjectMessage), + "the no-progress nudge must use a lane the interactive turn permits" + ); + assert!( + !interactive + .policy() + .is_allowed(SteeringCommandKind::Redirect), + "sanity: interactive still refuses Redirect (the lane that crashed it)" ); } diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs index 474bf8d697..c2af63d36e 100644 --- a/src/openhuman/tinyagents/mod.rs +++ b/src/openhuman/tinyagents/mod.rs @@ -29,6 +29,7 @@ pub(crate) mod observability; pub(crate) mod orchestration; pub(crate) mod payload_summarizer; mod policy_denial; +pub(crate) mod replay; pub(crate) mod retriever; mod routes; mod run_cancellation_context; @@ -46,8 +47,8 @@ use tinyagents::harness::context::{RunConfig, RunContext}; use tinyagents::harness::events::EventSink; use tinyagents::harness::message::Message as TaMessage; use tinyagents::harness::middleware::{ - ContextCompressionMiddleware, MessageTrimMiddleware, PromptCacheGuardMiddleware, - ToolPolicyMiddleware as TaToolPolicyMiddleware, + BudgetLimits, BudgetMiddleware, ContextCompressionMiddleware, MessageTrimMiddleware, + PromptCacheGuardMiddleware, ToolPolicyMiddleware as TaToolPolicyMiddleware, }; use tinyagents::harness::model::CapabilitySet; use tinyagents::harness::runtime::{AgentHarness, RunPolicy, UnknownToolPolicy}; @@ -156,6 +157,20 @@ fn run_policy_for(max_iterations: usize, response_cache_enabled: bool) -> RunPol policy.limits.max_tool_calls = max_iterations.saturating_mul(8).max(8); policy.limits.max_depth = MAX_SPAWN_DEPTH; policy.retry.max_attempts = 1; + // Unknown-tool recovery (01.2 / C3): the crate policy owns this end to end — + // the `__openhuman_unknown_tool__` sentinel tool + `UnknownToolRewriteMiddleware` + // were already deleted. We deliberately keep `ReturnToolError` rather than + // `Rewrite { tool_name }`: Rewrite requires a real catch-all target tool (the + // deleted sentinel was exactly that) and, when it hits, *silently* executes + // that tool and emits `AgentEvent::UnknownToolCall { recovery: "rewrite:.." }` + // WITHOUT injecting a tool message. `ReturnToolError` instead injects a + // recoverable `unknown tool `` (arguments: ..); valid tools: [..]` + // result naming the originally-requested tool. Two live consumers depend on + // that message: (1) the #4419 attempted-tool-name UX and (2) the failure + // classifier in `agent::hooks::sanitize_tool_output`, which labels the result + // `unknown_tool` by matching the "unknown tool" substring. Flipping to Rewrite + // would drop both. The original name + args are also preserved verbatim on + // `AgentEvent::UnknownToolCall` and projected by `OpenhumanEventBridge`. policy.unknown_tool = UnknownToolPolicy::ReturnToolError; // Prompt-prefix protection is always on (issue #4249, 03.2): the // `PromptCacheGuardMiddleware` records a `CacheLayoutEvent` whenever volatile @@ -512,9 +527,32 @@ pub(crate) async fn run_turn_via_tinyagents_shared( ); ctx = ctx.with_workspace(descriptor); } + // Assemble the run's store registry: the tool-result artifact index (when + // present) and — behind the default-ON session dual-write flag — the + // session KV store, so the harness carries a handle to the same + // `{workspace}/tinyagents_store/kv` tree the live dual-write mirrors into + // (issue #4249, 04.1). Both stores share one registry so neither clobbers + // the other. Reads stay legacy until 04.2; this registration is additive + // and best-effort (a workspace-resolve failure just skips it). + let mut stores: Option = None; if let Some(index) = tool_result_artifact_index { - let mut stores = StoreRegistry::new(); - stores.register(TINYAGENTS_TOOL_RESULT_ARTIFACT_STORE, index); + stores + .get_or_insert_with(StoreRegistry::new) + .register(TINYAGENTS_TOOL_RESULT_ARTIFACT_STORE, index); + } + // `session_kv_store` self-gates on the dual-write flag (config default ON + + // env kill switch), returning `None` when disabled or unresolvable. + if let Some(session_kv) = crate::openhuman::session_import::live::session_kv_store().await { + stores.get_or_insert_with(StoreRegistry::new).register( + crate::openhuman::session_import::live::TINYAGENTS_SESSION_KV_STORE, + session_kv, + ); + tracing::debug!( + "[session-store] registered session kv store on RunContext.stores under '{}'", + crate::openhuman::session_import::live::TINYAGENTS_SESSION_KV_STORE + ); + } + if let Some(stores) = stores { ctx = ctx.with_stores(stores); } @@ -533,7 +571,13 @@ pub(crate) async fn run_turn_via_tinyagents_shared( // (`on_progress = None`) turn so the run stays reconstructable, so the // EventSink is now created unconditionally — cheap (an empty sink) and, if // no consumer subscribes, inert. - let events = Some(EventSink::new()); + // + // Mint the durable run id *before* the sink and seed the sink stream prefix + // with it (`with_stream_id`), so every persisted observation's `event_id` is + // the restart-stable `{run_id}-evt-{offset}` a late-attach replay + // reconstructs the timeline from (05.1). The same id keys the journal + status. + let journal_run_id = journal::mint_run_id(); + let events = Some(EventSink::with_stream_id(journal_run_id.as_str())); let bridge = match (&events, on_progress) { (Some(events), Some(tx)) => { @@ -565,8 +609,17 @@ pub(crate) async fn run_turn_via_tinyagents_shared( // existing progress/global-bus path is untouched. Best-effort and non-fatal // — a failure to open/attach the journal returns `None` and the turn runs // unaffected. The handle stamps the terminal status once the run returns. + // A sub-agent turn records under its task scope as the status thread id, so + // `list_by_thread` can enumerate a task's runs (full parent/root lineage is + // a 05.2/05.3 follow-up). + let journal_thread_id = subagent_scope + .as_ref() + .map(|scope| tinyagents::harness::ids::ThreadId::new(scope.task_id.clone())); let turn_journal = match &events { - Some(events) => journal::attach_turn_journal(events, model).await, + Some(events) => { + journal::attach_turn_journal(events, model, journal_run_id.clone(), journal_thread_id) + .await + } None => None, }; @@ -712,9 +765,9 @@ pub(crate) async fn run_turn_via_tinyagents_shared( // `PromptCacheGuardMiddleware`'s recorded `CacheLayoutEvent`s and surface each // as a structured `[cache]` warning. Fires only when the cacheable prompt // prefix (system prompt + tool set) changed across model calls — i.e. volatile - // content silently busting the provider KV-cache prefix. The structured - // successor to `CacheAlignMiddleware`'s free-text warn-log (still installed in - // parallel until parity is shown). + // content silently busting the provider KV-cache prefix. This is now the sole + // owner of KV-cache-prefix drift detection: the warn-only + // `CacheAlignMiddleware` was deleted in C3. let cache_layout_events = prompt_cache_guard.layout_events(); if !cache_layout_events.is_empty() { tracing::debug!( @@ -896,8 +949,8 @@ struct AssembledTurnHarness { /// Crate prompt-cache guard (issue #4249, 03.2). Records a `CacheLayoutEvent` /// whenever the cacheable prompt prefix (system prompt + tool set) changes /// across model calls. Drained after the run and surfaced via - /// [`observability::surface_cache_layout_events`] — the structured successor to - /// the `CacheAlignMiddleware` warn-log. + /// [`observability::surface_cache_layout_events`] — the crate-native + /// replacement for the deleted `CacheAlignMiddleware` warn-log (C3). prompt_cache_guard: Arc, } @@ -1270,25 +1323,67 @@ fn assemble_turn_harness( // precede the guard; both run before the context middlewares below (they only // touch the volatile tail / tool bodies, never the stable prefix). The guard is // returned so the run loop can drain its events into the observability bridge — - // the structured successor to `CacheAlignMiddleware`'s warn-log (kept installed - // via `context_mw` until parity is shown). + // the crate-native replacement for the deleted `CacheAlignMiddleware` warn-log + // (C3: the warn-only shadow is gone; this guard is the sole owner). harness.push_middleware(Arc::new(middleware::PromptCacheSegmentMiddleware)); let prompt_cache_guard = Arc::new(PromptCacheGuardMiddleware::new()); harness.push_middleware(prompt_cache_guard.clone()); - // openhuman context concerns as graph middlewares (issue #4249): cache-align - // warnings, microcompact tool-body clearing, and the after-tool byte cap / - // payload summarizer. Installed before the summarization/trim block below so - // `before_model` hooks run cache-align → microcompact → compress → trim. - // Tool-result caps read the SDK registry policy snapshot, not the - // OpenHuman-side tool lookup. + // openhuman context concerns as graph middlewares (issue #4249): microcompact + // tool-body clearing and the after-tool byte cap / payload summarizer. + // Installed before the summarization/trim block below so `before_model` hooks + // run microcompact → compress → trim. (KV-cache-prefix drift is handled above + // by the crate `PromptCacheGuardMiddleware`; the warn-only CacheAlign shadow + // was deleted in C3.) Tool-result caps read the SDK registry policy snapshot, + // not the OpenHuman-side tool lookup. let tool_policies = harness.tools().policies(); context_mw.install(&mut harness, tool_policies); - // Pre-call cost budget gate (issue #4249, Phase 5): fail before a model call - // when OpenHuman's daily/monthly cost budget is already exceeded. Self-gating - // — a no-op unless cost budgets are configured. - harness.push_middleware(Arc::new(middleware::CostBudgetMiddleware)); + // Observe-only crate `BudgetMiddleware` (W2-budget-dedupe / workstream 06). + // Installed with empty `BudgetLimits` so it NEVER enforces or halts: its + // `before_model` preflight has no configured limit to trip, and its + // `after_model` only folds each call's usage into its shared `BudgetTracker`. + // It also re-emits `AgentEvent::UsageRecorded` per call (on top of the + // runtime's own emit); the event bridge dedupes those by model-call iteration + // so the global cost tracker still records each call exactly once (see + // `observability::OpenhumanEventBridge::record_usage`). Enforcement STAYS with + // the local `CostBudgetMiddleware` below (authoritative: reads the global + // daily/monthly `CostTracker`). + // + // FLIP CRITERIA — what must hold before the crate `BudgetMiddleware` becomes + // the enforcing owner and the local `CostBudgetMiddleware` + the + // `agent/harness/turn_subagent_usage.rs` task-local are DELETED (deletion + // ledger row: "crate-internal CostBudgetMiddleware + turn_subagent_usage.rs + // task-local", `docs/tinyagents-full-migration-plan/99-deletion-ledger.md`): + // 1. ≥ 500 production turns across BOTH parent and sub-agent runs with + // ZERO `[budget_shadow]` divergence log lines — proving the crate + // tracker's per-run token accounting matches the authoritative runtime + // `AgentRun.usage` on every model call. + // 2. A pricing table wired via `BudgetMiddleware::with_pricing(..)` at + // parity with `cost::catalog::estimate_cost_usd`, so the crate can own + // MONEY (USD) budgets. Today the shadow compares TOKENS only (the + // observe-only crate middleware has no pricing, so its cost stays $0) + // and the local gate is the sole money-budget authority. + // 3. Run-tree rollup wired: the same shared `BudgetTracker` handed to every + // sub-agent harness so a parent budget halts a recursive run pre-spend — + // replacing the `turn_subagent_usage` parent-turn rollup (06-cost step 3 + // / 07.2 TaskStore rollup). + // Until all three hold, this middleware is observe-only and the local gate + // enforces. + let shadow_budget = Arc::new(BudgetMiddleware::new(BudgetLimits::default())); + let shadow_budget_tracker = shadow_budget.tracker(); + harness.push_middleware(shadow_budget); + + // Pre-call cost budget gate (issue #4249, Phase 5) — AUTHORITATIVE + // enforcement: fail before a model call when OpenHuman's daily/monthly cost + // budget is already exceeded. Self-gating — a no-op unless cost budgets are + // configured. Demoted to a divergence-logging shadow owner (W2-budget-dedupe): + // it keeps enforcing exactly as before, but ALSO compares its per-run token + // accounting against the observe-only crate `BudgetMiddleware` above at end of + // run and logs `[budget_shadow]` parity/divergence. + harness.push_middleware(Arc::new(middleware::CostBudgetMiddleware::with_shadow( + shadow_budget_tracker, + ))); // Autocompaction parity: when the provider's context window is known, install // the two-stage context-management step (issue #4249). diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs index 20651274d1..0fd16c1cec 100644 --- a/src/openhuman/tinyagents/observability.rs +++ b/src/openhuman/tinyagents/observability.rs @@ -142,6 +142,15 @@ pub(crate) struct OpenhumanEventBridge { /// Shared `call_id → (success, failure)` side-channel written by /// `ToolOutcomeCaptureMiddleware`; read when projecting `ToolCallCompleted`. failure_map: ToolFailureMap, + /// Model-call iterations whose `UsageRecorded` has already been folded into + /// the global cost tracker (W2-budget-dedupe). A single model call can now + /// surface **two** `UsageRecorded` events — one from the harness runtime + /// (`agent_loop`, always) and one from the observe-only crate + /// `BudgetMiddleware::after_model` — both carrying identical usage and both + /// delivered to this bridge. Keyed on the run-scoped model-call identity (the + /// iteration cursor, bumped once per `ModelStarted`) so a given call's usage + /// is recorded exactly once. See [`OpenhumanEventBridge::record_usage`]. + recorded_iterations: Mutex>, state: Mutex, } @@ -183,6 +192,7 @@ impl OpenhumanEventBridge { cursor, tool_names, failure_map, + recorded_iterations: Mutex::new(std::collections::HashSet::new()), state: Mutex::new(BridgeState::default()), }) } @@ -231,6 +241,32 @@ impl OpenhumanEventBridge { /// `TurnCostUpdated` so the UI footer stays live. fn record_usage(&self, usage: &Usage) { let iteration = self.iteration(); + // Dedupe guard (W2-budget-dedupe): record a given model call's usage into + // the global cost tracker **exactly once**. Installing the observe-only + // crate `BudgetMiddleware` makes each model call emit two `UsageRecorded` + // events (the runtime's own at `agent_loop` + the middleware's + // `after_model` re-emit), both reaching this listener with identical + // usage. The two events have *distinct* stable ids, so an event-id key + // would not collapse them — instead we key on the run-scoped model-call + // identity: the iteration cursor, bumped once per `ModelStarted`. This + // bridge instance is per-run (parent or child scope), so the set is + // naturally (run, turn)-scoped. First writer for an iteration records; + // any later `UsageRecorded` for the same iteration is a duplicate. + { + let mut seen = self + .recorded_iterations + .lock() + .unwrap_or_else(|p| p.into_inner()); + if !seen.insert(iteration) { + tracing::debug!( + iteration, + model = %self.model, + child = self.scope.is_some(), + "[budget] duplicate UsageRecorded for model call — skipping double record" + ); + return; + } + } // Provider-reported charged USD has no home in the crate `Usage` (all // token counts), so estimate this call's cost from catalogued per-MTok // rates. Fixes the long-standing $0 cost on the tinyagents path, where @@ -614,12 +650,12 @@ impl EventListener for OpenhumanEventBridge { /// /// The guard records a layout event whenever the cacheable prompt prefix changes /// between turns (volatile content — a timestamp, uuid, injected memory, etc. — -/// silently busting the provider KV-cache prefix). This is the structured -/// successor to `CacheAlignMiddleware`'s free-text warn-log: instead of a -/// token-pattern heuristic it reports the exact before/after cacheable segment -/// ids. Drained by the turn loop after the run and logged here; `CacheAlign` is -/// kept installed in parallel until parity is shown (its deletion is a gated -/// follow-up). +/// silently busting the provider KV-cache prefix). This is the crate-native +/// replacement for the deleted `CacheAlignMiddleware` free-text warn-log: +/// instead of a token-pattern heuristic it reports the exact before/after +/// cacheable segment ids. Drained by the turn loop after the run and logged +/// here. The warn-only `CacheAlignMiddleware` shadow was deleted in C3; this +/// guard is now the sole owner of KV-cache-prefix drift detection. pub(crate) fn surface_cache_layout_events(model: &str, events: &[CacheLayoutEvent]) { for event in events { tracing::warn!( @@ -683,6 +719,64 @@ mod tests { assert_eq!((input, output), (100, 40)); } + /// W2-budget-dedupe: two `UsageRecorded` events for the *same* model call + /// (as happens once the observe-only crate `BudgetMiddleware` re-emits usage + /// its `after_model` folded, on top of the runtime's own emit) must be + /// recorded into the bridge accounting **exactly once**. Without the dedupe + /// guard the totals would double. + #[tokio::test] + async fn duplicate_usage_for_same_model_call_is_recorded_once() { + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let bridge = OpenhumanEventBridge::new(Some(tx), "mock-model", 10); + let sink = EventSink::new(); + sink.subscribe(bridge.clone()); + + // One model call → one `ModelStarted` (iteration cursor → 1). + sink.emit(AgentEvent::ModelStarted { + call_id: "c1".into(), + model: "mock-model".to_string(), + }); + // Same call surfaces usage twice (runtime emit + BudgetMiddleware re-emit). + sink.emit(AgentEvent::UsageRecorded { + usage: Usage::new(100, 40), + }); + sink.emit(AgentEvent::UsageRecorded { + usage: Usage::new(100, 40), + }); + + // Totals reflect a single record, not two. + let (input, output, _) = bridge.totals(); + assert_eq!( + (input, output), + (100, 40), + "the duplicate UsageRecorded for the same iteration must be skipped" + ); + + // Exactly one `TurnCostUpdated` footer emit for the call. + let mut cost_updates = 0; + while let Ok(p) = rx.try_recv() { + if matches!(p, AgentProgress::TurnCostUpdated { .. }) { + cost_updates += 1; + } + } + assert_eq!(cost_updates, 1, "footer must update once per model call"); + + // A genuinely new model call (iteration cursor → 2) records again. + sink.emit(AgentEvent::ModelStarted { + call_id: "c2".into(), + model: "mock-model".to_string(), + }); + sink.emit(AgentEvent::UsageRecorded { + usage: Usage::new(10, 5), + }); + let (input, output, _) = bridge.totals(); + assert_eq!( + (input, output), + (110, 45), + "a distinct model call (new iteration) must still record" + ); + } + // NOTE: the former `sentinel_tool_started_is_not_forwarded` test was removed // here. The #4249 migration (commit 60097ba8d, "use sdk unknown tool // recovery") deleted `UNKNOWN_TOOL_SENTINEL` + `UnknownToolRewriteMiddleware` diff --git a/src/openhuman/tinyagents/replay/mod.rs b/src/openhuman/tinyagents/replay/mod.rs new file mode 100644 index 0000000000..a3e11efb78 --- /dev/null +++ b/src/openhuman/tinyagents/replay/mod.rs @@ -0,0 +1,14 @@ +//! Read-only agent run replay + status RPC surface (workstream 05.x). +//! +//! Thin controllers over the C4 durable journal/status seams in +//! [`crate::openhuman::tinyagents::journal`]. Everything here is a reader: +//! no mutation, no writes, no security/approval/sandbox bypass. See +//! [`schemas`] for the three `agent`-namespace controllers and [`ops`] for the +//! workspace-parameterized read logic. + +pub(crate) mod ops; +mod schemas; + +pub(crate) use schemas::{ + all_agent_replay_controller_schemas, all_agent_replay_registered_controllers, +}; diff --git a/src/openhuman/tinyagents/replay/ops.rs b/src/openhuman/tinyagents/replay/ops.rs new file mode 100644 index 0000000000..0288481571 --- /dev/null +++ b/src/openhuman/tinyagents/replay/ops.rs @@ -0,0 +1,303 @@ +//! Read-only business logic for the agent replay/status RPC surface +//! (workstream 05.x). Every function here is a *reader* over the C4 durable +//! journal + status seams built in +//! [`crate::openhuman::tinyagents::journal`] — it opens the same +//! `{workspace}/tinyagents_store/{kv,journal}` stores and never writes, mutates, +//! or bypasses any security/approval/sandbox gate. +//! +//! The controller layer ([`super::schemas`]) resolves the configured workspace +//! and delegates here; these functions take an explicit `workspace` path so they +//! are unit-testable against a temp store (mirroring the `journal.rs` tests). + +use std::path::Path; + +use tinyagents::harness::events::HarnessRunStatus; +use tinyagents::harness::ids::ExecutionStatus; +use tinyagents::harness::observability::{ + AgentObservation, HarnessEventJournal, HarnessStatusStore, StoreEventJournal, +}; + +use crate::openhuman::session_import::ops::open_session_stores; +use crate::openhuman::tinyagents::journal::FileStatusStore; + +/// Default page size for [`read_run_events_page`] when the caller omits `limit`. +pub(crate) const DEFAULT_EVENTS_LIMIT: u64 = 200; + +/// Hard cap on a single replay page so one RPC can never fan a whole run into a +/// single response. +pub(crate) const MAX_EVENTS_LIMIT: u64 = 1000; + +/// One page of a run's durable event stream. +/// +/// `events` are [`AgentObservation`]s (the crate's durable observability +/// envelope — `event_id` / `run_id` / lineage / `offset` / `ts_ms` / typed +/// `event`) in ascending `offset` order. `next_offset` is the `offset` to pass +/// back to fetch the following page, or `None` once the stream is drained. +pub(crate) struct RunEventsPage { + pub events: Vec, + pub next_offset: Option, +} + +/// Paged late-attach replay reader over the durable journal. +/// +/// Returns up to `limit` observations for `run_id` whose stream offset is +/// `>= offset`, in order, plus a `next_offset` cursor (`None` when the last page +/// drained the stream). Backed by the C4 journal seam +/// ([`StoreEventJournal::read_from`], the same reader +/// [`crate::openhuman::tinyagents::journal::read_run_events`] uses). Best-effort: +/// a missing store or unknown run yields an empty page, not an error. +pub(crate) async fn read_run_events_page( + workspace: &Path, + run_id: &str, + offset: u64, + limit: u64, +) -> anyhow::Result { + // Guard the page size: clamp a zero/absurd limit into [1, MAX]. + let effective_limit = limit.clamp(1, MAX_EVENTS_LIMIT); + log::debug!( + "[agent] replay read_run_events_page run_id={run_id} offset={offset} \ + limit={limit} effective_limit={effective_limit}" + ); + + let stores = open_session_stores(workspace); + let journal = StoreEventJournal::new(stores.journal); + // Read one extra record to detect whether a further page exists without a + // second store round-trip. + let mut events = journal.read_from(run_id, offset).await.map_err(|e| { + anyhow::anyhow!("[agent] replay read_run_events_page failed run_id={run_id}: {e}") + })?; + + let has_more = events.len() as u64 > effective_limit; + if has_more { + events.truncate(effective_limit as usize); + } + // Offsets are monotonic within a run, so the cursor is simply "one past the + // last returned offset". `None` when this page drained the stream. + let next_offset = if has_more { + events.last().map(|obs| obs.offset + 1) + } else { + None + }; + + log::debug!( + "[agent] replay read_run_events_page run_id={run_id} returned={} next_offset={:?}", + events.len(), + next_offset + ); + Ok(RunEventsPage { + events, + next_offset, + }) +} + +/// Latest durable [`HarnessRunStatus`] for `run_id`, or `None` when the run is +/// unknown. Backed by the C4 status seam +/// ([`crate::openhuman::tinyagents::journal::read_run_status`] / +/// [`FileStatusStore::get_status`]). +pub(crate) async fn read_run_status( + workspace: &Path, + run_id: &str, +) -> anyhow::Result> { + log::debug!("[agent] replay read_run_status run_id={run_id}"); + let stores = open_session_stores(workspace); + let store = FileStatusStore::new(stores.kv); + let status = store.get_status(run_id).await.map_err(|e| { + anyhow::anyhow!("[agent] replay read_run_status failed run_id={run_id}: {e}") + })?; + log::debug!( + "[agent] replay read_run_status run_id={run_id} found={}", + status.is_some() + ); + Ok(status) +} + +/// Is a run still live (i.e. eligible for the "active" listing)? +/// +/// Mirrors the liveness predicate the crate's status store uses for +/// `list_active` (Pending / Running / Interrupted). +fn is_active(status: &HarnessRunStatus) -> bool { + matches!( + status.status, + ExecutionStatus::Pending | ExecutionStatus::Running | ExecutionStatus::Interrupted + ) +} + +/// Active runs, optionally filtered by `thread_id` and/or `root_run_id`. +/// +/// Backed by the C4 status seam: +/// - no filter → [`FileStatusStore::list_active`] +/// - `thread_id` → [`FileStatusStore::list_by_thread`] +/// - `root_run_id` → [`FileStatusStore::list_by_root`] +/// +/// The thread/root store queries return *all* runs (active and terminal), so the +/// active-liveness predicate is always applied on top — this controller only +/// ever surfaces live runs. When both filters are supplied, the base query uses +/// `thread_id` and the result is further restricted to `root_run_id`. +pub(crate) async fn list_active_runs( + workspace: &Path, + thread_id: Option<&str>, + root_run_id: Option<&str>, +) -> anyhow::Result> { + log::debug!( + "[agent] replay list_active_runs thread_id={:?} root_run_id={:?}", + thread_id, + root_run_id + ); + let stores = open_session_stores(workspace); + let store = FileStatusStore::new(stores.kv); + + let base = match (thread_id, root_run_id) { + (Some(thread), _) => store.list_by_thread(thread).await, + (None, Some(root)) => store.list_by_root(root).await, + (None, None) => store.list_active().await, + } + .map_err(|e| anyhow::anyhow!("[agent] replay list_active_runs failed: {e}"))?; + + let mut runs: Vec = base.into_iter().filter(is_active).collect(); + // If a caller supplied BOTH a thread and a root, the thread query drove the + // base list; narrow it to the requested root as well. + if thread_id.is_some() { + if let Some(root) = root_run_id { + runs.retain(|s| s.root_run_id.as_str() == root); + } + } + + log::debug!("[agent] replay list_active_runs returned={}", runs.len()); + Ok(runs) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use tinyagents::harness::events::{AgentEvent, EventSink}; + use tinyagents::harness::ids::{ComponentId, HarnessPhase, ThreadId}; + use tinyagents::harness::observability::{FanOutSink, JournalSink, StoreEventJournal}; + + use crate::openhuman::tinyagents::journal::mint_run_id; + + /// Seed `count` durable events for a fresh run under `workspace`, returning + /// the run id. Mirrors the seam wiring in the `journal.rs` tests: a run + /// [`EventSink`] seeded with the run id (so persisted `event_id`s are the + /// stable `{run_id}-evt-{offset}`) fanning out into a [`StoreEventJournal`]. + async fn seed_run_events(workspace: &Path, count: usize) -> String { + let stores = open_session_stores(workspace); + let run_id = mint_run_id(); + let journal: Arc = + Arc::new(StoreEventJournal::new(stores.journal)); + let sink = EventSink::with_stream_id(run_id.as_str()); + let journal_sink = JournalSink::new(journal, run_id.clone()); + sink.subscribe(Arc::new(FanOutSink::new().with(Arc::new(journal_sink)))); + for i in 0..count { + sink.emit(AgentEvent::ToolStarted { + call_id: format!("c{i}").into(), + tool_name: format!("tool-{i}"), + }); + } + run_id.as_str().to_string() + } + + /// Paging boundary: a page smaller than the stream reports a `next_offset` + /// cursor; the final page drains to `None` and never over-reads. + #[tokio::test] + async fn read_run_events_page_pages_and_drains() { + let tmp = std::env::temp_dir().join(format!("oh-replay-page-{}", uuid::Uuid::new_v4())); + let run_id = seed_run_events(&tmp, 3).await; + + // First page (limit 2) returns offsets 0,1 with a cursor at offset 2. + let page1 = read_run_events_page(&tmp, &run_id, 0, 2).await.unwrap(); + assert_eq!(page1.events.len(), 2); + assert_eq!(page1.events[0].offset, 0); + assert_eq!(page1.events[1].offset, 1); + assert_eq!(page1.next_offset, Some(2), "more events remain"); + + // Second page resumes at the cursor and drains → next_offset None. + let page2 = read_run_events_page(&tmp, &run_id, page1.next_offset.unwrap(), 2) + .await + .unwrap(); + assert_eq!(page2.events.len(), 1); + assert_eq!(page2.events[0].offset, 2); + assert_eq!(page2.next_offset, None, "stream drained on the last page"); + + // A page exactly the size of the remaining stream still drains to None + // (no phantom extra page). + let exact = read_run_events_page(&tmp, &run_id, 0, 3).await.unwrap(); + assert_eq!(exact.events.len(), 3); + assert_eq!(exact.next_offset, None); + + // Unknown run → empty page, not an error. + let empty = read_run_events_page(&tmp, "run.does-not-exist", 0, 10) + .await + .unwrap(); + assert!(empty.events.is_empty()); + assert_eq!(empty.next_offset, None); + + let _ = std::fs::remove_dir_all(&tmp); + } + + /// Status reader returns `None` for a run that was never recorded. + #[tokio::test] + async fn read_run_status_none_for_unknown_run() { + let tmp = std::env::temp_dir().join(format!("oh-replay-status-{}", uuid::Uuid::new_v4())); + let missing = read_run_status(&tmp, "run.nope").await.unwrap(); + assert!(missing.is_none()); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// Active listing surfaces a started run and filters by thread; a completed + /// run is excluded. + #[tokio::test] + async fn list_active_runs_returns_started_and_filters_by_thread() { + let tmp = std::env::temp_dir().join(format!("oh-replay-active-{}", uuid::Uuid::new_v4())); + let store = FileStatusStore::new(open_session_stores(&tmp).kv); + + // A running run on thread-A. + let run_a = mint_run_id(); + let mut status_a = + HarnessRunStatus::new(run_a.clone(), ComponentId::new("mock-model".to_string())) + .with_thread(ThreadId::new("thread-A")); + status_a.mark_running(HarnessPhase::Model); + store.put_status(status_a).await.unwrap(); + + // A completed run on thread-B (must NOT appear in the active listing). + let run_b = mint_run_id(); + let mut status_b = + HarnessRunStatus::new(run_b.clone(), ComponentId::new("mock-model".to_string())) + .with_thread(ThreadId::new("thread-B")); + status_b.mark_running(HarnessPhase::Model); + status_b.mark_completed(); + store.put_status(status_b).await.unwrap(); + + // No filter: only the running run. + let active = list_active_runs(&tmp, None, None).await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].run_id.as_str(), run_a.as_str()); + + // Filter by thread-A: the running run is returned. + let by_thread_a = list_active_runs(&tmp, Some("thread-A"), None) + .await + .unwrap(); + assert_eq!(by_thread_a.len(), 1); + assert_eq!(by_thread_a[0].run_id.as_str(), run_a.as_str()); + + // Filter by thread-B: the only run there is completed → excluded. + let by_thread_b = list_active_runs(&tmp, Some("thread-B"), None) + .await + .unwrap(); + assert!(by_thread_b.is_empty()); + + // Filter by an unknown thread: empty. + let by_thread_none = list_active_runs(&tmp, Some("nope"), None).await.unwrap(); + assert!(by_thread_none.is_empty()); + + // Filter by root_run_id (a top-level run's root equals its own id). + let by_root = list_active_runs(&tmp, None, Some(run_a.as_str())) + .await + .unwrap(); + assert_eq!(by_root.len(), 1); + assert_eq!(by_root[0].run_id.as_str(), run_a.as_str()); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src/openhuman/tinyagents/replay/schemas.rs b/src/openhuman/tinyagents/replay/schemas.rs new file mode 100644 index 0000000000..c7c599844d --- /dev/null +++ b/src/openhuman/tinyagents/replay/schemas.rs @@ -0,0 +1,348 @@ +//! Read-only JSON-RPC controllers for agent run replay + status +//! (workstream 05.x), over the C4 durable journal/status seams. +//! +//! Three controllers, all in the `agent` namespace, all **read-only** (no +//! mutation, no security/approval/sandbox bypass, no writes): +//! +//! - `openhuman.agent_run_events` — paged late-attach replay of a run's durable +//! event stream. Params: `run_id`, `offset` (default 0), `limit` (default +//! [`DEFAULT_EVENTS_LIMIT`], capped at [`MAX_EVENTS_LIMIT`]). Returns +//! `{ events: [...], next_offset: }` — `null` +//! once the stream is drained. +//! - `openhuman.agent_run_status` — latest [`HarnessRunStatus`] for `run_id`, +//! or `null` for an unknown run. +//! - `openhuman.agent_runs_active` — active runs, optionally filtered by +//! `thread_id` and/or `root_run_id`. Returns `{ runs: [...] }`. +//! +//! ## Serialization / DTO note +//! +//! Events are surfaced as the crate's [`AgentObservation`] serde shape and +//! statuses as the [`HarnessRunStatus`] serde shape, projected **directly** — +//! no bespoke DTO. This is deliberate: both types are exactly what the C4 layer +//! already persists as JSON in `{workspace}/tinyagents_store` (an +//! `AgentObservation` is the durable journal record; a `HarnessRunStatus` is the +//! durable status snapshot), so a direct projection is guaranteed round-trip +//! stable and cannot drift from what a replay reconstructs. `AgentObservation` +//! carries `{ event_id, run_id, parent_run_id?, root_run_id, offset, ts_ms, +//! event }`, where `event` is the internally-tagged (`"kind"`) `AgentEvent` +//! enum; `HarnessRunStatus` carries ids/lineage, `status`, `current_phase`, +//! call counters, usage/cost totals, and timestamps — never prompt text, tool +//! arguments, or provider payloads. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use tinyagents::harness::events::HarnessRunStatus; +use tinyagents::harness::observability::AgentObservation; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::ops::{ + list_active_runs, read_run_events_page, read_run_status, DEFAULT_EVENTS_LIMIT, MAX_EVENTS_LIMIT, +}; + +const NAMESPACE: &str = "agent"; + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct RunEventsParams { + run_id: String, + #[serde(default)] + offset: u64, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct RunStatusParams { + run_id: String, +} + +#[derive(Debug, Deserialize, Default)] +struct RunsActiveParams { + #[serde(default)] + thread_id: Option, + #[serde(default)] + root_run_id: Option, +} + +// --------------------------------------------------------------------------- +// Responses (direct projection of the crate serde shapes — see module docs) +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct RunEventsResponse { + events: Vec, + /// Cursor to fetch the next page, or `null` once the stream is drained. + next_offset: Option, +} + +#[derive(Debug, Serialize)] +struct RunsActiveResponse { + runs: Vec, +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +/// All read-only replay/status controller schemas (workstream 05.x). +pub(crate) fn all_agent_replay_controller_schemas() -> Vec { + vec![ + replay_schema("run_events"), + replay_schema("run_status"), + replay_schema("runs_active"), + ] +} + +/// All read-only replay/status registered controllers (workstream 05.x). +pub(crate) fn all_agent_replay_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: replay_schema("run_events"), + handler: handle_run_events, + }, + RegisteredController { + schema: replay_schema("run_status"), + handler: handle_run_status, + }, + RegisteredController { + schema: replay_schema("runs_active"), + handler: handle_runs_active, + }, + ] +} + +fn replay_schema(function: &str) -> ControllerSchema { + match function { + "run_events" => ControllerSchema { + namespace: NAMESPACE, + function: "run_events", + description: + "Read-only paged replay of a durable agent run's event stream (late-attach \ + reconnect/backfill). Returns AgentObservations at offset >= `offset` in order, \ + plus `next_offset` (null once drained). Never mutates state.", + inputs: vec![ + FieldSchema { + name: "run_id", + ty: TypeSchema::String, + comment: "Durable run id (as minted by the journal, e.g. `run.`).", + required: true, + }, + FieldSchema { + name: "offset", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Start stream offset (inclusive). Default 0 replays the whole run.", + required: false, + }, + FieldSchema { + name: "limit", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max events in this page. Defaults to 200, capped at 1000.", + required: false, + }, + ], + outputs: vec![ + FieldSchema { + name: "events", + ty: TypeSchema::Array(Box::new(TypeSchema::Json)), + comment: "Ordered AgentObservations (event_id, run_id, lineage, offset, \ + ts_ms, typed `event`).", + required: true, + }, + FieldSchema { + name: "next_offset", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Cursor for the next page, or null when the stream is drained.", + required: false, + }, + ], + }, + "run_status" => ControllerSchema { + namespace: NAMESPACE, + function: "run_status", + description: + "Read-only latest durable status snapshot (HarnessRunStatus) for a run, or null \ + when the run is unknown. Counters/phase/usage/cost only — never prompts or \ + payloads.", + inputs: vec![FieldSchema { + name: "run_id", + ty: TypeSchema::String, + comment: "Durable run id to look up.", + required: true, + }], + outputs: vec![FieldSchema { + name: "status", + ty: TypeSchema::Option(Box::new(TypeSchema::Json)), + comment: "The HarnessRunStatus snapshot, or null for an unknown run.", + required: false, + }], + }, + "runs_active" => ControllerSchema { + namespace: NAMESPACE, + function: "runs_active", + description: + "Read-only listing of active (pending/running/interrupted) agent runs, optionally \ + filtered by `thread_id` and/or `root_run_id`. Never mutates state.", + inputs: vec![ + FieldSchema { + name: "thread_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Restrict to runs on this conversation thread.", + required: false, + }, + FieldSchema { + name: "root_run_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Restrict to descendants of this root run.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "runs", + ty: TypeSchema::Array(Box::new(TypeSchema::Json)), + comment: "Active HarnessRunStatus snapshots matching the filter.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: NAMESPACE, + function: "unknown", + description: "Unknown agent replay controller.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// Resolve the configured internal workspace whose `tinyagents_store/` holds the +/// journal + status stores. +async fn configured_workspace() -> Result { + let config = crate::openhuman::config::Config::load_or_init() + .await + .map_err(|e| format!("failed to load config: {e}"))?; + Ok(config.workspace_dir) +} + +fn handle_run_events(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload: RunEventsParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + let limit = payload + .limit + .unwrap_or(DEFAULT_EVENTS_LIMIT) + .min(MAX_EVENTS_LIMIT); + log::debug!( + "[rpc] openhuman.agent_run_events run_id={} offset={} limit={}", + payload.run_id, + payload.offset, + limit + ); + + let workspace = configured_workspace().await?; + let page = read_run_events_page(&workspace, &payload.run_id, payload.offset, limit) + .await + .map_err(|e| format!("read run events failed: {e:#}"))?; + log::debug!( + "[rpc] openhuman.agent_run_events run_id={} returned={} next_offset={:?}", + payload.run_id, + page.events.len(), + page.next_offset + ); + + let response = RunEventsResponse { + events: page.events, + next_offset: page.next_offset, + }; + serde_json::to_value(response).map_err(|e| format!("serialize response failed: {e}")) + }) +} + +fn handle_run_status(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload: RunStatusParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + log::debug!("[rpc] openhuman.agent_run_status run_id={}", payload.run_id); + + let workspace = configured_workspace().await?; + let status = read_run_status(&workspace, &payload.run_id) + .await + .map_err(|e| format!("read run status failed: {e:#}"))?; + log::debug!( + "[rpc] openhuman.agent_run_status run_id={} found={}", + payload.run_id, + status.is_some() + ); + + // Bare projection: object for a known run, `null` for an unknown one. + serde_json::to_value(status).map_err(|e| format!("serialize response failed: {e}")) + }) +} + +fn handle_runs_active(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload: RunsActiveParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + log::debug!( + "[rpc] openhuman.agent_runs_active thread_id={:?} root_run_id={:?}", + payload.thread_id, + payload.root_run_id + ); + + let workspace = configured_workspace().await?; + let runs = list_active_runs( + &workspace, + payload.thread_id.as_deref(), + payload.root_run_id.as_deref(), + ) + .await + .map_err(|e| format!("list active runs failed: {e:#}"))?; + log::debug!("[rpc] openhuman.agent_runs_active returned={}", runs.len()); + + serde_json::to_value(RunsActiveResponse { runs }) + .map_err(|e| format!("serialize response failed: {e}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn controller_inventory_is_stable() { + let schemas = all_agent_replay_controller_schemas(); + assert_eq!(schemas.len(), 3); + assert!(schemas.iter().all(|s| s.namespace == "agent")); + let functions: Vec<&str> = schemas.iter().map(|s| s.function).collect(); + assert!(functions.contains(&"run_events")); + assert!(functions.contains(&"run_status")); + assert!(functions.contains(&"runs_active")); + + let controllers = all_agent_replay_registered_controllers(); + assert_eq!(controllers.len(), 3); + // rpc method names follow openhuman._. + let methods: Vec = controllers.iter().map(|c| c.rpc_method_name()).collect(); + assert!(methods.contains(&"openhuman.agent_run_events".to_string())); + assert!(methods.contains(&"openhuman.agent_run_status".to_string())); + assert!(methods.contains(&"openhuman.agent_runs_active".to_string())); + } + + #[tokio::test] + async fn run_events_rejects_missing_run_id() { + let err = handle_run_events(Map::new()).await.unwrap_err(); + assert!(err.contains("invalid params"), "{err}"); + } +} diff --git a/src/openhuman/tinyagents/tests.rs b/src/openhuman/tinyagents/tests.rs index 3760604556..6d7d458ce2 100644 --- a/src/openhuman/tinyagents/tests.rs +++ b/src/openhuman/tinyagents/tests.rs @@ -508,11 +508,12 @@ fn adapter_inventory_registers_model_tools_and_middleware() { // Lifecycle middleware, in registration order: memory-protocol enforcement // (outermost), repeated-tool-failure breaker, shadow tool-exposure, // prompt-cache segment + guard, cache-align + tool-output - // (TurnContextMiddleware::defaults), cost budget, context compression + - // message trim (window known + autocompact on), SDK tool-policy projection, - // tool-outcome capture, arg recovery. + // (TurnContextMiddleware::defaults), observe-only crate BudgetMiddleware + // (W2-budget-dedupe), cost budget (local enforcement + budget_shadow), + // context compression + message trim (window known + autocompact on), SDK + // tool-policy projection, tool-outcome capture, arg recovery. let mw = assembled.harness.middleware(); - assert_eq!(mw.len(), 13, "lifecycle middleware inventory"); + assert_eq!(mw.len(), 14, "lifecycle middleware inventory"); // Around-tool wraps: approval/security + CLI/RPC-only scope gate (no // builder tool policy on this call). assert_eq!(mw.tool_middleware_len(), 2, "tool middleware inventory"); diff --git a/src/openhuman/todos/graph_shadow.rs b/src/openhuman/todos/graph_shadow.rs new file mode 100644 index 0000000000..f74be76068 --- /dev/null +++ b/src/openhuman/todos/graph_shadow.rs @@ -0,0 +1,425 @@ +//! Shadow adapter: mirror the OpenHuman task board into the vendored +//! `tinyagents::graph::todos` crate `TaskBoard` (crate `Store` namespace +//! `graph.todos`). +//! +//! ADAPTER-FIRST / SHADOW ONLY — nothing in this module changes product +//! behavior. The legacy [`TaskBoardStore`](crate::openhuman::agent::task_board) +//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of +//! truth. This module (C2b first slice) mirrors post-mutation card snapshots +//! into a crate `Store` and shadow-runs the crate `claim_card` CAS purely to +//! prove parity ahead of the C2 cutover, logging any divergence. All work is +//! best-effort and fire-and-forget: a mirror/claim failure is logged and +//! swallowed, never surfaced to a caller. +//! +//! # Status mapping +//! The OpenHuman and crate `TaskCardStatus` enums are 1:1 +//! (`Todo`/`AwaitingApproval`/`Ready`/`InProgress`/`Blocked`/`Done`/`Rejected`), +//! so [`map_status_to_crate`] is total and lossless. +//! +//! # Known OpenHuman ↔ crate semantic divergences (logged, not reconciled) +//! - **Scratch boards.** OpenHuman has an in-memory, thread-less +//! [`BoardLocation::Scratch`](crate::openhuman::todos::ops::BoardLocation) +//! fallback (tool calls outside a chat thread). The crate task board is +//! always `(Store, thread_id)`, so scratch mutations have no mirror target +//! and are skipped (trace-logged). +//! - **Card id minting.** OpenHuman `normalise_board` mints missing ids as +//! `task-`; the crate mints `task-`. We pass ids through unchanged +//! so an already-persisted board round-trips, but a brand-new blank id would +//! diverge — logged if observed. +//! - **Timestamps.** OpenHuman stores `updated_at` as RFC3339; the crate stores +//! unix-epoch millis. Cosmetic; the mirror does not attempt to reconcile. +//! - **Single writer.** The crate `Store` has no compare-and-set, so both the +//! mirror and the shadow-claim assume the core process is the only writer of +//! ns `graph.todos` (it is). Concurrent shadow tasks converge to the latest +//! legacy state; only the log-line ordering is nondeterministic. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tinyagents::graph::todos::store as crate_todos; +use tinyagents::graph::todos::{ + TaskApprovalMode as CrateApprovalMode, TaskBoardCard as CrateCard, + TaskCardStatus as CrateStatus, +}; +use tinyagents::harness::store::{FileStore, Store}; + +use crate::openhuman::agent::task_board::{ + TaskApprovalMode as OhApprovalMode, TaskBoardCard as OhCard, TaskCardStatus as OhStatus, +}; +use crate::openhuman::todos::ops::BoardLocation; + +/// Sub-directory of the workspace holding the crate `FileStore` that backs the +/// shadow `graph.todos` namespace. Kept separate from the authoritative +/// `agent_task_boards/` JSON so the shadow never collides with product state. +const SHADOW_STORE_DIR: &str = "tinyagents_graph_store"; + +/// Maps an OpenHuman [`OhStatus`] to the crate [`CrateStatus`]. Total (the two +/// enums share the same seven variants). +pub(crate) fn map_status_to_crate(status: &OhStatus) -> CrateStatus { + match status { + OhStatus::Todo => CrateStatus::Todo, + OhStatus::AwaitingApproval => CrateStatus::AwaitingApproval, + OhStatus::Ready => CrateStatus::Ready, + OhStatus::InProgress => CrateStatus::InProgress, + OhStatus::Blocked => CrateStatus::Blocked, + OhStatus::Done => CrateStatus::Done, + OhStatus::Rejected => CrateStatus::Rejected, + } +} + +/// Maps a crate [`CrateStatus`] back to an OpenHuman [`OhStatus`]. Total; used +/// only to compare a shadow-claim result against the legacy outcome. +pub(crate) fn map_status_from_crate(status: CrateStatus) -> OhStatus { + match status { + CrateStatus::Todo => OhStatus::Todo, + CrateStatus::AwaitingApproval => OhStatus::AwaitingApproval, + CrateStatus::Ready => OhStatus::Ready, + CrateStatus::InProgress => OhStatus::InProgress, + CrateStatus::Blocked => OhStatus::Blocked, + CrateStatus::Done => OhStatus::Done, + CrateStatus::Rejected => OhStatus::Rejected, + } +} + +fn map_approval_mode(mode: &OhApprovalMode) -> CrateApprovalMode { + match mode { + OhApprovalMode::Required => CrateApprovalMode::Required, + OhApprovalMode::NotRequired => CrateApprovalMode::NotRequired, + } +} + +/// Converts an OpenHuman [`OhCard`] into the crate [`CrateCard`], preserving the +/// id, status, and all optional metadata so a persisted board round-trips. +pub(crate) fn to_crate_card(card: &OhCard) -> CrateCard { + CrateCard { + id: card.id.clone(), + title: card.title.clone(), + status: map_status_to_crate(&card.status), + objective: card.objective.clone(), + plan: card.plan.clone(), + assigned_agent: card.assigned_agent.clone(), + allowed_tools: card.allowed_tools.clone(), + approval_mode: card.approval_mode.as_ref().map(map_approval_mode), + acceptance_criteria: card.acceptance_criteria.clone(), + evidence: card.evidence.clone(), + notes: card.notes.clone(), + blocker: card.blocker.clone(), + session_thread_id: card.session_thread_id.clone(), + source_metadata: card.source_metadata.clone(), + order: card.order, + updated_at: card.updated_at.clone(), + } +} + +/// Builds the crate `Store` rooted at `/tinyagents_graph_store`. +pub(crate) fn crate_store_for(workspace_dir: &Path) -> Arc { + Arc::new(FileStore::new(workspace_dir.join(SHADOW_STORE_DIR))) +} + +/// Returns `(workspace_dir, thread_id)` for a mirrorable `Thread` board, or +/// `None` for the thread-less `Scratch` board (which has no crate target). +fn thread_target(location: &BoardLocation) -> Option<(PathBuf, String)> { + match location { + BoardLocation::Thread { + workspace_dir, + thread_id, + } => Some((workspace_dir.clone(), thread_id.clone())), + BoardLocation::Scratch => None, + } +} + +/// Fire-and-forget: mirror the post-mutation `cards` for `location` into the +/// crate `graph.todos` store. No-op for scratch boards or when no tokio runtime +/// is available (e.g. a sync unit test). Never affects the caller. +pub(crate) fn spawn_mirror(location: &BoardLocation, cards: &[OhCard]) { + let Some((workspace_dir, thread_id)) = thread_target(location) else { + tracing::trace!("[todos][graph-shadow] mirror skipped: scratch board has no crate target"); + return; + }; + let crate_cards: Vec = cards.iter().map(to_crate_card).collect(); + let in_progress = crate_cards + .iter() + .filter(|c| matches!(c.status, CrateStatus::InProgress)) + .count(); + spawn_best_effort(async move { + let store = crate_store_for(&workspace_dir); + match crate_todos::replace(&store, &thread_id, crate_cards).await { + Ok(snap) => { + tracing::debug!( + thread_id = %thread_id, + card_count = snap.cards.len(), + "[todos][graph-shadow] mirror ok" + ); + } + Err(e) => { + // The most likely divergence is the single-InProgress invariant: + // the crate rejects >1 in-progress. Product enforces the same + // rule before save, so a rejection here flags a real mismatch. + tracing::warn!( + thread_id = %thread_id, + in_progress, + error = %e, + "[todos][graph-shadow] mirror DIVERGENCE (crate replace rejected)" + ); + } + } + }); +} + +/// Fire-and-forget shadow of a `claim_card` CAS. Mirrors `pre_cards` (the board +/// as loaded, before the legacy claim mutated it) into the crate store, replays +/// the crate `claim_card`, and logs whether the crate outcome agrees with the +/// authoritative `legacy_ok`. Log-only: the legacy claim stays authoritative. +pub(crate) fn spawn_shadow_claim( + location: &BoardLocation, + pre_cards: Vec, + card_id: &str, + expected: Vec, + target: OhStatus, + legacy_ok: bool, +) { + let Some((workspace_dir, thread_id)) = thread_target(location) else { + tracing::trace!( + "[todos][graph-shadow] shadow-claim skipped: scratch board has no crate target" + ); + return; + }; + let card_id = card_id.to_string(); + let crate_cards: Vec = pre_cards.iter().map(to_crate_card).collect(); + let crate_expected: Vec = expected.iter().map(map_status_to_crate).collect(); + let crate_target = map_status_to_crate(&target); + spawn_best_effort(async move { + let store = crate_store_for(&workspace_dir); + // Seed the crate board with the pre-claim snapshot so the CAS runs + // against the same state the legacy claim saw (deterministic regardless + // of any concurrent mirror task). + if let Err(e) = crate_todos::replace(&store, &thread_id, crate_cards).await { + tracing::debug!( + thread_id = %thread_id, + card_id = %card_id, + error = %e, + "[todos][graph-shadow] shadow-claim seed replace failed; skipping compare" + ); + return; + } + let crate_result = + crate_todos::claim_card(&store, &thread_id, &card_id, &crate_expected, crate_target) + .await; + let crate_ok = crate_result.is_ok(); + if crate_ok == legacy_ok { + tracing::debug!( + thread_id = %thread_id, + card_id = %card_id, + outcome_ok = legacy_ok, + "[todos][graph-shadow] shadow-claim parity" + ); + } else { + tracing::warn!( + thread_id = %thread_id, + card_id = %card_id, + legacy_ok, + crate_ok, + crate_err = crate_result.as_ref().err().map(|e| e.to_string()), + "[todos][graph-shadow] shadow-claim DIVERGENCE (legacy vs crate CAS disagree)" + ); + } + }); +} + +/// Spawns `fut` onto the current tokio runtime if one exists; otherwise +/// trace-logs and drops it. Keeps the shadow entirely off the caller's path. +fn spawn_best_effort(fut: F) +where + F: std::future::Future + Send + 'static, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn(fut); + } + Err(_) => { + tracing::trace!( + "[todos][graph-shadow] no tokio runtime; shadow task skipped (sync context)" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn oh_card(id: &str, status: OhStatus) -> OhCard { + OhCard { + id: id.to_string(), + title: format!("card {id}"), + status, + objective: Some("obj".to_string()), + plan: vec!["step-1".to_string()], + assigned_agent: Some("planner".to_string()), + allowed_tools: vec!["todo".to_string()], + approval_mode: Some(OhApprovalMode::Required), + acceptance_criteria: vec!["tests pass".to_string()], + evidence: vec!["cargo test".to_string()], + notes: Some("note".to_string()), + blocker: None, + session_thread_id: Some("thread-x".to_string()), + source_metadata: Some(serde_json::json!({ "urgency": 0.5 })), + order: 3, + updated_at: "2026-07-03T00:00:00Z".to_string(), + } + } + + #[test] + fn status_mapping_is_total_and_round_trips() { + let all = [ + OhStatus::Todo, + OhStatus::AwaitingApproval, + OhStatus::Ready, + OhStatus::InProgress, + OhStatus::Blocked, + OhStatus::Done, + OhStatus::Rejected, + ]; + for oh in all { + let crate_status = map_status_to_crate(&oh); + // The stable string label must survive the mapping unchanged. + assert_eq!(oh.as_str(), crate_status.as_str()); + // And the mapping must round-trip losslessly. + assert_eq!(map_status_from_crate(crate_status), oh); + } + } + + #[test] + fn card_conversion_preserves_all_fields() { + let oh = oh_card("task-1", OhStatus::InProgress); + let c = to_crate_card(&oh); + assert_eq!(c.id, "task-1"); + assert_eq!(c.title, "card task-1"); + assert_eq!(c.status, CrateStatus::InProgress); + assert_eq!(c.objective.as_deref(), Some("obj")); + assert_eq!(c.plan, vec!["step-1".to_string()]); + assert_eq!(c.assigned_agent.as_deref(), Some("planner")); + assert_eq!(c.allowed_tools, vec!["todo".to_string()]); + assert_eq!(c.approval_mode, Some(CrateApprovalMode::Required)); + assert_eq!(c.acceptance_criteria, vec!["tests pass".to_string()]); + assert_eq!(c.evidence, vec!["cargo test".to_string()]); + assert_eq!(c.notes.as_deref(), Some("note")); + assert_eq!(c.session_thread_id.as_deref(), Some("thread-x")); + assert_eq!( + c.source_metadata, + Some(serde_json::json!({ "urgency": 0.5 })) + ); + assert_eq!(c.order, 3); + assert_eq!(c.updated_at, "2026-07-03T00:00:00Z"); + } + + #[test] + fn approval_mode_maps_both_variants() { + assert_eq!( + map_approval_mode(&OhApprovalMode::Required), + CrateApprovalMode::Required + ); + assert_eq!( + map_approval_mode(&OhApprovalMode::NotRequired), + CrateApprovalMode::NotRequired + ); + } + + #[test] + fn scratch_board_has_no_crate_target() { + assert!(thread_target(&BoardLocation::Scratch).is_none()); + let loc = BoardLocation::Thread { + workspace_dir: PathBuf::from("/tmp/ws"), + thread_id: "user-tasks".to_string(), + }; + let (ws, tid) = thread_target(&loc).expect("thread target"); + assert_eq!(ws, PathBuf::from("/tmp/ws")); + assert_eq!(tid, "user-tasks"); + } + + /// The crate `store::replace` mirror path applied end-to-end: mapping a + /// legacy board of OpenHuman cards into the crate store yields a crate board + /// whose statuses and ids match, proving the mirror adapter round-trips. + #[tokio::test] + async fn mirror_round_trips_through_crate_store() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = crate_store_for(dir.path()); + let cards = vec![ + oh_card("task-a", OhStatus::Todo), + oh_card("task-b", OhStatus::InProgress), + oh_card("task-c", OhStatus::Blocked), + ]; + let crate_cards: Vec = cards.iter().map(to_crate_card).collect(); + let snap = crate_todos::replace(&store, "user-tasks", crate_cards) + .await + .expect("replace ok"); + assert_eq!(snap.cards.len(), 3); + assert_eq!(snap.cards[0].id, "task-a"); + assert_eq!(snap.cards[1].status, CrateStatus::InProgress); + assert_eq!(snap.cards[2].status, CrateStatus::Blocked); + + // A re-read via the crate list op returns the same board. + let listed = crate_todos::list(&store, "user-tasks") + .await + .expect("list ok"); + assert_eq!(listed.cards.len(), 3); + } + + /// The single-InProgress invariant is shared: a legacy board that already + /// violates it (two in-progress) is rejected by the crate mirror exactly as + /// the product `enforce_single_in_progress` would — the divergence the + /// mirror is built to surface. + #[tokio::test] + async fn crate_mirror_rejects_double_in_progress_like_product() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = crate_store_for(dir.path()); + let cards = vec![ + to_crate_card(&oh_card("task-a", OhStatus::InProgress)), + to_crate_card(&oh_card("task-b", OhStatus::InProgress)), + ]; + let err = crate_todos::replace(&store, "user-tasks", cards) + .await + .expect_err("double in-progress must be rejected"); + assert!( + err.to_string().contains("in_progress"), + "unexpected error: {err}" + ); + } + + /// The crate `claim_card` CAS agrees with the legacy claim contract: + /// claiming a `Todo` card to `InProgress` succeeds, and a second claim + /// expecting `Todo` is rejected because the card already moved on. + #[tokio::test] + async fn crate_claim_cas_matches_legacy_contract() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = crate_store_for(dir.path()); + let cards = vec![to_crate_card(&oh_card("task-a", OhStatus::Todo))]; + crate_todos::replace(&store, "user-tasks", cards) + .await + .expect("seed"); + + let expected = [CrateStatus::Todo, CrateStatus::Ready]; + let claimed = crate_todos::claim_card( + &store, + "user-tasks", + "task-a", + &expected, + CrateStatus::InProgress, + ) + .await + .expect("first claim ok"); + assert_eq!(claimed.status, CrateStatus::InProgress); + + // Second claim expecting Todo now loses the CAS — matches the legacy + // "claim rejected" path the dispatcher relies on. + let rejected = crate_todos::claim_card( + &store, + "user-tasks", + "task-a", + &expected, + CrateStatus::InProgress, + ) + .await; + assert!(rejected.is_err(), "stale claim must be rejected"); + } +} diff --git a/src/openhuman/todos/mod.rs b/src/openhuman/todos/mod.rs index 8cb3dc1787..95a8141b4a 100644 --- a/src/openhuman/todos/mod.rs +++ b/src/openhuman/todos/mod.rs @@ -13,6 +13,7 @@ //! `markdown` string so the chat UI / agent transcript can render the //! list directly without re-formatting. +pub mod graph_shadow; pub mod ops; pub mod runs; pub mod schemas; diff --git a/src/openhuman/todos/ops.rs b/src/openhuman/todos/ops.rs index 400b646bcd..aef41bdd5c 100644 --- a/src/openhuman/todos/ops.rs +++ b/src/openhuman/todos/ops.rs @@ -153,7 +153,12 @@ fn save_cards( }; normalise_board(&mut board); let store = TaskBoardStore::new(workspace_dir.clone()); - Ok(store.put(board)?.cards) + let saved = store.put(board)?.cards; + // C2b shadow (adapter-first): mirror the persisted board into the + // vendored crate `graph.todos` store. Fire-and-forget, log-only — + // never affects this authoritative write. + super::graph_shadow::spawn_mirror(location, &saved); + Ok(saved) } BoardLocation::Scratch => { let mut board = TaskBoard { @@ -541,6 +546,55 @@ pub fn claim_card( let _scratch_guard = maybe_scratch_lock(location); let mut cards = load_cards(location)?; + // Snapshot the pre-claim board so the C2b shadow can replay the crate CAS + // against the same state the legacy claim saw (see below). + let pre_cards = cards.clone(); + + // Compute the authoritative outcome without early-returning, so the shadow + // observes the same ok/err verdict (including the not-found/wrong-status + // rejection paths the dispatcher relies on). + let legacy = apply_claim(&mut cards, card_id, expected, target.clone()); + let legacy_ok = legacy.is_ok(); + + let result = match legacy { + Ok(claimed_card) => { + let saved = save_cards(location, cards)?; + emit_progress(location, &saved); + tracing::info!( + card_id = %card_id, + new_status = %claimed_card.status.as_str(), + "[todos][ops] claim_card ok" + ); + Ok(claimed_card) + } + Err(e) => Err(e), + }; + + // Shadow the CAS onto the vendored crate `graph.todos` store (adapter-first, + // log-only). The legacy claim above stays authoritative. + super::graph_shadow::spawn_shadow_claim( + location, + pre_cards, + card_id, + expected.to_vec(), + target, + legacy_ok, + ); + + result +} + +/// Applies a claim to an in-memory card set: find `card_id`, verify its status +/// is in `expected`, transition it to `target`, and enforce the single- +/// `InProgress` invariant. Returns the claimed card (cloned) on success. Does +/// **not** persist — the caller saves the mutated `cards`. Extracted so +/// [`claim_card`] can capture a single ok/err verdict for its crate shadow. +fn apply_claim( + cards: &mut [TaskBoardCard], + card_id: &str, + expected: &[TaskCardStatus], + target: TaskCardStatus, +) -> Result { let card = cards .iter_mut() .find(|c| c.id == card_id) @@ -563,15 +617,7 @@ pub fn claim_card( card.updated_at = Utc::now().to_rfc3339(); let claimed_card = card.clone(); - enforce_single_in_progress(&cards)?; - let cards = save_cards(location, cards)?; - emit_progress(location, &cards); - - tracing::info!( - card_id = %card_id, - new_status = %claimed_card.status.as_str(), - "[todos][ops] claim_card ok" - ); + enforce_single_in_progress(cards)?; Ok(claimed_card) } diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index bd023ddd6a..8d86253889 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -25,6 +25,7 @@ pub use crate::openhuman::credentials::tools::*; pub use crate::openhuman::cron::tools::*; pub use crate::openhuman::dashboard::tools::*; pub use crate::openhuman::doctor::tools::*; +pub use crate::openhuman::flows::tools::*; pub use crate::openhuman::health::tools::*; pub use crate::openhuman::integrations::tools::*; pub use crate::openhuman::learning::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index a67cfb8e96..e0ae2c6342 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -257,6 +257,11 @@ pub fn all_tools_with_runtime( Box::new(CronUpdateTool::new(config.clone(), security.clone())), Box::new(CronRunTool::new(config.clone())), Box::new(CronRunsTool::new(config.clone())), + // Agent-first Workflow authoring (issue B4): validates a candidate + // graph and returns a proposal summary — never creates/enables a + // flow itself. Only the chat UI's WorkflowProposalCard "Save & + // enable" action calls `flows_create`. + Box::new(ProposeWorkflowTool::new(config.clone())), // Wallet tools — expose wallet operations to the agent tool-call pipeline // so the crypto sub-agent can prepare transfers, check status, etc. Box::new(WalletStatusTool::new()), diff --git a/tests/agent_session_turn_raw_coverage_e2e.rs b/tests/agent_session_turn_raw_coverage_e2e.rs index 2ddb911739..326acc97d5 100644 --- a/tests/agent_session_turn_raw_coverage_e2e.rs +++ b/tests/agent_session_turn_raw_coverage_e2e.rs @@ -787,8 +787,23 @@ async fn turn_xml_failures_checkpoint_policy_visibility_and_hooks_are_publicly_e channel_permissions, ..AgentConfig::default() }) + // Budget must clear the rendered policy-denial message (~400 B) so the + // `denied by policy 'round17-deny'` assertion below still sees it. Before + // the tinyagents 1.5 migration (#4473) policy denials bypassed the + // per-result budget entirely; the migration now routes them through + // `ToolOutputMiddleware.after_tool`, so a tiny 96 B budget truncated the + // denial down to a `[… truncated …]` stub and the assertion no longer + // saw it. Production's default budget is 16 KiB, so real denials (~400 B) + // are never truncated — the old 96 B here was an artificial value with no + // assertion depending on truncation actually happening. + // TODO(follow-up): restore the "policy denials are exempt from the + // per-result budget" contract in production (tag the denial render with + // POLICY_BLOCKED_MARKER and skip the budget/persist path for it in + // `ToolOutputMiddleware.after_tool`). That also re-enables the no-progress + // `hard_reject` fast-path, which currently never fires for policy denials + // because their render omits the marker it greps for. .context_config(ContextConfig { - tool_result_budget_bytes: 96, + tool_result_budget_bytes: 8192, ..ContextConfig::default() }) .post_turn_hooks(vec![Arc::new(RecordingHook { diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 266d46584c..802855f2fe 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2802,6 +2802,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_get_meet_settings", "openhuman.config_get_memory_sync_settings", "openhuman.config_get_onboarding_completed", + "openhuman.config_get_privacy_mode", "openhuman.config_get_runtime_flags", "openhuman.config_get_sandbox_settings", "openhuman.config_get_search_settings", @@ -2811,6 +2812,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_resolve_api_url", "openhuman.config_set_browser_allow_all", "openhuman.config_set_onboarding_completed", + "openhuman.config_set_privacy_mode", "openhuman.config_set_super_context_enabled", "openhuman.config_update_activity_level_settings", "openhuman.config_update_agent_paths", diff --git a/vendor/tinyagents b/vendor/tinyagents new file mode 160000 index 0000000000..ac7338241d --- /dev/null +++ b/vendor/tinyagents @@ -0,0 +1 @@ +Subproject commit ac7338241d50f2ecbfe52e518626b31d52c16ce6