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
72 changes: 16 additions & 56 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,6 @@ import {
launchWebhookTrigger,
} from "@corbits/webhook-triggers";
import {
createWorkflowDetailRoute,
createScheduledWorkflowRoutes,
ensureRunSession,
isConversationalWorkflowName,
} from "@corbits/workflows";
Expand Down Expand Up @@ -219,10 +217,7 @@ import type { SidecarProvisioner } from "@intx/hub-sessions";
import { withTurnPartWriteDefaults } from "./turn-part-content-default";
import { createDrizzleTurnTextSnapshotReader } from "./turn-text-snapshot";
import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory";
import {
runNowScheduledDefinition,
triggerNativeWorkflowRoutineRun,
} from "./native-workflow-routine-launch";
import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch";
import { createCronEmitter } from "@corbits/workflow-schedule/emitter";
import { createToolGrantsForPins } from "./tool-grants";
import { drainHubServer, shutdownHub } from "./shutdown";
Expand Down Expand Up @@ -1497,53 +1492,18 @@ export async function createHub(config: HubConfig) {
`${TENANT_PREFIX}/inbox`,
createInboxRoutes({ db: mailboxDb, bus: mailboxBus }),
);
// A workflow definition's own detail page (CL-7371): what it is,
// whether it can run right now, its steps, and its access surface.
// Mounted alongside — not inside — the vendored
// `createWorkflowDefinitionRoutes` (`vendor/intx/hub-api/src/app.ts`
// already mounts that one at this same `/workflows/definitions`
// prefix): this GET is a hub-composed read over native
// rows, so it lives in `@corbits/workflows`'s `./detail`, not the
// vendored tree.
app.route(
`${TENANT_PREFIX}/workflows/definitions`,
createWorkflowDetailRoute({
db,
requireGrant: createRequireGrant({
grantStore: chatGrantStore,
conditionRegistry: grantConditionRegistry,
}),
}),
);
// Hub-zero T4 (CL-8126): the scheduled delivery join is cut — a
// triggered run launches straight into the runner with no
// settings-row lookup and no chat join. Pre-cutover rows carry no
// migration burden: the join never persisted anything (it read the
// settings registry and joined the run to a chat in memory per
// trigger), so there is no backfill and no default to apply.
app.route(
`${TENANT_PREFIX}/workflows`,
createScheduledWorkflowRoutes({
db,
requireGrant: createRequireGrant({
grantStore: chatGrantStore,
conditionRegistry: grantConditionRegistry,
}),
runNow: async (args) =>
runNowScheduledDefinition(
{ db, sidecarRouter },
{
tenantId: args.tenantId,
definitionId: args.definitionId,
principalId: args.principalId,
fromDomain: args.fromDomain,
content: args.content,
name: args.name,
definitionAssetId: args.assetId,
},
),
}),
);
// CL-8160: the Workbench-composed workflow detail route
// (`@corbits/workflows`'s former `./detail/detail-route.ts`, mounted
// here at this same `/workflows/definitions` prefix as the vendored
// `createWorkflowDefinitionRoutes`) and the scheduled-workflow routes
// (`./schedule/scheduled-route.ts`: list/run-now/available-catalog) are
// both deleted per the owner ruling that workflow detail and schedule
// state derive from stock `@intx/hub-api` reads, not a hub-composed
// route. Stock's `GET /workflows/definitions` (mounted below by
// `createWorkflowDefinitionRoutes`) does not yet expose enough to
// reconstruct what these routes gave (no manifest/package metadata, no
// wire projection, no grant snapshot) — see the PR for CL-8160 for the
// upstream ask.
{
const mailboxApp = new Hono<TenantEnv>();
mountMailbox(mailboxApp, {
Expand Down Expand Up @@ -1892,9 +1852,9 @@ export async function createHub(config: HubConfig) {
// Recurring auto-fire for schedule-triggered deployments. The cron and
// the tick arithmetic belong to `@corbits/workflow-schedule`; this root
// only supplies the two host bindings the emitter cannot own — which
// deployments are live, and how a tick fires. The fire is the same
// native trigger `runNowScheduledDefinition` uses, so a scheduled tick
// and a run-now are the same primitive with a different clock.
// deployments are live, and how a tick fires. CL-8160 deleted the
// hub-mounted run-now route (`triggerNativeWorkflowRoutineRun`'s only
// other caller); this cron fire is the sole remaining caller now.
const cronEmitter = createCronEmitter({
listCronDeployments: async () =>
(await listDeployedCronDefinitions(db)).map((definition) => ({
Expand Down
29 changes: 0 additions & 29 deletions apps/hub/src/native-workflow-routine-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,32 +184,3 @@ export async function triggerNativeWorkflowRoutineRun(

return { runId: anchor.id, address };
}

export type RunNowScheduledDefinitionArgs = {
tenantId: string;
definitionId: string;
principalId: string;
fromDomain: string;
content: string;
name: string;
definitionAssetId: string;
};

/**
* Fire a schedule-triggered definition now and return its `{ runId }`. No
* delivery join: the run launches straight into the runner (hub-zero T4,
* CL-8126).
*/
export async function runNowScheduledDefinition(
deps: NativeWorkflowRoutineTriggerDeps,
args: RunNowScheduledDefinitionArgs,
): Promise<{ runId: string }> {
const triggered = await triggerNativeWorkflowRoutineRun(deps, {
tenantId: args.tenantId,
definitionId: args.definitionId,
principalId: args.principalId,
fromDomain: args.fromDomain,
content: args.content,
});
return { runId: triggered.runId };
}
187 changes: 29 additions & 158 deletions apps/web/src/pages/workflow-detail-page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
// `/workflows/<definitionAssetId>` (CL-7371) — a workflow definition's own
// page: what it is, whether it can run right now, its steps in execution
// order, and its access surface. Read-only, first useful version: header
// (name, lifecycle, source commit), steps, declared-vs-approved grants and
// credential binding names, and a "why not launchable" strip when the
// lifecycle isn't `deployed`.
//
// Never renders a credential value — only the binding names the hub
// route already redacted to (`@corbits/workflows`'s `./detail`'s
// `detail-route.ts`) — and never reads `workflow.json` (see
// docs/workflow-model.md's retirement).
import {
Badge,
EmptyState,
PageShell,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@corbits/react-ui";
// `/workflows/<definitionId>` (CL-7371) — a workflow definition's own
// page. CL-8160: the hub-composed detail read (name, lifecycle, source
// commit, steps, declared-vs-approved grants, credential bindings) is
// gone with `@corbits/workflows`'s deleted `./detail/detail-route.ts` —
// stock's `GET /workflows/definitions` (`workflow-detail-api.ts`) exposes
// only name, description, status (`deployed` | `stopped`), current
// version, and timestamps. This renders exactly that; see the CL-8160 PR
// for the upstream ask to expose more (package manifest, wire projection,
// grant snapshot) through a stock route.
import { Badge, EmptyState, PageShell } from "@corbits/react-ui";
import type { BadgeTone } from "@corbits/react-ui";
import { Clock, FlowArrow } from "@corbits/icons";

Expand All @@ -37,52 +25,35 @@ import {
type WorkflowDefinitionDetailT,
} from "../workflow-detail-api";

const LIFECYCLE_LABEL: Readonly<
Record<WorkflowDefinitionDetailT["lifecycle"], string>
const STATUS_LABEL: Readonly<
Record<WorkflowDefinitionDetailT["status"], string>
> = {
"source-only": "Source only",
"pending-approval": "Pending approval",
deployed: "Deployed",
superseded: "Superseded",
"build-failed": "Build failed",
stopped: "Stopped",
};

const LIFECYCLE_TONE: Readonly<
Record<WorkflowDefinitionDetailT["lifecycle"], BadgeTone>
const STATUS_TONE: Readonly<
Record<WorkflowDefinitionDetailT["status"], BadgeTone>
> = {
"source-only": "neutral",
"pending-approval": "warning",
deployed: "success",
superseded: "neutral",
"build-failed": "danger",
stopped: "neutral",
};

function shortSha(sha: string): string {
return sha.length > 0 ? sha.slice(0, 7) : "";
}

/** The header row: display name, lifecycle badge, and (when known) the
* source commit that produced the current definition. */
/** The header row: display name, status badge, and current version. */
function WorkflowDetailHeader({
detail,
}: {
readonly detail: WorkflowDefinitionDetailT;
}) {
const sha =
detail.source !== undefined && detail.source !== null
? shortSha(detail.source.commitSha)
: "";
return (
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge tone={LIFECYCLE_TONE[detail.lifecycle]}>
{LIFECYCLE_LABEL[detail.lifecycle]}
<Badge tone={STATUS_TONE[detail.status]}>
{STATUS_LABEL[detail.status]}
</Badge>
{sha !== "" ? (
<span className="font-mono text-xs text-[var(--ui-fg-muted)]">
{sha}
</span>
) : null}
<span className="font-mono text-xs text-[var(--ui-fg-muted)]">
v{detail.currentVersion}
</span>
</div>
{detail.description !== undefined && detail.description !== null ? (
<p className="m-0 text-sm text-[var(--ui-fg-muted)]">
Expand All @@ -93,15 +64,14 @@ function WorkflowDetailHeader({
);
}

/** Why this definition can't be launched right now, and the honest next
* action — absent entirely once it is `deployed`, never a strip with
* nothing true to say. */
/** Why this definition can't be launched right now — absent entirely once
* it is `deployed`, never a strip with nothing true to say. */
export function NotLaunchableStrip({
lifecycle,
status,
}: {
readonly lifecycle: WorkflowDefinitionDetailT["lifecycle"];
readonly status: WorkflowDefinitionDetailT["status"];
}) {
const reason = workflowNotLaunchableReason(lifecycle);
const reason = workflowNotLaunchableReason(status);
if (reason === null) return null;
return (
<div className="rounded-md border border-[var(--ui-border)] bg-[var(--ui-bg-muted)] px-4 py-3 text-sm">
Expand All @@ -110,103 +80,6 @@ export function NotLaunchableStrip({
);
}

/** Every step in execution order, with its role, model, director, tool
* pins, and the grants the deploy-time capability walk froze onto it. */
export function WorkflowStepsSection({
steps,
}: {
readonly steps: WorkflowDefinitionDetailT["steps"];
}) {
return (
<section className="flex flex-col gap-2">
<h2 className="m-0 text-sm font-semibold uppercase tracking-wide text-[var(--ui-fg-muted)]">
Steps
</h2>
{steps.length === 0 ? (
<p className="m-0 text-sm text-[var(--ui-fg-muted)]">
No approved steps yet — this workflow has not been deployed.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Step</TableHead>
<TableHead>Role</TableHead>
<TableHead>Director</TableHead>
<TableHead>Model</TableHead>
<TableHead>Tools</TableHead>
<TableHead>Grants</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{steps.map((step) => (
<TableRow key={step.id}>
<TableCell className="font-mono text-xs">{step.id}</TableCell>
<TableCell>{step.role}</TableCell>
<TableCell>{step.director ?? "—"}</TableCell>
<TableCell>{step.model ?? "—"}</TableCell>
<TableCell>
{step.toolPins.length === 0 ? "—" : step.toolPins.join(", ")}
</TableCell>
<TableCell>
{step.grants.length === 0 ? "—" : step.grants.join(", ")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
);
}

/** Declared (what the source asks for) vs. approved (what the last freeze
* actually granted) — and the credential binding names a step can use.
* Names only, never a resolved credential value. */
export function WorkflowAccessSection({
detail,
}: {
readonly detail: WorkflowDefinitionDetailT;
}) {
return (
<section className="flex flex-col gap-4">
<h2 className="m-0 text-sm font-semibold uppercase tracking-wide text-[var(--ui-fg-muted)]">
Access
</h2>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-[var(--ui-fg-muted)]">
Declared grants
</span>
<span className="text-sm">
{detail.grants.declared.length === 0
? "None declared"
: detail.grants.declared.join(", ")}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-[var(--ui-fg-muted)]">
Approved grants
</span>
<span className="text-sm">
{detail.grants.approved.length === 0
? "None approved yet"
: detail.grants.approved.join(", ")}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-[var(--ui-fg-muted)]">
Credential bindings
</span>
<span className="text-sm">
{detail.credentialBindings.length === 0
? "None"
: detail.credentialBindings.join(", ")}
</span>
</div>
</section>
);
}

/** The whole page body, given a resolved detail — pure, so the layout is
* testable without a fetch or a router. */
export function WorkflowDetailPage({
Expand All @@ -219,15 +92,13 @@ export function WorkflowDetailPage({
<StageTopBar
crumbs={[
{ label: "Workflows", href: WORKFLOWS_PATH_PREFIX },
{ label: detail.displayName },
{ label: detail.name },
]}
/>
<PageShell width="full" className="page-fill">
<div className="flex flex-col gap-6">
<WorkflowDetailHeader detail={detail} />
<NotLaunchableStrip lifecycle={detail.lifecycle} />
<WorkflowStepsSection steps={detail.steps} />
<WorkflowAccessSection detail={detail} />
<NotLaunchableStrip status={detail.status} />
</div>
</PageShell>
</div>
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/path-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@ export function routineSegmentFromPath(path: string): string | null {
return entityIdFromTopLevelPath(path, ROUTINES_PATH_PREFIX);
}

/** The definition asset id `/workflows/<id>` addresses — `null` for the
/** The workflow definition id `/workflows/<id>` addresses — `null` for the
* bare prefix or a path outside it. A workflow definition has no slug of
* its own, so — like a routine — it is addressed by its opaque id. */
* its own, so — like a routine — it is addressed by its opaque id.
* CL-8160: since the detail page now reads stock's `workflow_definition`
* list directly, this is `workflowDefinition.id`, not `asset.id` (the
* former Workbench-composed detail route's own address space) — the
* export name is kept to limit the diff. */
export function workflowDefinitionAssetIdFromPath(path: string): string | null {
return entityIdFromTopLevelPath(path, WORKFLOWS_PATH_PREFIX);
}
Expand Down
Loading
Loading