AgentSourcesPanel.tsx11.4 KBView on GitHub
'use client';

import {
  AgentSourcesSection,
  type AgentSourcesError,
  type NewSourceRequest,
  type PlaybookSource,
} from './AgentSourcesSection';
import type { AgentInvocationSource, PlaybookTriggerRef } from '@/modules/agents/types';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAgentPlaybookVersion } from '@/modules/agents/queries';
import { AgentWebhookPanel } from './AgentWebhookPanel';
import { useTRPC } from '@/providers/query-provider';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';

/**
 * The tRPC half of Config §1 — everything ./AgentSourcesSection.tsx deliberately
 * does not know.
 *
 * Split so the section stays props-in and renders with no provider in scope: the
 * interesting part of that component is what it puts on screen for a given list,
 * and a component that reaches for a query client to be rendered at all cannot be
 * tested on that. Everything below is wiring.
 *
 * Both mutations return the RE-RESOLVED list (the server re-reads the playbook
 * after writing), so the cache is filled from the response rather than refetched:
 * the answer in hand is already the one the next read would produce.
 */
export function AgentSourcesPanel({
  agentId,
  targetUserId,
  aopId,
  sources,
  runCount30d,
  isLoading,
  adding,
  onAddingChange,
  className,
}: {
  agentId: string;
  /** An org admin/owner viewing a teammate's agent — see `AgentView`'s prop of the same name. */
  targetUserId?: string;
  /** The AOP whose status options seeded this playbook's `<stage>` elements. */
  aopId?: string | null;
  sources?: AgentInvocationSource[];
  runCount30d?: number;
  isLoading?: boolean;
  /** Add-a-source is opened from the Config page's heading row, so it is controlled. */
  adding?: boolean;
  onAddingChange?: (adding: boolean) => void;
  className?: string;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const [error, setError] = useState<AgentSourcesError | null>(null);

  /**
   * The optimistic-concurrency token. Read separately from `agent.get` because a
   * client re-reads it between edits without wanting the whole agent — and
   * because every write hands back the next one, which is written straight into
   * this cache entry below.
   *
   * Shared hook, not a local `useQuery`: AgentView fires the same one on
   * mount so this is already answered by the time the Config tab renders.
   */
  const { data: playbook } = useAgentPlaybookVersion(agentId, targetUserId);

  /**
   * The stages the add-form may offer, from the AOP's `status` options.
   *
   * That field is what SEEDS the playbook's stages — seed-playbook.ts writes one
   * `<stage id="{value}" label="{label}">` per option — and `upsertPlaybookSource`
   * matches a trigger's stage against those ids, case-insensitively, and refuses
   * anything it cannot find. So this list is the set of stages a trigger can
   * legally name, which is exactly what the dropdown should contain.
   *
   * A hand-authored playbook whose stage ids were edited away from the AOP's option
   * values is the one case this can miss; the server still refuses clearly, and the
   * old text box could not have helped anyway.
   */
  const { data: aopData } = useQuery({
    ...trpc.aop.getAopById.queryOptions({ id: aopId ?? '', targetUserId }),
    enabled: !!aopId,
  });

  const stages = useMemo(() => {
    const options = aopData?.aop?.conversationFieldDefinitions?.status?.options;
    if (!Array.isArray(options)) return undefined;
    return options
      .map((o) => ({ value: String(o.value ?? ''), label: o.label || String(o.value ?? '') }))
      .filter((o) => o.value.length > 0);
  }, [aopData]);

  const onSettledWrite = (result: { sources: AgentInvocationSource[]; version: number }) => {
    setError(null);
    queryClient.setQueryData(
      trpc.agent.getInvocationSources.queryKey({ agentId, targetUserId }),
      result.sources,
    );
    queryClient.setQueryData(
      trpc.agent.getPlaybookVersion.queryKey({ agentId, targetUserId }),
      (prev) => (prev ? { ...prev, version: result.version } : prev),
    );
    // The agent list shows a "never fires" hint derived from this same list.
    void queryClient.invalidateQueries({ queryKey=[redacted] });
  };

  const onWriteError = (err: unknown) => setError(toSourcesError(err));

  const { mutate: addSource, isPending: isAdding } = useMutation(
    trpc.agent.upsertPlaybookSource.mutationOptions({
      onSuccess: (result) => {
        onSettledWrite(result);
        toast.success(result.changed ? 'Source added' : 'That source was already configured');
      },
      onError: onWriteError,
    }),
  );

  /**
   * The webhook's own mutation, because it is a different act: it mints the
   * `playbook_webhooks` row AND patches the playbook, and deletes the row again if
   * the patch fails. Routing it through `upsertPlaybookSource` would ask that
   * procedure to write a block whose other half does not exist yet.
   */
  const { mutate: createWebhook, isPending: isCreatingWebhook } = useMutation(
    trpc.agent.createWebhookSource.mutationOptions({
      onSuccess: (result) => {
        onSettledWrite(result);
        toast.success('Webhook created — copy the URL from the trigger');
      },
      onError: onWriteError,
    }),
  );

  /**
   * The per-trigger instruction — the same `upsertPlaybookSource` procedure, on
   * purpose: writing a `<ref>` body and adding a `<ref>` are one act against one
   * element, and the server decides which it is by whether the ref is there.
   *
   * A mutation of its OWN, though, only so the toast can say what happened. Two
   * `useMutation`s over one route are two independent pending flags and one
   * server behaviour, which is exactly the split wanted here.
   */
  const { mutate: writeInstructions, isPending: isWritingInstructions } = useMutation(
    trpc.agent.upsertPlaybookSource.mutationOptions({
      onSuccess: (result) => {
        onSettledWrite(result);
        if (result.changed) toast.success('Instructions saved');
      },
      onError: onWriteError,
    }),
  );

  const { mutate: removeSource, isPending: isRemoving } = useMutation(
    trpc.agent.removePlaybookSource.mutationOptions({
      onSuccess: (result) => {
        onSettledWrite(result);
        toast.success('Source removed');
      },
      onError: onWriteError,
    }),
  );

  // Without a version there is nothing to write against — the optimistic guard is
  // required, not optional — so the affordances are withheld rather than sending a
  // guess. Compared inline rather than through a `canEdit` boolean so the narrowing
  // reaches inside the callbacks.
  const expectedVersion = playbook?.version;

  return (
    <AgentSourcesSection
      sources={sources}
      runCount30d={runCount30d}
      isLoading={isLoading}
      className={className}
      isMutating={isAdding || isRemoving || isCreatingWebhook || isWritingInstructions}
      error={error}
      {...(stages ? { stages } : {})}
      {...(adding === undefined ? {} : { adding })}
      {...(onAddingChange ? { onAddingChange } : {})}
      onAddSource={
        expectedVersion === undefined
          ? undefined
          : (request: NewSourceRequest) =>
              request.kind === 'webhook'
                ? createWebhook({
                    agentId,
                    targetUserId,
                    expectedVersion,
                    ...(request.label ? { label: request.label } : {}),
                  })
                : addSource({ agentId, targetUserId, trigger: request.trigger, expectedVersion })
      }
      renderWebhookPanel={(webhookId, url) => (
        <AgentWebhookPanel key=[redacted] webhookId={webhookId} url={url} />
      )}
      onDeleteSource={
        expectedVersion === undefined
          ? undefined
          : (source: PlaybookSource) =>
              removeSource(removeInput(agentId, targetUserId, source, expectedVersion))
      }
      onUpdateInstructions={
        expectedVersion === undefined
          ? undefined
          : (source: PlaybookSource, instructions: string) =>
              writeInstructions(
                instructionsInput(agentId, targetUserId, source, instructions, expectedVersion),
              )
      }
    />
  );
}

/**
 * The remove payload, built as a NAMED value rather than inline.
 *
 * `sourceId` is the block's stable handle — the whole reason two same-shape
 * triggers can be deleted independently. Absent (an older playbook, or a row
 * whose id the PLAYBOOK.md editor round-trip dropped) the server matches on the
 * trigger's shape, exactly as it did before ids existed, so the delete still
 * works; it is just aimed at the first block of that shape.
 *
 * Named, and not an inline literal, so it reads as one payload beside
 * `instructionsInput` below rather than as an argument list. Both procedures now
 * declare `sourceId`, so it flows straight through.
 */
function removeInput(
  agentId: string,
  targetUserId: string | undefined,
  source: PlaybookSource,
  expectedVersion: number,
) {
  return {
    agentId,
    targetUserId,
    trigger: toWriteTrigger(source.trigger),
    sourceId: source.sourceId,
    expectedVersion,
  };
}

/**
 * The instruction payload — an upsert aimed at a block that is ALREADY THERE.
 *
 * `sourceId` matters more here than anywhere else: an add can settle for the
 * first block of a matching shape, because if there isn't one it makes one. An
 * instruction edit cannot — the user is looking at one row, and two blocks of the
 * same shape (the same cron authored twice) are one address without the id. Where
 * the row has none, the server still falls back to the shape, which is the
 * pre-id behaviour and the best that can be done for a document with no handles.
 */
function instructionsInput(
  agentId: string,
  targetUserId: string | undefined,
  source: PlaybookSource,
  instructions: string,
  expectedVersion: number,
) {
  return {
    agentId,
    // Carried for the same reason `removeInput` carries it, and it was the one payload
    // on this panel that did not: an instruction edit made while viewing a teammate's
    // agent patched the ADMIN's playbook — a write to the wrong person's document,
    // under a screen showing somebody else's.
    targetUserId,
    trigger: toWriteTrigger(source.trigger),
    sourceId: source.sourceId,
    instructions,
    expectedVersion,
  };
}

/**
 * The read model's trigger minus the two fields the WRITE side never reads out of
 * the playbook: a webhook's `url` and `lastFiredAt` come from `playbook_webhooks`.
 * Mirrors `toWriteTrigger` in services/agent-workspace/delete-agent.ts.
 */
function toWriteTrigger(trigger: PlaybookTriggerRef) {
  if (trigger.type !== 'webhook') return trigger;
  const { type, scope, stage, webhookId } = trigger;
  return { type, scope, ...(stage ? { stage } : {}), webhookId };
}

/**
 * A version conflict is its own kind, because it is the only failure whose fix is
 * "reload" rather than "change what you asked for". The router maps
 * `PLAYBOOK_VERSION_CONFLICT` onto a tRPC CONFLICT, so the shape is checked
 * before the message — a string match on prose would break the day the copy
 * changes.
 */
function toSourcesError(err: unknown): AgentSourcesError {
  const code = (err as { data?: { code?: string } } | null)?.data?.code;
  if (code === 'CONFLICT') return { kind: 'conflict', message: 'The playbook changed.' };
  return {
    kind: 'error',
    message: err instanceof Error ? err.message : 'Could not update the playbook',
  };
}