mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Improve execplan accuracy
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# Extract Create-Agent Guided Setup Lifecycle From AgentStudioPage
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
This repository keeps plan requirements at `.agent/PLANS.md`. This ExecPlan must be maintained in accordance with `.agent/PLANS.md`.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
`src/app/page.tsx` currently combines user-interface wiring and create-agent lifecycle orchestration in one high-churn module. The create path spans guard decisions, guided setup compilation, queue orchestration, gateway-side create/setup writes, pending setup persistence/retry behavior, and timeout recovery in the same page component. After this refactor, those lifecycle decisions move into operation-layer modules so the page is primarily wiring and rendering. This is a pure refactor: no user-visible behavior should change in create, pending setup retry/discard, or timeout handling.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] (2026-02-17 00:44Z) Created `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` with dependency-injected create lifecycle orchestration (`runCreateAgentMutationLifecycle`, `runPendingCreateSetupRetryLifecycle`, `isCreateBlockTimedOut`).
|
||||
- [x] (2026-02-17 00:44Z) Moved create-submit orchestration out of `src/app/page.tsx` and delegated through `runCreateAgentMutationLifecycle`.
|
||||
- [x] (2026-02-17 00:44Z) Replaced inline create-timeout branch logic with timeout evaluation routed through `isCreateBlockTimedOut` (which delegates to `resolveMutationTimeoutIntent`).
|
||||
- [x] (2026-02-17 00:44Z) Extracted page-local pending setup retry glue from `applyPendingCreateSetupForAgentId` into `runPendingCreateSetupRetryLifecycle`.
|
||||
- [x] (2026-02-17 00:44Z) Added unit coverage in `tests/unit/createAgentMutationLifecycleOperation.test.ts` (7 tests covering guard, validation, success, pending, retry, and timeout mapping).
|
||||
- [x] (2026-02-17 00:44Z) Ran `npm run typecheck`, targeted `vitest --run` suites, and full `vitest --run` suite successfully.
|
||||
- [x] (2026-02-17 00:44Z) Confirmed page-size reduction and boundary cleanliness (`src/app/page.tsx` 2707 -> 2688 lines; no infrastructure/browser imports in extracted operation module per `rg` check).
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
- Observation: timeout policy for create/rename/delete already exists and is unit-tested in `resolveMutationTimeoutIntent`.
|
||||
Evidence: `src/features/agents/operations/agentMutationLifecycleController.ts:197` and `tests/unit/agentMutationLifecycleController.test.ts` include explicit `create-timeout` assertions.
|
||||
- Observation: full test suite still emits intentional stderr from throwing-storage tests while passing.
|
||||
Evidence: `tests/unit/pendingGuidedSetupStore.test.ts` logs `getItem/setItem/removeItem failed` in expected negative-path assertions; suite result remained green (`109 passed` files, `505 passed` tests).
|
||||
- Observation: line-count reduction was measurable but smaller than the initial projection.
|
||||
Evidence: `wc -l src/app/page.tsx` changed from `2707` to `2688` after extraction because this pass moved lifecycle branching into operation modules but retained page-owned UI wiring callbacks.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: Extract the create-agent guided setup lifecycle first.
|
||||
Rationale: This is the highest-impact entanglement by weighted score across blast radius, testability damage, bug surface, change frequency, and extraction feasibility. It sits in the highest-churn file (`src/app/page.tsx`, 162 commit touches) and interleaves domain decisions with gateway calls, session storage lifecycle, timers, and UI side effects.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Keep pending setup auto-retry and session-storage modules in place, and extract only the page-level orchestration seam that currently glues them together.
|
||||
Rationale: `guidedCreateWorkflow`, `pendingGuidedSetupRetryOperation`, and `pendingGuidedSetupAutoRetryOperation` already represent partial separations. The best first cut is to remove the remaining orchestration center from `src/app/page.tsx` rather than rewrite working policy helpers.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Reuse `resolveMutationTimeoutIntent` instead of introducing a second timeout policy helper.
|
||||
Rationale: The existing helper already encodes create-timeout behavior and is covered by unit tests. Reusing it avoids policy drift and duplicated test surface.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Mirror the dependency-injected pattern used by `runAgentConfigMutationLifecycle` for the create extraction.
|
||||
Rationale: The repo already uses operation-layer lifecycle modules with callback dependencies to keep page components thin and unit-testable. Following that pattern minimizes conceptual overhead.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Keep all extracted create lifecycle logic in a single new operation module (`createAgentMutationLifecycleOperation.ts`) for this refactor.
|
||||
Rationale: The current objective is to reduce `src/app/page.tsx` entanglement with the smallest number of new concepts. One module is sufficient and matches existing operation naming/placement conventions.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Accept the smaller-than-planned line-count reduction as complete for this refactor.
|
||||
Rationale: The intended boundary extraction was completed (create submit, pending retry glue, and timeout policy routing), typecheck/tests are green, and remaining `page.tsx` size is primarily UI wiring rather than the extracted lifecycle policy.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
- Implemented successfully with behavior preserved and test coverage added.
|
||||
- New operation boundary: `src/features/agents/operations/createAgentMutationLifecycleOperation.ts`.
|
||||
- `src/app/page.tsx` now delegates create submit/retry/timeout policy decisions to operation-layer helpers instead of owning the full branching lifecycle inline.
|
||||
- Verification:
|
||||
- `npm run typecheck` passed.
|
||||
- Targeted tests passed (9 files / 38 tests).
|
||||
- Full test run passed (109 files / 505 tests).
|
||||
- Structural evidence:
|
||||
- `src/app/page.tsx`: `2707` -> `2688` lines.
|
||||
- `rg -n "@/lib/gateway|@/lib/http|window\\.|sessionStorage|fetch\\(|WebSocket|useEffect\\(" src/features/agents/operations/createAgentMutationLifecycleOperation.ts` returned no matches.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
The target file is `src/app/page.tsx` (currently 2707 lines). The create lifecycle is spread across several regions:
|
||||
|
||||
- `src/app/page.tsx:693-727`: page callback `applyPendingCreateSetupForAgentId` composes retry operation, in-flight guards, `loadAgents`, and error fanout.
|
||||
- `src/app/page.tsx:856-885`: pending setup load/persist effect pair around `window.sessionStorage` scope management.
|
||||
- `src/app/page.tsx:887-914`: auto-retry effect that bridges page state into `runPendingGuidedSetupAutoRetryViaStudio`.
|
||||
- `src/app/page.tsx:1514-1657`: `handleCreateAgentSubmit`, which currently orchestrates guard checks, compile/validation, queueing, create/apply/pending behavior, UI state updates, and error handling.
|
||||
- `src/app/page.tsx:1659-1672`: create-timeout effect that clears block state, closes modal, reloads agents, and sets the timeout error.
|
||||
|
||||
The create submit branch also includes nontrivial UI side effects that must stay behaviorally identical after extraction: avatar persistence via `persistAvatarSeed` (`src/app/page.tsx:1494`), draft flush and focus filter reset (`src/app/page.tsx:1582-1584`), focused agent selection and pane changes (`src/app/page.tsx:1585-1588`), immediate modal close after queue submission (`src/app/page.tsx:1630`), and consistent busy/block teardown in success/catch/finally paths.
|
||||
|
||||
The extracted logic must continue to compose these existing modules and helpers:
|
||||
|
||||
- `src/features/agents/operations/guidedCreateWorkflow.ts` (`runGuidedCreateWorkflow`, `resolveGuidedCreateCompletion`, `runGuidedRetryWorkflow`).
|
||||
- `src/features/agents/operations/pendingGuidedSetupRetryOperation.ts` and `src/features/agents/operations/pendingGuidedSetupAutoRetryOperation.ts`.
|
||||
- `src/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle.ts` and `src/features/agents/creation/recovery.ts`.
|
||||
- `src/features/agents/operations/agentMutationLifecycleController.ts` (`resolveMutationStartGuard`, `buildQueuedMutationBlock`, `resolveMutationTimeoutIntent`).
|
||||
- `src/features/agents/operations/useConfigMutationQueue.ts` (`ConfigMutationKind`, queue contract).
|
||||
|
||||
A “guided setup” means the compiled per-agent setup payload (`agentOverrides`, `files`, `execApprovals`) built from `compileGuidedAgentCreation` in `src/features/agents/creation/compiler.ts:430`. A “pending guided setup” means agent creation succeeded but setup application failed, so setup data is retained for manual/automatic retry.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
Milestone 1 introduces `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` and moves create-submit orchestration there. The new module should be operation-only: no React hooks, no browser globals, and no direct gateway/http imports. It should accept dependencies for side effects (queue submission, create/apply calls, state update callbacks), run guard and workflow policy helpers, and return a typed success/failure outcome.
|
||||
|
||||
Milestone 2 moves pending setup retry glue out of `src/app/page.tsx`. Today, the page-level callback `applyPendingCreateSetupForAgentId` still composes retry in-flight guards, pending map lookups, `runGuidedRetryWorkflow`, and user-facing error behavior. Extract that callback logic into the same new operation module, while keeping existing `pendingGuidedSetupRetryOperation` and `pendingGuidedSetupAutoRetryOperation` as the underlying policy/adapter layers.
|
||||
|
||||
Milestone 3 updates timeout handling to reuse existing policy. Replace inline timeout math in the create-timeout effect with `resolveMutationTimeoutIntent` using a mapped create mutation block, then execute the same page side effects (`setCreateAgentBlock(null)`, `setCreateAgentModalOpen(false)`, `loadAgents`, timeout error message) when intent is `create-timeout`.
|
||||
|
||||
Milestone 4 adds unit coverage and validates parity. Add tests for the new operation module following existing Vitest conventions (`vi.fn` callback stubs, explicit call-order assertions). Keep existing workflow and lifecycle tests green to prove no behavioral drift.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
All commands below run from `/Users/georgepickett/.codex/worktrees/db4a/openclaw-studio`.
|
||||
|
||||
1. Capture baseline shape and anchors.
|
||||
|
||||
wc -l src/app/page.tsx
|
||||
rg -n "persistAvatarSeed|applyPendingCreateSetupForAgentId|handleCreateAgentSubmit|runPendingGuidedSetupAutoRetryViaStudio|Agent creation timed out|createBlockStatusLine" src/app/page.tsx
|
||||
rg -n "resolveMutationStartGuard|buildQueuedMutationBlock|resolveMutationTimeoutIntent" src/features/agents/operations/agentMutationLifecycleController.ts
|
||||
|
||||
Expected outcome: current create lifecycle anchors appear in `src/app/page.tsx`, and timeout policy helper exists in `agentMutationLifecycleController.ts`.
|
||||
|
||||
2. Create the operation module and define concrete interfaces.
|
||||
|
||||
test -f src/features/agents/operations/createAgentMutationLifecycleOperation.ts || touch src/features/agents/operations/createAgentMutationLifecycleOperation.ts
|
||||
|
||||
Implement operation functions that encapsulate:
|
||||
|
||||
- create-submit lifecycle orchestration,
|
||||
- pending setup retry orchestration now in `applyPendingCreateSetupForAgentId`,
|
||||
- create-timeout intent mapping via `resolveMutationTimeoutIntent`.
|
||||
|
||||
Expected outcome: module exports are present and referenced by `src/app/page.tsx` with no browser/global API usage in the module.
|
||||
|
||||
3. Rewire page handlers/effects to operation boundaries.
|
||||
|
||||
rg -n "handleCreateAgentSubmit|applyPendingCreateSetupForAgentId|createAgentBlock.phase === \"queued\"|Agent creation timed out" src/app/page.tsx
|
||||
|
||||
Update `src/app/page.tsx` to keep local state updates and render wiring only; remove inline lifecycle branching now owned by the operation module.
|
||||
|
||||
4. Add focused unit tests for the extracted operation module.
|
||||
|
||||
test -f tests/unit/createAgentMutationLifecycleOperation.test.ts || touch tests/unit/createAgentMutationLifecycleOperation.test.ts
|
||||
|
||||
Cover at least:
|
||||
|
||||
- guard denial when disconnected,
|
||||
- compile/validation failure mapping to modal error,
|
||||
- successful create + setup apply path,
|
||||
- pending setup fallback path after setup failure,
|
||||
- pending retry path success and failure handling,
|
||||
- timeout intent mapping to `create-timeout`.
|
||||
|
||||
Follow local testing style already used in `tests/unit/agentConfigMutationLifecycleOperation.test.ts`: dependency-injected callback stubs, call-order assertions, and explicit outcome assertions.
|
||||
|
||||
5. Validate with targeted and full tests.
|
||||
|
||||
npm run typecheck
|
||||
npm run test -- --run tests/unit/createAgentMutationLifecycleOperation.test.ts tests/unit/guidedCreateWorkflow.test.ts tests/unit/guidedCreateWorkflow.integration.test.ts tests/unit/pendingSetupLifecycleWorkflow.test.ts tests/unit/pendingGuidedSetupRetryOperation.test.ts tests/unit/pendingGuidedSetupAutoRetryOperation.test.ts tests/unit/pendingGuidedSetupSessionStorageLifecycle.test.ts tests/unit/agentMutationLifecycleController.test.ts tests/unit/agentMutationLifecycleController.integration.test.ts
|
||||
npm run test -- --run
|
||||
wc -l src/app/page.tsx
|
||||
rg -n "@/lib/gateway|@/lib/http|window\.|sessionStorage|fetch\(|WebSocket|useEffect\(" src/features/agents/operations/createAgentMutationLifecycleOperation.ts || true
|
||||
|
||||
Expected outcome: typecheck passes, targeted suites pass, full suite passes, page line count decreases materially, and extracted operation module remains infrastructure/browser independent.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Acceptance requires structural proof and behavior parity proof.
|
||||
|
||||
Structural acceptance:
|
||||
|
||||
- `src/app/page.tsx` is reduced measurably after extraction (target was 120 to 220 lines; observed reduction in this pass was 19 lines).
|
||||
- `handleCreateAgentSubmit` is no longer the orchestration center; it delegates to operation-layer functions.
|
||||
- `applyPendingCreateSetupForAgentId` page-level glue is removed or reduced to a thin call-through.
|
||||
- create-timeout effect uses lifecycle timeout policy (`resolveMutationTimeoutIntent`) rather than inline elapsed-time branching.
|
||||
- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` has no direct `@/lib/gateway/*`, `@/lib/http`, `window`, `sessionStorage`, `fetch`, `WebSocket`, or React-hook imports.
|
||||
- `src/app/page.tsx` still performs the same UI side effects for successful create (`persistAvatarSeed`, focus reset, agent select, modal close, pane set) and for failure/timeout paths.
|
||||
|
||||
Behavior acceptance:
|
||||
|
||||
1. When disconnected, create submit still surfaces `Connect to gateway before creating an agent.` in modal error state.
|
||||
2. Validation failures from `compileGuidedAgentCreation` still block create and show the first validation error.
|
||||
3. Successful create/apply still clears pending setup, reloads agents, and keeps create status text behavior (`Waiting for active runs to finish` -> `Submitting config change` -> `Applying guided setup`).
|
||||
4. Setup failure after create still preserves pending setup and shows the existing pending setup error banner from `resolveGuidedCreateCompletion`.
|
||||
5. Manual retry via pending setup card still routes through the same retry lifecycle behavior and error messages.
|
||||
6. Timeout still clears create block, closes modal, reloads agents, and sets `Agent creation timed out.`.
|
||||
|
||||
Verification commands are mandatory: `npm run typecheck`, targeted `vitest --run`, and full `vitest --run` must all pass.
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
This refactor is additive and retriable. Running the edit sequence multiple times is safe because it introduces one operation module and rewires imports/callbacks.
|
||||
|
||||
If rollback is needed:
|
||||
|
||||
git checkout -- src/app/page.tsx
|
||||
rm -f src/features/agents/operations/createAgentMutationLifecycleOperation.ts
|
||||
rm -f tests/unit/createAgentMutationLifecycleOperation.test.ts
|
||||
|
||||
If the new files are already tracked at rollback time, replace `rm -f` with:
|
||||
|
||||
git checkout -- src/features/agents/operations/createAgentMutationLifecycleOperation.ts tests/unit/createAgentMutationLifecycleOperation.test.ts
|
||||
|
||||
Then rerun:
|
||||
|
||||
npm run typecheck
|
||||
npm run test -- --run
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Baseline evidence for this plan revision:
|
||||
|
||||
- `src/app/page.tsx` line count: `2707`.
|
||||
- Churn snapshot from git history:
|
||||
- `src/app/page.tsx`: `162` touches
|
||||
- `src/features/agents/state/gatewayRuntimeEventHandler.ts`: `18` touches
|
||||
- `src/features/agents/components/AgentInspectPanels.tsx`: `15` touches
|
||||
- Existing timeout policy helper already present and tested:
|
||||
- `src/features/agents/operations/agentMutationLifecycleController.ts:197`
|
||||
- `tests/unit/agentMutationLifecycleController.test.ts`
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
Create `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` with explicit dependency-injected interfaces that match existing patterns in `src/features/agents/operations/agentConfigMutationLifecycleOperation.ts`.
|
||||
|
||||
Define operation interfaces around current real types:
|
||||
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation";
|
||||
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
|
||||
export type CreateAgentMutationLifecycleDeps = {
|
||||
enqueueConfigMutation: (params: { kind: ConfigMutationKind; label: string; run: () => Promise<void> }) => Promise<void>;
|
||||
createAgent: (name: string) => Promise<{ id: string }>;
|
||||
applySetup: (agentId: string, setup: AgentGuidedSetup) => Promise<void>;
|
||||
upsertPending: (agentId: string, setup: AgentGuidedSetup) => void;
|
||||
removePending: (agentId: string) => void;
|
||||
onQueued: (params: { agentName: string; startedAt: number }) => void;
|
||||
onCreating: (agentName: string) => void;
|
||||
onApplyingSetup: (params: { agentName: string; agentId: string }) => void;
|
||||
onCreatedAgent: (params: { agentId: string; avatarSeed: string | null }) => void;
|
||||
onCompletion: (params: { shouldReloadAgents: boolean; shouldCloseCreateModal: boolean; pendingErrorMessage: string | null }) => Promise<void> | void;
|
||||
onModalError: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
clearCreateBlock: () => void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
resolveAgentName: (agentId: string) => string;
|
||||
loadAgents: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function runCreateAgentMutationLifecycle(params: {
|
||||
payload: AgentCreateModalSubmitPayload;
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
hasCreateBlock: boolean;
|
||||
hasRenameBlock: boolean;
|
||||
hasDeleteBlock: boolean;
|
||||
createAgentBusy: boolean;
|
||||
isLocalGateway: boolean;
|
||||
}, deps: CreateAgentMutationLifecycleDeps): Promise<boolean>;
|
||||
|
||||
export async function runPendingCreateSetupRetryLifecycle(params: {
|
||||
agentId: string;
|
||||
source: "auto" | "manual";
|
||||
retryBusyAgentId: string | null;
|
||||
pendingSetupsByAgentId: Record<string, AgentGuidedSetup>;
|
||||
inFlightAgentIds: Set<string>;
|
||||
executeRetry: (agentId: string) => Promise<{ applied: boolean }>;
|
||||
setRetryBusyAgentId: (next: string | null | ((current: string | null) => string | null)) => void;
|
||||
}, deps: {
|
||||
onApplied: () => Promise<void> | void;
|
||||
onError: (message: string) => void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
resolveAgentName: (agentId: string) => string;
|
||||
}): Promise<boolean>;
|
||||
|
||||
export function isCreateBlockTimedOut(params: {
|
||||
startedAt: number;
|
||||
nowMs: number;
|
||||
maxWaitMs: number;
|
||||
}): boolean;
|
||||
|
||||
`isCreateBlockTimedOut` should delegate to `resolveMutationTimeoutIntent` with a mapped create block shape so timeout policy remains single-sourced.
|
||||
|
||||
Do not introduce new policy logic for guard checks or guided create outcomes; reuse:
|
||||
|
||||
- `resolveMutationStartGuard` and `buildQueuedMutationBlock` from `src/features/agents/operations/agentMutationLifecycleController.ts`.
|
||||
- `runGuidedCreateWorkflow` and `resolveGuidedCreateCompletion` from `src/features/agents/operations/guidedCreateWorkflow.ts`.
|
||||
- `applyPendingGuidedSetupRetryViaStudio` composition path already used in `src/features/agents/operations/pendingGuidedSetupRetryOperation.ts`.
|
||||
|
||||
Revision notes:
|
||||
|
||||
- 2026-02-17: Initial plan authored from `find-entangled-flows` analysis. Chosen extraction is create-agent guided setup lifecycle orchestration from `src/app/page.tsx` into a dedicated operation module.
|
||||
- 2026-02-17: Improved via deep code-grounded review. Removed speculative timeout-helper duplication, aligned timeout work to existing `resolveMutationTimeoutIntent`, added missing pending-retry glue extraction, tightened concrete tests/commands against existing unit suites, and aligned interface guidance with existing operation-layer patterns.
|
||||
- 2026-02-17: Improved again via adjacency pass. Removed remaining multi-file ambiguity by fixing extraction target to one module, added missing side-effect parity checks tied to exact `src/app/page.tsx` anchors, and tightened dependency guidance to the existing `applyPendingGuidedSetupRetryViaStudio` composition path.
|
||||
- 2026-02-17: Implemented end-to-end. Added `createAgentMutationLifecycleOperation`, rewired `src/app/page.tsx` create/retry/timeout flows to operation boundaries, added unit coverage, and validated with typecheck plus targeted/full tests.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Extract Gateway Event Ingress Decision Workflow From `src/app/page.tsx`
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
This repository includes `.agent/PLANS.md`; this document must be maintained in accordance with it.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
The highest-impact architectural entanglement in this repository is the gateway event ingress flow in `src/app/page.tsx`, where runtime event wiring, approval domain decisions, cron domain parsing, React state mutations, and transcript side effects are interleaved in one callback chain. After this refactor, ingress domain interpretation will live in one pure workflow module with plain inputs and outputs, and `page.tsx` will remain orchestration-only. This is a pure refactor with no intended user-visible behavior changes: approvals and cron transcript updates should behave exactly as they do today.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] (2026-02-17 01:13Z) Identify and lock current ingress behavior with a new unit test file for extracted decisions.
|
||||
- [x] (2026-02-17 01:13Z) Add `src/features/agents/state/gatewayEventIngressWorkflow.ts` with pure resolver interfaces for approval + cron ingress decisions.
|
||||
- [x] (2026-02-17 01:13Z) Refactor `src/app/page.tsx` to call the resolver and apply resulting commands/effects.
|
||||
- [x] (2026-02-17 01:13Z) Run typecheck, focused tests, and full unit test run.
|
||||
- [x] (2026-02-17 01:13Z) Documented deferred manual Studio sanity checks because no reachable gateway is available in this execution environment.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
- Observation: The refactor removed ingress decision parsing from `src/app/page.tsx`, but total file line count only dropped from 2688 to 2686.
|
||||
Evidence: `wc -l src/app/page.tsx` before/after check; side-effect application code intentionally remains in `page.tsx` orchestration.
|
||||
- Observation: Structural grep checks that verify removal of inline ingress parsing now return exit code 1 because no matches remain.
|
||||
Evidence: `rg -n "handleExecApprovalEvent|resolveExecApprovalEventEffects|parseAgentIdFromSessionKey|event\\.event === \"cron\"|record\\.action === \"finished\"" src/app/page.tsx` returned no matches after refactor.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: Extract gateway event ingress decision logic from `src/app/page.tsx` before other candidates.
|
||||
Rationale: Weighted entanglement scoring across top candidates identified this as the single worst boundary violation.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Keep `client.onEvent` subscription ownership and side-effect application in `src/app/page.tsx`; extract only domain decision interpretation into a pure workflow module.
|
||||
Rationale: This is the highest-ROI cut with minimal churn to unrelated runtime handler internals while immediately reducing mixed decision/side-effect logic in the hottest callback path.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Preserve cron dedupe ordering semantics exactly (record dedupe key before agent-exists check) and preserve transcript metadata contract.
|
||||
Rationale: Current behavior in `src/app/page.tsx:1954-1999` relies on this order and metadata shape; changing it risks duplicate lines or regressions in transcript rendering.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
- Decision: Use `removePendingApprovalEverywhere` while applying removal effects in the refactored ingress orchestrator.
|
||||
Rationale: `src/features/agents/approvals/pendingStore.ts` already provides an idempotent scoped+unscoped removal helper with unit coverage in `tests/unit/pendingExecApprovalsStore.test.ts`; reusing it reduces duplicated reducer logic and drift risk.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
Implemented the extraction end-to-end with no user-visible behavior changes intended. Ingress decision interpretation now lives in `src/features/agents/state/gatewayEventIngressWorkflow.ts`, and `src/app/page.tsx` delegates gateway ingress events through `resolveGatewayEventIngressDecision` before applying side effects.
|
||||
|
||||
Verification results are strong: `npm run typecheck` passed, focused regression suites passed, and full unit suite passed (`110` files, `512` tests). New unit coverage in `tests/unit/gatewayEventIngressWorkflow.test.ts` verifies malformed cron rejection, dedupe behavior, known/unknown agent handling, transcript formatting/timestamp fallback, and approval effect delegation.
|
||||
|
||||
Manual runtime validation was deferred because this session does not have a reachable gateway target. The implementation risk that remains is runtime-only drift in live gateway event payloads not represented in unit fixtures.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
The core user-facing flows in this codebase are:
|
||||
|
||||
1. Gateway connect and runtime subscription (`src/lib/gateway/GatewayClient.ts`, `src/app/page.tsx`).
|
||||
2. Live agent/chat runtime streaming to transcript (`src/features/agents/state/gatewayRuntimeEventHandler.ts`, `src/app/page.tsx`).
|
||||
3. Exec approval event intake and approval resolution (`src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, `src/features/agents/approvals/execApprovalResolveOperation.ts`, `src/app/page.tsx`).
|
||||
4. Agent creation and guided setup lifecycle (`src/features/agents/operations/createAgentMutationLifecycleOperation.ts`, `src/app/page.tsx`).
|
||||
5. History synchronization and transcript reconciliation (`src/features/agents/operations/historySyncOperation.ts`, `src/app/page.tsx`).
|
||||
|
||||
The worst entanglement is in flow 2+3 overlap inside `src/app/page.tsx`:
|
||||
|
||||
- `src/app/page.tsx:1888-1932` contains `handleExecApprovalEvent`, where approval event interpretation and React store mutation orchestration are tightly interleaved.
|
||||
- `src/app/page.tsx:1954-1999` contains the `client.onEvent` callback that simultaneously:
|
||||
- delegates runtime stream handling (`handler.handleEvent`),
|
||||
- runs approval decision interpretation,
|
||||
- parses raw cron payload shape and validity,
|
||||
- performs dedupe decisions,
|
||||
- resolves agent identity,
|
||||
- emits transcript side effects and activity updates.
|
||||
|
||||
This file is currently 2688 lines (`wc -l src/app/page.tsx`), so this mixed callback carries substantial blast radius and merge risk.
|
||||
|
||||
For comparison, other high-value flows are already separated into workflow/operation modules with dedicated tests (`tests/unit/createAgentMutationLifecycleOperation.test.ts`, `tests/unit/agentConfigMutationLifecycleOperation.test.ts`, `tests/unit/historySyncOperation.test.ts`), which reduces their current entanglement score relative to ingress.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
Milestone 1 introduces a new pure decision workflow module named `src/features/agents/state/gatewayEventIngressWorkflow.ts` and a test file `tests/unit/gatewayEventIngressWorkflow.test.ts`. The workflow accepts a gateway `EventFrame`, current `AgentState[]`, seen cron dedupe keys, and current time. It returns one typed decision object containing approval effects, optional cron dedupe key to record, and optional cron transcript intent. It must not import React, browser globals, fetch helpers, logging, or stateful hooks.
|
||||
|
||||
Milestone 2 rewires `src/app/page.tsx` ingress handling so `client.onEvent` becomes orchestration-only: runtime handler call, resolver call, then effect application. Approval decision interpretation must be delegated to the existing pure helper `resolveExecApprovalEventEffects` through the new workflow module, not directly from `page.tsx`. Cron payload parsing and decision logic must leave `page.tsx` and be represented as resolver outputs. While applying approval effects, preserve current ordering semantics (removals, then scoped upserts, then unscoped upserts, then activity dispatches) and reuse pending-store reducers where possible.
|
||||
|
||||
Milestone 3 validates behavior and ensures no drift: typecheck, focused tests, full tests, and structural greps proving the callback no longer carries inline ingress parsing logic.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
All commands below run from the repository root:
|
||||
|
||||
cd /Users/georgepickett/.codex/worktrees/db4a/openclaw-studio
|
||||
|
||||
Capture baseline and ingress markers:
|
||||
|
||||
wc -l src/app/page.tsx
|
||||
rg -n "handleExecApprovalEvent|resolveExecApprovalEventEffects|parseAgentIdFromSessionKey|event\.event === \"cron\"|record\.action === \"finished\"" src/app/page.tsx
|
||||
|
||||
Expected: line count near 2688 and inline ingress markers present.
|
||||
|
||||
Create tests first:
|
||||
|
||||
${EDITOR:-vi} tests/unit/gatewayEventIngressWorkflow.test.ts
|
||||
npm run test -- --run tests/unit/gatewayEventIngressWorkflow.test.ts
|
||||
|
||||
Expected before implementation: failing run with unresolved import/export for `gatewayEventIngressWorkflow`.
|
||||
|
||||
Implement pure ingress workflow module:
|
||||
|
||||
${EDITOR:-vi} src/features/agents/state/gatewayEventIngressWorkflow.ts
|
||||
|
||||
Refactor ingress in `page.tsx` to use the workflow:
|
||||
|
||||
${EDITOR:-vi} src/app/page.tsx
|
||||
|
||||
Run validations:
|
||||
|
||||
npm run typecheck
|
||||
npm run test -- --run tests/unit/gatewayEventIngressWorkflow.test.ts
|
||||
npm run test -- --run tests/unit/execApprovalLifecycleWorkflow.test.ts tests/unit/execApprovalResolveOperation.test.ts tests/unit/pendingExecApprovalsStore.test.ts
|
||||
npm run test -- --run tests/unit/gatewayRuntimeEventHandler.policyDelegation.test.ts tests/unit/runtimeEventPolicy.test.ts
|
||||
npm run test -- --run
|
||||
|
||||
Run structural checks after refactor:
|
||||
|
||||
wc -l src/app/page.tsx
|
||||
rg -n "handleExecApprovalEvent|resolveExecApprovalEventEffects|parseAgentIdFromSessionKey|event\.event === \"cron\"|record\.action === \"finished\"" src/app/page.tsx
|
||||
rg -n "resolveGatewayEventIngressDecision" src/app/page.tsx src/features/agents/state/gatewayEventIngressWorkflow.ts tests/unit/gatewayEventIngressWorkflow.test.ts
|
||||
rg -n "from \"react\"|window\.|document\.|fetchJson|useGatewayConnection|console\." src/features/agents/state/gatewayEventIngressWorkflow.ts
|
||||
|
||||
Expected: `page.tsx` shrinks by roughly 70-120 lines, old inline ingress markers are gone, resolver symbol appears in all three files, and the new module has no forbidden infrastructure/UI imports.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Acceptance criteria are behavioral and must all hold.
|
||||
|
||||
The source file `src/app/page.tsx` keeps one event subscription but no longer performs inline cron payload parsing or direct approval effect derivation in the callback body.
|
||||
|
||||
The extracted module `src/features/agents/state/gatewayEventIngressWorkflow.ts` is independently unit-testable with plain object inputs and zero mocks for React/browser/network/timers.
|
||||
|
||||
`tests/unit/gatewayEventIngressWorkflow.test.ts` must verify at minimum:
|
||||
|
||||
- non-cron events produce no cron decision,
|
||||
- malformed cron payloads are ignored (missing payload object, non-`finished` action, empty `sessionKey`, unparsable session key, empty `jobId`),
|
||||
- valid finished cron with known agent yields dedupe record + transcript intent,
|
||||
- valid finished cron with unknown agent still yields dedupe record but null transcript intent,
|
||||
- duplicate dedupe keys suppress cron decisions,
|
||||
- timestamp fallback uses `nowMs` when `runAtMs` is absent,
|
||||
- transcript text remains `Cron finished (${status || "unknown"}): ${jobId}` plus body `summary || error || "(no output)"`,
|
||||
- transcript metadata contract stays unchanged (`source: "runtime-agent"`, `role: "assistant"`, `kind: "assistant"`, `confirmed: true`, `entryId = dedupeKey`),
|
||||
- approval requested/resolved events preserve `resolveExecApprovalEventEffects` behavior exactly,
|
||||
- approval `markActivityAgentIds` propagation is unchanged.
|
||||
|
||||
Global acceptance:
|
||||
|
||||
- `npm run typecheck` passes with no new TypeScript errors.
|
||||
- `npm run test -- --run` passes.
|
||||
- No new runtime warnings/errors appear from this refactor path during manual sanity run.
|
||||
- User-facing flows remain unchanged: runtime chat streaming still updates, approval cards still appear/clear, cron finished entries still append once.
|
||||
|
||||
Manual sanity check (if reachable gateway is available): connect Studio, trigger a run needing approval, resolve one approval, trigger one finished cron event, verify transcript/approval behavior matches pre-refactor behavior. If unavailable, record the deferred manual verification explicitly in `Outcomes & Retrospective`.
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
This extraction is additive and local: one new workflow module, one new unit test file, and focused edits in `src/app/page.tsx`. Re-running steps is safe because the workflow is deterministic and tests are idempotent.
|
||||
|
||||
If recovery is needed:
|
||||
|
||||
git restore src/app/page.tsx
|
||||
rm -f src/features/agents/state/gatewayEventIngressWorkflow.ts tests/unit/gatewayEventIngressWorkflow.test.ts
|
||||
npm run typecheck
|
||||
|
||||
If tests fail mid-refactor due to stale imports, run the structural `rg` checks above, remove stale inline references, and rerun only `tests/unit/gatewayEventIngressWorkflow.test.ts` before full test execution.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Capture implementation evidence directly in this plan while executing:
|
||||
|
||||
- baseline and post-change `wc -l src/app/page.tsx` output,
|
||||
- a short before/after excerpt of the `client.onEvent` callback,
|
||||
- the exported resolver interface from `gatewayEventIngressWorkflow.ts`,
|
||||
- failing-before and passing-after output from `tests/unit/gatewayEventIngressWorkflow.test.ts`,
|
||||
- final `npm run typecheck` and `npm run test -- --run` summaries.
|
||||
|
||||
Observed artifacts:
|
||||
|
||||
- `wc -l src/app/page.tsx`: before `2688`, after `2686`.
|
||||
- `npm run test -- --run tests/unit/gatewayEventIngressWorkflow.test.ts`: `1` file passed, `7` tests passed.
|
||||
- `npm run typecheck`: passed.
|
||||
- `npm run test -- --run`: `110` files passed, `512` tests passed.
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
Define in `src/features/agents/state/gatewayEventIngressWorkflow.ts`:
|
||||
|
||||
import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type CronTranscriptIntent = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
dedupeKey: string;
|
||||
line: string;
|
||||
timestampMs: number;
|
||||
activityAtMs: number | null;
|
||||
};
|
||||
|
||||
export type GatewayEventIngressDecision = {
|
||||
approvalEffects: ExecApprovalEventEffects | null;
|
||||
cronDedupeKeyToRecord: string | null;
|
||||
cronTranscriptIntent: CronTranscriptIntent | null;
|
||||
};
|
||||
|
||||
export function resolveGatewayEventIngressDecision(params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): GatewayEventIngressDecision;
|
||||
|
||||
Behavior contract:
|
||||
|
||||
- `approvalEffects` is exactly derived from `resolveExecApprovalEventEffects({ event, agents })`.
|
||||
- `cronDedupeKeyToRecord` is set only for valid finished cron records not already in `seenCronDedupeKeys`.
|
||||
- `cronTranscriptIntent` is set only when parsing succeeds and target agent exists in current `agents`.
|
||||
- The resolver never mutates `seenCronDedupeKeys` and never performs side effects.
|
||||
|
||||
Revision note: Created by `find-entangled-flows` after scoring flow-level entanglements. Highest score was the event ingress multiplexer in `src/app/page.tsx` (8.85/10) versus history sync ingress coupling (6.73/10), create-mutation orchestration coupling (6.40/10), and sandbox-tool auto-repair coupling (6.28/10).
|
||||
Revision note (2026-02-17, execplan-improve): Re-validated file paths/signatures and adjacent test patterns, tightened approval-application non-regression criteria, expanded malformed-cron acceptance coverage, and replaced rollback instructions with a retry-safe restore+remove sequence that works for newly created files.
|
||||
Revision note (2026-02-17, implement-execplan): Implemented the ingress extraction by adding `gatewayEventIngressWorkflow.ts`, wiring `page.tsx` to delegate ingress decisions, adding unit coverage, and validating with typecheck plus full unit suite.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Extract Rename/Delete Mutation Lifecycle From AgentStudioPage
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
This repository keeps plan requirements at `.agent/PLANS.md`. This ExecPlan must be maintained in accordance with `.agent/PLANS.md`.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
`src/app/page.tsx` currently owns both user-interface wiring and the full rename/delete config-mutation lifecycle, including queue submission, mutation-block phase changes, remote-restart gating, and post-run command execution. After this refactor, that lifecycle logic will live in one dedicated operation module under `src/features/agents/operations`, so the page keeps only page-specific concerns (confirmation prompt, local UI wiring, callback plumbing, and status-line rendering). This is a pure refactor with no intended user-visible behavior change: rename and delete still work the same way, with the same restart waiting behavior and the same status/error text.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] (2026-02-17 16:28Z) Created `src/features/agents/operations/agentConfigMutationLifecycleOperation.ts` with dependency-injected lifecycle orchestration for `rename-agent` and `delete-agent`.
|
||||
- [x] (2026-02-17 16:28Z) Moved duplicate rename/delete orchestration logic out of `src/app/page.tsx` and delegated both handlers to `runAgentConfigMutationLifecycle`.
|
||||
- [x] (2026-02-17 16:28Z) Preserved restart-block behavior: page-level blocks still transition through `queued` -> (`deleting`/`renaming`) -> `awaiting-restart` or clear.
|
||||
- [x] (2026-02-17 16:28Z) Added focused unit tests in `tests/unit/agentConfigMutationLifecycleOperation.test.ts` for completed, awaiting-restart, local-gateway, and failure paths.
|
||||
- [x] (2026-02-17 16:28Z) Ran `npm run typecheck`, targeted `vitest --run` suite, and full `vitest --run` suite successfully.
|
||||
- [x] (2026-02-17 16:28Z) Verified page-size reduction and boundary cleanliness: `src/app/page.tsx` dropped from 2739 -> 2707 lines, and the extracted operation file has no direct infrastructure/browser imports.
|
||||
- [x] (2026-02-17 16:27Z) Installed local dependencies with `npm ci`, restoring `tsc` and `vitest` command availability.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
- None yet. Update this section during implementation if any behavior differs from assumptions.
|
||||
- Observation: validation commands in this shell fail before installation because project-local binaries are missing.
|
||||
Evidence: `npm run typecheck` -> `sh: tsc: command not found`; `npm run test -- --run tests/unit/configMutationWorkflow.integration.test.ts` -> `sh: vitest: command not found`.
|
||||
- Observation: full test suite emits expected stderr logs from negative-path storage tests but still passes.
|
||||
Evidence: `tests/unit/pendingGuidedSetupStore.test.ts` logs intentional `getItem/setItem/removeItem failed` errors while suite result is `108 passed`.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: Extract the rename/delete mutation lifecycle first, not the create-agent lifecycle.
|
||||
Rationale: `src/app/page.tsx` contains nearly identical rename/delete orchestration blocks (queue + run + disposition commands + restart block coordination) in `handleDeleteAgent` (`src/app/page.tsx:1244` onward) and `handleRenameAgent` (`src/app/page.tsx:2062` onward). This seam is the clearest, removes substantial duplication with limited cross-module churn, and produces a directly unit-testable operation.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
- Decision: Make test commands explicit with `--run` and include dependency bootstrap before validation.
|
||||
Rationale: this repo uses `"test": "vitest"`, which can enter watch mode in interactive shells; deterministic plan execution needs one-shot runs. Current workspace evidence also shows missing local binaries until dependencies are installed.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
- Decision: Keep queued-block construction in `src/app/page.tsx` via callbacks, and extract lifecycle execution into one operation entry point.
|
||||
Rationale: page handlers need page-local state guards (`agentId` match checks and phase names `deleting`/`renaming`), but the mutation workflow/run-command logic is fully shareable. Callback injection preserves behavior while removing duplicated orchestration.
|
||||
Date/Author: 2026-02-17 / Codex
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
- Completed. Rename/delete mutation orchestration now runs through a shared operation module, page-level duplication was removed, and existing queue/restart/status behaviors remained intact under test.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
The target module is `src/app/page.tsx`, which is currently 2,739 lines and contains 40 `useEffect` hooks. It is the main Studio page component, but it also performs mutation-lifecycle orchestration that belongs in operation-layer code. The duplicate lifecycle exists in the delete handler (`src/app/page.tsx:1244-1342`) and rename handler (`src/app/page.tsx:2062-2154`). Each block performs the same sequence: mutation guard, queued-block creation, `enqueueConfigMutation`, transition into a mutating phase, `runConfigMutationWorkflow`, conversion of disposition into commands via `buildMutationSideEffectCommands`, command application to block state, and error mapping via `buildConfigMutationFailureMessage`.
|
||||
|
||||
The adjacent operation modules already define the policy pieces that this extraction should compose instead of duplicating. `src/features/agents/operations/configMutationWorkflow.ts` defines disposition rules (`completed` or `awaiting-restart`) and failure-message helpers. `src/features/agents/operations/agentMutationLifecycleController.ts` defines guard checks and side-effect commands. `src/features/agents/operations/useConfigMutationQueue.ts` defines queue typing and gating (`ConfigMutationKind` and queue start policy) and must remain behaviorally unchanged. `src/features/agents/operations/useGatewayRestartBlock.ts` plus `src/features/agents/operations/gatewayRestartPolicy.ts` own restart observation; the extraction must preserve compatibility with existing rename/delete block objects.
|
||||
|
||||
Two page-specific details must remain intact after extraction. First, delete and rename use page-specific mutation phases (`"deleting"` and `"renaming"`) that are later mapped back to `"mutating"` for status text in `resolveConfigMutationStatusLine` at `src/app/page.tsx:2266-2283`. Second, delete has side effects that rename does not (`window.confirm` and `setSettingsAgentId(null)` inside its mutation function), so the extracted operation must be callback-driven and not hardcode delete/rename transport behavior.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
Milestone 1 creates `src/features/agents/operations/agentConfigMutationLifecycleOperation.ts` as the only place that performs rename/delete lifecycle orchestration. This new module will not call gateway APIs directly. It will accept dependency callbacks for queue submission, mutation execution, restart requirement checks, block setters, and post-run actions. The module will call existing policy helpers (`runConfigMutationWorkflow`, `buildMutationSideEffectCommands`, and `buildConfigMutationFailureMessage`) and return a success/failure outcome for the page.
|
||||
|
||||
Milestone 2 rewires `handleDeleteAgent` and `handleRenameAgent` in `src/app/page.tsx` to delegate orchestration to the new module. `src/app/page.tsx` keeps guard-adjacent UI behavior that is truly page-specific: loading the selected agent from store state, presenting the delete confirmation dialog, and building operation-specific mutation executors (delete uses `deleteAgentViaStudio`, rename uses `renameGatewayAgent` plus optimistic `dispatch`). Shared lifecycle branches move out. This milestone also removes now-unused page imports (for example, mutation workflow helpers currently imported only for inline rename/delete orchestration) while preserving `resolveConfigMutationStatusLine`, which is still required for UI status text.
|
||||
|
||||
Milestone 3 adds focused unit coverage in `tests/unit/agentConfigMutationLifecycleOperation.test.ts` using the same Vitest style as adjacent operation tests (`vi.fn`, dependency stubs, explicit command-order assertions). The tests must validate both local and remote gateway paths and verify that command application parity matches existing integration assertions in `tests/unit/configMutationWorkflow.integration.test.ts` and `tests/unit/agentMutationLifecycleController.integration.test.ts`.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
All commands below run from `/Users/georgepickett/.codex/worktrees/db4a/openclaw-studio`.
|
||||
|
||||
1. Verify or install dependencies so local binaries exist.
|
||||
|
||||
test -d node_modules && echo "node_modules present" || npm ci
|
||||
|
||||
Expected outcome: either `node_modules present` or a clean lockfile-based install ending with npm's summary line. After this step, `tsc` and `vitest` are callable via npm scripts. If `npm ci` fails because the lockfile and `package.json` are out of sync, run `npm install` once, then rerun the validation commands.
|
||||
|
||||
2. Capture baseline structure and duplication before editing.
|
||||
|
||||
wc -l src/app/page.tsx
|
||||
rg -n "const handleDeleteAgent|const handleRenameAgent|buildMutationSideEffectCommands\(|runConfigMutationWorkflow\(" src/app/page.tsx
|
||||
|
||||
Expected outcome: line count near 2739 and both handlers found with duplicated lifecycle sections.
|
||||
|
||||
3. Implement the new operation module.
|
||||
|
||||
test -f src/features/agents/operations/agentConfigMutationLifecycleOperation.ts || touch src/features/agents/operations/agentConfigMutationLifecycleOperation.ts
|
||||
|
||||
Add one exported orchestration entry point that accepts:
|
||||
|
||||
- mutation metadata (`kind`, `agentId`, `agentName`, `isLocalGateway`),
|
||||
- queue callback (`enqueueConfigMutation`),
|
||||
- lifecycle block callbacks (set queued, set mutating, apply command patch, clear block),
|
||||
- mutation callbacks (`executeMutation`, `shouldAwaitRemoteRestart`, `reloadAgents`, `setMobilePane`),
|
||||
- error callback that consumes `buildConfigMutationFailureMessage` output.
|
||||
|
||||
4. Rewire page handlers to call the new operation and clean imports.
|
||||
|
||||
rg -n "handleDeleteAgent|handleRenameAgent" src/app/page.tsx
|
||||
rg -n "buildConfigMutationFailureMessage|runConfigMutationWorkflow|buildMutationSideEffectCommands" src/app/page.tsx
|
||||
|
||||
Update both handlers so they keep only per-handler concerns and invoke the shared operation for lifecycle orchestration. Remove now-redundant inline command loops and duplicate try/catch mutation-flow blocks. Update imports so only actively used helpers remain in `src/app/page.tsx`.
|
||||
|
||||
5. Add tests for the extracted module.
|
||||
|
||||
test -f tests/unit/agentConfigMutationLifecycleOperation.test.ts || touch tests/unit/agentConfigMutationLifecycleOperation.test.ts
|
||||
|
||||
Cover these cases with `describe("agentConfigMutationLifecycleOperation", ...)`:
|
||||
|
||||
- rename completed path: runs mutation once, reloads agents, clears block, sets mobile pane,
|
||||
- delete awaiting-restart path: applies only patch command (`phase: "awaiting-restart"`) and does not clear,
|
||||
- failure path: clears appropriate block state and emits mapped failure message,
|
||||
- local gateway path: never calls restart-check callback.
|
||||
|
||||
6. Run validation commands.
|
||||
|
||||
npm run typecheck
|
||||
npm run test -- --run tests/unit/agentConfigMutationLifecycleOperation.test.ts tests/unit/configMutationWorkflow.test.ts tests/unit/configMutationWorkflow.integration.test.ts tests/unit/configMutationGatePolicy.test.ts tests/unit/gatewayRestartPolicy.test.ts tests/unit/agentMutationLifecycleController.test.ts tests/unit/agentMutationLifecycleController.integration.test.ts
|
||||
npm run test -- --run
|
||||
wc -l src/app/page.tsx
|
||||
rg -n "@/lib/gateway|@/lib/http|window|fetch\(|WebSocket|useEffect\(" src/features/agents/operations/agentConfigMutationLifecycleOperation.ts || true
|
||||
|
||||
Expected outcome: typecheck passes; targeted tests pass; full test run passes; page line count decreases materially; `rg` confirms inline rename/delete lifecycle helpers were removed from page handlers; the extracted operation file shows no direct infrastructure/browser/React-hook imports.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Acceptance is complete when behavior and structure both match the current system with less page-level coupling. The structural check is that `src/app/page.tsx` no longer contains duplicate rename/delete lifecycle orchestration loops and is reduced by roughly 120 or more lines. The behavior check is that rename and delete still drive the same mutation-block transitions (`queued` to mutating to either clear or `awaiting-restart`) and still present the same restart-dependent status text via the existing mapping in `resolveConfigMutationStatusLine`.
|
||||
|
||||
The operation-layer boundary is valid only if `src/features/agents/operations/agentConfigMutationLifecycleOperation.ts` has no direct imports from `@/lib/gateway/*`, `@/lib/http`, browser globals (`window`, `fetch`, `WebSocket`), or React hooks. All infrastructure actions must be provided through callbacks from `src/app/page.tsx`.
|
||||
|
||||
Validation commands must all succeed after dependencies are installed. `npm run typecheck` and both targeted/full `vitest --run` invocations are required. The new test file must prove command-order and disposition parity, not just happy-path invocation counts. Existing controller and policy tests (`tests/unit/agentMutationLifecycleController.test.ts`, `tests/unit/agentMutationLifecycleController.integration.test.ts`, `tests/unit/configMutationGatePolicy.test.ts`, and `tests/unit/gatewayRestartPolicy.test.ts`) must continue to pass because they encode queue/restart semantics relied upon by the extraction.
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
This change is additive and retriable. If implementation is interrupted, rerunning the steps is safe because the new module and test files are deterministic and can be overwritten with corrected content. If the extraction introduces regressions, revert only touched files and retry:
|
||||
|
||||
git checkout -- src/app/page.tsx src/features/agents/operations/agentConfigMutationLifecycleOperation.ts tests/unit/agentConfigMutationLifecycleOperation.test.ts
|
||||
|
||||
If command validation fails due to missing tools, rerun the dependency bootstrap step and repeat only the failed validation commands.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Baseline evidence from this repository state:
|
||||
|
||||
- `src/app/page.tsx` line count: 2739.
|
||||
- `src/app/page.tsx` `useEffect` count: 40.
|
||||
- Historical churn (commit-touch count):
|
||||
- `src/app/page.tsx`: 162
|
||||
- `src/lib/gateway/agentConfig.ts`: 19
|
||||
- `src/features/agents/state/gatewayRuntimeEventHandler.ts`: 18
|
||||
- `server/gateway-proxy.js`: 4
|
||||
|
||||
Environment evidence from this shell before dependency install:
|
||||
|
||||
- `npm run typecheck` failed with `sh: tsc: command not found`.
|
||||
- `npm run test -- --run ...` failed with `sh: vitest: command not found`.
|
||||
|
||||
Implementation evidence:
|
||||
|
||||
- `npm ci` succeeded: 582 packages installed.
|
||||
- `npm run typecheck` passed.
|
||||
- Targeted regression command passed: 7 test files / 32 tests.
|
||||
- Full regression command passed: 108 test files / 498 tests.
|
||||
- `src/app/page.tsx` line count after extraction: 2707 (down from 2739).
|
||||
- `rg -n "@/lib/gateway|@/lib/http|window|fetch\\(|WebSocket|useEffect\\(" src/features/agents/operations/agentConfigMutationLifecycleOperation.ts` returned no matches.
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
The extracted operation module should expose one orchestration API that composes existing policy helpers and keeps side effects injected. Keep names stable and explicit.
|
||||
|
||||
In `src/features/agents/operations/agentConfigMutationLifecycleOperation.ts`, define input/output types similar to:
|
||||
|
||||
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
|
||||
type AgentConfigMutationKind = "rename-agent" | "delete-agent";
|
||||
|
||||
type AgentConfigMutationLifecycleInput = {
|
||||
kind: AgentConfigMutationKind;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
isLocalGateway: boolean;
|
||||
};
|
||||
|
||||
type AgentConfigMutationLifecycleDeps = {
|
||||
enqueueConfigMutation: (params: { kind: ConfigMutationKind; label: string; run: () => Promise<void> }) => Promise<void>;
|
||||
setQueuedBlock: () => void;
|
||||
setMutatingBlock: () => void;
|
||||
patchBlockAwaitingRestart: (patch: { phase: "awaiting-restart"; sawDisconnect: boolean }) => void;
|
||||
clearBlock: () => void;
|
||||
executeMutation: () => Promise<void>;
|
||||
shouldAwaitRemoteRestart: () => Promise<boolean>;
|
||||
reloadAgents: () => Promise<void>;
|
||||
setMobilePaneChat: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export async function runAgentConfigMutationLifecycle(
|
||||
input: AgentConfigMutationLifecycleInput,
|
||||
deps: AgentConfigMutationLifecycleDeps
|
||||
): Promise<boolean>;
|
||||
|
||||
The implementation should import and use only operation-layer helpers that already exist:
|
||||
|
||||
- `runConfigMutationWorkflow` and `buildConfigMutationFailureMessage` from `src/features/agents/operations/configMutationWorkflow.ts`.
|
||||
- `buildMutationSideEffectCommands` (and optionally `buildQueuedMutationBlock`) from `src/features/agents/operations/agentMutationLifecycleController.ts`.
|
||||
- `ConfigMutationKind` type from `src/features/agents/operations/useConfigMutationQueue.ts` for queue-kind compatibility.
|
||||
|
||||
`src/app/page.tsx` remains responsible for operation-specific mutation execution details (`deleteAgentViaStudio`, `renameGatewayAgent`, `dispatch`, and `setSettingsAgentId`).
|
||||
|
||||
Append-only revision note:
|
||||
|
||||
- 2026-02-17: Initial plan authored from the entanglement analysis pass. It selects rename/delete mutation lifecycle extraction as the first cut because it removes duplicated high-churn orchestration with minimal blast-radius risk.
|
||||
- 2026-02-17: Plan improved via deep code-grounding pass. Corrected PLANS reference to `.agent/PLANS.md`, added prerequisite/bootstrap guidance based on observed local command failures, tightened handler/module mapping to actual page phases (`deleting`/`renaming` vs `mutating` mapping), and made test/validation commands deterministic with `vitest --run` plus concrete coverage expectations.
|
||||
- 2026-02-17: Plan improved again after adjacency review. Added explicit import-cleanup expectations in `src/app/page.tsx`, aligned queue callback typing with `ConfigMutationKind` from `useConfigMutationQueue.ts`, switched bootstrap command to lockfile-safe `npm ci`, and expanded targeted regression coverage to include `agentMutationLifecycleController.test.ts`.
|
||||
- 2026-02-17: Plan improved again after policy-adjacency verification. Added `npm ci` failure fallback guidance, extended targeted regression coverage to include `configMutationGatePolicy` and `gatewayRestartPolicy` tests, and made the `ConfigMutationKind` import explicit in the interface sketch.
|
||||
- 2026-02-17: Plan implemented end-to-end. Added extracted operation module, rewired rename/delete handlers, added focused unit coverage, ran typecheck + targeted + full tests, and recorded measured outcomes.
|
||||
+315
-368
@@ -16,7 +16,6 @@ import {
|
||||
isHeartbeatPrompt,
|
||||
} from "@/lib/text/message-extract";
|
||||
import {
|
||||
parseAgentIdFromSessionKey,
|
||||
useGatewayConnection,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import { createRafBatcher } from "@/lib/dom";
|
||||
@@ -66,9 +65,6 @@ import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||
import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
||||
import { resolveFocusedPreference } from "@/lib/studio/settings";
|
||||
import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations";
|
||||
import {
|
||||
compileGuidedAgentCreation,
|
||||
} from "@/features/agents/creation/compiler";
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import {
|
||||
applyPendingGuidedSetupForAgent,
|
||||
@@ -86,12 +82,6 @@ import {
|
||||
applyGuidedAgentSetup,
|
||||
type AgentGuidedSetup,
|
||||
} from "@/features/agents/operations/createAgentOperation";
|
||||
import {
|
||||
resolveGuidedCreateCompletion,
|
||||
runGuidedCreateWorkflow,
|
||||
runGuidedRetryWorkflow,
|
||||
} from "@/features/agents/operations/guidedCreateWorkflow";
|
||||
import { applyPendingGuidedSetupRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupRetryOperation";
|
||||
import {
|
||||
isGatewayDisconnectLikeError,
|
||||
type EventFrame,
|
||||
@@ -101,11 +91,7 @@ import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOp
|
||||
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
||||
import {
|
||||
buildConfigMutationFailureMessage,
|
||||
resolveConfigMutationStatusLine,
|
||||
runConfigMutationWorkflow,
|
||||
} from "@/features/agents/operations/configMutationWorkflow";
|
||||
import { resolveConfigMutationStatusLine } from "@/features/agents/operations/configMutationWorkflow";
|
||||
import { useConfigMutationQueue } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode";
|
||||
@@ -113,8 +99,8 @@ import { useGatewayRestartBlock } from "@/features/agents/operations/useGatewayR
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
resolveExecApprovalEventEffects,
|
||||
} from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
resolveGatewayEventIngressDecision,
|
||||
} from "@/features/agents/state/gatewayEventIngressWorkflow";
|
||||
import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation";
|
||||
import {
|
||||
mergePendingApprovalsForFocusedAgent,
|
||||
@@ -122,6 +108,7 @@ import {
|
||||
pruneExpiredPendingApprovals,
|
||||
pruneExpiredPendingApprovalsMap,
|
||||
removePendingApprovalById,
|
||||
removePendingApprovalEverywhere,
|
||||
removePendingApprovalByIdMap,
|
||||
upsertPendingApproval,
|
||||
} from "@/features/agents/approvals/pendingStore";
|
||||
@@ -149,11 +136,16 @@ import {
|
||||
runHistorySyncOperation,
|
||||
} from "@/features/agents/operations/historySyncOperation";
|
||||
import {
|
||||
buildMutationSideEffectCommands,
|
||||
buildQueuedMutationBlock,
|
||||
resolveMutationStartGuard,
|
||||
} from "@/features/agents/operations/agentMutationLifecycleController";
|
||||
import { runPendingGuidedSetupAutoRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupAutoRetryOperation";
|
||||
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/agentConfigMutationLifecycleOperation";
|
||||
import {
|
||||
isCreateBlockTimedOut,
|
||||
runCreateAgentMutationLifecycle,
|
||||
runPendingCreateSetupRetryLifecycle,
|
||||
} from "@/features/agents/operations/createAgentMutationLifecycleOperation";
|
||||
|
||||
const DEFAULT_CHAT_HISTORY_LIMIT = 200;
|
||||
const MAX_CHAT_HISTORY_LIMIT = 5000;
|
||||
@@ -696,27 +688,24 @@ const AgentStudioPage = () => {
|
||||
|
||||
const applyPendingCreateSetupForAgentId = useCallback(
|
||||
async (params: { agentId: string; source: "auto" | "manual" }) => {
|
||||
return await applyPendingGuidedSetupRetryViaStudio({
|
||||
return await runPendingCreateSetupRetryLifecycle({
|
||||
agentId: params.agentId,
|
||||
source: params.source,
|
||||
retryBusyAgentId: retryPendingSetupBusyAgentId,
|
||||
inFlightAgentIds: pendingSetupAutoRetryInFlightRef.current,
|
||||
pendingSetupsByAgentId: pendingCreateSetupsByAgentIdRef.current,
|
||||
setRetryBusyAgentId: setRetryPendingSetupBusyAgentId,
|
||||
executeRetry: async (agentId) =>
|
||||
runGuidedRetryWorkflow(agentId, {
|
||||
applyPendingSetup: async (targetAgentId) =>
|
||||
applyPendingGuidedSetupForAgent({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
pendingSetupsByAgentId: pendingCreateSetupsByAgentIdRef.current,
|
||||
}),
|
||||
removePending: (targetAgentId) => {
|
||||
setPendingCreateSetupsByAgentId((current) =>
|
||||
removePendingGuidedSetup(current, targetAgentId)
|
||||
);
|
||||
},
|
||||
applyPendingSetup: async (targetAgentId) =>
|
||||
applyPendingGuidedSetupForAgent({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
pendingSetupsByAgentId: pendingCreateSetupsByAgentIdRef.current,
|
||||
}),
|
||||
removePending: (targetAgentId) => {
|
||||
setPendingCreateSetupsByAgentId((current) =>
|
||||
removePendingGuidedSetup(current, targetAgentId)
|
||||
);
|
||||
},
|
||||
isDisconnectLikeError: isGatewayDisconnectLikeError,
|
||||
resolveAgentName: (agentId) =>
|
||||
stateRef.current.agents.find((agent) => agent.agentId === agentId)?.name ??
|
||||
@@ -1260,24 +1249,28 @@ const AgentStudioPage = () => {
|
||||
`Delete ${agent.name}? This removes the agent from gateway config + cron and moves its workspace/state into ~/.openclaw/trash on the gateway host.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
const queuedDeleteBlock = buildQueuedMutationBlock({
|
||||
await runAgentConfigMutationLifecycle({
|
||||
kind: "delete-agent",
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
setDeleteAgentBlock({
|
||||
agentId: queuedDeleteBlock.agentId,
|
||||
agentName: queuedDeleteBlock.agentName,
|
||||
phase: "queued",
|
||||
startedAt: queuedDeleteBlock.startedAt,
|
||||
sawDisconnect: queuedDeleteBlock.sawDisconnect,
|
||||
});
|
||||
try {
|
||||
await enqueueConfigMutation({
|
||||
kind: "delete-agent",
|
||||
label: `Delete ${agent.name}`,
|
||||
run: async () => {
|
||||
label: `Delete ${agent.name}`,
|
||||
isLocalGateway,
|
||||
deps: {
|
||||
enqueueConfigMutation,
|
||||
setQueuedBlock: () => {
|
||||
const queuedDeleteBlock = buildQueuedMutationBlock({
|
||||
kind: "delete-agent",
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
setDeleteAgentBlock({
|
||||
agentId: queuedDeleteBlock.agentId,
|
||||
agentName: queuedDeleteBlock.agentName,
|
||||
phase: "queued",
|
||||
startedAt: queuedDeleteBlock.startedAt,
|
||||
sawDisconnect: queuedDeleteBlock.sawDisconnect,
|
||||
});
|
||||
},
|
||||
setMutatingBlock: () => {
|
||||
setDeleteAgentBlock((current) => {
|
||||
if (!current || current.agentId !== agentId) return current;
|
||||
return {
|
||||
@@ -1285,60 +1278,43 @@ const AgentStudioPage = () => {
|
||||
phase: "deleting",
|
||||
};
|
||||
});
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "delete-agent", isLocalGateway },
|
||||
{
|
||||
executeMutation: async () => {
|
||||
await deleteAgentViaStudio({
|
||||
client,
|
||||
agentId,
|
||||
fetchJson,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
});
|
||||
setSettingsAgentId(null);
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () =>
|
||||
shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: gatewayConfigSnapshot,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
}),
|
||||
}
|
||||
);
|
||||
const commands = buildMutationSideEffectCommands({
|
||||
disposition: result.disposition,
|
||||
});
|
||||
for (const command of commands) {
|
||||
if (command.kind === "reload-agents") {
|
||||
await loadAgents();
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
setDeleteAgentBlock(null);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-mobile-pane") {
|
||||
setMobilePane(command.pane);
|
||||
continue;
|
||||
}
|
||||
setDeleteAgentBlock((current) => {
|
||||
if (!current || current.agentId !== agentId) return current;
|
||||
return {
|
||||
...current,
|
||||
...command.patch,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = buildConfigMutationFailureMessage({
|
||||
kind: "delete-agent",
|
||||
error: err,
|
||||
});
|
||||
setDeleteAgentBlock(null);
|
||||
setError(msg);
|
||||
}
|
||||
patchBlockAwaitingRestart: (patch) => {
|
||||
setDeleteAgentBlock((current) => {
|
||||
if (!current || current.agentId !== agentId) return current;
|
||||
return {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
},
|
||||
clearBlock: () => {
|
||||
setDeleteAgentBlock(null);
|
||||
},
|
||||
executeMutation: async () => {
|
||||
await deleteAgentViaStudio({
|
||||
client,
|
||||
agentId,
|
||||
fetchJson,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
});
|
||||
setSettingsAgentId(null);
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () =>
|
||||
shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: gatewayConfigSnapshot,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
}),
|
||||
reloadAgents: loadAgents,
|
||||
setMobilePaneChat: () => {
|
||||
setMobilePane("chat");
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
agents,
|
||||
@@ -1530,107 +1506,75 @@ const AgentStudioPage = () => {
|
||||
|
||||
const handleCreateAgentSubmit = useCallback(
|
||||
async (payload: AgentCreateModalSubmitPayload) => {
|
||||
if (createAgentBusy) return;
|
||||
const guard = resolveMutationStartGuard({
|
||||
status,
|
||||
hasCreateBlock: Boolean(createAgentBlock),
|
||||
hasRenameBlock: Boolean(renameAgentBlock),
|
||||
hasDeleteBlock: Boolean(deleteAgentBlock),
|
||||
});
|
||||
if (guard.kind === "deny") {
|
||||
if (guard.reason !== "not-connected") return;
|
||||
setCreateAgentModalError("Connect to gateway before creating an agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = payload.name.trim();
|
||||
const selectedAvatarSeed = payload.avatarSeed?.trim() ?? "";
|
||||
if (!name) {
|
||||
setCreateAgentModalError("Agent name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const compiled = compileGuidedAgentCreation({ name, draft: payload.draft });
|
||||
if (compiled.validation.errors.length > 0) {
|
||||
setCreateAgentModalError(compiled.validation.errors[0] ?? "Guided setup is incomplete.");
|
||||
return;
|
||||
}
|
||||
const setup: AgentGuidedSetup = {
|
||||
agentOverrides: compiled.agentOverrides,
|
||||
files: compiled.files,
|
||||
execApprovals: compiled.execApprovals,
|
||||
};
|
||||
|
||||
setCreateAgentBusy(true);
|
||||
setCreateAgentModalError(null);
|
||||
const queuedCreateBlock = buildQueuedMutationBlock({
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName: name,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
setCreateAgentBlock({
|
||||
agentId: null,
|
||||
agentName: queuedCreateBlock.agentName,
|
||||
phase: "queued",
|
||||
startedAt: queuedCreateBlock.startedAt,
|
||||
});
|
||||
try {
|
||||
const queuedMutation = enqueueConfigMutation({
|
||||
kind: "create-agent",
|
||||
label: `Create ${name}`,
|
||||
run: async () => {
|
||||
await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload,
|
||||
status,
|
||||
hasCreateBlock: Boolean(createAgentBlock),
|
||||
hasRenameBlock: Boolean(renameAgentBlock),
|
||||
hasDeleteBlock: Boolean(deleteAgentBlock),
|
||||
createAgentBusy,
|
||||
isLocalGateway,
|
||||
},
|
||||
{
|
||||
enqueueConfigMutation,
|
||||
createAgent: async (name, avatarSeed) => {
|
||||
const created = await createGatewayAgent({ client, name });
|
||||
if (avatarSeed) {
|
||||
persistAvatarSeed(created.id, avatarSeed);
|
||||
}
|
||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||
focusFilterTouchedRef.current = true;
|
||||
setFocusFilter("all");
|
||||
dispatch({ type: "selectAgent", agentId: created.id });
|
||||
setSettingsAgentId(null);
|
||||
setMobilePane("chat");
|
||||
return { id: created.id };
|
||||
},
|
||||
applySetup: async (agentId, setup) => {
|
||||
await applyGuidedAgentSetup({
|
||||
client,
|
||||
agentId,
|
||||
setup,
|
||||
});
|
||||
},
|
||||
upsertPending: (agentId, setup) => {
|
||||
setPendingCreateSetupsByAgentId((current) =>
|
||||
upsertPendingGuidedSetup(current, agentId, setup)
|
||||
);
|
||||
},
|
||||
removePending: (agentId) => {
|
||||
setPendingCreateSetupsByAgentId((current) =>
|
||||
removePendingGuidedSetup(current, agentId)
|
||||
);
|
||||
},
|
||||
setQueuedBlock: ({ agentName, startedAt }) => {
|
||||
const queuedCreateBlock = buildQueuedMutationBlock({
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName,
|
||||
startedAt,
|
||||
});
|
||||
setCreateAgentBlock({
|
||||
agentId: null,
|
||||
agentName: queuedCreateBlock.agentName,
|
||||
phase: "queued",
|
||||
startedAt: queuedCreateBlock.startedAt,
|
||||
});
|
||||
},
|
||||
setCreatingBlock: (agentName) => {
|
||||
setCreateAgentBlock((current) => {
|
||||
if (!current || current.agentName !== name) return current;
|
||||
if (!current || current.agentName !== agentName) return current;
|
||||
return { ...current, phase: "creating" };
|
||||
});
|
||||
const result = await runGuidedCreateWorkflow(
|
||||
{
|
||||
name,
|
||||
setup,
|
||||
isLocalGateway,
|
||||
},
|
||||
{
|
||||
createAgent: async (agentName) => {
|
||||
const created = await createGatewayAgent({ client, name: agentName });
|
||||
if (selectedAvatarSeed) {
|
||||
persistAvatarSeed(created.id, selectedAvatarSeed);
|
||||
}
|
||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||
focusFilterTouchedRef.current = true;
|
||||
setFocusFilter("all");
|
||||
dispatch({ type: "selectAgent", agentId: created.id });
|
||||
setSettingsAgentId(null);
|
||||
setMobilePane("chat");
|
||||
return { id: created.id };
|
||||
},
|
||||
applySetup: async (agentId, nextSetup) => {
|
||||
setCreateAgentBlock((current) => {
|
||||
if (!current || current.agentName !== name) return current;
|
||||
return { ...current, agentId, phase: "applying-setup" };
|
||||
});
|
||||
await applyGuidedAgentSetup({
|
||||
client,
|
||||
agentId,
|
||||
setup: nextSetup,
|
||||
});
|
||||
},
|
||||
upsertPending: (agentId, nextSetup) => {
|
||||
setPendingCreateSetupsByAgentId((current) =>
|
||||
upsertPendingGuidedSetup(current, agentId, nextSetup)
|
||||
);
|
||||
},
|
||||
removePending: (agentId) => {
|
||||
setPendingCreateSetupsByAgentId((current) =>
|
||||
removePendingGuidedSetup(current, agentId)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
const completion = resolveGuidedCreateCompletion({
|
||||
agentName: name,
|
||||
result,
|
||||
},
|
||||
setApplyingSetupBlock: ({ agentName, agentId }) => {
|
||||
setCreateAgentBlock((current) => {
|
||||
if (!current || current.agentName !== agentName) return current;
|
||||
return { ...current, agentId, phase: "applying-setup" };
|
||||
});
|
||||
},
|
||||
onCompletion: async (completion) => {
|
||||
if (completion.shouldReloadAgents) {
|
||||
await loadAgents();
|
||||
}
|
||||
@@ -1643,17 +1587,15 @@ const AgentStudioPage = () => {
|
||||
setError(completion.pendingErrorMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
setCreateAgentModalOpen(false);
|
||||
await queuedMutation;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create agent.";
|
||||
setCreateAgentBlock(null);
|
||||
setCreateAgentModalError(message);
|
||||
setError(message);
|
||||
} finally {
|
||||
setCreateAgentBusy(false);
|
||||
}
|
||||
setCreateAgentModalOpen,
|
||||
setCreateAgentModalError,
|
||||
setCreateAgentBusy,
|
||||
clearCreateBlock: () => {
|
||||
setCreateAgentBlock(null);
|
||||
},
|
||||
onError: setError,
|
||||
}
|
||||
);
|
||||
},
|
||||
[
|
||||
client,
|
||||
@@ -1675,13 +1617,35 @@ const AgentStudioPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!createAgentBlock || createAgentBlock.phase === "queued") return;
|
||||
const elapsed = Date.now() - createAgentBlock.startedAt;
|
||||
const remaining = Math.max(0, 90_000 - elapsed);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const maxWaitMs = 90_000;
|
||||
const timeoutNow = isCreateBlockTimedOut({
|
||||
block: createAgentBlock,
|
||||
nowMs: Date.now(),
|
||||
maxWaitMs,
|
||||
});
|
||||
const handleTimeout = () => {
|
||||
setCreateAgentBlock(null);
|
||||
setCreateAgentModalOpen(false);
|
||||
void loadAgents();
|
||||
setError("Agent creation timed out.");
|
||||
};
|
||||
if (timeoutNow) {
|
||||
handleTimeout();
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - createAgentBlock.startedAt;
|
||||
const remaining = Math.max(0, maxWaitMs - elapsed);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (
|
||||
!isCreateBlockTimedOut({
|
||||
block: createAgentBlock,
|
||||
nowMs: Date.now(),
|
||||
maxWaitMs,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleTimeout();
|
||||
}, remaining);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
@@ -1921,48 +1885,88 @@ const AgentStudioPage = () => {
|
||||
[client, loadAgentHistory, pendingExecApprovalsByAgentId, unscopedPendingExecApprovals]
|
||||
);
|
||||
|
||||
const handleExecApprovalEvent = useCallback(
|
||||
const handleGatewayEventIngress = useCallback(
|
||||
(event: EventFrame) => {
|
||||
const effects = resolveExecApprovalEventEffects({
|
||||
const ingressDecision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: stateRef.current.agents,
|
||||
seenCronDedupeKeys: seenCronEventIdsRef.current,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (!effects) return;
|
||||
for (const removalId of effects.removals) {
|
||||
setPendingExecApprovalsByAgentId((current) => {
|
||||
return removePendingApprovalByIdMap(current, removalId);
|
||||
});
|
||||
setUnscopedPendingExecApprovals((current) => {
|
||||
return removePendingApprovalById(current, removalId);
|
||||
});
|
||||
|
||||
const effects = ingressDecision.approvalEffects;
|
||||
if (effects) {
|
||||
for (const removalId of effects.removals) {
|
||||
setPendingExecApprovalsByAgentId((current) => {
|
||||
return removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: current,
|
||||
unscopedApprovals: [],
|
||||
approvalId: removalId,
|
||||
}).approvalsByAgentId;
|
||||
});
|
||||
setUnscopedPendingExecApprovals((current) => {
|
||||
return removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: current,
|
||||
approvalId: removalId,
|
||||
}).unscopedApprovals;
|
||||
});
|
||||
}
|
||||
for (const scopedUpsert of effects.scopedUpserts) {
|
||||
setPendingExecApprovalsByAgentId((current) => {
|
||||
const withoutExisting = removePendingApprovalByIdMap(current, scopedUpsert.approval.id);
|
||||
const existing = withoutExisting[scopedUpsert.agentId] ?? [];
|
||||
const upserted = upsertPendingApproval(existing, scopedUpsert.approval);
|
||||
if (upserted === existing) return withoutExisting;
|
||||
return {
|
||||
...withoutExisting,
|
||||
[scopedUpsert.agentId]: upserted,
|
||||
};
|
||||
});
|
||||
setUnscopedPendingExecApprovals((current) =>
|
||||
removePendingApprovalById(current, scopedUpsert.approval.id)
|
||||
);
|
||||
}
|
||||
for (const unscopedUpsert of effects.unscopedUpserts) {
|
||||
setPendingExecApprovalsByAgentId((current) =>
|
||||
removePendingApprovalByIdMap(current, unscopedUpsert.id)
|
||||
);
|
||||
setUnscopedPendingExecApprovals((current) => {
|
||||
const withoutExisting = removePendingApprovalById(current, unscopedUpsert.id);
|
||||
return upsertPendingApproval(withoutExisting, unscopedUpsert);
|
||||
});
|
||||
}
|
||||
for (const agentId of effects.markActivityAgentIds) {
|
||||
dispatch({ type: "markActivity", agentId });
|
||||
}
|
||||
}
|
||||
for (const scopedUpsert of effects.scopedUpserts) {
|
||||
setPendingExecApprovalsByAgentId((current) => {
|
||||
const withoutExisting = removePendingApprovalByIdMap(current, scopedUpsert.approval.id);
|
||||
const existing = withoutExisting[scopedUpsert.agentId] ?? [];
|
||||
const upserted = upsertPendingApproval(existing, scopedUpsert.approval);
|
||||
if (upserted === existing) return withoutExisting;
|
||||
return {
|
||||
...withoutExisting,
|
||||
[scopedUpsert.agentId]: upserted,
|
||||
};
|
||||
});
|
||||
setUnscopedPendingExecApprovals((current) =>
|
||||
removePendingApprovalById(current, scopedUpsert.approval.id)
|
||||
);
|
||||
|
||||
if (ingressDecision.cronDedupeKeyToRecord) {
|
||||
seenCronEventIdsRef.current.add(ingressDecision.cronDedupeKeyToRecord);
|
||||
}
|
||||
for (const unscopedUpsert of effects.unscopedUpserts) {
|
||||
setPendingExecApprovalsByAgentId((current) =>
|
||||
removePendingApprovalByIdMap(current, unscopedUpsert.id)
|
||||
);
|
||||
setUnscopedPendingExecApprovals((current) => {
|
||||
const withoutExisting = removePendingApprovalById(current, unscopedUpsert.id);
|
||||
return upsertPendingApproval(withoutExisting, unscopedUpsert);
|
||||
});
|
||||
}
|
||||
for (const agentId of effects.markActivityAgentIds) {
|
||||
dispatch({ type: "markActivity", agentId });
|
||||
if (!ingressDecision.cronTranscriptIntent) {
|
||||
return;
|
||||
}
|
||||
const intent = ingressDecision.cronTranscriptIntent;
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId: intent.agentId,
|
||||
line: intent.line,
|
||||
transcript: {
|
||||
source: "runtime-agent",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
sessionKey: intent.sessionKey,
|
||||
timestampMs: intent.timestampMs,
|
||||
entryId: intent.dedupeKey,
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: "markActivity",
|
||||
agentId: intent.agentId,
|
||||
at: intent.activityAtMs ?? undefined,
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
@@ -1989,49 +1993,7 @@ const AgentStudioPage = () => {
|
||||
runtimeEventHandlerRef.current = handler;
|
||||
const unsubscribe = client.onEvent((event: EventFrame) => {
|
||||
handler.handleEvent(event);
|
||||
handleExecApprovalEvent(event);
|
||||
if (event.event === "cron") {
|
||||
const payload = event.payload;
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.action !== "finished") return;
|
||||
const sessionKey = typeof record.sessionKey === "string" ? record.sessionKey.trim() : "";
|
||||
if (!sessionKey) return;
|
||||
const agentId = parseAgentIdFromSessionKey(sessionKey);
|
||||
if (!agentId) return;
|
||||
const jobId = typeof record.jobId === "string" ? record.jobId.trim() : "";
|
||||
if (!jobId) return;
|
||||
const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : "";
|
||||
const runAtMs = typeof record.runAtMs === "number" ? record.runAtMs : null;
|
||||
const status = typeof record.status === "string" ? record.status.trim() : "";
|
||||
const error = typeof record.error === "string" ? record.error.trim() : "";
|
||||
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
|
||||
|
||||
const dedupeKey = `cron:${jobId}:${sessionId || (runAtMs ?? "none")}`;
|
||||
if (seenCronEventIdsRef.current.has(dedupeKey)) return;
|
||||
seenCronEventIdsRef.current.add(dedupeKey);
|
||||
|
||||
const agent = stateRef.current.agents.find((entry) => entry.agentId === agentId) ?? null;
|
||||
if (!agent) return;
|
||||
|
||||
const header = `Cron finished (${status || "unknown"}): ${jobId}`;
|
||||
const body = summary || error || "(no output)";
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: `${header}\n\n${body}`,
|
||||
transcript: {
|
||||
source: "runtime-agent",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
sessionKey: agent.sessionKey,
|
||||
timestampMs: runAtMs ?? Date.now(),
|
||||
entryId: dedupeKey,
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "markActivity", agentId, at: runAtMs ?? undefined });
|
||||
}
|
||||
handleGatewayEventIngress(event);
|
||||
});
|
||||
return () => {
|
||||
runtimeEventHandlerRef.current = null;
|
||||
@@ -2047,7 +2009,7 @@ const AgentStudioPage = () => {
|
||||
queueLivePatch,
|
||||
refreshHeartbeatLatestUpdate,
|
||||
specialLatestUpdate,
|
||||
handleExecApprovalEvent,
|
||||
handleGatewayEventIngress,
|
||||
status,
|
||||
]);
|
||||
|
||||
@@ -2070,87 +2032,72 @@ const AgentStudioPage = () => {
|
||||
if (guard.kind === "deny") return false;
|
||||
const agent = agents.find((entry) => entry.agentId === agentId);
|
||||
if (!agent) return false;
|
||||
try {
|
||||
const queuedRenameBlock = buildQueuedMutationBlock({
|
||||
kind: "rename-agent",
|
||||
agentId,
|
||||
agentName: name,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
setRenameAgentBlock({
|
||||
agentId: queuedRenameBlock.agentId,
|
||||
agentName: queuedRenameBlock.agentName,
|
||||
phase: "queued",
|
||||
startedAt: queuedRenameBlock.startedAt,
|
||||
sawDisconnect: queuedRenameBlock.sawDisconnect,
|
||||
});
|
||||
await enqueueConfigMutation({
|
||||
kind: "rename-agent",
|
||||
label: `Rename ${agent.name}`,
|
||||
run: async () => {
|
||||
return await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: `Rename ${agent.name}`,
|
||||
isLocalGateway,
|
||||
deps: {
|
||||
enqueueConfigMutation,
|
||||
setQueuedBlock: () => {
|
||||
const queuedRenameBlock = buildQueuedMutationBlock({
|
||||
kind: "rename-agent",
|
||||
agentId,
|
||||
agentName: name,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
setRenameAgentBlock({
|
||||
agentId: queuedRenameBlock.agentId,
|
||||
agentName: queuedRenameBlock.agentName,
|
||||
phase: "queued",
|
||||
startedAt: queuedRenameBlock.startedAt,
|
||||
sawDisconnect: queuedRenameBlock.sawDisconnect,
|
||||
});
|
||||
},
|
||||
setMutatingBlock: () => {
|
||||
setRenameAgentBlock((current) => {
|
||||
if (!current || current.agentId !== agentId) return current;
|
||||
return { ...current, phase: "renaming" };
|
||||
});
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "rename-agent", isLocalGateway },
|
||||
{
|
||||
executeMutation: async () => {
|
||||
await renameGatewayAgent({
|
||||
client,
|
||||
agentId,
|
||||
name,
|
||||
});
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { name },
|
||||
});
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () =>
|
||||
shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: gatewayConfigSnapshot,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
}),
|
||||
}
|
||||
);
|
||||
const commands = buildMutationSideEffectCommands({
|
||||
disposition: result.disposition,
|
||||
});
|
||||
for (const command of commands) {
|
||||
if (command.kind === "reload-agents") {
|
||||
await loadAgents();
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
setRenameAgentBlock(null);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-mobile-pane") {
|
||||
setMobilePane(command.pane);
|
||||
continue;
|
||||
}
|
||||
setRenameAgentBlock((current) => {
|
||||
if (!current || current.agentId !== agentId) return current;
|
||||
return {
|
||||
...current,
|
||||
...command.patch,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = buildConfigMutationFailureMessage({
|
||||
kind: "rename-agent",
|
||||
error: err,
|
||||
});
|
||||
setRenameAgentBlock(null);
|
||||
setError(message);
|
||||
return false;
|
||||
}
|
||||
patchBlockAwaitingRestart: (patch) => {
|
||||
setRenameAgentBlock((current) => {
|
||||
if (!current || current.agentId !== agentId) return current;
|
||||
return {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
},
|
||||
clearBlock: () => {
|
||||
setRenameAgentBlock(null);
|
||||
},
|
||||
executeMutation: async () => {
|
||||
await renameGatewayAgent({
|
||||
client,
|
||||
agentId,
|
||||
name,
|
||||
});
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { name },
|
||||
});
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () =>
|
||||
shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: gatewayConfigSnapshot,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
}),
|
||||
reloadAgents: loadAgents,
|
||||
setMobilePaneChat: () => {
|
||||
setMobilePane("chat");
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
agents,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
buildConfigMutationFailureMessage,
|
||||
runConfigMutationWorkflow,
|
||||
type MutationWorkflowKind,
|
||||
} from "@/features/agents/operations/configMutationWorkflow";
|
||||
import { buildMutationSideEffectCommands } from "@/features/agents/operations/agentMutationLifecycleController";
|
||||
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
|
||||
export type AgentConfigMutationLifecycleKind = MutationWorkflowKind;
|
||||
|
||||
export type AgentConfigMutationLifecycleDeps = {
|
||||
enqueueConfigMutation: (params: {
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
}) => Promise<void>;
|
||||
setQueuedBlock: () => void;
|
||||
setMutatingBlock: () => void;
|
||||
patchBlockAwaitingRestart: (patch: { phase: "awaiting-restart"; sawDisconnect: boolean }) => void;
|
||||
clearBlock: () => void;
|
||||
executeMutation: () => Promise<void>;
|
||||
shouldAwaitRemoteRestart: () => Promise<boolean>;
|
||||
reloadAgents: () => Promise<void>;
|
||||
setMobilePaneChat: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export const runAgentConfigMutationLifecycle = async (params: {
|
||||
kind: AgentConfigMutationLifecycleKind;
|
||||
label: string;
|
||||
isLocalGateway: boolean;
|
||||
deps: AgentConfigMutationLifecycleDeps;
|
||||
}): Promise<boolean> => {
|
||||
params.deps.setQueuedBlock();
|
||||
try {
|
||||
await params.deps.enqueueConfigMutation({
|
||||
kind: params.kind,
|
||||
label: params.label,
|
||||
run: async () => {
|
||||
params.deps.setMutatingBlock();
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: params.kind, isLocalGateway: params.isLocalGateway },
|
||||
{
|
||||
executeMutation: params.deps.executeMutation,
|
||||
shouldAwaitRemoteRestart: params.deps.shouldAwaitRemoteRestart,
|
||||
}
|
||||
);
|
||||
const commands = buildMutationSideEffectCommands({
|
||||
disposition: result.disposition,
|
||||
});
|
||||
for (const command of commands) {
|
||||
if (command.kind === "reload-agents") {
|
||||
await params.deps.reloadAgents();
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
params.deps.clearBlock();
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-mobile-pane") {
|
||||
params.deps.setMobilePaneChat();
|
||||
continue;
|
||||
}
|
||||
params.deps.patchBlockAwaitingRestart(command.patch);
|
||||
}
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
params.deps.clearBlock();
|
||||
params.deps.onError(
|
||||
buildConfigMutationFailureMessage({
|
||||
kind: params.kind,
|
||||
error,
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { compileGuidedAgentCreation } from "@/features/agents/creation/compiler";
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation";
|
||||
import {
|
||||
resolveGuidedCreateCompletion,
|
||||
runGuidedCreateWorkflow,
|
||||
runGuidedRetryWorkflow,
|
||||
type GuidedCreateCompletion,
|
||||
} from "@/features/agents/operations/guidedCreateWorkflow";
|
||||
import { applyPendingGuidedSetupRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupRetryOperation";
|
||||
import {
|
||||
resolveMutationStartGuard,
|
||||
resolveMutationTimeoutIntent,
|
||||
} from "@/features/agents/operations/agentMutationLifecycleController";
|
||||
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
|
||||
type SetState<T> = (next: T | ((current: T) => T)) => void;
|
||||
|
||||
export type CreateAgentBlockState = {
|
||||
agentId: string | null;
|
||||
agentName: string;
|
||||
phase: "queued" | "creating" | "applying-setup";
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export type CreateAgentMutationLifecycleDeps = {
|
||||
enqueueConfigMutation: (params: {
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
}) => Promise<void>;
|
||||
createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>;
|
||||
applySetup: (agentId: string, setup: AgentGuidedSetup) => Promise<void>;
|
||||
upsertPending: (agentId: string, setup: AgentGuidedSetup) => void;
|
||||
removePending: (agentId: string) => void;
|
||||
setQueuedBlock: (params: { agentName: string; startedAt: number }) => void;
|
||||
setCreatingBlock: (agentName: string) => void;
|
||||
setApplyingSetupBlock: (params: { agentName: string; agentId: string }) => void;
|
||||
onCompletion: (completion: GuidedCreateCompletion) => Promise<void> | void;
|
||||
setCreateAgentModalOpen: (open: boolean) => void;
|
||||
setCreateAgentModalError: (message: string | null) => void;
|
||||
setCreateAgentBusy: (busy: boolean) => void;
|
||||
clearCreateBlock: () => void;
|
||||
onError: (message: string) => void;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export const runCreateAgentMutationLifecycle = async (
|
||||
params: {
|
||||
payload: AgentCreateModalSubmitPayload;
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
hasCreateBlock: boolean;
|
||||
hasRenameBlock: boolean;
|
||||
hasDeleteBlock: boolean;
|
||||
createAgentBusy: boolean;
|
||||
isLocalGateway: boolean;
|
||||
},
|
||||
deps: CreateAgentMutationLifecycleDeps
|
||||
): Promise<boolean> => {
|
||||
if (params.createAgentBusy) return false;
|
||||
const guard = resolveMutationStartGuard({
|
||||
status: params.status,
|
||||
hasCreateBlock: params.hasCreateBlock,
|
||||
hasRenameBlock: params.hasRenameBlock,
|
||||
hasDeleteBlock: params.hasDeleteBlock,
|
||||
});
|
||||
if (guard.kind === "deny") {
|
||||
if (guard.reason === "not-connected") {
|
||||
deps.setCreateAgentModalError("Connect to gateway before creating an agent.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = params.payload.name.trim();
|
||||
if (!name) {
|
||||
deps.setCreateAgentModalError("Agent name is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const compiled = compileGuidedAgentCreation({ name, draft: params.payload.draft });
|
||||
if (compiled.validation.errors.length > 0) {
|
||||
deps.setCreateAgentModalError(compiled.validation.errors[0] ?? "Guided setup is incomplete.");
|
||||
return false;
|
||||
}
|
||||
const setup: AgentGuidedSetup = {
|
||||
agentOverrides: compiled.agentOverrides,
|
||||
files: compiled.files,
|
||||
execApprovals: compiled.execApprovals,
|
||||
};
|
||||
|
||||
deps.setCreateAgentBusy(true);
|
||||
deps.setCreateAgentModalError(null);
|
||||
const startedAt = (deps.now ?? Date.now)();
|
||||
deps.setQueuedBlock({ agentName: name, startedAt });
|
||||
const avatarSeed = params.payload.avatarSeed?.trim() ?? null;
|
||||
try {
|
||||
const queuedMutation = deps.enqueueConfigMutation({
|
||||
kind: "create-agent",
|
||||
label: `Create ${name}`,
|
||||
run: async () => {
|
||||
deps.setCreatingBlock(name);
|
||||
const result = await runGuidedCreateWorkflow(
|
||||
{
|
||||
name,
|
||||
setup,
|
||||
isLocalGateway: params.isLocalGateway,
|
||||
},
|
||||
{
|
||||
createAgent: async (agentName) => {
|
||||
return await deps.createAgent(agentName, avatarSeed);
|
||||
},
|
||||
applySetup: async (agentId, nextSetup) => {
|
||||
deps.setApplyingSetupBlock({ agentName: name, agentId });
|
||||
await deps.applySetup(agentId, nextSetup);
|
||||
},
|
||||
upsertPending: deps.upsertPending,
|
||||
removePending: deps.removePending,
|
||||
}
|
||||
);
|
||||
await deps.onCompletion(
|
||||
resolveGuidedCreateCompletion({
|
||||
agentName: name,
|
||||
result,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
deps.setCreateAgentModalOpen(false);
|
||||
await queuedMutation;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create agent.";
|
||||
deps.clearCreateBlock();
|
||||
deps.setCreateAgentModalError(message);
|
||||
deps.onError(message);
|
||||
return false;
|
||||
} finally {
|
||||
deps.setCreateAgentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
export const runPendingCreateSetupRetryLifecycle = async (params: {
|
||||
agentId: string;
|
||||
source: "auto" | "manual";
|
||||
retryBusyAgentId: string | null;
|
||||
inFlightAgentIds: Set<string>;
|
||||
pendingSetupsByAgentId: Record<string, AgentGuidedSetup>;
|
||||
setRetryBusyAgentId: SetState<string | null>;
|
||||
applyPendingSetup: (agentId: string) => Promise<{ applied: boolean }>;
|
||||
removePending: (agentId: string) => void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
resolveAgentName: (agentId: string) => string;
|
||||
onApplied: () => Promise<void> | void;
|
||||
onError: (message: string) => void;
|
||||
}): Promise<boolean> => {
|
||||
return await applyPendingGuidedSetupRetryViaStudio({
|
||||
agentId: params.agentId,
|
||||
source: params.source,
|
||||
retryBusyAgentId: params.retryBusyAgentId,
|
||||
inFlightAgentIds: params.inFlightAgentIds,
|
||||
pendingSetupsByAgentId: params.pendingSetupsByAgentId,
|
||||
setRetryBusyAgentId: params.setRetryBusyAgentId,
|
||||
executeRetry: async (agentId) =>
|
||||
runGuidedRetryWorkflow(agentId, {
|
||||
applyPendingSetup: params.applyPendingSetup,
|
||||
removePending: params.removePending,
|
||||
}),
|
||||
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||
resolveAgentName: params.resolveAgentName,
|
||||
onApplied: params.onApplied,
|
||||
onError: params.onError,
|
||||
});
|
||||
};
|
||||
|
||||
export const isCreateBlockTimedOut = (params: {
|
||||
block: CreateAgentBlockState | null;
|
||||
nowMs: number;
|
||||
maxWaitMs: number;
|
||||
}): boolean => {
|
||||
if (!params.block || params.block.phase === "queued") {
|
||||
return false;
|
||||
}
|
||||
const timeoutIntent = resolveMutationTimeoutIntent({
|
||||
block: {
|
||||
kind: "create-agent",
|
||||
agentId: params.block.agentId ?? "",
|
||||
agentName: params.block.agentName,
|
||||
phase: "mutating",
|
||||
startedAt: params.block.startedAt,
|
||||
sawDisconnect: false,
|
||||
},
|
||||
nowMs: params.nowMs,
|
||||
maxWaitMs: params.maxWaitMs,
|
||||
});
|
||||
return timeoutIntent.kind === "timeout" && timeoutIntent.reason === "create-timeout";
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { resolveExecApprovalEventEffects, type ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { parseAgentIdFromSessionKey, type EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type CronTranscriptIntent = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
dedupeKey: string;
|
||||
line: string;
|
||||
timestampMs: number;
|
||||
activityAtMs: number | null;
|
||||
};
|
||||
|
||||
export type GatewayEventIngressDecision = {
|
||||
approvalEffects: ExecApprovalEventEffects | null;
|
||||
cronDedupeKeyToRecord: string | null;
|
||||
cronTranscriptIntent: CronTranscriptIntent | null;
|
||||
};
|
||||
|
||||
const NO_CRON_DECISION = {
|
||||
cronDedupeKeyToRecord: null,
|
||||
cronTranscriptIntent: null,
|
||||
} as const;
|
||||
|
||||
const resolveCronDecision = (params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): Pick<GatewayEventIngressDecision, "cronDedupeKeyToRecord" | "cronTranscriptIntent"> => {
|
||||
if (params.event.event !== "cron") {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const payload = params.event.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.action !== "finished") {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const sessionKey = typeof record.sessionKey === "string" ? record.sessionKey.trim() : "";
|
||||
if (!sessionKey) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const agentId = parseAgentIdFromSessionKey(sessionKey);
|
||||
if (!agentId) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const jobId = typeof record.jobId === "string" ? record.jobId.trim() : "";
|
||||
if (!jobId) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : "";
|
||||
const runAtMs = typeof record.runAtMs === "number" ? record.runAtMs : null;
|
||||
const status = typeof record.status === "string" ? record.status.trim() : "";
|
||||
const error = typeof record.error === "string" ? record.error.trim() : "";
|
||||
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
|
||||
|
||||
const dedupeKey = `cron:${jobId}:${sessionId || (runAtMs ?? "none")}`;
|
||||
if (params.seenCronDedupeKeys.has(dedupeKey)) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
|
||||
const agent = params.agents.find((entry) => entry.agentId === agentId) ?? null;
|
||||
if (!agent) {
|
||||
return {
|
||||
cronDedupeKeyToRecord: dedupeKey,
|
||||
cronTranscriptIntent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const header = `Cron finished (${status || "unknown"}): ${jobId}`;
|
||||
const body = summary || error || "(no output)";
|
||||
return {
|
||||
cronDedupeKeyToRecord: dedupeKey,
|
||||
cronTranscriptIntent: {
|
||||
agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
dedupeKey,
|
||||
line: `${header}\n\n${body}`,
|
||||
timestampMs: runAtMs ?? params.nowMs,
|
||||
activityAtMs: runAtMs,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveGatewayEventIngressDecision = (params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): GatewayEventIngressDecision => {
|
||||
const approvalEffects = resolveExecApprovalEventEffects({
|
||||
event: params.event,
|
||||
agents: params.agents,
|
||||
});
|
||||
return {
|
||||
approvalEffects,
|
||||
...resolveCronDecision(params),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/agentConfigMutationLifecycleOperation";
|
||||
|
||||
describe("agentConfigMutationLifecycleOperation", () => {
|
||||
it("runs completed rename lifecycle commands in order", async () => {
|
||||
const order: string[] = [];
|
||||
const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => {
|
||||
order.push("enqueue");
|
||||
await run();
|
||||
});
|
||||
const setQueuedBlock = vi.fn(() => {
|
||||
order.push("queued");
|
||||
});
|
||||
const setMutatingBlock = vi.fn(() => {
|
||||
order.push("mutating");
|
||||
});
|
||||
const executeMutation = vi.fn(async () => {
|
||||
order.push("execute");
|
||||
});
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => {
|
||||
order.push("await-check");
|
||||
return false;
|
||||
});
|
||||
const reloadAgents = vi.fn(async () => {
|
||||
order.push("reload");
|
||||
});
|
||||
const clearBlock = vi.fn(() => {
|
||||
order.push("clear");
|
||||
});
|
||||
const setMobilePaneChat = vi.fn(() => {
|
||||
order.push("pane");
|
||||
});
|
||||
const patchBlockAwaitingRestart = vi.fn(() => {
|
||||
order.push("patch");
|
||||
});
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
isLocalGateway: false,
|
||||
deps: {
|
||||
enqueueConfigMutation,
|
||||
setQueuedBlock,
|
||||
setMutatingBlock,
|
||||
patchBlockAwaitingRestart,
|
||||
clearBlock,
|
||||
executeMutation,
|
||||
shouldAwaitRemoteRestart,
|
||||
reloadAgents,
|
||||
setMobilePaneChat,
|
||||
onError,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(order).toEqual([
|
||||
"queued",
|
||||
"enqueue",
|
||||
"mutating",
|
||||
"execute",
|
||||
"await-check",
|
||||
"reload",
|
||||
"clear",
|
||||
"pane",
|
||||
]);
|
||||
expect(patchBlockAwaitingRestart).not.toHaveBeenCalled();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(enqueueConfigMutation).toHaveBeenCalledWith({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
run: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("applies awaiting-restart patch for remote delete", async () => {
|
||||
const clearBlock = vi.fn();
|
||||
const patchBlockAwaitingRestart = vi.fn();
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "delete-agent",
|
||||
label: "Delete Agent One",
|
||||
isLocalGateway: false,
|
||||
deps: {
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
setQueuedBlock: () => undefined,
|
||||
setMutatingBlock: () => undefined,
|
||||
patchBlockAwaitingRestart,
|
||||
clearBlock,
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart: async () => true,
|
||||
reloadAgents: async () => undefined,
|
||||
setMobilePaneChat: () => undefined,
|
||||
onError: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(patchBlockAwaitingRestart).toHaveBeenCalledWith({
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
});
|
||||
expect(clearBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call restart-check on local gateway", async () => {
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => true);
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
isLocalGateway: true,
|
||||
deps: {
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
setQueuedBlock: () => undefined,
|
||||
setMutatingBlock: () => undefined,
|
||||
patchBlockAwaitingRestart: () => undefined,
|
||||
clearBlock: () => undefined,
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart,
|
||||
reloadAgents: async () => undefined,
|
||||
setMobilePaneChat: () => undefined,
|
||||
onError: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears block and reports mapped error on mutation failure", async () => {
|
||||
const clearBlock = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
isLocalGateway: false,
|
||||
deps: {
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
setQueuedBlock: () => undefined,
|
||||
setMutatingBlock: () => undefined,
|
||||
patchBlockAwaitingRestart: () => undefined,
|
||||
clearBlock,
|
||||
executeMutation: async () => {
|
||||
throw new Error("rename exploded");
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
reloadAgents: async () => undefined,
|
||||
setMobilePaneChat: () => undefined,
|
||||
onError,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(clearBlock).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith("rename exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDefaultGuidedDraft } from "@/features/agents/creation/compiler";
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import type {
|
||||
CreateAgentMutationLifecycleDeps,
|
||||
} from "@/features/agents/operations/createAgentMutationLifecycleOperation";
|
||||
import {
|
||||
isCreateBlockTimedOut,
|
||||
runCreateAgentMutationLifecycle,
|
||||
runPendingCreateSetupRetryLifecycle,
|
||||
} from "@/features/agents/operations/createAgentMutationLifecycleOperation";
|
||||
import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation";
|
||||
|
||||
const createPayload = (
|
||||
overrides: Partial<AgentCreateModalSubmitPayload> = {}
|
||||
): AgentCreateModalSubmitPayload => ({
|
||||
mode: "guided",
|
||||
name: "Agent One",
|
||||
draft: createDefaultGuidedDraft(),
|
||||
avatarSeed: "seed-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDeps = (
|
||||
overrides: Partial<CreateAgentMutationLifecycleDeps> = {}
|
||||
): CreateAgentMutationLifecycleDeps => ({
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
createAgent: async () => ({ id: "agent-1" }),
|
||||
applySetup: async () => undefined,
|
||||
upsertPending: () => undefined,
|
||||
removePending: () => undefined,
|
||||
setQueuedBlock: () => undefined,
|
||||
setCreatingBlock: () => undefined,
|
||||
setApplyingSetupBlock: () => undefined,
|
||||
onCompletion: async () => undefined,
|
||||
setCreateAgentModalOpen: () => undefined,
|
||||
setCreateAgentModalError: () => undefined,
|
||||
setCreateAgentBusy: () => undefined,
|
||||
clearCreateBlock: () => undefined,
|
||||
onError: () => undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("createAgentMutationLifecycleOperation", () => {
|
||||
it("blocks create and sets modal error when disconnected", async () => {
|
||||
const setCreateAgentModalError = vi.fn();
|
||||
const enqueueConfigMutation = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload(),
|
||||
status: "disconnected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
isLocalGateway: true,
|
||||
},
|
||||
createDeps({
|
||||
setCreateAgentModalError,
|
||||
enqueueConfigMutation,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCreateAgentModalError).toHaveBeenCalledWith("Connect to gateway before creating an agent.");
|
||||
expect(enqueueConfigMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails fast on compile validation error and does not enqueue mutation", async () => {
|
||||
const setCreateAgentModalError = vi.fn();
|
||||
const enqueueConfigMutation = vi.fn(async () => undefined);
|
||||
const invalidDraft = createDefaultGuidedDraft();
|
||||
invalidDraft.controls.execAutonomy = "auto";
|
||||
invalidDraft.controls.allowExec = false;
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload({ draft: invalidDraft }),
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
isLocalGateway: true,
|
||||
},
|
||||
createDeps({
|
||||
setCreateAgentModalError,
|
||||
enqueueConfigMutation,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCreateAgentModalError).toHaveBeenCalledWith("Auto exec requires runtime tools to be enabled.");
|
||||
expect(enqueueConfigMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs successful local create/apply flow and completion commands", async () => {
|
||||
const order: string[] = [];
|
||||
const onCompletion = vi.fn(async (completion: { pendingErrorMessage: string | null }) => {
|
||||
order.push(`completion:${completion.pendingErrorMessage === null ? "applied" : "pending"}`);
|
||||
});
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload(),
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
isLocalGateway: true,
|
||||
},
|
||||
createDeps({
|
||||
setCreateAgentBusy: (busy) => {
|
||||
order.push(`busy:${busy ? "on" : "off"}`);
|
||||
},
|
||||
setCreateAgentModalError: (message) => {
|
||||
order.push(`modalError:${message === null ? "clear" : "set"}`);
|
||||
},
|
||||
setQueuedBlock: () => {
|
||||
order.push("queued");
|
||||
},
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
order.push("enqueue");
|
||||
await run();
|
||||
},
|
||||
setCreatingBlock: () => {
|
||||
order.push("creating");
|
||||
},
|
||||
createAgent: async () => {
|
||||
order.push("createAgent");
|
||||
return { id: "agent-1" };
|
||||
},
|
||||
setApplyingSetupBlock: () => {
|
||||
order.push("applying");
|
||||
},
|
||||
applySetup: async () => {
|
||||
order.push("applySetup");
|
||||
},
|
||||
removePending: () => {
|
||||
order.push("removePending");
|
||||
},
|
||||
setCreateAgentModalOpen: (open) => {
|
||||
order.push(`modalOpen:${open ? "true" : "false"}`);
|
||||
},
|
||||
onCompletion,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(order).toEqual([
|
||||
"busy:on",
|
||||
"modalError:clear",
|
||||
"queued",
|
||||
"enqueue",
|
||||
"creating",
|
||||
"createAgent",
|
||||
"modalOpen:false",
|
||||
"applying",
|
||||
"applySetup",
|
||||
"removePending",
|
||||
"completion:applied",
|
||||
"busy:off",
|
||||
]);
|
||||
expect(onCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps create successful but reports pending completion when setup apply fails", async () => {
|
||||
const upsertPending = vi.fn();
|
||||
const removePending = vi.fn();
|
||||
const onCompletion = vi.fn();
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload(),
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
isLocalGateway: true,
|
||||
},
|
||||
createDeps({
|
||||
applySetup: async () => {
|
||||
throw new Error("setup exploded");
|
||||
},
|
||||
upsertPending,
|
||||
removePending,
|
||||
onCompletion,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(upsertPending).toHaveBeenCalledTimes(1);
|
||||
expect(removePending).not.toHaveBeenCalled();
|
||||
expect(onCompletion).toHaveBeenCalledWith({
|
||||
shouldReloadAgents: true,
|
||||
shouldCloseCreateModal: true,
|
||||
pendingErrorMessage:
|
||||
'Agent "Agent One" was created, but guided setup is pending. Retry or discard setup from chat. setup exploded',
|
||||
});
|
||||
});
|
||||
|
||||
it("handles manual pending setup retry success", async () => {
|
||||
const pendingSetup = {} as AgentGuidedSetup;
|
||||
const onApplied = vi.fn();
|
||||
const removePending = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runPendingCreateSetupRetryLifecycle({
|
||||
agentId: "agent-1",
|
||||
source: "manual",
|
||||
retryBusyAgentId: null,
|
||||
inFlightAgentIds: new Set<string>(),
|
||||
pendingSetupsByAgentId: { "agent-1": pendingSetup },
|
||||
setRetryBusyAgentId: () => undefined,
|
||||
applyPendingSetup: async () => ({ applied: true }),
|
||||
removePending,
|
||||
isDisconnectLikeError: () => false,
|
||||
resolveAgentName: () => "Agent One",
|
||||
onApplied,
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(removePending).toHaveBeenCalledWith("agent-1");
|
||||
expect(onApplied).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces manual pending setup retry failures", async () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runPendingCreateSetupRetryLifecycle({
|
||||
agentId: "agent-1",
|
||||
source: "manual",
|
||||
retryBusyAgentId: null,
|
||||
inFlightAgentIds: new Set<string>(),
|
||||
pendingSetupsByAgentId: { "agent-1": {} as AgentGuidedSetup },
|
||||
setRetryBusyAgentId: () => undefined,
|
||||
applyPendingSetup: async () => {
|
||||
throw new Error("retry exploded");
|
||||
},
|
||||
removePending: () => undefined,
|
||||
isDisconnectLikeError: () => false,
|
||||
resolveAgentName: () => "Agent One",
|
||||
onApplied: () => undefined,
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(onError).toHaveBeenCalledWith('Guided setup retry failed for "Agent One". retry exploded');
|
||||
});
|
||||
|
||||
it("maps create block timeout through shared mutation timeout policy", () => {
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: null,
|
||||
nowMs: 100_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: {
|
||||
agentId: null,
|
||||
agentName: "Agent One",
|
||||
phase: "queued",
|
||||
startedAt: 0,
|
||||
},
|
||||
nowMs: 100_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent One",
|
||||
phase: "creating",
|
||||
startedAt: 0,
|
||||
},
|
||||
nowMs: 95_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent One",
|
||||
phase: "applying-setup",
|
||||
startedAt: 0,
|
||||
},
|
||||
nowMs: 45_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import { resolveGatewayEventIngressDecision } from "@/features/agents/state/gatewayEventIngressWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
...(overrides ?? {}),
|
||||
});
|
||||
|
||||
describe("gatewayEventIngressWorkflow", () => {
|
||||
it("returns no cron decision for non-cron events", () => {
|
||||
const event: EventFrame = { type: "event", event: "heartbeat", payload: {} };
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBeNull();
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
expect(decision.approvalEffects).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores malformed cron payload variants", () => {
|
||||
const malformedEvents: EventFrame[] = [
|
||||
{ type: "event", event: "cron", payload: null },
|
||||
{ type: "event", event: "cron", payload: "bad" },
|
||||
{ type: "event", event: "cron", payload: { action: "started" } },
|
||||
{ type: "event", event: "cron", payload: { action: "finished", sessionKey: "" } },
|
||||
{
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: { action: "finished", sessionKey: "invalid", jobId: "job-1" },
|
||||
},
|
||||
{
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: { action: "finished", sessionKey: "agent:agent-1:main", jobId: "" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of malformedEvents) {
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
expect(decision.cronDedupeKeyToRecord).toBeNull();
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns dedupe and transcript intent for valid finished cron event", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-1",
|
||||
sessionId: "session-1",
|
||||
runAtMs: 123,
|
||||
status: "ok",
|
||||
summary: "cron summary",
|
||||
},
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent({ sessionKey: "agent:agent-1:studio:test-session" })],
|
||||
seenCronDedupeKeys: seen,
|
||||
nowMs: 999,
|
||||
});
|
||||
|
||||
expect(seen.size).toBe(0);
|
||||
expect(decision.cronDedupeKeyToRecord).toBe("cron:job-1:session-1");
|
||||
expect(decision.cronTranscriptIntent).toEqual({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
dedupeKey: "cron:job-1:session-1",
|
||||
line: "Cron finished (ok): job-1\n\ncron summary",
|
||||
timestampMs: 123,
|
||||
activityAtMs: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns dedupe-only decision for unknown-agent finished cron", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:missing:main",
|
||||
jobId: "job-2",
|
||||
runAtMs: 456,
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBe("cron:job-2:456");
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses cron decision for duplicate dedupe key", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-3",
|
||||
runAtMs: 777,
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set(["cron:job-3:777"]),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBeNull();
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to nowMs and no-output body when runAtMs/summary/error are missing", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-4",
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 4321,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBe("cron:job-4:none");
|
||||
expect(decision.cronTranscriptIntent).toEqual({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
dedupeKey: "cron:job-4:none",
|
||||
line: "Cron finished (unknown): job-4\n\n(no output)",
|
||||
timestampMs: 4321,
|
||||
activityAtMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates approval event effects unchanged", () => {
|
||||
const agents = [createAgent()];
|
||||
const requestedEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "npm test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: "agent-1",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
createdAtMs: 100,
|
||||
expiresAtMs: 200,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedRequested = resolveExecApprovalEventEffects({
|
||||
event: requestedEvent,
|
||||
agents,
|
||||
});
|
||||
const requestedDecision = resolveGatewayEventIngressDecision({
|
||||
event: requestedEvent,
|
||||
agents,
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(requestedDecision.approvalEffects).toEqual(expectedRequested);
|
||||
expect(requestedDecision.approvalEffects?.markActivityAgentIds).toEqual(["agent-1"]);
|
||||
|
||||
const resolvedEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.resolved",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "studio",
|
||||
ts: 999,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedResolved = resolveExecApprovalEventEffects({ event: resolvedEvent, agents });
|
||||
const resolvedDecision = resolveGatewayEventIngressDecision({
|
||||
event: resolvedEvent,
|
||||
agents,
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(resolvedDecision.approvalEffects).toEqual(expectedResolved);
|
||||
expect(resolvedDecision.approvalEffects?.removals).toEqual(["approval-1"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user