Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ frozen projection carries a native `ScheduleTrigger`. Cadence is set
from inside conversation, not from a separate scheduling console: a
person names what should happen again, on what schedule, and where the
result should land. See `@corbits/workflows` for the schedule/cron
helpers and the hub's `workflow-scheduler.ts` for the poller.
helpers and the hub's `workflow-scheduler.ts` for the poller. The
Routines page also offers an Available section (CL-7073): every
catalog workflow this workbench hasn't added yet, with an Add action
that deploys it in place — no separate create flow.

## Inbox and approvals

Expand Down
10 changes: 9 additions & 1 deletion apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,11 @@ import {
type PresenceRoomKey,
} from "@corbits/presence";
import { supportedCredentialProviders } from "@corbits/connections/credential-test";
import { createGitWorkflowPusher } from "@corbits/seeding";
import {
CATALOG_WORKFLOWS,
catalogWorkflowDeployableOnThisPin,
createGitWorkflowPusher,
} from "@corbits/seeding";
import { createHubAPI } from "@corbits/hub-api-client";
import {
createDrizzlePendingSeedStore,
Expand Down Expand Up @@ -1805,6 +1809,10 @@ export async function createHub(config: HubConfig) {
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
}),
catalogAssetNames: CATALOG_WORKFLOWS.map(
(workflow) => workflow.assetName,
),
catalogWorkflowDeployable: catalogWorkflowDeployableOnThisPin,
runNow: async (args) =>
runNowScheduledDefinition(
{ db, sidecarRouter, ...scheduledDeliveryJoinDeps },
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/pages/routines-available-strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copy for the Routines page's "Available" section (CL-7073) — kept apart
// from JSX so a wording change never touches component logic. Every
// string here reads as consumer language: no "deploy", "asset", or other
// platform-side noun (see `scripts/checks/ui-vocabulary.ts`).

export const AVAILABLE_SECTION_TITLE = "Available";
export const AVAILABLE_SECTION_SUBTITLE = "Add one of these to this workbench.";

export const ADD_BUTTON_LABEL = "Add";
export const ADD_BUTTON_BUSY_LABEL = "Adding…";

export function addSuccessMessage(displayName: string): string {
return `${displayName} added`;
}

export function addFailureMessage(displayName: string, detail: string): string {
return `Couldn't add ${displayName}: ${detail}`;
}

/**
* Every connector id `WORKFLOW_CATALOG` currently names as a required
* connection (`packages/workflows/src/catalog.ts`), mapped to the
* consumer-facing name the Plugins page shows for it. A connector id
* with no entry here falls back to itself capitalized — never a raw
* lowercase id in copy, but also never a silent crash for a future
* catalog entry naming a connector this list hasn't caught up with yet.
*/
const CONNECTOR_DISPLAY_NAMES: Readonly<Record<string, string>> = {
github: "GitHub",
granola: "Granola",
linear: "Linear",
exa: "Exa",
scrapecreators: "ScrapeCreators",
attio: "Attio",
};

function connectorDisplayName(connectorId: string): string {
return (
CONNECTOR_DISPLAY_NAMES[connectorId] ??
connectorId.charAt(0).toUpperCase() + connectorId.slice(1)
);
}

export function missingConnectionsReason(
missingConnections: readonly string[],
): string {
const names = missingConnections.map(connectorDisplayName);
if (names.length === 1) {
return `Connect ${names[0]} first.`;
}
return `Connect ${names.join(" and ")} first.`;
}

export const CONNECT_LINK_LABEL = "Go to Plugins";

export const NOT_DEPLOYABLE_YET_REASON =
"Coming with the next platform update.";
131 changes: 131 additions & 0 deletions apps/web/src/pages/routines-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Routines: an ops table of authored workflow definitions that carry a
// ScheduleTrigger, including paused (`stopped`) ones. Pause/resume and
// run-now are the only writes; schedules are authored on the definition.
// The "Available" section (CL-7073) is scoped to the current bench only
// — adding a catalog workflow is a single-tenant write, unlike the
// scheduled roster above, which aggregates every bench the account
// belongs to.
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
EmptyState,
RichEmptyState,
RunNowButton,
Expand All @@ -12,18 +19,138 @@ import {
TableHead,
TableHeader,
TableRow,
toast,
} from "@corbits/react-ui";
import { Clock } from "@corbits/icons";
import { cronSentence } from "@corbits/workflows/client";
import { reportError } from "@corbits/error-sink";
import { describeApiError, ApiQueryError } from "@corbits/api-query";

import { useGlobalRoutines, useRoutineActions } from "../global-routines";
import type { GlobalRoutineRow } from "../global-routines";
import { routineDetailPath } from "../global-routines";
import { useBench } from "../bench-context";
import { tenantKeys } from "../query-client";
import {
listAvailableCatalogWorkflows,
type AvailableCatalogWorkflow,
} from "../routines-api";
import { deployWorkbenchTemplateBlock } from "../workbench-templates-api";
import { Link } from "../navigation";
import { PLUGINS_PATH_PREFIX } from "../path-ids";
import { StageTopBar } from "../shell/stage-top-bar";
import {
ADD_BUTTON_BUSY_LABEL,
ADD_BUTTON_LABEL,
AVAILABLE_SECTION_SUBTITLE,
AVAILABLE_SECTION_TITLE,
CONNECT_LINK_LABEL,
NOT_DEPLOYABLE_YET_REASON,
addFailureMessage,
addSuccessMessage,
missingConnectionsReason,
} from "./routines-available-strings";

export type { GlobalRoutineRow } from "../global-routines";

export function AvailableCatalogWorkflowsSection({
tenantId,
}: {
readonly tenantId: string;
}) {
const queryClient = useQueryClient();
const [pendingAssetName, setPendingAssetName] = useState<string | null>(null);
const query = useQuery({
queryKey: tenantKeys.availableCatalogWorkflows(tenantId),
queryFn: () => listAvailableCatalogWorkflows(tenantId),
});

if (query.isLoading) return null;
const items: readonly AvailableCatalogWorkflow[] = query.data ?? [];
if (items.length === 0) return null;

async function handleAdd(entry: AvailableCatalogWorkflow) {
setPendingAssetName(entry.assetName);
try {
await deployWorkbenchTemplateBlock(tenantId, entry.assetName);
await queryClient.invalidateQueries({
queryKey: tenantKeys.availableCatalogWorkflows(tenantId),
});
await queryClient.invalidateQueries({
queryKey: tenantKeys.routines(tenantId),
});
toast(addSuccessMessage(entry.displayName));
} catch (cause) {
reportError(cause, {
operation: "catalog_workflow_add",
tenantId,
});
const detail = describeApiError(cause, "adding this");
const refId = cause instanceof ApiQueryError ? cause.refId : undefined;
toast(
addFailureMessage(
entry.displayName,
refId !== undefined ? `${detail} (Reference: ${refId})` : detail,
),
);
} finally {
setPendingAssetName(null);
}
}

return (
<div className="flex flex-col gap-2 border-b border-[var(--ui-border)] p-4">
<div className="flex flex-col">
<h2 className="text-sm font-medium">{AVAILABLE_SECTION_TITLE}</h2>
<p className="text-xs text-[var(--ui-fg-muted)]">
{AVAILABLE_SECTION_SUBTITLE}
</p>
</div>
<ul className="flex flex-col gap-2">
{items.map((entry) => {
const notDeployableYet = !entry.deployable;
const disabled = notDeployableYet || !entry.connectionsSatisfied;
const busy = pendingAssetName === entry.assetName;
return (
<li
key={entry.assetName}
data-ctx-available-workflow={entry.assetName}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--ui-border)] p-3"
>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{entry.displayName}</span>
<span className="text-xs text-[var(--ui-fg-muted)]">
{entry.description}
</span>
{notDeployableYet ? (
<span className="text-xs text-[var(--ui-fg-muted)]">
{NOT_DEPLOYABLE_YET_REASON}
</span>
) : !entry.connectionsSatisfied ? (
<span className="flex items-center gap-2 text-xs text-[var(--ui-fg-muted)]">
{missingConnectionsReason(entry.missingConnections)}
<Link to={PLUGINS_PATH_PREFIX} className="underline">
{CONNECT_LINK_LABEL}
</Link>
</span>
) : null}
</div>
<Button
variant="outline"
size="sm"
disabled={disabled || busy}
onClick={() => void handleAdd(entry)}
>
{busy ? ADD_BUTTON_BUSY_LABEL : ADD_BUTTON_LABEL}
</Button>
</li>
);
})}
</ul>
</div>
);
}

export function scheduleSentence(cron: string): string {
return cronSentence(cron) ?? cron;
}
Expand Down Expand Up @@ -109,6 +236,7 @@ export function RoutinesRoute() {
const routinesQuery = useGlobalRoutines();
const actions = useRoutineActions();
const rows = routinesQuery.kind === "ready" ? routinesQuery.data : [];
const { selectedTenantId } = useBench();

return (
<div className="flex h-full min-h-0 flex-col">
Expand All @@ -117,6 +245,9 @@ export function RoutinesRoute() {
subtitle="Scheduled workflows. Pause, resume, or run now."
/>
<div className="stage-content flex min-h-0 flex-1 flex-col overflow-y-auto">
{selectedTenantId !== null ? (
<AvailableCatalogWorkflowsSection tenantId={selectedTenantId} />
) : null}
{routinesQuery.kind === "loading" ? (
<div className="flex flex-1 items-center justify-center p-6">
<EmptyState icon={<Clock />} title="Loading routines…" />
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/query-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export const tenantKeys = {
runView: (tenantId: string, runId: string) =>
["tenant", tenantId, "runs", runId] as const,
routines: (tenantId: string) => ["tenant", tenantId, "routines"] as const,
availableCatalogWorkflows: (tenantId: string) =>
["tenant", tenantId, "routines", "available"] as const,
skills: (tenantId: string) => ["tenant", tenantId, "skills"] as const,
mcpServers: (tenantId: string) =>
["tenant", tenantId, "mcp-servers"] as const,
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ const ScheduledWorkflowsResponse = type({
items: ScheduledWorkflowDefinition.array(),
});

export const AvailableCatalogWorkflow = type({
assetName: "string",
displayName: "string",
description: "string",
requiredConnections: "string[]",
missingConnections: "string[]",
connectionsSatisfied: "boolean",
deployable: "boolean",
"notDeployableReason?": "'credential_bindings_unsupported'",
});

export type AvailableCatalogWorkflow = typeof AvailableCatalogWorkflow.infer;

const AvailableCatalogWorkflowsResponse = type({
items: AvailableCatalogWorkflow.array(),
});

const RunNowResponse = type({ runId: "string" });

type Validator<T> = (data: unknown) => T | ArkErrors;
Expand Down Expand Up @@ -88,6 +105,19 @@ export function scheduledWorkflowsPath(tenantId: string): string {
return `/api/tenants/${tenantId}/workflows/scheduled`;
}

export function availableCatalogWorkflowsPath(tenantId: string): string {
return `/api/tenants/${tenantId}/workflows/available`;
}

export function listAvailableCatalogWorkflows(
tenantId: string,
): Promise<readonly AvailableCatalogWorkflow[]> {
return request(
availableCatalogWorkflowsPath(tenantId),
AvailableCatalogWorkflowsResponse,
).then((page) => page.items);
}

export function scheduledWorkflowRunPath(
tenantId: string,
definitionId: string,
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/workbench-templates-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type WorkbenchDefinition,
} from "@workbench/templates";
import { ApiQueryError } from "@corbits/api-query";
import { parseErrorEnvelope } from "@corbits/error-sink";

const TemplateLibraryEntry = type({ id: "string > 0", content: "string > 0" });

Expand Down Expand Up @@ -51,16 +52,17 @@ export async function deployWorkbenchTemplateBlock(
path,
);
}
const json: unknown = await response.json().catch(() => undefined);
if (!response.ok) {
const envelope = parseErrorEnvelope(json);
throw new ApiQueryError(
`The server answered ${response.status}.`,
envelope?.error.userMessage ?? `The server answered ${response.status}.`,
response.status,
path,
envelope?.error.refId,
);
}
const parsed = TemplateBlockDeployResponse(
await response.json().catch(() => undefined),
);
const parsed = TemplateBlockDeployResponse(json);
if (parsed instanceof type.errors) {
throw new ApiQueryError(
`Couldn't set that up — the answer didn't look right: ${parsed.summary}`,
Expand Down
Loading
Loading