diff --git a/AGENTS.md b/AGENTS.md index ef91fbef2c..2911c9b407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ scripts/checks/report-error.ts --write-baseline` after fixing (or tracked on its own ticket opts out instead with a `report-error-ignore: ` comment on the catch or in its body — that's for a violation actively being fixed, never a way to clear a baseline entry quietly. -- A package's `browser-safe` subpath (e.g. `@corbits/routines/client`) may +- A package's `browser-safe` subpath (e.g. `@corbits/inbox/client`) may never import a server-only dependency (`postgres`, `drizzle-orm`, `hono`, any `@intx/*`) — `check:browser-safe-subpaths` walks the real import graph from each declared entry point. diff --git a/PRODUCT.md b/PRODUCT.md index d4d8d4d287..20842b15e3 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -177,12 +177,12 @@ for how a workbench's settings relate to its tenant, and ## Routines, through conversation -A **Routine** is the named, recurring (or manual) parent over runs of one -agent definition — a trigger (or none), a delivery destination, and a run -history. Routines are set up and managed 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 -`packages/routines` for the underlying shape. +A **Routine** is a scheduled workflow — an authored definition whose +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. ## Inbox and approvals diff --git a/VENDORED.md b/VENDORED.md index 609db403d9..587825348e 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -26,7 +26,7 @@ never a convenience. | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | | `apps/sidecar` | Derived from upstream's own `apps/sidecar`: six modules byte-identical (`default-harness.ts`, `signing-keypair.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), three near-verbatim (`atomic-write.ts` differs only by the repo-wide logger namespace), the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `step-agent-tools.ts`, `workflow-host-wiring/`, `workflow-substrate-factory/`, …) substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; one local delta (CL-7190): `ToolBundle.beforeToolExtension` (`tool.ts`) and its composition into `ResolvedTools.beforeToolExtensions` (`agent.ts`), so a tool package can contribute its own suspend-capable extension without the reactor or director special-casing it by name; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections, plus `0089` adding `workflow_definition.schedule_claimed_minute` for the hub's native ScheduleTrigger poller (CL-4455); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; one local delta (CL-6396): `SessionEventSink` / `publishWorkflowInferenceEvent` stamp optional `childRunId` on `agent.event`; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | diff --git a/apps/hub/README.md b/apps/hub/README.md index a1fd820fd5..bc55337ad4 100644 --- a/apps/hub/README.md +++ b/apps/hub/README.md @@ -24,8 +24,8 @@ native tenant middleware. - Serves the built `apps/web` SPA from its own origin (`HUB_STATIC_DIR` points at `apps/web/dist`), so every `/api` call the interface makes is same-origin. -- `credential-expiry-sweep.ts`, `cron-due.ts`, `routine-launcher.ts`, - `routine-scheduler.ts`, `tenant-create-guard.ts` are host-level +- `credential-expiry-sweep.ts`, `cron-due.ts`, `workflow-scheduler.ts`, + `tenant-create-guard.ts` are host-level background jobs and guards wired at boot, alongside the route mounts. - `in-flight-requests.ts` and `shutdown.ts` bound SIGINT/SIGTERM: the hub waits for Hono handlers that have not yet returned a Response (a diff --git a/apps/hub/package.json b/apps/hub/package.json index f427c01a4a..a8a4ea709e 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -38,7 +38,6 @@ "@corbits/notify": "workspace:*", "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", - "@corbits/routines": "workspace:*", "@corbits/run-key-history": "workspace:*", "@corbits/run-scope": "workspace:*", "@corbits/sidecar-placement": "workspace:*", diff --git a/apps/hub/src/credential-expiry-sweep.ts b/apps/hub/src/credential-expiry-sweep.ts index 0a0feeb19c..8ca491bdcd 100644 --- a/apps/hub/src/credential-expiry-sweep.ts +++ b/apps/hub/src/credential-expiry-sweep.ts @@ -10,13 +10,12 @@ // column gets refreshed in place instead — the reconnect nudge is now a // last resort, sent only when that refresh itself fails. // -// This mirrors `routine-scheduler.ts`'s own shape: the decision of +// This mirrors the hub's native schedule poller shape: the decision of // *which* Hugging-Face-style credentials are due lives in // `@corbits/notify` (`findDueCredentialExpiries`), pure and unit-tested -// there; `CredentialExpirySweepStore` is the same store-behind-an- -// interface seam `@corbits/routines`' `RoutineStore` uses, so this loop's -// own claim/refresh/mail orchestration is testable against an in-memory -// store without a live Postgres. +// there; `CredentialExpirySweepStore` is a store-behind-an-interface +// seam, so this loop's own claim/refresh/mail orchestration is testable +// against an in-memory store without a live Postgres. // // Interchange's inference reactor already fails a credential-category // error over to the next configured source with no changes here — for diff --git a/apps/hub/src/cron-due.ts b/apps/hub/src/cron-due.ts index 89d6d41b91..e9cc7d4d61 100644 --- a/apps/hub/src/cron-due.ts +++ b/apps/hub/src/cron-due.ts @@ -1,7 +1,6 @@ -// A re-export of `@corbits/routines`' own cron matcher — the same +// A re-export of `@corbits/workflows`' own cron matcher — the same // grammar `isValidCronExpression` validates at save time and -// `nextCronFireAt` uses to persist a routine's next fire. Kept as its -// own module (rather than importing `@corbits/routines` at every call -// site in this app) so this hub has one seam onto the shared parser; +// `cronMatchesMinute` uses to decide a native ScheduleTrigger tick. +// Kept as its own module so this hub has one seam onto the shared parser; // it is never a second implementation of it. -export { cronMatchesMinute, minuteKey } from "@corbits/routines"; +export { cronMatchesMinute, minuteKey } from "@corbits/workflows"; diff --git a/apps/hub/src/crypto-provider-cache-wiring.test.ts b/apps/hub/src/crypto-provider-cache-wiring.test.ts index a973a1fd28..c61bdfb2c5 100644 --- a/apps/hub/src/crypto-provider-cache-wiring.test.ts +++ b/apps/hub/src/crypto-provider-cache-wiring.test.ts @@ -2,6 +2,13 @@ // that same reference to every mail sender. A second constructor would // still typecheck; this scan is the pin. crypto-cache.test.ts covers the // Map; platform-adapter.test.ts covers chat injection only. +// +// CL-4455 removed the custom routine scheduler's `createHubRoutineLauncher` +// call site: a native ScheduleTrigger now fires through +// `triggerNativeWorkflowRoutineRun` (native-workflow-routine-launch.ts), +// which signs its trigger mail with a freshly generated per-call keypair +// rather than the shared cache — there is no longer a fourth mail-sender +// call site to pin here. import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; @@ -67,9 +74,6 @@ describe("hub crypto-provider cache wiring", () => { expect(firstCall(source, "launchWebhookTrigger")).toContain( `cryptoProviderCache: ${assigned}`, ); - expect(firstCall(source, "createHubRoutineLauncher")).toContain( - `cryptoProviderCache: ${assigned}`, - ); expect(firstCall(source, "runOneShotFoldedPrompt")).toContain(assigned); }); }); diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ec2fba6e76..b1bdd2ef1f 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -14,7 +14,6 @@ import { createSidecarAllocationStore, createSignalCorrelationStore, createWorkflowRunDispatchStore, - createWorkflowRunStore, listVisibleOfferings, resolveCredentialByName, resolveCredentialRequirement, @@ -108,7 +107,6 @@ import { postRoomMessage, recordSourcesDigest, startWorkflowCommand, - sendWorkbenchMessage, settleConnectedService, workbenchLaunchPersistExtra, } from "@corbits/chat"; @@ -118,7 +116,6 @@ import type { FinalizedTurnToolCall } from "@corbits/turn-artifacts"; import { decodedOrNull } from "@corbits/url-path"; import { createCryptoProviderCache, - findFoldedRunByAddress, lookupFoldedRunReconnectKey, tagCredentialCipher, } from "@corbits/folded-runs"; @@ -189,8 +186,6 @@ import { import { deliveryWorkbenchRequiredForWorkflowName, isConversationalWorkflowName, - validateTriggerFieldsAtCreate, - workflowCatalogEntry, workflowDisplayName, workbenchTemplateLibraryEntries, } from "@workbench/templates"; @@ -199,20 +194,10 @@ import { webhookTriggerName } from "@corbits/connections/connect-github-setup"; import { createTemplateBlockRoutes } from "./templates/template-block-routes"; import { createWorkflowDetailRoute, + createScheduledWorkflowRoutes, renderWorkflowSourceTree, WORKFLOW_SOURCE_ENTRY, } from "@corbits/workflows"; -import { - createDrizzleRoutineStore, - createRoutineRoutes, - createRoutineTargetRoutes, - createWorkflowRoutineRoutes, - listRoutineTargets, - resolveLaunchableDefinition, - routine as routineTable, - routineRun as routineRunTable, - settleRoutineFireFromTurn, -} from "@corbits/routines"; import { createSidecarProvisioner as createE2BSidecarProvisioner, readProvisionerConfig as readE2BProvisionerConfig, @@ -372,11 +357,16 @@ import { } from "./config"; import type { SidecarProvisioner } from "@intx/hub-sessions"; import { scheduleEnvProviderCredentialPlant } from "./env-credential-plant"; -import { createHubRoutineLauncher } from "./routine-launcher"; import { withTurnPartWriteDefaults } from "./turn-part-content-default"; -import { createHubRunSummaryResolver } from "./routine-run-summary"; import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory"; -import { createRoutineScheduler } from "./routine-scheduler"; +import { + claimScheduleMinuteFromDb, + createWorkflowScheduler, + launchScheduledDefinitionFromDb, + listScheduledDefinitionsFromDb, + runNowScheduledDefinition, + type ScheduledDeliveryJoinDeps, +} from "./workflow-scheduler"; import { createToolGrantsForPins } from "./tool-grants"; import { createMcpCredentialBindingsFor } from "./mcp-credential-bindings"; import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; @@ -828,31 +818,6 @@ export async function createHub(config: HubConfig) { store: insightsUsage.store, generateId: () => generateId("inferenceTurn"), }); - // CL-6778: a routine fire's folded delivery agent stays deployed after - // it replies, so `workflow_run.status` would linger on `running` unless - // something stamps it terminal. `onTurnFinalized` is that stamp — - // `markTerminal` + `endedAt`, gated on a `routine_run` row so a - // workbench host or invited agent is never settled by accident. - const workflowRunStore = createWorkflowRunStore(db); - const routineFireSettlePort = { - async lookupRunByAddress(address: string) { - const row = await findFoldedRunByAddress(db, address); - return row === undefined ? undefined : { id: row.id }; - }, - async isRoutineFire(runId: string) { - const rows = await db - .select({ runId: routineRunTable.runId }) - .from(routineRunTable) - .where(eq(routineRunTable.runId, runId)) - .limit(1); - return rows.length > 0; - }, - markTerminal: ( - runId: string, - status: "completed" | "failed" | "cancelled", - endedAt: Date, - ) => workflowRunStore.markTerminal(runId, status, endedAt), - }; // CL-6257: per-message-run stage latency (message-received → // reactor.start → inference.start → first-token → reply-posted). The // vendored event collector never persists the events this reads (see @@ -873,16 +838,6 @@ export async function createHub(config: HubConfig) { db: withTurnPartPersistGuard(withTurnPartWriteDefaults(db)), onTurnFinalized: (agentAddress, turn) => { artifactDeliveryHandlerRef.current?.(agentAddress, turn); - void settleRoutineFireFromTurn(routineFireSettlePort, agentAddress, { - status: turn.status, - hadReply: turn.hadReply, - }).catch((err: unknown) => { - log.warn`Failed to settle routine fire for ${agentAddress}: ${err instanceof Error ? err.message : String(err)}`; - reportError(err, { - operation: "routine_fire_settle_from_turn", - agentId: agentAddress, - }); - }); }, // Per-turn usage, emitted once when the collector finalizes a turn. onUsage: (_agentAddress, usage) => { @@ -1637,23 +1592,6 @@ export async function createHub(config: HubConfig) { isConversationalAgentDefinition(definition) && !isPlannerCreatedDefinitionName(definition.name); - /** The address a principal's own message is posted under: their id at - * their tenant's domain, the same shape every participant address - * carries. */ - const senderAddressFor = async ( - tenantId: string, - principalId: string, - ): Promise => { - const row = await db.query.tenant.findFirst({ - where: eq(tenantTable.id, tenantId), - columns: { domain: true }, - }); - if (row === undefined) { - throw new Error(`No tenant "${tenantId}" to derive a sender address in`); - } - return `${principalId}@${row.domain}`; - }; - const chatDeps: Parameters[0] = { store: chatStore, roomMessages, @@ -1806,6 +1744,41 @@ export async function createHub(config: HubConfig) { }), }), ); + const scheduledDeliveryJoinDeps: ScheduledDeliveryJoinDeps = { + deliveryWorkbenchRequired: deliveryWorkbenchRequiredForWorkflowName, + resolveDeliveryWorkbench: async (tenantId) => { + const rows = await chatStore.listWorkbenchSettings(tenantId); + const first = [...rows].sort((a, b) => + a.workbenchId.localeCompare(b.workbenchId), + )[0]; + return first?.workbenchId; + }, + joinDeliveryWorkbench: (input) => + joinRunParticipant({ store: chatStore }, input), + }; + app.route( + `${TENANT_PREFIX}/workflows`, + createScheduledWorkflowRoutes({ + db, + requireGrant: createRequireGrant({ + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + }), + runNow: async (args) => + runNowScheduledDefinition( + { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, + { + tenantId: args.tenantId, + definitionId: args.definitionId, + principalId: args.principalId, + fromDomain: args.fromDomain, + content: args.content, + name: args.name, + definitionAssetId: args.assetId, + }, + ), + }), + ); // Run key identity diagnostics: read side of the append-only // `run_key_history` table above — per-run key lifecycle, divergence // against `workflow_run.public_key`, and tenant-wide counts by @@ -2843,34 +2816,7 @@ export async function createHub(config: HubConfig) { // so the Agent Directory and the shell's "Running" bands stop // deriving that exclusion client-side from a tenant's workbenches alone // (see `@corbits/folded-runs`'s `scope-routes.ts`, which a folded run - // with no workbench involved silently slipped past). The route's - // `feed=fires` mode (Insights, CL-6249) needs the one bridge - // `@corbits/folded-runs` cannot own itself — resolving a folded run id - // back to the routine that fired it — wired here, the one place in - // the hub that already depends on both packages. Plain `db` queries - // against `@corbits/routines`' own tables, not its `RoutineStore`: - // that store does not exist yet at this point in composition (built - // just below, for the routine grant/launcher wiring), and this lookup - // needs nothing from it beyond the two tables. - async function resolveRoutineFires(runIds: readonly string[]) { - const rows = await db - .select({ - runId: routineRunTable.runId, - routineId: routineRunTable.routineId, - routineName: routineTable.name, - }) - .from(routineRunTable) - .innerJoin(routineTable, eq(routineTable.id, routineRunTable.routineId)) - .where(inArray(routineRunTable.runId, runIds)); - const fires = new Map(); - for (const row of rows) { - fires.set(row.runId, { - routineId: row.routineId, - routineName: row.routineName, - }); - } - return fires; - } + // with no workbench involved silently slipped past). app.route( `${TENANT_PREFIX}/top-level-runs`, createTopLevelRunRoutes({ @@ -2879,272 +2825,23 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), - resolveRoutineFires, }), ); - // Routines: its own grant store (routines authorize against the - // `workflow-run:*` resource family, the same one native run routes - // use — see `@corbits/routines`' routes.ts), the launcher adapter - // that turns a routine's `launchRoutineRun` call into a real folded - // run via `@corbits/folded-runs` (routine-launcher.ts), and a run - // summary resolver so `GET /routines/:id/runs` reports each fire's - // real status instead of a bare run id. - const routineGrantStore = createGrantStore(db); - const routineStore = createDrizzleRoutineStore(db); - // The honest end-to-end delivery-destination rule: a workflow that - // never posts to a workbench (e.g. recurring-task, always delivering - // to its creator's Inbox — see @workbench/templates's - // `deliveryMode`) must never be forced to collect, or block on - // missing, a `deliveryWorkbenchId` it would just discard. An unknown - // definitionId (row missing, or its asset name isn't catalog-known) - // defaults to workbench-required — the safe, prior behavior. - async function routineDeliveryWorkbenchRequired( - tenantId: string, - definitionId: string, - ): Promise { - const row = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { name: true }, - }); - if (row === undefined) return true; - return deliveryWorkbenchRequiredForWorkflowName(row.name); - } - // Create-time boundary check for a routine's stored `input` against - // its own definition's declared trigger fields. CL-6358: inputs bind - // at USE, never at creation — a required field with no value at all - // is never rejected here (`validateTriggerFieldsAtCreate` only - // checks a value the caller actually provided), only resolved - // further for an `"agent"`-kind field — that a provided value names - // a real conversational definition. An unknown definitionId or asset - // name passes here (its 404 comes from `definitionInTenant` instead); - // a definition with no declared trigger fields accepts any input, same - // as today. - async function routineInputValid( - tenantId: string, - definitionId: string, - input: Record, - ): Promise<{ ok: true } | { ok: false; message: string }> { - const row = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { name: true }, - }); - if (row === undefined) return { ok: true }; - const entry = workflowCatalogEntry(row.name); - if (entry?.triggerFields === undefined) return { ok: true }; - - const shapeResult = validateTriggerFieldsAtCreate( - entry.triggerFields, - input, - ); - if (!shapeResult.ok) return shapeResult; - - for (const field of entry.triggerFields) { - if (field.kind !== "agent") continue; - const value = input[field.key]; - if (typeof value !== "string" || value === "") continue; - const agentRow = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, value), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { name: true, status: true }, - }); - if ( - agentRow === undefined || - agentRow.status !== "deployed" || - !isConversationalAgentDefinition(agentRow) - ) { - return { - ok: false, - message: `"${field.label}" must be a conversational agent`, - }; - } - } - return { ok: true }; - } - - const routineLauncher = createHubRoutineLauncher({ - db, - sessionService, - assetService, - sidecarRouter, - eventCollectors, - credentialCipher, - toolGrantsForPins, - mcpCredentialBindingsFor, - pinnedPackageCredentialBindingsFor, - cryptoProviderCache: cryptoProviders, - joinDeliveryWorkbench: (input) => - joinRunParticipant({ store: chatStore }, input), - }); - // A `{kind: "webhook"}` routine trigger and the `@corbits/webhook- - // triggers` row it names are two views of one binding: the row still - // points at a `workflow_definition` row id (`workflowDefinitionId`), - // while a routine now names its stable `definitionAssetId` — so - // agreement means the definition row's own `assetId` equals the - // routine's `definitionAssetId`, not a direct id comparison. - const webhookTriggerInTenant = async ( - tenantId: string, - webhookTriggerId: string, - definitionAssetId: string, - ): Promise => { - const trigger = await webhookTriggerStore.get(tenantId, webhookTriggerId); - if (trigger === undefined) return false; - const definitionRow = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, trigger.workflowDefinitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { assetId: true }, - }); - return definitionRow?.assetId === definitionAssetId; - }; - const routineWorkbenchNotice = { - postWorkbenchNotice: (input: { - tenantId: string; - workbenchId: string; - principalId: string; - text: string; - }) => - senderAddressFor(input.tenantId, input.principalId) - .then((senderAddress) => - sendWorkbenchMessage( - { - store: chatStore, - platform: chatPlatform, - roomMessages, - publish: workbenchSubscribers.publish, - turnQueue, - turnCancellation, - turnMailCorrelation, - }, - { - tenantId: input.tenantId, - principalId: input.principalId, - senderAddress, - workbenchId: input.workbenchId, - messageParts: [{ kind: "text", text: input.text }], - }, - ), - ) - .then(() => undefined), - }; - // Routines routes own their `/routines` prefix, so mount at the - // tenant root (same pattern as a package that ships absolute - // resource paths) rather than under a second `/routines` segment. - app.route( - TENANT_PREFIX, - createRoutineRoutes({ - store: routineStore, - workbenchNotice: routineWorkbenchNotice, - launcher: routineLauncher, - requireGrant: createRequireGrant({ - grantStore: routineGrantStore, - conditionRegistry: chatConditionRegistry, - }), - // CL-7354: a create or retarget's resolved target must also - // authorize for the acting principal — same `grantStore` the grant - // check above already uses. - grantStore: routineGrantStore, - conditionRegistry: chatConditionRegistry, - // A run-now or a scheduled fire's result is a message into the - // routine's delivery workbench root timeline — never a pre-opened - // thread; see `@corbits/routines`' `RoutineLauncher` doc comment - // for the multi-message contract. - runSummaryResolver: createHubRunSummaryResolver(db), - resolveTarget: (tenantId, definitionAssetId) => - resolveLaunchableDefinition({ db, tenantId, definitionAssetId }), - // A `{kind: "webhook"}` trigger's `webhookTriggerId` must resolve - // to a real `webhook_trigger` row in this tenant, pointed at the - // exact same workflow definition the routine itself runs — see - // `webhookTriggerValid`'s doc comment in - // `@corbits/routines`' routes.ts for why the two ids must agree. - webhookTriggerInTenant, - deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, - validateRoutineInput: routineInputValid, - }), - ); - // Routine target discovery (CL-7351): the one list of deployed, frozen - // definitions a routine may reference, beside the platform's own - // `/workflows/definitions` listing but authorized per row for the - // acting principal — see `@corbits/routines`' targets.ts. - app.route( - `${TENANT_PREFIX}/workflows/targets`, - createRoutineTargetRoutes({ + // Recurring auto-fire: `workflow-scheduler.ts` ticks authored, deployed + // definitions whose frozen projection carries a native ScheduleTrigger. + // This hub has no general job-runner today, so the loop is scoped to + // exactly that job rather than standing up a bespoke cron daemon as a + // hidden dependency. Every hub replica can safely run it: each native + // fire is claimed on `workflow_definition.schedule_claimed_minute`. + const workflowScheduler = createWorkflowScheduler({ + listScheduledDefinitions: listScheduledDefinitionsFromDb(db), + claimScheduleMinute: claimScheduleMinuteFromDb(db), + launch: launchScheduledDefinitionFromDb({ db, - grantStore: routineGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ); - // Myra's own routine-management surface (`@corbits/routines-tools`' - // `routine_list`/`routine_create`/`routine_update`/`routine_run_now`): - // the workflow-run-authenticated counterpart to the tenant-session - // mount just above, reusing the exact same store/launcher/delivery - // ports — see `@corbits/routines`' `workflow-routine-routes.ts` for - // the deliberate self-tenant-scoped authorization decision this route - // enforces in place of `requireGrant`. - app.route( - "/api/workflow-routines", - createWorkflowRoutineRoutes({ - store: routineStore, - launcher: routineLauncher, - workbenchNotice: routineWorkbenchNotice, - authenticator: createWorkflowRunAuthenticator({ db }), - resolveTarget: (tenantId, definitionAssetId) => - resolveLaunchableDefinition({ db, tenantId, definitionAssetId }), - listTargets: (query) => - listRoutineTargets( - { - db, - grantStore: routineGrantStore, - conditionRegistry: chatConditionRegistry, - }, - query, - ), - // CL-7354: same authorization the tenant-session surface enforces - // above, for Myra's own create/retarget path. - grantStore: routineGrantStore, - conditionRegistry: chatConditionRegistry, - webhookTriggerInTenant, - deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, - // A routine created from inside a workbench delivers into that - // workbench: the creating run is a workbench participant, so its - // address (runId@domain) resolves straight to the workbench. - resolveRunWorkbench: async (tenantId, runId) => { - const row = await db.query.tenant.findFirst({ - where: eq(tenantTable.id, tenantId), - columns: { domain: true }, - }); - if (row === undefined) return undefined; - const hit = await chatStore.findWorkbenchByParticipantAddress( - tenantId, - `${runId}@${row.domain}`, - ); - return hit?.workbenchId; - }, - validateRoutineInput: routineInputValid, + sidecarRouter, + ...scheduledDeliveryJoinDeps, }), - ); - // Recurring auto-fire: a minimal in-process poller (routine-scheduler.ts) - // over `@corbits/routines`' own `fireScheduledRoutine` — this hub has no - // general job-runner today, so this loop is scoped to exactly one job - // (fire due routines) rather than standing up a bespoke cron daemon as a - // hidden dependency. Every hub replica can safely run this poller: each - // fire is claimed with a conditional update on the routine's persisted - // `nextFireAt` before anything launches, so two replicas racing the same - // fire never both win, and a fire that falls due while every replica is - // down is caught up (not lost) the next time any of them polls. - const routineScheduler = createRoutineScheduler({ - store: routineStore, - launcher: routineLauncher, - deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, ...(config.routineSchedulerPollIntervalMs !== undefined ? { pollIntervalMs: config.routineSchedulerPollIntervalMs } : {}), @@ -3621,7 +3318,7 @@ export async function createHub(config: HubConfig) { } envCredentialPlant.stop(); chatOrchestrator.dispose(); - routineScheduler.stop(); + workflowScheduler.stop(); credentialExpirySweep.stop(); inboxUnsnoozeSweep.stop(); benchProvisioner.stop(); diff --git a/apps/hub/src/native-workflow-routine-launch.ts b/apps/hub/src/native-workflow-routine-launch.ts index 6a702f81e2..1ce8e574d9 100644 --- a/apps/hub/src/native-workflow-routine-launch.ts +++ b/apps/hub/src/native-workflow-routine-launch.ts @@ -108,10 +108,8 @@ export type TriggeredNativeWorkflowRun = { * `GET /workflows/:id/runs` for a run id that did not exist before this * call, then that run's own `/events` for its terminal event — see * `scripts/e2e/cl-6324-launch-proof.ts`'s `driveSectionOccurrence` for - * the proven pattern. Nothing in this repo joins that per-fire - * correlation back to `@corbits/routines`' run history yet; this - * function's return value is deliberately the coarser deployment-level - * handle until that join is built. + * the proven pattern. This function's return value is deliberately the + * coarser deployment-level handle until a per-fire join is built. */ export async function triggerNativeWorkflowRoutineRun( deps: NativeWorkflowRoutineTriggerDeps, diff --git a/apps/hub/src/routine-launcher.test.ts b/apps/hub/src/routine-launcher.test.ts deleted file mode 100644 index 22009ac130..0000000000 --- a/apps/hub/src/routine-launcher.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -// Proves the gap CL-6038 closes: a routine's stored `input` (the -// stepper-collected topic/focus a routine's creator recorded) reaches -// the launched run as its first-turn mail, via the same -// `sendFoldedMailWithRetry` seam every other folded-run first message -// goes through — and that a delivery failure past launch never un-does -// or hides the already-real run. `@corbits/folded-runs` is real here -// except for `launchFoldedRun`/`sendFoldedMailWithRetry`/ -// `readDefinitionProjection`, which would otherwise need a real tenant catalog -// and asset store — the same "swap the one export that needs a join" -// approach `packages/folded-runs/test/launch.test.ts` and -// `packages/webhook-triggers/test/launch.test.ts` use. -import { describe, expect, mock, test } from "bun:test"; -import { CHAT_TURN_TIMEOUT_MS } from "@corbits/chat"; - -const actualFoldedRuns = await import("@corbits/folded-runs"); - -const FOLDED_BODY = { - systemPrompt: "you are a routine agent", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: "claude-sonnet-5", -}; - -const FRAME_HEADER = "Input from this routine's setup:"; - -let launchFoldedRunCalls: unknown[] = []; -const launchRowUpdates: unknown[] = []; -let sendFoldedMailWithRetryCalls: unknown[] = []; -let sendFoldedMailWithRetryResult: unknown = { - ok: true, - mail: { id: "m_1", createdAt: new Date().toISOString() }, -}; - -// "single" mirrors every shipped routine today (readFoldedBody -// succeeds); "multi" simulates a code-sourced, multi-step definition — -// readFoldedBody always throws for one of these, by construction (see -// packages/folded-runs/src/definition.ts) — so the launcher's own -// try/catch routing is what these tests exercise, not a fake that -// picks its own outcome. -let foldedBodyMode: "single" | "multi" = "single"; - -mock.module("@corbits/folded-runs", () => ({ - ...actualFoldedRuns, - readDefinitionProjection: async () => ({ __fake: true }), - readFoldedBody: (_projection: unknown, _grantRequirements: unknown) => { - if (foldedBodyMode === "multi") { - throw new actualFoldedRuns.MultiStepFoldUnsupportedError("wfd_1", 3); - } - return FOLDED_BODY; - }, - launchFoldedRun: async (...args: unknown[]) => { - launchFoldedRunCalls.push(args); - return { - instancePrincipalId: "prn_run1", - sessionId: "ses_run1", - sourcesDigest: "digest_run1", - }; - }, - sendFoldedMailWithRetry: async (...args: unknown[]) => { - sendFoldedMailWithRetryCalls.push(args); - return sendFoldedMailWithRetryResult; - }, - createCryptoProviderCache: () => ({ - get: async () => ({ __fakeCryptoProvider: true }) as never, - }), -})); - -// The multi-step native trigger is exercised for real here (not -// mocked): `mock.module` replaces a module process-wide, and this -// file's own `./native-workflow-routine-launch.test.ts` sibling needs -// the genuine export it would otherwise shadow for the whole bun test -// process. A fake db/sidecarRouter (below) is enough to drive it. -const { createHubRoutineLauncher } = await import("./routine-launcher"); - -const NATIVE_ANCHOR_ROW = { - id: "wfr_native1", - address: "wfr_native1@acme.workbench.test", - status: "deployed" as const, -}; - -let routeMailCalls: unknown[] = []; -let routeMailShouldDeliver = true; - -const DEFINITION_ROW = { - id: "wfd_1", - tenantId: "ten_1", - status: "deployed" as const, - assetId: "ast_1", -}; - -const TENANT_ROW = { - id: "ten_1", - domain: "acme.workbench.test", -}; - -// One `workflow_definition` candidate for `resolveLaunchableDefinition`'s -// asset-id lookup — deployed and frozen, so it resolves to `wfd_1` -// (`DEFINITION_ROW`'s id) exactly like every existing fixture expects. -const DEFINITION_CANDIDATE_ROW = { - id: DEFINITION_ROW.id, - tenantId: DEFINITION_ROW.tenantId, - status: DEFINITION_ROW.status, - createdAt: new Date("2024-01-01T00:00:00.000Z"), - approvedWireHash: "hash_1", - grantSnapshot: {}, - wireProjection: {}, -}; - -// A stand-in `.select().from()...` chain covering both drizzle queries -// this launcher and `triggerNativeWorkflowRoutineRun` run: the native -// anchor-run lookup (`.orderBy().limit()`) and -// `resolveLaunchableDefinition`'s asset lookup (`.leftJoin()...orderBy()`, -// awaited directly, no `.limit()`). A `.leftJoin()` call is what tells -// the two apart. -function makeSelectMock( - nativeRows: readonly unknown[], - candidateRows: readonly unknown[], -) { - return () => { - let sawLeftJoin = false; - const chain = { - from: () => chain, - leftJoin: () => { - sawLeftJoin = true; - return chain; - }, - where: () => chain, - orderBy: () => { - const rows = sawLeftJoin ? candidateRows : nativeRows; - const result = Promise.resolve(rows) as Promise & { - limit: (n: number) => Promise; - }; - result.limit = async () => nativeRows; - return result; - }, - }; - return chain; - }; -} - -function createFakeDb( - overrides: { - definition?: unknown; - tenant?: unknown; - candidateRows?: readonly unknown[]; - } = {}, -) { - return { - query: { - workflowDefinition: { - findFirst: async () => - "definition" in overrides ? overrides.definition : DEFINITION_ROW, - }, - tenant: { - findFirst: async () => - "tenant" in overrides ? overrides.tenant : TENANT_ROW, - }, - }, - // Drives `triggerNativeWorkflowRoutineRun`'s real anchor-run - // lookup for the multi-step tests below — see that module's own - // test file for coverage of its query shape in isolation. - select: makeSelectMock( - [NATIVE_ANCHOR_ROW], - overrides.candidateRows ?? [DEFINITION_CANDIDATE_ROW], - ), - // `recordSourcesDigest` writes the deployed inference chain's digest - // onto the launch row once `launchFoldedRun` returns (CL-6687). - update: () => ({ - set: (values: unknown) => ({ - where: async () => { - launchRowUpdates.push(values); - }, - }), - }), - }; -} - -function baseInput(input: Record) { - return { - tenantId: "ten_1", - principalId: "usr_1", - definitionAssetId: "ast_1", - input, - }; -} - -let joinDeliveryWorkbenchCalls: unknown[] = []; - -function buildLauncher(overrides: { definition?: unknown } = {}) { - return createHubRoutineLauncher({ - joinDeliveryWorkbench: async (input: unknown) => { - joinDeliveryWorkbenchCalls.push(input); - }, - db: createFakeDb(overrides) as never, - sessionService: {} as never, - assetService: {} as never, - sidecarRouter: { - routeMail: (address: string, base64: string, messageId: string) => { - routeMailCalls.push({ address, base64, messageId }); - return routeMailShouldDeliver; - }, - } as never, - toolGrantsForPins: () => [], - eventCollectors: {} as never, - cryptoProviderCache: { get: async () => ({}) as never }, - }); -} - -describe("createHubRoutineLauncher", () => { - test("threads the routine's stored input into the run's first-turn mail", async () => { - launchFoldedRunCalls = []; - sendFoldedMailWithRetryCalls = []; - sendFoldedMailWithRetryResult = { - ok: true, - mail: { id: "m_1", createdAt: new Date().toISOString() }, - }; - - const result = await buildLauncher().launchRoutineRun( - baseInput({ topic: "AI coding agents", focus: "Competing launches" }), - ); - - expect(result.runId).toBeTruthy(); - expect(launchFoldedRunCalls).toHaveLength(1); - expect(sendFoldedMailWithRetryCalls).toHaveLength(1); - - const [, params] = sendFoldedMailWithRetryCalls[0] as [ - unknown, - { - sessionId: string; - agentAddress: string; - from: string; - domain: string; - content: string; - }, - ]; - expect(params.sessionId).toBe("ses_run1"); - expect(params.domain).toBe("acme.workbench.test"); - expect(params.from).toBe("usr_1@acme.workbench.test"); - expect(params.content).toBe( - `${FRAME_HEADER}\ntopic: AI coding agents\nfocus: Competing launches`, - ); - }); - - // CL-6678: the run deploys under AGENT_SECTION_MODE (an `onTrigger` - // section, CL-6329/CL-6367) — a turn only ever runs in response to an - // inbound mail. Skipping mail on empty stored input (the pre-CL-6367 - // "starts from system prompt alone" behavior) left the section with - // zero occurrences forever: deployed, never delivering, stuck - // "running". A placeholder mail — mirroring - // `triggerNativeWorkflowRoutineRun`'s own empty-content substitution — - // is what actually fires the section's first occurrence. - test("sends a placeholder trigger mail when the routine's stored input is empty", async () => { - launchFoldedRunCalls = []; - sendFoldedMailWithRetryCalls = []; - sendFoldedMailWithRetryResult = { - ok: true, - mail: { id: "m_1", createdAt: new Date().toISOString() }, - }; - - const result = await buildLauncher().launchRoutineRun(baseInput({})); - - expect(result.runId).toBeTruthy(); - expect(launchFoldedRunCalls).toHaveLength(1); - expect(sendFoldedMailWithRetryCalls).toHaveLength(1); - - const [, params] = sendFoldedMailWithRetryCalls[0] as [ - unknown, - { content: string }, - ]; - expect(params.content).toBe("Run this routine now."); - }); - - // CL-6367: a routine-driven run with no stable-id -> current-run - // mapping could never be relaunched after its sidecar died — chat's - // terminal sweep and wake path both resolve through that mapping. - test("launches as an onTrigger section and persists the relaunch mapping with the run", async () => { - launchFoldedRunCalls = []; - sendFoldedMailWithRetryCalls = []; - - const result = await buildLauncher().launchRoutineRun(baseInput({})); - - const [, params] = launchFoldedRunCalls[0] as [ - unknown, - { - mode: unknown; - persistExtra: (tx: unknown) => Promise; - }, - ]; - expect(params.mode).toEqual({ - kind: "section", - turnTimeoutMs: CHAT_TURN_TIMEOUT_MS, - }); - - const written: { values: unknown }[] = []; - await params.persistExtra({ - insert: () => ({ - values: async (values: unknown) => { - written.push({ values }); - }, - }), - } as never); - expect(written).toHaveLength(1); - expect(written[0]?.values).toMatchObject({ - tenantId: "ten_1", - instanceId: result.runId, - currentRunId: result.runId, - foldedBody: FOLDED_BODY, - }); - }); - - test("still returns the run id when input delivery fails after every retry — the run is never hidden or un-launched", async () => { - launchFoldedRunCalls = []; - sendFoldedMailWithRetryCalls = []; - sendFoldedMailWithRetryResult = { - ok: false, - error: new Error("sidecar unreachable"), - attempts: 3, - }; - - const result = await buildLauncher().launchRoutineRun( - baseInput({ topic: "AI coding agents" }), - ); - - expect(result.runId).toBeTruthy(); - expect(launchFoldedRunCalls).toHaveLength(1); - expect(sendFoldedMailWithRetryCalls).toHaveLength(1); - }); -}); - -describe("createHubRoutineLauncher — delivery workbench", () => { - test("joins the launched run into the routine's delivery workbench so the orchestrator posts its replies there", async () => { - launchFoldedRunCalls = []; - joinDeliveryWorkbenchCalls = []; - - const result = await buildLauncher().launchRoutineRun({ - ...baseInput({}), - deliveryWorkbenchId: "chn_delivery", - routineName: "Daily GTM digest", - }); - - expect(joinDeliveryWorkbenchCalls).toEqual([ - { - tenantId: "ten_1", - workbenchId: "chn_delivery", - principalId: "usr_1", - address: `${result.runId}@acme.workbench.test`, - handle: "daily-gtm-digest", - }, - ]); - }); - - test("joins nothing when the routine has no delivery workbench", async () => { - joinDeliveryWorkbenchCalls = []; - await buildLauncher().launchRoutineRun(baseInput({})); - expect(joinDeliveryWorkbenchCalls).toHaveLength(0); - }); - - test("a join failure never un-launches or hides the run", async () => { - const launcher = createHubRoutineLauncher({ - joinDeliveryWorkbench: async () => { - throw new Error("settings write failed"); - }, - db: createFakeDb() as never, - sessionService: {} as never, - assetService: {} as never, - sidecarRouter: {} as never, - toolGrantsForPins: () => [], - eventCollectors: {} as never, - cryptoProviderCache: { get: async () => ({}) as never }, - }); - const result = await launcher.launchRoutineRun({ - ...baseInput({}), - deliveryWorkbenchId: "chn_delivery", - }); - expect(result.runId).toBeTruthy(); - }); -}); - -describe("createHubRoutineLauncher — multi-step native routing", () => { - test("routes a multi-step definition onto the native trigger instead of the folded launcher", async () => { - foldedBodyMode = "multi"; - launchFoldedRunCalls = []; - routeMailCalls = []; - routeMailShouldDeliver = true; - - const result = await buildLauncher().launchRoutineRun( - baseInput({ topic: "AI coding agents" }), - ); - - expect(result).toEqual({ runId: NATIVE_ANCHOR_ROW.id }); - // The folded launcher never runs for a multi-step definition — no - // coexisting path silently folds it to one step. - expect(launchFoldedRunCalls).toHaveLength(0); - expect(routeMailCalls).toHaveLength(1); - - const [call] = routeMailCalls as [ - { address: string; base64: string; messageId: string }, - ]; - expect(call.address).toBe(NATIVE_ANCHOR_ROW.address); - expect(Buffer.from(call.base64, "base64").toString("utf-8")).toContain( - "AI coding agents", - ); - - foldedBodyMode = "single"; - }); - - test("still fires the native trigger when the routine stored no input, rather than launching nothing", async () => { - foldedBodyMode = "multi"; - routeMailCalls = []; - routeMailShouldDeliver = true; - - const result = await buildLauncher().launchRoutineRun(baseInput({})); - - expect(result).toEqual({ runId: NATIVE_ANCHOR_ROW.id }); - expect(routeMailCalls).toHaveLength(1); - - foldedBodyMode = "single"; - }); - - test("joins the delivery workbench using the native deployment's own address", async () => { - foldedBodyMode = "multi"; - routeMailShouldDeliver = true; - joinDeliveryWorkbenchCalls = []; - - await buildLauncher().launchRoutineRun({ - ...baseInput({}), - deliveryWorkbenchId: "chn_delivery", - routineName: "Native pipeline", - }); - - expect(joinDeliveryWorkbenchCalls).toEqual([ - { - tenantId: "ten_1", - workbenchId: "chn_delivery", - principalId: "usr_1", - address: NATIVE_ANCHOR_ROW.address, - handle: "native-pipeline", - }, - ]); - - foldedBodyMode = "single"; - }); - - test("throws when the multi-step definition has no live native deployment, rather than launching nothing", async () => { - foldedBodyMode = "multi"; - - const { NativeWorkflowDeploymentMissingError } = - await import("./native-workflow-routine-launch"); - const launcher = createHubRoutineLauncher({ - joinDeliveryWorkbench: async () => {}, - db: { - query: { - workflowDefinition: { findFirst: async () => DEFINITION_ROW }, - tenant: { findFirst: async () => TENANT_ROW }, - }, - select: makeSelectMock([], [DEFINITION_CANDIDATE_ROW]), - } as never, - sessionService: {} as never, - assetService: {} as never, - sidecarRouter: { routeMail: () => true } as never, - toolGrantsForPins: () => [], - eventCollectors: {} as never, - cryptoProviderCache: { get: async () => ({}) as never }, - }); - - await expect(launcher.launchRoutineRun(baseInput({}))).rejects.toThrow( - NativeWorkflowDeploymentMissingError, - ); - - foldedBodyMode = "single"; - }); -}); diff --git a/apps/hub/src/routine-launcher.ts b/apps/hub/src/routine-launcher.ts deleted file mode 100644 index cd2aed23d0..0000000000 --- a/apps/hub/src/routine-launcher.ts +++ /dev/null @@ -1,294 +0,0 @@ -// Adapts `@corbits/folded-runs`' `launchFoldedRun` to `@corbits/routines`' -// `RoutineLauncher` port. Mirrors `@corbits/chat`'s `launchInvite` path -// (packages/chat/src/platform-adapter.ts) exactly: look up the deployed -// workflow definition, read its folded body off the materialized asset, -// mint a fresh instance id and trigger address, and launch. Routines owns -// no launch machinery of its own — this file only wires the two packages -// together, per "apps stay generic; packages own the domain": the domain -// logic (what a folded run is, how a routine fires) lives in those -// packages, and this adapter is pure composition. -// -// After launch, the routine's stored `input` (the stepper-collected -// topic/focus a routine's creator recorded), or a placeholder when it -// stored none, is delivered as the run's first inbound mail via -// `sendFoldedMailWithRetry` — the same seam `@corbits/webhook-triggers`' -// `launchWebhookTrigger` uses for its own rendered input (both hardened -// identically; see that file's own note). The mail is never optional: the -// run deploys under `AGENT_SECTION_MODE`, an `onTrigger` section whose -// one and only occurrence starts in response to an inbound mail, so a -// routine with no stored input still needs a real message to ever run a -// turn (CL-6678). Both "run now" and a scheduled fire land here (see -// `@corbits/routines`' `launchAndCorrelate`), so this one call covers both; a -// webhook-triggered routine's fire never reaches this adapter at all -// (`launchWebhookTrigger` launches directly), so its own input delivery -// is that package's concern, not this one's. -// -// A run is already real (principal/session/workflow_run rows committed, -// the sidecar deployed) the moment `launchFoldedRun` returns, so a -// delivery failure past that point must never un-launch it or hide it -// from `@corbits/routines`' correlation (`GET /routines/:id/runs`) — -// this function still returns the run id on a delivery failure, after -// exhausting `sendFoldedMailWithRetry`'s bounded retries, and only logs -// (naming the run) rather than throwing. -import { and, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; -import { reportError } from "@corbits/error-sink"; -import { - domainOf, - launchFoldedRun, - readDefinitionProjection, - readFoldedBody, - sendFoldedMailWithRetry, - MultiStepFoldUnsupportedError, - type CryptoProviderCache, - type FoldedRunsDeps, -} from "@corbits/folded-runs"; -import { generateId } from "@intx/hub-common"; -import { getLogger } from "@intx/log"; -import { formatRunAddress } from "@intx/types"; -import type { AssetService } from "@intx/hub-sessions"; -import { - AGENT_SECTION_MODE, - handleFromName, - recordSourcesDigest, - workbenchLaunchPersistExtra, -} from "@corbits/chat"; -import { - renderRoutineInput, - resolveLaunchableDefinition, - RoutineTargetUnresolvableError, - type RoutineLauncher, -} from "@corbits/routines"; -import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; - -const log = getLogger(["hub", "routine-launcher"]); - -export type CreateHubRoutineLauncherDeps = FoldedRunsDeps & { - db: DB["db"]; - assetService: AssetService; - cryptoProviderCache: CryptoProviderCache; - /** Adds the launched run's address to the routine's delivery workbench - * as a participant — hub wires this to `@corbits/chat`'s - * `joinRunParticipant`. Membership is what makes the chat - * orchestrator post the run's replies into that workbench: a routine - * delivers into a workbench through the exact same participant path - * an invited agent's replies take, never a second posting mechanism. */ - joinDeliveryWorkbench: (input: { - tenantId: string; - workbenchId: string; - principalId: string; - address: string; - handle: string; - }) => Promise; -}; - -/** - * Builds the hub's `RoutineLauncher`: every routine fire — "run now" or - * scheduled — resolves to exactly this launch path, the same folded-run - * launch every other agent instance in this hub goes through. - */ -export function createHubRoutineLauncher( - deps: CreateHubRoutineLauncherDeps, -): RoutineLauncher { - return { - async launchRoutineRun(input) { - const resolution = await resolveLaunchableDefinition({ - db: deps.db, - tenantId: input.tenantId, - definitionAssetId: input.definitionAssetId, - }); - if (!resolution.ok) { - reportError( - new RoutineTargetUnresolvableError( - input.definitionAssetId, - resolution.reason, - ), - { - operation: "routine-launcher.launchRoutineRun", - tenantId: input.tenantId, - extra: { definitionAssetId: input.definitionAssetId }, - }, - ); - throw new RoutineTargetUnresolvableError( - input.definitionAssetId, - resolution.reason, - ); - } - const definitionId = resolution.definitionId; - - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, input.tenantId), - ), - }); - if ( - definitionRow === undefined || - definitionRow.status !== "deployed" || - definitionRow.assetId === null - ) { - const reason = - definitionRow === undefined ? "not_found" : "not_deployed"; - reportError( - new RoutineTargetUnresolvableError(input.definitionAssetId, reason), - { - operation: "routine-launcher.launchRoutineRun", - tenantId: input.tenantId, - extra: { definitionAssetId: input.definitionAssetId, definitionId }, - }, - ); - throw new RoutineTargetUnresolvableError( - input.definitionAssetId, - reason, - ); - } - - const tenantRow = await deps.db.query.tenant.findFirst({ - where: eq(tenantTable.id, input.tenantId), - }); - if (tenantRow === undefined) { - throw new Error(`no tenant "${input.tenantId}"`); - } - - const projection = await readDefinitionProjection(deps.db, definitionRow); - - // A multi-step definition can only exist as a code-sourced - // `@intx/workflow` deployed through `POST /workflows/deployments` - // (see `native-workflow-routine-launch.ts`'s own header) — it never - // reaches `readFoldedBody` successfully, because that reader's - // deploy target has no notion of step order at all. Route it onto - // Interchange's native workflow-run trigger instead of throwing - // the folded path's 500: this is a deliberate split by definition - // shape, not two launchers competing for the same case — a - // single-step, hand-authored definition has no source of its own - // and still needs `launchFoldedRun`'s render-and-deploy bridge - // below; a multi-step definition already has real, deployed - // source and only needs firing. - let foldedBody; - try { - foldedBody = readFoldedBody( - projection, - definitionRow.grantRequirements, - ); - } catch (err) { - if (!(err instanceof MultiStepFoldUnsupportedError)) throw err; - const content = renderRoutineInput(input.input); - const triggered = await triggerNativeWorkflowRoutineRun(deps, { - tenantId: input.tenantId, - definitionId, - principalId: input.principalId, - fromDomain: tenantRow.domain, - // A native run only starts on its first trigger mail — unlike - // a folded run, there is no "start from the system prompt - // alone" fallback, so an empty stored input still needs a - // real message to fire the deployment. - content: content === "" ? "Run this routine now." : content, - }); - - if ( - input.deliveryWorkbenchId !== undefined && - input.deliveryWorkbenchId !== null && - input.deliveryWorkbenchId !== "" - ) { - try { - await deps.joinDeliveryWorkbench({ - tenantId: input.tenantId, - workbenchId: input.deliveryWorkbenchId, - principalId: input.principalId, - address: triggered.address, - handle: handleFromName( - input.routineName ?? "", - triggered.address, - ), - }); - } catch (joinErr) { - const reason = - joinErr instanceof Error ? joinErr.message : String(joinErr); - log.error`routine run ${triggered.runId} launched but could not join delivery workbench ${input.deliveryWorkbenchId}: ${reason}`; - } - } - - return { runId: triggered.runId }; - } - - const instanceId = generateId("workflowRun"); - const triggerAddress = formatRunAddress(instanceId, tenantRow.domain); - - const launched = await launchFoldedRun(deps, { - tenantId: input.tenantId, - instanceId, - triggerAddress, - definitionId, - foldedBody, - launchLabel: "a routine", - // The same `onTrigger` section shape and stable-id → current-run - // mapping every room-invited agent launches with (CL-6367): - // without the mapping row, a routine run that dies with its - // sidecar could never be relaunched — chat's terminal sweep and - // wake path both resolve through it. - mode: AGENT_SECTION_MODE, - persistExtra: workbenchLaunchPersistExtra({ - tenantId: input.tenantId, - instanceId, - foldedBody, - }), - }); - await recordSourcesDigest(deps.db, instanceId, launched.sourcesDigest); - - if ( - input.deliveryWorkbenchId !== undefined && - input.deliveryWorkbenchId !== null && - input.deliveryWorkbenchId !== "" - ) { - try { - await deps.joinDeliveryWorkbench({ - tenantId: input.tenantId, - workbenchId: input.deliveryWorkbenchId, - principalId: input.principalId, - address: triggerAddress, - handle: handleFromName(input.routineName ?? "", triggerAddress), - }); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - log.error`routine run ${instanceId} launched but could not join delivery workbench ${input.deliveryWorkbenchId}: ${reason}`; - } - } - - // The run just deployed under AGENT_SECTION_MODE — an `onTrigger` - // section (CL-6329/CL-6367) whose one and only occurrence starts - // in response to an inbound mail. Unlike the pre-CL-6367 `step` - // shape, there is no "start from the system prompt alone" - // fallback: skipping mail here leaves the section permanently - // un-triggered (deployed, zero occurrences, forever "running" — - // CL-6678). Mirror `triggerNativeWorkflowRoutineRun`'s own - // empty-content substitution so a routine with no separately - // stored input still fires. - const renderedContent = renderRoutineInput(input.input); - const content = - renderedContent === "" ? "Run this routine now." : renderedContent; - // Keyed by the launched run's instance id (`generateId("workflowRun")`), - // the same string shape chat uses for workbench ids — this adapter - // takes the host's process-wide cache rather than minting its own. - const cryptoProvider = await deps.cryptoProviderCache.get(instanceId); - const result = await sendFoldedMailWithRetry(deps, { - tenantId: input.tenantId, - sessionId: launched.sessionId, - agentAddress: triggerAddress, - from: `${input.principalId}@${tenantRow.domain}`, - domain: domainOf(triggerAddress), - content, - cryptoProvider, - }); - if (!result.ok) { - const reason = - result.error instanceof Error - ? result.error.message - : String(result.error); - log.error`routine run ${instanceId} launched but its trigger mail failed to deliver after ${result.attempts} attempts: ${reason}`; - } - - return { runId: instanceId }; - }, - }; -} diff --git a/apps/hub/src/routine-run-summary.ts b/apps/hub/src/routine-run-summary.ts deleted file mode 100644 index 98f89e6242..0000000000 --- a/apps/hub/src/routine-run-summary.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Enriches a routine run's bare id with the run's own status/timing off -// `workflow_run` — the same row `launchFoldedRun` (via `@corbits/folded-runs`) -// writes at launch and the platform's own event pipeline settles into a -// terminal status. Optional per `@corbits/routines`' `RunSummaryResolver` -// contract: a host that skips this still gets bare run ids and timestamps -// back from `GET /routines/:id/runs`. -import { and, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { workflowRun } from "@intx/db/schema"; -import type { RunSummaryResolver } from "@corbits/routines"; -import { listingTurnsByRunId } from "@corbits/run-scope"; - -export function createHubRunSummaryResolver(db: DB["db"]): RunSummaryResolver { - return { - async resolveRunSummary(tenantId, runId) { - const row = await db.query.workflowRun.findFirst({ - where: and( - eq(workflowRun.id, runId), - eq(workflowRun.tenantId, tenantId), - ), - }); - if (row === undefined) return undefined; - const turns = (await listingTurnsByRunId(db, [runId])).get(runId) ?? []; - return { - status: row.status, - createdAt: row.createdAt.toISOString(), - endedAt: row.endedAt?.toISOString() ?? null, - hasInFlightTurn: turns.length > 0, - turns, - }; - }, - }; -} diff --git a/apps/hub/src/routine-scheduler.ts b/apps/hub/src/routine-scheduler.ts deleted file mode 100644 index ed325cb6de..0000000000 --- a/apps/hub/src/routine-scheduler.ts +++ /dev/null @@ -1,151 +0,0 @@ -// A minimal periodic loop that fires due routines — the one piece -// `@corbits/routines` deliberately does not own (it exposes -// `fireScheduledRoutine` for exactly this, but ships no scheduler: see -// that package's routes.ts doc comment). This mirrors -// `@corbits/agent-lifecycle`'s own `setInterval` sweep (the only other -// periodic loop in this repo) rather than pulling in a new dependency. -// The vendor's own `ScheduleTrigger` (`@intx/workflow`'s -// `definition/triggers.ts`) is declared upstream but has no runtime -// consumer at our pin — `live-inert-projector.ts` only projects it -// structurally — which is exactly why this poller exists. -// -// Three guarantees, precisely stated: -// -// - Exactly-once against a *concurrent claim*: `RoutineStore.claimRoutineFire` -// is a conditional update (enabled, not deleted, not dead-lettered, -// `nextFireAt <= now` in its WHERE clause, advanced to the trigger's -// next occurrence in its SET) — a second hub replica racing the same -// fire loses, because the winner already moved `nextFireAt` into the -// future before either replica launches anything. -// - At-least-once against a *launch failure*, with exponential backoff: -// a claim that wins but whose `fireScheduledRoutine` call then throws -// is marked failed via `markFailedFire` — consecutiveFailures ticks -// up, `nextFireAt` is set to `failedAt + backoff`, and a -// `schedule-failed` run is recorded. After -// `MAX_ROUTINE_FIRE_FAILURES` consecutive failures the routine is -// dead-lettered (`deadLetteredAt` set, `nextFireAt` null) and the -// scheduler stops claiming it until an operator re-enables/edits it. -// - Missed fires survive a restart: `nextFireAt` is persisted, so -// "due" means `nextFireAt <= now`, not "does the current wall-clock -// minute match" — a fire that was due while the hub was down is still -// due (and gets caught up) the next time this loop polls. -import type { RoutineLauncher, RoutineStore } from "@corbits/routines"; -import { fireScheduledRoutine } from "@corbits/routines"; -import { getLogger } from "@intx/log"; - -export type RoutineSchedulerDeps = { - store: RoutineStore; - launcher: RoutineLauncher; - /** See `@corbits/routines`' `fireScheduledRoutine` — passed straight - * through so a scheduled fire enforces the same honest - * workbench-required-or-not rule a manual "run now" does. */ - deliveryWorkbenchRequired?: ( - tenantId: string, - definitionAssetId: string, - ) => Promise; - /** Injectable for deterministic tests; defaults to `Date.now`-backed wall time. */ - now?: () => Date; - /** How often the poller sweeps for due routines. Defaults to - * `DEFAULT_ROUTINE_SCHEDULER_POLL_INTERVAL_MS`. Test/dev-only override - * (see `ROUTINE_SCHEDULER_POLL_INTERVAL_MS` in `config.ts`) — a live - * hub always runs the real cadence unless that env var is set, which - * only the e2e harness does. */ - pollIntervalMs?: number; -}; - -export const DEFAULT_ROUTINE_SCHEDULER_POLL_INTERVAL_MS = 30_000; -const log = getLogger(["hub", "routine-scheduler"]); - -/** - * One poll: claim and fire every routine due at `at`. Exported (rather - * than kept as a closure inside `createRoutineScheduler`) so a test can - * drive a single, deterministic poll against an injected clock without - * waiting on `setInterval`. - */ -export async function tickRoutineScheduler( - deps: Pick< - RoutineSchedulerDeps, - "store" | "launcher" | "deliveryWorkbenchRequired" - >, - at: Date, -): Promise { - const dueRoutines = await deps.store.listDueRoutines(at); - for (const candidate of dueRoutines) { - const claimed = await deps.store.claimRoutineFire(candidate.id, at); - // `undefined` means another replica already claimed this exact - // fire between `listDueRoutines` and this claim attempt — not an - // error, just the atomic claim doing its job. - if (claimed === undefined) continue; - try { - const fireDeps: Parameters[0] = { - store: deps.store, - launcher: deps.launcher, - }; - if (deps.deliveryWorkbenchRequired !== undefined) { - fireDeps.deliveryWorkbenchRequired = deps.deliveryWorkbenchRequired; - } - await fireScheduledRoutine(fireDeps, { - tenantId: claimed.tenantId, - routine: claimed, - }); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - log.error`scheduled fire of routine ${claimed.id} failed: ${reason}`; - // The claim already advanced `nextFireAt` past `at`; since the - // launch never happened, mark the failure with backoff (or - // dead-letter). `claimed.nextFireAt` is the value the claim - // itself just wrote (never null — a claim only succeeds for a - // triggered routine), passed through so the mark is conditional - // and can't clobber a newer trigger edit. - try { - if (claimed.nextFireAt !== null) { - const result = await deps.store.markFailedFire({ - routineId: claimed.id, - tenantId: claimed.tenantId, - claimedNextFireAt: claimed.nextFireAt, - failedAt: at, - reason, - }); - if (result?.deadLettered) { - log.error`routine ${claimed.id} dead-lettered after ${result.consecutiveFailures} consecutive launch failures`; - } - } - } catch (markErr) { - log.error`marking routine ${claimed.id}'s failed fire also failed: ${ - markErr instanceof Error ? markErr.message : String(markErr) - }`; - } - } - } -} - -export function createRoutineScheduler(deps: RoutineSchedulerDeps) { - const now = deps.now ?? (() => new Date()); - let tickInFlight = false; - - async function tick(): Promise { - if (tickInFlight) return; - tickInFlight = true; - try { - await tickRoutineScheduler(deps, now()); - } catch (error) { - log.error`routine scheduler tick failed: ${ - error instanceof Error ? error.message : String(error) - }`; - } finally { - tickInFlight = false; - } - } - - const interval = setInterval( - () => void tick(), - deps.pollIntervalMs ?? DEFAULT_ROUTINE_SCHEDULER_POLL_INTERVAL_MS, - ); - if (typeof interval.unref === "function") interval.unref(); - - return { - stop(): void { - clearInterval(interval); - }, - }; -} diff --git a/apps/hub/src/system-seed.ts b/apps/hub/src/system-seed.ts index 92a0c8cf63..7f9291fc54 100644 --- a/apps/hub/src/system-seed.ts +++ b/apps/hub/src/system-seed.ts @@ -22,7 +22,6 @@ import { getLogger } from "@intx/log"; import { seedTenant, seedCatalog, - ensureDefaultRoutines, createGitWorkflowPusher, publishCorbitsToolsRegistry, DEFAULT_WORKFLOWS, @@ -69,10 +68,9 @@ export type SystemSeedDeps = { /** * Seeds the root tenant once: the default workflow set, then the tenant * catalog (a real credential when a hub-owned seed model is configured, a - * placeholder one otherwise), then the default routine presets. Never - * throws — a failure that persists past the deadline is logged and left - * for the next boot to retry, the same "safe to re-run" property - * `workbench seed` always had. + * placeholder one otherwise). Never throws — a failure that persists past + * the deadline is logged and left for the next boot to retry, the same + * "safe to re-run" property `workbench seed` always had. */ export async function runSystemSeed(deps: SystemSeedDeps): Promise { const api: ApiCall = createHubAPI(deps.baseUrl); @@ -134,13 +132,6 @@ export async function runSystemSeed(deps: SystemSeedDeps): Promise { : { placeholderCredential: true }), }); - await ensureDefaultRoutines( - api, - session.cookies, - tenant.tenantId, - (line) => log.info`${line}`, - ); - log.info`root tenant seed complete`; return; } catch (cause) { diff --git a/apps/hub/src/tool-grants.test.ts b/apps/hub/src/tool-grants.test.ts index b2636f3045..987dd0b385 100644 --- a/apps/hub/src/tool-grants.test.ts +++ b/apps/hub/src/tool-grants.test.ts @@ -9,22 +9,26 @@ import { createToolGrantsForPins } from "./tool-grants"; const DESCRIPTIONS = [ { - name: "@corbits/routines-tools", + name: "@corbits/memory-tools", version: "0.0.1", tools: [ { - qualifiedId: "@corbits/routines-tools/routines:routine_create", + qualifiedId: "@corbits/memory-tools/memory:memory_add", approval: "ask" as const, }, { - qualifiedId: "@corbits/routines-tools/routines:routine_list", + qualifiedId: "@corbits/memory-tools/memory:memory_list", }, ], }, { - name: "@corbits/memory-tools", + name: "@corbits/connections-tools", version: "0.0.1", - tools: [{ qualifiedId: "@corbits/memory-tools/memory:memory_add" }], + tools: [ + { + qualifiedId: "@corbits/connections-tools/connections:list_connections", + }, + ], }, ]; @@ -32,16 +36,16 @@ describe("createToolGrantsForPins", () => { test("mints tool:/invoke for every tool a pinned package declares", () => { const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS); const grants = toolGrantsForPins([ - { name: "@corbits/routines-tools", version: "^1" }, + { name: "@corbits/memory-tools", version: "^1" }, ]); expect(grants).toEqual([ { - resource: "tool:@corbits/routines-tools/routines:routine_create", + resource: "tool:@corbits/memory-tools/memory:memory_add", action: "invoke", effect: "ask", }, { - resource: "tool:@corbits/routines-tools/routines:routine_list", + resource: "tool:@corbits/memory-tools/memory:memory_list", action: "invoke", effect: "allow", }, @@ -51,26 +55,26 @@ describe("createToolGrantsForPins", () => { test('floors an unmarked tool at allow and a `approval: "ask"` tool at ask', () => { const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS); const grants = toolGrantsForPins([ - { name: "@corbits/routines-tools", version: "^1" }, + { name: "@corbits/memory-tools", version: "^1" }, ]); - expect( - grants.find((g) => g.resource.endsWith("routine_create"))?.effect, - ).toBe("ask"); - expect( - grants.find((g) => g.resource.endsWith("routine_list"))?.effect, - ).toBe("allow"); + expect(grants.find((g) => g.resource.endsWith("memory_add"))?.effect).toBe( + "ask", + ); + expect(grants.find((g) => g.resource.endsWith("memory_list"))?.effect).toBe( + "allow", + ); }); test("unions grants across every pinned package", () => { const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS); const grants = toolGrantsForPins([ - { name: "@corbits/routines-tools", version: "^1" }, { name: "@corbits/memory-tools", version: "^1" }, + { name: "@corbits/connections-tools", version: "^1" }, ]); expect(grants.map((g) => g.resource)).toEqual([ - "tool:@corbits/routines-tools/routines:routine_create", - "tool:@corbits/routines-tools/routines:routine_list", "tool:@corbits/memory-tools/memory:memory_add", + "tool:@corbits/memory-tools/memory:memory_list", + "tool:@corbits/connections-tools/connections:list_connections", ]); }); diff --git a/apps/hub/src/workflow-scheduler.ts b/apps/hub/src/workflow-scheduler.ts new file mode 100644 index 0000000000..5c5f5e5f63 --- /dev/null +++ b/apps/hub/src/workflow-scheduler.ts @@ -0,0 +1,317 @@ +// A minimal periodic loop that ticks native ScheduleTrigger cadences on +// authored, deployed workflow definitions. The hashed cron lives on the +// frozen inert projection, and launch goes through +// `triggerNativeWorkflowRoutineRun`. +// +// Exactly-once against a concurrent claim: `claimScheduleMinute` is a +// conditional update on `workflow_definition.schedule_claimed_minute` +// (`IS DISTINCT FROM` the UTC minuteKey being claimed). A 30s poll matches +// twice per minute per process, and again per replica; the CAS loser skips +// launch. Missed minutes are not caught up (skip-missed): only the current +// wall-clock minute can match. A launch that throws is logged and the +// column is left claimed, so the next matching minute retries. +import { and, eq, sql } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { + tenant as tenantTable, + workflowDefinition, + workflowDefinitionVersion, +} from "@intx/db/schema"; +import { handleFromName, type JoinRunParticipantInput } from "@corbits/chat"; +import { reportError } from "@corbits/error-sink"; +import { + cronMatchesMinute, + minuteKey, + scheduleCronFromProjection, +} from "@corbits/workflows"; + +import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; +import type { NativeWorkflowRoutineTriggerDeps } from "./native-workflow-routine-launch"; + +export type ScheduledDefinition = { + definitionId: string; + tenantId: string; + creatorPrincipalId: string | null; + definitionAssetId: string; + name: string; + cron: string; +}; + +export type ScheduledDeliveryJoinDeps = { + deliveryWorkbenchRequired?: (name: string) => boolean | Promise; + resolveDeliveryWorkbench?: (tenantId: string) => Promise; + joinDeliveryWorkbench?: (input: JoinRunParticipantInput) => Promise; +}; + +/** + * After a scheduled tick launches, join the run to a workbench when the + * catalog says this workflow delivers there. No workbench → still launched + * (join omitted). Inbox-mode workflows skip the join. A join throw is the + * caller's to log; this helper rethrows. + */ +export async function joinScheduledDefinitionToWorkbench( + deps: ScheduledDeliveryJoinDeps, + def: ScheduledDefinition, + address: string, +): Promise { + if (def.creatorPrincipalId === null) return; + if ( + deps.deliveryWorkbenchRequired !== undefined && + (await deps.deliveryWorkbenchRequired(def.name)) !== true + ) { + return; + } + if ( + deps.resolveDeliveryWorkbench === undefined || + deps.joinDeliveryWorkbench === undefined + ) { + return; + } + const workbenchId = await deps.resolveDeliveryWorkbench(def.tenantId); + if (workbenchId === undefined) return; + await deps.joinDeliveryWorkbench({ + tenantId: def.tenantId, + workbenchId, + principalId: def.creatorPrincipalId, + address, + handle: handleFromName(def.name, address), + }); +} + +export type WorkflowSchedulerDeps = { + listScheduledDefinitions: () => Promise; + claimScheduleMinute: ( + definitionId: string, + minute: string, + ) => Promise; + launch: (def: ScheduledDefinition) => Promise; + now?: () => Date; + pollIntervalMs?: number; +}; + +export const DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS = 30_000; + +/** + * One poll: claim and launch every scheduled definition due at `at`. + * Exported so a test can drive a single, deterministic poll against an + * injected clock without waiting on `setInterval`. + */ +export async function tickWorkflowScheduler( + deps: Pick< + WorkflowSchedulerDeps, + "listScheduledDefinitions" | "claimScheduleMinute" | "launch" + >, + at: Date, +): Promise { + const listed = await deps.listScheduledDefinitions(); + const minute = String(minuteKey(at)); + for (const def of listed) { + try { + if (!cronMatchesMinute(def.cron, at)) continue; + if (def.creatorPrincipalId === null) continue; + const claimed = await deps.claimScheduleMinute(def.definitionId, minute); + if (!claimed) continue; + try { + await deps.launch(def); + } catch (error) { + reportError(error, { + operation: "workflow-scheduler.launch", + tenantId: def.tenantId, + extra: { definitionId: def.definitionId }, + }); + } + } catch (error) { + reportError(error, { + operation: "workflow-scheduler.tick-definition", + tenantId: def.tenantId, + extra: { definitionId: def.definitionId }, + }); + } + } +} + +export function createWorkflowScheduler(deps: WorkflowSchedulerDeps) { + const now = deps.now ?? (() => new Date()); + let tickInFlight = false; + + async function tick(): Promise { + if (tickInFlight) return; + tickInFlight = true; + try { + await tickWorkflowScheduler(deps, now()); + } catch (error) { + reportError(error, { operation: "workflow-scheduler.tick" }); + } finally { + tickInFlight = false; + } + } + + const interval = setInterval( + () => void tick(), + deps.pollIntervalMs ?? DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS, + ); + if (typeof interval.unref === "function") interval.unref(); + + return { + stop(): void { + clearInterval(interval); + }, + }; +} + +export function claimScheduleMinuteFromDb( + db: DB["db"], +): WorkflowSchedulerDeps["claimScheduleMinute"] { + return async (definitionId, minute) => { + const updated = await db + .update(workflowDefinition) + .set({ scheduleClaimedMinute: minute }) + .where( + and( + eq(workflowDefinition.id, definitionId), + eq(workflowDefinition.status, "deployed"), + sql`${workflowDefinition.scheduleClaimedMinute} is distinct from ${minute}`, + ), + ) + .returning({ id: workflowDefinition.id }); + return updated.length > 0; + }; +} + +export function listScheduledDefinitionsFromDb( + db: DB["db"], +): WorkflowSchedulerDeps["listScheduledDefinitions"] { + return async () => { + const rows = await db + .select({ + definitionId: workflowDefinition.id, + tenantId: workflowDefinition.tenantId, + creatorPrincipalId: workflowDefinition.creatorPrincipalId, + definitionAssetId: workflowDefinition.assetId, + name: workflowDefinition.name, + wireProjection: workflowDefinitionVersion.wireProjection, + }) + .from(workflowDefinition) + .innerJoin( + workflowDefinitionVersion, + and( + eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), + eq( + workflowDefinitionVersion.version, + workflowDefinition.currentVersion, + ), + ), + ) + .where( + and( + eq(workflowDefinition.origin, "authored"), + eq(workflowDefinition.status, "deployed"), + ), + ); + + const scheduled: ScheduledDefinition[] = []; + for (const row of rows) { + if (row.definitionAssetId === null) continue; + const cron = scheduleCronFromProjection(row.wireProjection); + if (cron === undefined) continue; + scheduled.push({ + definitionId: row.definitionId, + tenantId: row.tenantId, + creatorPrincipalId: row.creatorPrincipalId, + definitionAssetId: row.definitionAssetId, + name: row.name, + cron, + }); + } + return scheduled; + }; +} + +const SCHEDULE_TICK_CONTENT = "Scheduled tick."; + +export function launchScheduledDefinitionFromDb( + deps: NativeWorkflowRoutineTriggerDeps & ScheduledDeliveryJoinDeps, +): WorkflowSchedulerDeps["launch"] { + return async (def) => { + if (def.creatorPrincipalId === null) return; + const [tenant] = await deps.db + .select({ domain: tenantTable.domain }) + .from(tenantTable) + .where(eq(tenantTable.id, def.tenantId)) + .limit(1); + if (tenant === undefined) { + throw new Error(`no tenant "${def.tenantId}"`); + } + const triggered = await triggerNativeWorkflowRoutineRun(deps, { + tenantId: def.tenantId, + definitionId: def.definitionId, + principalId: def.creatorPrincipalId, + fromDomain: tenant.domain, + content: SCHEDULE_TICK_CONTENT, + }); + try { + await joinScheduledDefinitionToWorkbench(deps, def, triggered.address); + } catch (error) { + reportError(error, { + operation: "workflow-scheduler.join-workbench", + tenantId: def.tenantId, + extra: { + definitionId: def.definitionId, + address: triggered.address, + }, + }); + } + }; +} + +export type RunNowScheduledDefinitionArgs = { + tenantId: string; + definitionId: string; + principalId: string; + fromDomain: string; + content: string; + name: string; + definitionAssetId: string; +}; + +/** + * Fire a scheduled definition now, then join the run to a workbench the + * same way the poller does. Join failures are reported and do not fail + * the launch — the caller still gets `{ runId }`. + */ +export async function runNowScheduledDefinition( + deps: NativeWorkflowRoutineTriggerDeps & ScheduledDeliveryJoinDeps, + 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, + }); + try { + await joinScheduledDefinitionToWorkbench( + deps, + { + definitionId: args.definitionId, + tenantId: args.tenantId, + creatorPrincipalId: args.principalId, + definitionAssetId: args.definitionAssetId, + name: args.name, + cron: "", + }, + triggered.address, + ); + } catch (error) { + reportError(error, { + operation: "scheduled-workflow.run-now.join-workbench", + tenantId: args.tenantId, + extra: { + definitionId: args.definitionId, + address: triggered.address, + }, + }); + } + return { runId: triggered.runId }; +} diff --git a/apps/hub/test/cron-due.test.ts b/apps/hub/test/cron-due.test.ts index 2c663d67cd..8195014dcd 100644 --- a/apps/hub/test/cron-due.test.ts +++ b/apps/hub/test/cron-due.test.ts @@ -1,11 +1,11 @@ // Pure-function proof for the scheduler's "is it time yet" decision — no -// clock, database, or launch involved. `cronExpressionForTrigger` -// (@corbits/routines) is the only producer of these expressions in this +// clock, database, or launch involved. `isValidCronExpression` +// (@corbits/workflows) is the only producer of these expressions in this // repo today; these cases cover its four preset renderings plus the // raw-cron escape hatch's comma/range/step grammar. import { describe, expect, test } from "bun:test"; -import { isValidCronExpression } from "@corbits/routines"; +import { isValidCronExpression } from "@corbits/workflows"; import { cronMatchesMinute, minuteKey } from "../src/cron-due.ts"; describe("cronMatchesMinute", () => { diff --git a/apps/hub/test/routine-mount.test.ts b/apps/hub/test/routine-mount.test.ts deleted file mode 100644 index c526ec3603..0000000000 --- a/apps/hub/test/routine-mount.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Proves the routines mount exists: an unauthenticated request to a -// routines route under the tenant prefix answers with the platform's own -// auth-error envelope, not a 404 (no route) or a crash (bad deps -// literal). Mirrors `chat-mount.test.ts`; `@corbits/routines`' own -// request/response behavior belongs to that package's tests, and the -// launcher adapter's own behavior belongs to routine-launcher.test.ts. - -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -// DB-gated: skipped when DATABASE_URL is unset, matching this repo's -// convention for tests that talk to a real Postgres. -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const root = mkdtempSync(path.join(tmpdir(), "hub-routine-mount-")); -const staticDir = path.join(root, "static"); -mkdirSync(staticDir, { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -mkdirSync(path.join(root, "data"), { recursive: true }); - -const config: HubConfig = { - databaseUrl, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], - // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the - // credential-cipher seam, so the dev opt-in keeps boot working. - allowPlaintextSecrets: true, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, - chatIdleReapMs: 30 * 60_000, -}; - -const closers: (() => Promise)[] = []; - -afterAll(async () => { - for (const close of closers) await close(); - rmSync(root, { recursive: true, force: true }); -}); - -describeIfDb("routines mount", () => { - test("routine routes mount inside the native tenant middleware", async () => { - const hub = await createHub(config); - closers.push(hub.close); - - const gated = await hub.app.request("/api/tenants/some-tenant/routines", { - method: "GET", - }); - expect(gated.status).toBe(401); - expect(await gated.json()).toEqual({ - error: { code: "unauthorized", message: "Authentication required" }, - }); - - // The route exists only under the tenant scope; outside it the - // path falls through to the interface shell. - const outside = await hub.app.request("/routines"); - expect(await outside.text()).toBe("shell"); - }); -}); diff --git a/apps/hub/test/routine-scheduler.test.ts b/apps/hub/test/routine-scheduler.test.ts deleted file mode 100644 index 0270c71bff..0000000000 --- a/apps/hub/test/routine-scheduler.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -// Scheduler poller: claim → fire, backoff on failure, dead-letter at max. -import { afterEach, describe, expect, jest, test } from "bun:test"; -import { - backoffMsForFailure, - createInMemoryRoutineStore, - MAX_ROUTINE_FIRE_FAILURES, - type RoutineLauncher, -} from "@corbits/routines"; -import { - createRoutineScheduler, - DEFAULT_ROUTINE_SCHEDULER_POLL_INTERVAL_MS, - tickRoutineScheduler, -} from "../src/routine-scheduler"; - -const CRON = { kind: "cron" as const, expression: "0 * * * *" }; - -function launcher(impl: RoutineLauncher["launchRoutineRun"]): RoutineLauncher { - return { launchRoutineRun: impl }; -} - -describe("tickRoutineScheduler", () => { - test("claims a due routine and launches it once", async () => { - const store = createInMemoryRoutineStore(); - const routine = await store.createRoutine({ - tenantId: "t1", - name: "hourly", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: { x: 1 }, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - const at = new Date( - Math.max(Date.now(), routine.nextFireAt?.getTime() ?? 0), - ); - const launches: string[] = []; - await tickRoutineScheduler( - { - store, - launcher: launcher(async (input) => { - launches.push(input.definitionAssetId); - return { runId: "run_1" }; - }), - }, - at, - ); - expect(launches).toEqual(["def_1"]); - const runs = await store.listRunsForRoutine("t1", routine.id); - expect(runs).toHaveLength(1); - expect(runs[0]?.triggeredBy).toBe("schedule"); - expect(runs[0]?.runId).toBe("run_1"); - }); - - test("threads deliveryWorkbenchRequired through to fireScheduledRoutine, firing a workbench-less routine when the port says one isn't needed", async () => { - const store = createInMemoryRoutineStore(); - const routine = await store.createRoutine({ - tenantId: "t1", - name: "inbox-only task", - definitionAssetId: "def_inbox_only", - trigger: CRON, - scope: "bench", - input: { agent: "wfd_agent", prompt: "Do it" }, - createdBy: "user_1", - }); - const at = new Date( - Math.max(Date.now(), routine.nextFireAt?.getTime() ?? 0), - ); - const launches: string[] = []; - await tickRoutineScheduler( - { - store, - launcher: launcher(async (input) => { - launches.push(input.definitionAssetId); - return { runId: "run_task_1" }; - }), - deliveryWorkbenchRequired: async () => false, - }, - at, - ); - expect(launches).toEqual(["def_inbox_only"]); - const runs = await store.listRunsForRoutine("t1", routine.id); - expect(runs).toHaveLength(1); - }); - - test("a scheduled fire launches with no delivery thread — the run posts to the workbench root", async () => { - const store = createInMemoryRoutineStore(); - const routine = await store.createRoutine({ - tenantId: "t1", - name: "hourly digest", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: {}, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - const at = new Date( - Math.max(Date.now(), routine.nextFireAt?.getTime() ?? 0), - ); - let seenInput: - Parameters[0] | undefined; - await tickRoutineScheduler( - { - store, - launcher: launcher(async (input) => { - seenInput = input; - return { runId: "run_1" }; - }), - }, - at, - ); - expect(seenInput).not.toHaveProperty("deliveryThreadId"); - }); - - test("a launch failure backs off and records schedule-failed", async () => { - const store = createInMemoryRoutineStore(); - const routine = await store.createRoutine({ - tenantId: "t1", - name: "flaky", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: {}, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - const at = new Date( - Math.max(Date.now(), routine.nextFireAt?.getTime() ?? 0), - ); - await tickRoutineScheduler( - { - store, - launcher: launcher(async () => { - throw new Error("launch exploded"); - }), - }, - at, - ); - - const after = await store.getRoutine("t1", routine.id); - if (!after) throw new Error("expected routine after failure"); - expect(after.consecutiveFailures).toBe(1); - const afterNext = after.nextFireAt; - if (!afterNext) throw new Error("expected nextFireAt after failure"); - expect(afterNext.getTime()).toBe(at.getTime() + backoffMsForFailure(1)); - // Not immediately due again at the same instant. - expect(await store.listDueRoutines(at)).toEqual([]); - - const runs = await store.listRunsForRoutine("t1", routine.id); - expect(runs).toHaveLength(1); - expect(runs[0]?.triggeredBy).toBe("schedule-failed"); - expect(runs[0]?.error).toContain("launch exploded"); - }); - - test("after backoff elapses the routine is claimed again", async () => { - const store = createInMemoryRoutineStore(); - const routine = await store.createRoutine({ - tenantId: "t1", - name: "retry", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: {}, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - const at = new Date( - Math.max(Date.now(), routine.nextFireAt?.getTime() ?? 0), - ); - await tickRoutineScheduler( - { - store, - launcher: launcher(async () => { - throw new Error("first"); - }), - }, - at, - ); - const afterFail = await store.getRoutine("t1", routine.id); - if (!afterFail) throw new Error("expected routine after failure"); - const retryAt = afterFail.nextFireAt; - if (!retryAt) throw new Error("expected nextFireAt after failure"); - let launches = 0; - await tickRoutineScheduler( - { - store, - launcher: launcher(async () => { - launches += 1; - return { runId: "run_ok" }; - }), - }, - retryAt, - ); - expect(launches).toBe(1); - const recovered = await store.getRoutine("t1", routine.id); - if (!recovered) throw new Error("expected recovered routine"); - expect(recovered.consecutiveFailures).toBe(0); - }); - - test("after MAX failures the routine is dead-lettered and never claimed again", async () => { - const store = createInMemoryRoutineStore(); - const routine = await store.createRoutine({ - tenantId: "t1", - name: "dead", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: {}, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - let clock = new Date( - Math.max(Date.now(), routine.nextFireAt?.getTime() ?? 0), - ); - for (let i = 0; i < MAX_ROUTINE_FIRE_FAILURES; i++) { - const current = await store.getRoutine("t1", routine.id); - if (!current) throw new Error("expected routine in loop"); - if (current.nextFireAt !== null) { - clock = new Date( - Math.max(clock.getTime(), current.nextFireAt.getTime()), - ); - } - await tickRoutineScheduler( - { - store, - launcher: launcher(async () => { - throw new Error(`fail ${i + 1}`); - }), - }, - clock, - ); - } - const final = await store.getRoutine("t1", routine.id); - if (!final) throw new Error("expected routine after dead-letter"); - expect(final.deadLetteredAt).not.toBeNull(); - expect(final.consecutiveFailures).toBe(MAX_ROUTINE_FIRE_FAILURES); - expect( - await store.listDueRoutines(new Date(clock.getTime() + 1e12)), - ).toEqual([]); - }); -}); - -// `createRoutineScheduler`'s own `setInterval` wrapper — the piece an -// e2e test cannot drive with fake timers because it lives in a spawned -// child process (see CL-7250). Proven here instead, entirely -// in-process with Bun's fake timers, so the real 30s production -// cadence and the injectable override are both covered in -// milliseconds rather than by waiting either one out for real. -describe("createRoutineScheduler's setInterval wiring", () => { - afterEach(() => { - jest.useRealTimers(); - }); - - test("defaults to the real 30s cadence when pollIntervalMs is unset", async () => { - jest.useFakeTimers(); - const store = createInMemoryRoutineStore(); - await store.createRoutine({ - tenantId: "t1", - name: "hourly", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: {}, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - let launches = 0; - const scheduler = createRoutineScheduler({ - store, - launcher: launcher(async () => { - launches += 1; - return { runId: "run_1" }; - }), - now: () => new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), - }); - try { - jest.advanceTimersByTime(DEFAULT_ROUTINE_SCHEDULER_POLL_INTERVAL_MS - 1); - await Promise.resolve(); - expect(launches).toBe(0); - - jest.advanceTimersByTime(1); - await Promise.resolve(); - await Promise.resolve(); - expect(launches).toBe(1); - } finally { - scheduler.stop(); - } - }); - - test("an injected pollIntervalMs overrides the default cadence", async () => { - jest.useFakeTimers(); - const store = createInMemoryRoutineStore(); - await store.createRoutine({ - tenantId: "t1", - name: "hourly", - definitionAssetId: "def_1", - trigger: CRON, - scope: "bench", - input: {}, - deliveryWorkbenchId: "ch_delivery", - createdBy: "user_1", - }); - let launches = 0; - const scheduler = createRoutineScheduler({ - store, - launcher: launcher(async () => { - launches += 1; - return { runId: "run_1" }; - }), - now: () => new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), - pollIntervalMs: 500, - }); - try { - jest.advanceTimersByTime(499); - await Promise.resolve(); - expect(launches).toBe(0); - - jest.advanceTimersByTime(1); - await Promise.resolve(); - await Promise.resolve(); - expect(launches).toBe(1); - } finally { - scheduler.stop(); - } - }); -}); diff --git a/apps/hub/test/workflow-scheduler.test.ts b/apps/hub/test/workflow-scheduler.test.ts new file mode 100644 index 0000000000..5553e660b5 --- /dev/null +++ b/apps/hub/test/workflow-scheduler.test.ts @@ -0,0 +1,407 @@ +// Scheduler poller: cron match → CAS → launch. Skip-missed, no backoff. +import { afterEach, describe, expect, jest, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; +import { + createWorkflowScheduler, + DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS, + joinScheduledDefinitionToWorkbench, + runNowScheduledDefinition, + tickWorkflowScheduler, + type ScheduledDefinition, + type WorkflowSchedulerDeps, +} from "../src/workflow-scheduler"; + +const AT_0900 = new Date("2026-01-01T09:00:00.000Z"); +const AT_0901 = new Date("2026-01-01T09:01:00.000Z"); +const NEXT_DAY_0900 = new Date("2026-01-02T09:00:00.000Z"); +const DAILY_0900 = "0 9 * * *"; + +function scheduled( + overrides: Partial = {}, +): ScheduledDefinition { + return { + definitionId: "def_1", + tenantId: "t1", + creatorPrincipalId: "user_1", + definitionAssetId: "ast_1", + name: "workbench-digest", + cron: DAILY_0900, + ...overrides, + }; +} + +function memoryClaim() { + const claimed = new Map(); + return { + claimScheduleMinute: async (definitionId: string, minute: string) => { + if (claimed.get(definitionId) === minute) return false; + claimed.set(definitionId, minute); + return true; + }, + }; +} + +function tickDeps( + overrides: Partial< + Pick< + WorkflowSchedulerDeps, + "listScheduledDefinitions" | "claimScheduleMinute" | "launch" + > + > & + Pick, +): Parameters[0] { + const claim = memoryClaim(); + return { + listScheduledDefinitions: async () => [scheduled()], + claimScheduleMinute: claim.claimScheduleMinute, + ...overrides, + }; +} + +describe("tickWorkflowScheduler", () => { + test("due + matching minute launches exactly once", async () => { + const launches: string[] = []; + await tickWorkflowScheduler( + tickDeps({ + launch: async (def) => { + launches.push(def.definitionId); + }, + }), + AT_0900, + ); + expect(launches).toEqual(["def_1"]); + }); + + test("cron does not match → no launch", async () => { + let launches = 0; + await tickWorkflowScheduler( + tickDeps({ + launch: async () => { + launches += 1; + }, + }), + AT_0901, + ); + expect(launches).toBe(0); + }); + + test("status not listed (stopped omitted from list) → no launch", async () => { + let launches = 0; + await tickWorkflowScheduler( + tickDeps({ + listScheduledDefinitions: async () => [], + launch: async () => { + launches += 1; + }, + }), + AT_0900, + ); + expect(launches).toBe(0); + }); + + test("same minuteKey second tick → no second launch (CAS loser)", async () => { + const claim = memoryClaim(); + let launches = 0; + const deps = tickDeps({ + claimScheduleMinute: claim.claimScheduleMinute, + launch: async () => { + launches += 1; + }, + }); + await tickWorkflowScheduler(deps, AT_0900); + await tickWorkflowScheduler(deps, AT_0900); + expect(launches).toBe(1); + }); + + test("clock jumps over the minute → no catch-up", async () => { + let launches = 0; + await tickWorkflowScheduler( + tickDeps({ + launch: async () => { + launches += 1; + }, + }), + AT_0901, + ); + expect(launches).toBe(0); + }); + + test("null creator → no launch", async () => { + let launches = 0; + await tickWorkflowScheduler( + tickDeps({ + listScheduledDefinitions: async () => [ + scheduled({ creatorPrincipalId: null }), + ], + launch: async () => { + launches += 1; + }, + }), + AT_0900, + ); + expect(launches).toBe(0); + }); + + test("launch throw is logged and the next matching minute can retry", async () => { + const claim = memoryClaim(); + let attempts = 0; + const deps = tickDeps({ + claimScheduleMinute: claim.claimScheduleMinute, + launch: async () => { + attempts += 1; + if (attempts === 1) throw new Error("launch exploded"); + }, + }); + await tickWorkflowScheduler(deps, AT_0900); + expect(attempts).toBe(1); + await tickWorkflowScheduler(deps, AT_0900); + expect(attempts).toBe(1); + await tickWorkflowScheduler(deps, NEXT_DAY_0900); + expect(attempts).toBe(2); + }); + + test("claim throw on one definition does not skip the rest of the poll", async () => { + const launches: string[] = []; + await tickWorkflowScheduler( + tickDeps({ + listScheduledDefinitions: async () => [ + scheduled({ definitionId: "def_bad" }), + scheduled({ definitionId: "def_ok" }), + ], + claimScheduleMinute: async (definitionId) => { + if (definitionId === "def_bad") throw new Error("claim exploded"); + return true; + }, + launch: async (def) => { + launches.push(def.definitionId); + }, + }), + AT_0900, + ); + expect(launches).toEqual(["def_ok"]); + }); +}); + +describe("createWorkflowScheduler's setInterval wiring", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test("defaults to the real 30s cadence when pollIntervalMs is unset", async () => { + jest.useFakeTimers(); + let launches = 0; + const scheduler = createWorkflowScheduler({ + listScheduledDefinitions: async () => [scheduled()], + ...memoryClaim(), + launch: async () => { + launches += 1; + }, + now: () => AT_0900, + }); + try { + jest.advanceTimersByTime(DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS - 1); + await Promise.resolve(); + expect(launches).toBe(0); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + expect(launches).toBe(1); + } finally { + scheduler.stop(); + } + }); + + test("an injected pollIntervalMs overrides the default cadence", async () => { + jest.useFakeTimers(); + let launches = 0; + const scheduler = createWorkflowScheduler({ + listScheduledDefinitions: async () => [scheduled()], + ...memoryClaim(), + launch: async () => { + launches += 1; + }, + now: () => AT_0900, + pollIntervalMs: 500, + }); + try { + jest.advanceTimersByTime(499); + await Promise.resolve(); + expect(launches).toBe(0); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + expect(launches).toBe(1); + } finally { + scheduler.stop(); + } + }); +}); + +describe("joinScheduledDefinitionToWorkbench", () => { + test("joins the first workbench when delivery is required", async () => { + const joined: unknown[] = []; + await joinScheduledDefinitionToWorkbench( + { + deliveryWorkbenchRequired: async () => true, + resolveDeliveryWorkbench: async () => "chn_1", + joinDeliveryWorkbench: async (input) => { + joined.push(input); + }, + }, + scheduled(), + "run@example.test", + ); + expect(joined).toEqual([ + { + tenantId: "t1", + workbenchId: "chn_1", + principalId: "user_1", + address: "run@example.test", + handle: "workbench-digest", + }, + ]); + }); + + test("omits join when the tenant has no workbench and still returns", async () => { + let joined = 0; + await joinScheduledDefinitionToWorkbench( + { + deliveryWorkbenchRequired: async () => true, + resolveDeliveryWorkbench: async () => undefined, + joinDeliveryWorkbench: async () => { + joined += 1; + }, + }, + scheduled(), + "run@example.test", + ); + expect(joined).toBe(0); + }); + + test("omits join when the catalog says inbox delivery", async () => { + let joined = 0; + await joinScheduledDefinitionToWorkbench( + { + deliveryWorkbenchRequired: async () => false, + resolveDeliveryWorkbench: async () => "chn_1", + joinDeliveryWorkbench: async () => { + joined += 1; + }, + }, + scheduled(), + "run@example.test", + ); + expect(joined).toBe(0); + }); +}); + +type FakeRunRow = { + id: string; + definitionId: string; + tenantId: string; + anchorRunId: string; + address: string | null; + status: string; + createdAt: Date; +}; + +function createFakeTriggerDb(rows: FakeRunRow[]) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => + rows + .filter((row) => row.anchorRunId === row.id) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + }), + }), + }), + }), + }; +} + +const LIVE_ANCHOR: FakeRunRow = { + id: "wfr_anchor1", + definitionId: "wfd_digest", + tenantId: "t1", + anchorRunId: "wfr_anchor1", + address: "wfr_anchor1@acme.workbench.test", + status: "deployed", + createdAt: new Date("2026-01-01T00:00:00Z"), +}; + +function runNowArgs() { + return { + tenantId: "t1", + definitionId: "wfd_digest", + principalId: "clicker_1", + fromDomain: "acme.workbench.test", + content: "Run now.", + name: "workbench-digest", + definitionAssetId: "ast_1", + }; +} + +describe("runNowScheduledDefinition", () => { + test("joins the first workbench after a successful trigger", async () => { + const joined: unknown[] = []; + const result = await runNowScheduledDefinition( + { + db: createFakeTriggerDb([LIVE_ANCHOR]) as never, + sidecarRouter: { routeMail: () => true } as never, + deliveryWorkbenchRequired: async () => true, + resolveDeliveryWorkbench: async () => "chn_1", + joinDeliveryWorkbench: async (input) => { + joined.push(input); + }, + }, + runNowArgs(), + ); + expect(result).toEqual({ runId: "wfr_anchor1" }); + expect(joined).toEqual([ + { + tenantId: "t1", + workbenchId: "chn_1", + principalId: "clicker_1", + address: "wfr_anchor1@acme.workbench.test", + handle: "workbench-digest", + }, + ]); + }); + + test("join throw reports and still returns runId", async () => { + const boom = new Error("join failed"); + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + try { + const result = await runNowScheduledDefinition( + { + db: createFakeTriggerDb([LIVE_ANCHOR]) as never, + sidecarRouter: { routeMail: () => true } as never, + deliveryWorkbenchRequired: async () => true, + resolveDeliveryWorkbench: async () => "chn_1", + joinDeliveryWorkbench: async () => { + throw boom; + }, + }, + runNowArgs(), + ); + expect(result).toEqual({ runId: "wfr_anchor1" }); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBe(boom); + expect(report.mock.calls[0]?.[1]).toEqual({ + operation: "scheduled-workflow.run-now.join-workbench", + tenantId: "t1", + extra: { + definitionId: "wfd_digest", + address: "wfr_anchor1@acme.workbench.test", + }, + }); + } finally { + report.mockRestore(); + } + }); +}); diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index 680b3fc31b..a2a7881afe 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -409,8 +409,8 @@ export function createSidecarStepBuildEnv( // surface. hubCapabilitiesUrl: deps.hubArtifactsUrl, // Same hub HTTP origin again, under the keys the Myra manager-tools - // bundles declare (CL-5879 follow-up: routines-tools, - // connections-tools, agent-directory-tools) for their own + // bundles declare (CL-5879 follow-up: connections-tools, + // agent-directory-tools) for their own // workflow-run-authenticated surfaces — one hub origin, one env key // per tool-bundle surface, matching every precedent above rather // than overloading an existing name for an unrelated surface. diff --git a/apps/web/README.md b/apps/web/README.md index 84d3b2718f..1c612f34c4 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -47,9 +47,7 @@ reads `NAV_ROUTES` at all: it has nothing to do with which pages exist. Running routines in the contextual column are sourced today from `@corbits/chat-ui`'s workflow-run listing (`src/shell/routine-activity.ts`) -rather than a dedicated routines package, which isn't published yet — the -column depends only on that file's `RoutineActivityItem` shape, so swapping -in a real `@corbits/routines` listing later touches nothing else. +— the column depends only on that file's `RoutineActivityItem` shape. ## Screens diff --git a/apps/web/package.json b/apps/web/package.json index b81e9e7e57..47ef955892 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,7 +35,6 @@ "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", - "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", "@corbits/shell-layout": "workspace:*", "@corbits/slug": "workspace:*", diff --git a/apps/web/src/command-palette-actions.test.ts b/apps/web/src/command-palette-actions.test.ts index 776019f171..2d34f4d489 100644 --- a/apps/web/src/command-palette-actions.test.ts +++ b/apps/web/src/command-palette-actions.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { ACTION_COMMANDS, consumePendingNewSkill, - requestNewRoutine, resetPendingDialogRequests, runActionCommand, } from "./command-palette-actions"; @@ -27,7 +26,6 @@ function context(overrides: { const dispatched: string[] = []; let themeCycled = false; let canvasClosed = false; - const openedRoutines: (string | null)[] = []; const listener = (event: Event) => dispatched.push(event.type); for (const type of ["workbench:skills:create", "workbench:tasks:create"]) { window.addEventListener(type, listener); @@ -43,9 +41,6 @@ function context(overrides: { closeCanvas: () => { canvasClosed = true; }, - openRoutine: (subject: { readonly routineId?: string | null }) => { - openedRoutines.push(subject.routineId ?? null); - }, }; return { ctx, @@ -53,7 +48,6 @@ function context(overrides: { dispatched, themeCycled: () => themeCycled, canvasClosed: () => canvasClosed, - openedRoutines, }; } @@ -108,13 +102,6 @@ describe("runActionCommand", () => { expect(navigated).toEqual([NEW_WORKBENCH_PATH]); }); - test("new-routine opens the routine panel synchronously, beside whatever page is showing — no navigation, no pending flag", async () => { - const { ctx, navigated, openedRoutines } = context({ path: "/library" }); - await runActionCommand("new-routine", ctx); - expect(navigated).toEqual([]); - expect(openedRoutines).toEqual([null]); - }); - test("new-skill off-route navigates and records a pending flag instead of dispatching", async () => { const { ctx, navigated, dispatched } = context({ path: "/library" }); await runActionCommand("new-skill", ctx); @@ -224,47 +211,3 @@ describe("runActionCommand", () => { expect(navigated).toEqual(["/w/chan_myra_dm"]); }); }); - -// Backs the `>` command palette's "New routine" and "Make this a routine" -// (chat-page.tsx) — a caller with no command-palette -// `ActionCommandContext`. Canvas state lives above every route, so this -// opens the panel synchronously, beside whatever page is already showing -// — no navigation, no pending flag, no window event, no mount race to -// guard against. -describe("requestNewRoutine", () => { - test("opens a fresh routine panel with no navigation", () => { - const openedRoutines: (string | null)[] = []; - - requestNewRoutine({ - openRoutine: (subject) => openedRoutines.push(subject.routineId ?? null), - }); - - expect(openedRoutines).toEqual([null]); - }); - - test("carries an initial name and instruction through to the opened subject", () => { - const openedSubjects: { - readonly routineId: string | null; - readonly initialName?: string; - readonly initialInstruction?: string; - }[] = []; - - requestNewRoutine({ - openRoutine: (subject) => - openedSubjects.push({ - ...subject, - routineId: subject.routineId ?? null, - }), - initialName: "Weekly digest", - initialInstruction: "Summarize last week's calls", - }); - - expect(openedSubjects).toEqual([ - { - routineId: null, - initialName: "Weekly digest", - initialInstruction: "Summarize last week's calls", - }, - ]); - }); -}); diff --git a/apps/web/src/command-palette-actions.ts b/apps/web/src/command-palette-actions.ts index 9d8c86bd7a..fb26c39be1 100644 --- a/apps/web/src/command-palette-actions.ts +++ b/apps/web/src/command-palette-actions.ts @@ -17,15 +17,6 @@ // hop the sidebar's own "+" control uses. A second "new-agent" row that // said and did the same thing was dropped (CL-6820). "New thread" is out // of scope (killed by owner decision). -// -// "New routine" (CL-6125, reworked CL-6139) needs none of that: it opens -// the canvas column's routine pane, and canvas state lives in -// `ShellChromeProvider` above every route, not inside a page that has to -// mount first — so `requestNewRoutine` just calls the caller's own -// `openRoutine` (from `useOpenRoutineInCanvas`) synchronously, no pending -// flag, no window event, and — per the CL-6139 owner note — no navigation -// either: the panel opens beside whatever page is already showing, it -// never hops to `/routines` first the way this used to. import { createPendingDialogRequest } from "@corbits/shell-layout"; import { CHAT_STRINGS } from "@corbits/chat-ui"; @@ -35,7 +26,6 @@ import { listAgentDefinitions } from "./agents-api"; import { openAgentDmChat } from "./agent-dm-launch"; import { findMyraDefinition } from "./myra-workbench"; import { requestLibraryUpload } from "./library-upload"; -import type { RoutinePanelSubject } from "./shell/canvas-availability"; export const NEW_SKILL_EVENT = "workbench:skills:create"; @@ -44,30 +34,6 @@ const newSkillRequest = createPendingDialogRequest(); /** Consumed by skills-settings-section.tsx on mount. */ export const consumePendingNewSkill = newSkillRequest.consumePending; -/** - * Opens the routine panel's editor view directly on a brand-new routine, - * beside whatever page is already showing — never a `/routines` hop. Every - * "start a routine" affordance with something to prefill (the `>` command - * palette, "Make this a routine") funnels through this one function; the - * chat header and `/run` open the panel's list view instead (see - * `chat-page.tsx`), since they have nothing to prefill. - */ -export function requestNewRoutine(args: { - readonly openRoutine: (subject: RoutinePanelSubject) => void; - readonly initialName?: string; - readonly initialInstruction?: string; -}): void { - args.openRoutine({ - routineId: null, - ...(args.initialName !== undefined - ? { initialName: args.initialName } - : {}), - ...(args.initialInstruction !== undefined - ? { initialInstruction: args.initialInstruction } - : {}), - }); -} - /** Test helper — drop leftover pending state between cases. */ export function resetPendingDialogRequests(): void { newSkillRequest.resetPending(); @@ -75,7 +41,6 @@ export function resetPendingDialogRequests(): void { export type ActionCommandId = | "new-workbench" - | "new-routine" | "new-skill" | "upload-artifact" | "toggle-theme" @@ -100,11 +65,6 @@ export const ACTION_COMMANDS: readonly ActionCommand[] = [ title: CHAT_STRINGS.newWorkbenchAction, subtitle: "Start a new workbench", }, - { - id: "new-routine", - title: "New routine", - subtitle: "Schedule · trigger · demand", - }, { id: "new-skill", title: "New skill", subtitle: "Workbench capability" }, { id: "upload-artifact", @@ -140,7 +100,6 @@ export type ActionCommandContext = { readonly tenantId: string | null; readonly cycleTheme: () => void; readonly closeCanvas: () => void; - readonly openRoutine: (subject: RoutinePanelSubject) => void; }; /** @@ -159,10 +118,6 @@ export async function runActionCommand( ctx.navigate(NEW_WORKBENCH_PATH); return; } - case "new-routine": { - requestNewRoutine({ openRoutine: ctx.openRoutine }); - return; - } case "new-skill": { newSkillRequest.request({ alreadyOnTargetRoute: diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 922c66f371..dae90b101b 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -11,6 +11,7 @@ import { filterBenchMemberships, listWorkbenchTenantIds, } from "@corbits/bench-ui"; +import { reportError } from "@corbits/error-sink"; import { useQuery } from "@tanstack/react-query"; import { buildCommandPaletteGroups, @@ -51,17 +52,18 @@ import { recentsStoreForBench } from "./command-palette-recents"; import { NAV_ROUTES } from "./routes"; import { ArtifactListPageSchema, useAPIQuery } from "./api"; import { useBench } from "./bench-context"; -import { - useCloseCanvas, - useOpenRoutineInCanvas, -} from "./shell/canvas-availability"; +import { useCloseCanvas } from "./shell/canvas-availability"; import { listMcpServers } from "@corbits/plugins-ui"; import { AGENTS_PATH_PREFIX, PLUGINS_PATH_PREFIX, SKILLS_PATH_PREFIX, } from "./path-ids"; -import { listRoutines, runRoutineNow, useTenantQuery } from "./routines-api"; +import { + listScheduledWorkflows, + runScheduledWorkflowNow, + useTenantQuery, +} from "./routines-api"; import { listSkills } from "./skills-api"; import { meKeys, tenantKeys } from "./query-client"; import type { Navigate } from "./navigation"; @@ -106,7 +108,6 @@ export function CommandPaletteProvider({ const [recents, setRecents] = useState([]); const { cycleMode } = useTheme(); const closeCanvas = useCloseCanvas(); - const openRoutine = useOpenRoutineInCanvas(); const recentsStore = useMemo( () => @@ -267,7 +268,7 @@ export function CommandPaletteProvider({ const routinesQuery = useTenantQuery( tenantKeys.routines(selectedTenantId ?? ""), open && selectedTenantId !== null, - () => listRoutines(selectedTenantId ?? ""), + () => listScheduledWorkflows(selectedTenantId ?? ""), ); const skillsQuery = useTenantQuery( tenantKeys.skills(selectedTenantId ?? ""), @@ -361,7 +362,7 @@ export function CommandPaletteProvider({ const runNow = routinesQuery.kind === "ready" ? routinesQuery.data.map((routine) => ({ - id: `action:run-routine:${routine.id}`, + id: `action:run-routine:${routine.definitionId}`, title: `Run · ${routine.name}`, subtitle: "Run this routine now", })) @@ -404,12 +405,9 @@ export function CommandPaletteProvider({ () => routinesQuery.kind === "ready" ? routinesQuery.data.map((routine) => ({ - id: `entity:routines:${routine.id}`, + id: `entity:routines:${routine.definitionId}`, title: routine.name, - subtitle: - routine.scope === "personal" - ? "Personal routine" - : "Workbench routine", + subtitle: "Scheduled workflow", })) : [], [routinesQuery], @@ -523,7 +521,16 @@ export function CommandPaletteProvider({ } else if (id.startsWith("action:run-routine:")) { const routineId = id.slice("action:run-routine:".length); if (selectedTenantId !== null) { - void runRoutineNow(selectedTenantId, routineId); + void (async () => { + try { + await runScheduledWorkflowNow(selectedTenantId, routineId); + } catch (cause) { + reportError(cause, { + operation: "scheduled_workflow_run_now", + tenantId: selectedTenantId, + }); + } + })(); } navigate(`/routines/${encodeURIComponent(routineId)}`); } else if (id.startsWith("action:")) { @@ -533,7 +540,6 @@ export function CommandPaletteProvider({ tenantId: selectedTenantId, cycleTheme: cycleMode, closeCanvas, - openRoutine, }); } else if (id.startsWith("route:")) { const routePath = id.slice("route:".length); @@ -596,7 +602,6 @@ export function CommandPaletteProvider({ selectedTenantId, cycleMode, closeCanvas, - openRoutine, pushRecent, workbenchItems, agentItems, diff --git a/apps/web/src/global-routines.ts b/apps/web/src/global-routines.ts index 4ca29e6029..804c0bd12b 100644 --- a/apps/web/src/global-routines.ts +++ b/apps/web/src/global-routines.ts @@ -1,31 +1,12 @@ -// Every routine across every bench the signed-in account belongs to, as -// one flat list — the aggregation both routines surfaces read from: the -// roster at `/routines` and the detail page at `/routines/`. -// -// It lives here rather than inside `pages/routines-page.tsx` because the -// detail page needs the identical resolution (a slug names a routine in -// *some* bench, not in the currently selected one) and two copies of a -// membership fan-out would drift the moment either page changed which -// benches it looks in. -// -// Visibility resolves through the same membership the sidebar's bench -// switcher uses (`useBench().memberships`, the `/api/me/principals` / -// CL-6332 principal model), filtered to actual benches with -// `classifyBenchMembership` — never just the currently selected one, and -// never creator-scoped: `GET /routines` already lists every routine a -// bench's own grant covers, regardless of who created it. -import { listWorkbenches } from "@corbits/chat-ui"; +// Scheduled workflow definitions across every bench the signed-in account +// belongs to — the aggregation both Routines surfaces read from: the +// roster at `/routines` and the detail page at `/routines/`. import { classifyBenchMembership, listWorkbenchTenantIds, } from "@corbits/bench-ui"; -import { - routineActionFailedToast, - routineRunStartedToast, - routineSlug, - timezoneForTrigger, -} from "@corbits/routines/client"; import { toast } from "@corbits/react-ui"; +import { reportError } from "@corbits/error-sink"; import { useMemo } from "react"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { describeApiError } from "@corbits/api-query"; @@ -36,61 +17,24 @@ import { useBench } from "./bench-context"; import { ROUTINES_PATH_PREFIX } from "./path-ids"; import { meKeys, tenantKeys } from "./query-client"; import { - listRoutineRuns, - listRoutines, - runRoutineNow, - updateRoutine, + listScheduledWorkflows, + runScheduledWorkflowNow, + setScheduledWorkflowStatus, } from "./routines-api"; -import type { Routine, RoutineRun } from "./routines-api"; +import type { ScheduledWorkflowDefinition } from "./routines-api"; -/** The query-key suffix both routines surfaces share, so a mutation on - * either invalidates the other's copy of the same fetch. */ const ROUTINES_QUERY_SCOPE = "global-page"; export type GlobalRoutineRow = { - readonly routine: Routine; + readonly definition: ScheduledWorkflowDefinition; readonly tenantId: string; readonly tenantName: string; - readonly deliveryWorkbenchName: string | null; - readonly runs: readonly RoutineRun[]; }; -/** - * A routine's own page. Addressed by id, which is the only address a - * routine actually has: DESIGN.md permits a slug in a route only where it - * is immutable and tenant-unique by hard database constraint, and a - * routine has no slug column — so a name-derived slug is the soft - * convention that rule forbids, and the opaque id is the documented - * fallback. `routineSlugPath` below still builds the name address, for - * resolving links people typed or shared. - */ -export function routineDetailPath(routineId: string): string { - return `${ROUTINES_PATH_PREFIX}/${encodeURIComponent(routineId)}`; -} - -/** The name-derived address a person may have typed or shared — resolved - * and redirected to `routineDetailPath` by the detail route, never - * rendered as canonical. `null` when the name has nothing sluggable in - * it. */ -export function routineSlugPath(name: string): string | null { - const slug = routineSlug(name); - return slug === "" ? null : `${ROUTINES_PATH_PREFIX}/${slug}`; +export function routineDetailPath(definitionId: string): string { + return `${ROUTINES_PATH_PREFIX}/${encodeURIComponent(definitionId)}`; } -/** The rows whose routine answers to `slug`. More than one means two - * routines share a name — the caller says so rather than silently picking - * one (DESIGN.md: a route never invents a slug that might collide). */ -export function rowsForSlug( - rows: readonly GlobalRoutineRow[], - slug: string, -): readonly GlobalRoutineRow[] { - return rows.filter((row) => routineSlug(row.routine.name) === slug); -} - -/** Every bench the signed-in account belongs to — not just the currently - * selected one — the same classification the bench switcher uses so a - * workbench child tenancy or a raw-id row never masquerades as a bench a - * person can browse routines in. */ function useMemberBenches(): { readonly kind: "loading" | "ready"; readonly benches: readonly { tenantId: string; tenantName: string }[]; @@ -124,44 +68,12 @@ function useMemberBenches(): { return { kind: "ready", benches }; } -type BenchRoutinesData = { - readonly routines: readonly Routine[]; - readonly workbenchNames: ReadonlyMap; - readonly runHistories: ReadonlyMap; -}; - -async function fetchBenchRoutinesData( - tenantId: string, -): Promise { - const [routines, workbenches] = await Promise.all([ - listRoutines(tenantId), - listWorkbenches(tenantId, "workbench"), - ]); - const runHistoryEntries = await Promise.all( - routines.map( - async (r) => [r.id, await listRoutineRuns(tenantId, r.id)] as const, - ), - ); - return { - routines, - workbenchNames: new Map(workbenches.map((w) => [w.id, w.title])), - runHistories: new Map(runHistoryEntries), - }; -} - -/** Every routine across every bench the account belongs to, flattened - * into one list with its own workbench attribution — the aggregation - * `GET /routines` doesn't do server-side (it's tenant-scoped, per bench), - * done the cheapest correct client-side way: one fetch per bench, run in - * parallel. A server-side health summary that collapses this fan-out into - * a single request is ticketed separately; it changes where the numbers - * are computed, not what they mean. */ export function useGlobalRoutines(): APIQuery { const { kind: benchesKind, benches } = useMemberBenches(); const results = useQueries({ queries: benches.map((bench) => ({ queryKey: [...tenantKeys.routines(bench.tenantId), ROUTINES_QUERY_SCOPE], - queryFn: () => fetchBenchRoutinesData(bench.tenantId), + queryFn: () => listScheduledWorkflows(bench.tenantId), })), }); @@ -183,25 +95,19 @@ export function useGlobalRoutines(): APIQuery { const rows: GlobalRoutineRow[] = []; benches.forEach((bench, index) => { - const data = results[index]?.data; - if (data === undefined) return; - for (const routine of data.routines) { + const items = results[index]?.data; + if (items === undefined) return; + for (const definition of items) { rows.push({ - routine, + definition, tenantId: bench.tenantId, tenantName: bench.tenantName, - deliveryWorkbenchName: - routine.deliveryWorkbenchId !== null - ? (data.workbenchNames.get(routine.deliveryWorkbenchId) ?? null) - : null, - runs: data.runHistories.get(routine.id) ?? [], }); } }); return { kind: "ready", data: rows }; } -/** Refetch one bench's routines after a mutation on either surface. */ export function useInvalidateRoutines(): (tenantId: string) => void { const queryClient = useQueryClient(); return (tenantId: string) => { @@ -212,84 +118,54 @@ export function useInvalidateRoutines(): (tenantId: string) => void { } export type RoutineActions = { - /** Toasts and resolves on failure — the caller has no second thing to - * say, and an unhandled rejection is not a user-facing error message. */ readonly runNow: (row: GlobalRoutineRow) => Promise; readonly setEnabled: ( row: GlobalRoutineRow, enabled: boolean, ) => Promise; - /** Toasts and *rethrows*, so a schedule editor can also keep the draft - * on screen and say what happened next to the field. */ - readonly saveCronSchedule: ( - row: GlobalRoutineRow, - expression: string, - ) => Promise; }; -/** - * The three routine mutations, each of which reports its own failure. - * Shared by the roster and the detail page so a refused write reads the - * same on both, and so neither surface can quietly drop one: every path - * here either invalidates on success or says what went wrong in words. - * Nothing is applied optimistically — the row changes when the hub says - * it changed. - */ export function useRoutineActions(): RoutineActions { const invalidate = useInvalidateRoutines(); return { runNow: async (row) => { try { - await runRoutineNow(row.tenantId, row.routine.id); + await runScheduledWorkflowNow( + row.tenantId, + row.definition.definitionId, + ); invalidate(row.tenantId); - toast(routineRunStartedToast(row.routine.name)); + toast(`${row.definition.name} started`); } catch (cause) { + reportError(cause, { + operation: "scheduled_workflow_run_now", + tenantId: row.tenantId, + }); toast( - routineActionFailedToast( - "run", - row.routine.name, - describeApiError(cause, "starting this routine"), - ), + `Couldn't start ${row.definition.name}: ${describeApiError(cause, "starting this routine")}`, ); } }, setEnabled: async (row, enabled) => { try { - await updateRoutine(row.tenantId, row.routine.id, { enabled }); + await setScheduledWorkflowStatus( + row.tenantId, + row.definition.definitionId, + enabled ? "deployed" : "stopped", + ); invalidate(row.tenantId); } catch (cause) { - toast( - routineActionFailedToast( - enabled ? "resume" : "pause", - row.routine.name, - describeApiError( - cause, - enabled ? "resuming this routine" : "pausing this routine", - ), - ), - ); - } - }, - saveCronSchedule: async (row, expression) => { - const timezone = timezoneForTrigger(row.routine.trigger); - try { - await updateRoutine(row.tenantId, row.routine.id, { - trigger: - timezone === "UTC" - ? { kind: "cron", expression } - : { kind: "cron", expression, timezone }, + reportError(cause, { + operation: "scheduled_workflow_set_status", + tenantId: row.tenantId, }); - } catch (cause) { toast( - routineActionFailedToast( - "schedule", - row.routine.name, - describeApiError(cause, "saving this schedule"), - ), + `Couldn't ${enabled ? "resume" : "pause"} ${row.definition.name}: ${describeApiError( + cause, + enabled ? "resuming this routine" : "pausing this routine", + )}`, ); - throw cause; } - invalidate(row.tenantId); }, }; } diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 420d378cf5..1d0c9a7bf0 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { FIRE_RUNNING_WINDOW_MS } from "@corbits/routines/client"; +import { FIRE_RUNNING_WINDOW_MS } from "@corbits/workflows/client"; import { computeInsightsStats, @@ -12,7 +12,7 @@ import { runDisplayName, } from "./insights-stats"; import type { InsightsRun, RunTraceSpan } from "./insights-api"; -import type { Routine } from "./routines-api"; +import type { ScheduledWorkflowDefinition } from "./routines-api"; function span( partial: Partial & Pick, @@ -47,20 +47,15 @@ function run( }; } -function routine( - partial: Partial & Pick, -): Routine { +function scheduled( + partial: Partial & + Pick, +): ScheduledWorkflowDefinition { return { + assetId: "ast_def", name: "Daily dig", - definitionAssetId: "ast_def", - definitionId: "def", - trigger: { kind: "interval", unit: "hours", every: 24 }, - scope: "bench", - input: {}, - deliveryWorkbenchId: null, - consecutiveFailures: 0, - deadLetteredAt: null, - nextFireAt: null, + tenantId: "t1", + cron: "0 9 * * *", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...partial, @@ -94,8 +89,8 @@ describe("computeInsightsStats", () => { }), ], [ - routine({ id: "r1", enabled: true }), - routine({ id: "r2", enabled: false }), + scheduled({ definitionId: "r1", status: "deployed" }), + scheduled({ definitionId: "r2", status: "stopped" }), ], INSIGHTS_RECENT_LIMIT, Date.parse("2026-01-03T00:01:00.000Z"), diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index 761ed59485..7ded59b02d 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -6,10 +6,10 @@ import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-nami import { runOutcomeStatus, withListingAbandoned, -} from "@corbits/routines/client"; +} from "@corbits/workflows/client"; import type { InsightsRun, RunTraceSpan } from "./insights-api"; -import type { Routine } from "./routines-api"; +import type { ScheduledWorkflowDefinition } from "./routines-api"; export type TraceStats = { readonly steps: number; @@ -160,7 +160,7 @@ export function filterRunsByCreatedAt( export function computeInsightsStats( runs: readonly InsightsRun[], - routines: readonly Routine[], + routines: readonly ScheduledWorkflowDefinition[], recentLimit: number = INSIGHTS_RECENT_LIMIT, now: number = Date.now(), ): InsightsStats { @@ -201,7 +201,7 @@ export function computeInsightsStats( stopped, deployed, routineCount: routines.length, - enabledRoutines: routines.filter((r) => r.enabled).length, + enabledRoutines: routines.filter((r) => r.status === "deployed").length, recentRuns, }; } diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index a63caddcba..e3be21125e 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -50,7 +50,6 @@ import { import { useOpenArtifactInCanvas, useOpenProfileInCanvas, - useOpenRoutineInCanvas, } from "../shell/canvas-availability"; import { useRegisterComposerInsert } from "../shell/composer-insertion"; import { tenantResolutionFromBench } from "../shell/tenant-resolution"; @@ -75,7 +74,6 @@ export function ChatPage({ const openProfile = useOpenProfileInCanvas(); const registerComposerInsert = useRegisterComposerInsert(); const openArtifactInCanvas = useOpenArtifactInCanvas(); - const openRoutine = useOpenRoutineInCanvas(); const tenant = tenantResolutionFromBench(bench); const principalId = bench.selectedPrincipalId ?? undefined; const queryClient = useQueryClient(); @@ -301,18 +299,6 @@ export function ChatPage({ ? { connectServiceActions } : {})} listMembers={listMembers} - // `/routine`: opens the editor directly on a brand-new routine - // bound to this workbench. Routines and Insights (CL-6362, CL-6099) - // are global-only pages now, reached from the shell rail — no - // per-workbench header button or `/run` command opens a scoped view - // of either here. - onCreateRoutineInSpace={(inSpaceWorkbenchId, preselectedAssetId) => - openRoutine({ - routineId: null, - workbenchId: inSpaceWorkbenchId, - ...(preselectedAssetId !== undefined ? { preselectedAssetId } : {}), - }) - } onWorkbenchNotFound={reportWorkbenchNotFound} onGoToMissionControl={() => navigate(MISSION_CONTROL_PATH)} onNewWorkbench={() => navigate(NEW_WORKBENCH_PATH)} diff --git a/apps/web/src/pages/insights-page.test.ts b/apps/web/src/pages/insights-page.test.ts index f0fb0168a8..ac035b9719 100644 --- a/apps/web/src/pages/insights-page.test.ts +++ b/apps/web/src/pages/insights-page.test.ts @@ -9,7 +9,7 @@ // so this landing scope never takes a workbench override anymore. import { describe, expect, test } from "bun:test"; import type { DayActivity } from "@corbits/insights/client"; -import { FIRE_RUNNING_WINDOW_MS } from "@corbits/routines/client"; +import { FIRE_RUNNING_WINDOW_MS } from "@corbits/workflows/client"; import { costPerDay, diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 48b23edfae..6e936f17a3 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -36,7 +36,7 @@ import { runOutcomeStatus, runStatusLabel, withListingAbandoned, -} from "@corbits/routines/client"; +} from "@corbits/workflows/client"; import type * as React from "react"; import { useEffect, useMemo, useState } from "react"; @@ -112,7 +112,11 @@ import { useNavigate } from "../navigation"; import { tenantKeys } from "../query-client"; import { INSIGHTS_PATH_PREFIX, INSIGHTS_RUNS_PATH } from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; -import { listRoutines, useTenantQuery, type Routine } from "../routines-api"; +import { + listScheduledWorkflows, + useTenantQuery, + type ScheduledWorkflowDefinition, +} from "../routines-api"; import { WorkbenchTimelineRoute } from "./workbench-timeline"; function dash(value: string | number | null | undefined): string { @@ -706,7 +710,7 @@ function InsightsLanding({ * disclose the cap instead of silently presenting a truncated series as * complete. */ readonly runsNextCursor: string | null; - readonly routines: readonly Routine[]; + readonly routines: readonly ScheduledWorkflowDefinition[]; /** Null while `/workbenches` hasn't resolved (or this landing is already * scoped to one workbench, where a breakdown of one has nothing to * show) — the activity-by-workbench chart and active-workbenches KPI @@ -1373,7 +1377,7 @@ export function InsightsPage({ data: readonly InsightsRun[]; nextCursor: string | null; }>; - readonly routines: APIQuery; + readonly routines: APIQuery; /** `/workbenches` — this scope's own row plus one per descendant * workbench, used for the "activity by workbench" chart and the * "active workbenches" KPI. */ @@ -1752,10 +1756,10 @@ export function InsightsRoute({ path }: { readonly path?: string }) { ? ["tenant", "none", "routines"] : tenantKeys.routines(selectedTenantId), selectedTenantId !== null, - () => listRoutines(selectedTenantId as string), + () => listScheduledWorkflows(selectedTenantId as string), ); - const routinesForPage: APIQuery = + const routinesForPage: APIQuery = selectedTenantId === null ? { kind: "ready", data: [] } : routines; // Unwrap package envelopes ({ days }, { tools }) for the page surface. diff --git a/apps/web/src/pages/mission-control-page.tsx b/apps/web/src/pages/mission-control-page.tsx index ef4a6f4c88..638e567168 100644 --- a/apps/web/src/pages/mission-control-page.tsx +++ b/apps/web/src/pages/mission-control-page.tsx @@ -36,7 +36,7 @@ import { runOutcomeStatus, runStatusLabel, withListingAbandoned, -} from "@corbits/routines/client"; +} from "@corbits/workflows/client"; import { approveApproval, rejectApproval, useAPIQuery } from "../api"; import { useBench } from "../bench-context"; diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index 4ded234983..3325bce617 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -1,612 +1,128 @@ -// `/routines/` — a routine's own page (CL-6418), replacing the -// placeholder CL-6412 routed here. The id is the address (see -// `resolveRoutineSegment` at the bottom for why, and for how a name still -// resolves onto it). -// -// A routine is a workflow on a schedule: schedule + target workflow + -// health + history, and nothing else. It never shows an agent it "runs -// as" — there isn't one; the thing that runs is a workflow definition, -// and its steps are read on the platform's own run surface under -// `/insights/runs`, which this page links into rather than re-rendering. -// -// The schedule reads as a sentence first and stays editable as raw cron -// underneath (DESIGN.md, Copy): the sentence is what a person checks, the -// expression is what they change. Editing writes a `cron` trigger — the -// canonical form every preset already renders to (`cronExpressionForTrigger`) -// — so saving a preset routine's expression is a schedule change, not a -// change of trigger kind. -// -// Run-now and Pause/Resume are the routine's two lifecycle actions and -// they live in the top bar's action slot, never in the page body -// (DESIGN.md, Pages & Routing). Both reuse the routines package's -// existing mutations — `POST /routines/:id/run` and `PATCH {enabled}`, -// which also clears a dead-letter — so neither is a new write path. -import { - Badge, - Button, - Input, - PageShell, - EmptyState, - RunNowButton, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - formatRelativeTime, -} from "@corbits/react-ui"; -import { Clock, FlowArrow } from "@corbits/icons"; -import { useEffect, useState, type ReactNode } from "react"; -import { - isValidCronExpression, - cronExpressionForTrigger, - cronSentence, - fireNeverStarted, - routineHealth, - routineScheduleSentence, - timezoneForTrigger, - withListingAbandoned, -} from "@corbits/routines/client"; -import type { RoutineHealth } from "@corbits/routines/client"; +// `/routines/` — a scheduled definition's own page: name, +// cron sentence, pause, run now. The id is the definition id. +import { Button, PageShell, RunNowButton } from "@corbits/react-ui"; +import type { ReactNode } from "react"; -import { - routineDetailPath, - rowsForSlug, - useGlobalRoutines, - useRoutineActions, -} from "../global-routines"; +import { useGlobalRoutines, useRoutineActions } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; -import { runDetailPath } from "../insights-deeplinks"; import { Link } from "../navigation"; import { ROUTINES_PATH_PREFIX } from "../path-ids"; -import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; -import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; import { StageTopBar } from "../shell/stage-top-bar"; -import { nextRunLabel, RunStatusCell, TriggeredByCell } from "./routines-page"; -import { listAllRoutineTargets, useTenantQuery } from "../routines-api"; -import { tenantKeys } from "../query-client"; +import { scheduleSentence } from "./routines-page"; -/** The cron expression behind a routine's schedule — `null` for the - * trigger shapes that have no clock at all (manual, webhook, run-once), - * which get no expression field rather than an inert one. */ -function editableCronFor(row: GlobalRoutineRow): string | null { - const { trigger } = row.routine; - if (trigger === null) return null; - if (trigger.kind === "webhook" || trigger.kind === "once") return null; - return cronExpressionForTrigger(trigger); -} - -function formatDuration(ms: number): string { - if (ms < 1000) return `${String(ms)} ms`; - const seconds = Math.round(ms / 1000); - if (seconds < 90) return `${String(seconds)}s`; - const minutes = Math.round(seconds / 60); - if (minutes < 90) return `${String(minutes)} min`; - return `${String(Math.round(minutes / 60))} h`; -} - -function RailFact({ - label, +function RoutineNotice({ + title, + description, children, }: { - readonly label: string; - readonly children: ReactNode; -}) { - return ( -
- - {label} - - {children} -
- ); -} - -/** Streak, typical duration, and the last failure — the three questions - * "is this routine well?" actually decomposes into, all read off history - * the scheduler already writes. */ -export function RoutineHealthRail({ - health, - row, - now, -}: { - readonly health: RoutineHealth; - readonly row: GlobalRoutineRow; - readonly now: number; -}) { - return ( - - ); -} - -/** - * The schedule, sentence first: the raw expression is editable behind it - * and the sentence re-renders from whatever is typed, so a person sees - * what their change means before they save it. - * - * Saveable means two things, and both are the same question — "will this - * do what it reads like?": the scheduler's own parser must accept it - * (`isValidCronExpression`, the check the server saves against) *and* it - * must be describable, because an expression this page cannot put into - * words is one the person cannot check before committing to it. Whatever - * the field's whitespace, exactly one trimmed expression is compared, - * previewed, and sent. - */ -export function RoutineScheduleSection({ - row, - onSave, -}: { - readonly row: GlobalRoutineRow; - readonly onSave: (expression: string) => Promise; + readonly title: string; + readonly description: string; + readonly children?: ReactNode; }) { - const stored = editableCronFor(row); - const timezone = timezoneForTrigger(row.routine.trigger); - const [draft, setDraft] = useState(stored ?? ""); - const [saving, setSaving] = useState(false); - const [failure, setFailure] = useState(null); - const expression = draft.trim(); - const preview = isValidCronExpression(expression) - ? cronSentence(expression, timezone) - : null; - const changed = stored !== null && expression !== stored; - return ( -
-

- Schedule -

-

- {routineScheduleSentence(row.routine.trigger)} -

- {stored === null ? null : ( -
- -
- setDraft(event.target.value)} - /> - -
-

- {failure ?? preview ?? "That isn't a schedule this can run."} +

+ + +

{description}

+ {children ?? ( +

+ Back to Routines

-
- )} -
- ); -} - -/** The workflow this routine runs, with a way through to its steps on the - * platform's own run surface. The link points at the most recent run — - * that is where steps are actually readable — and is simply absent until - * there is a run to read, never a button that goes nowhere. */ -export function RoutineTargetSection({ - workflowName, - latestRunId, -}: { - readonly workflowName: string; - readonly latestRunId: string | null; -}) { - return ( -
-

- Runs this workflow -

-
- {workflowName} - {latestRunId === null ? ( - - Its steps show up here after the first run. - - ) : ( - )} -
-
- ); -} - -/** Every fire on record, each row a door into that run's own trace. */ -export function RoutineRunHistory({ - row, - now, -}: { - readonly row: GlobalRoutineRow; - readonly now: number; -}) { - return ( -
-

- Run history -

- {row.runs.length === 0 ? ( -

- This routine has not run yet — on its schedule or by hand. -

- ) : ( - - - - Triggered by - Status - When - Trace - - - - {row.runs.map((run) => ( - - - - - - - - {formatRelativeTime(run.createdAt, now)} - - {fireNeverStarted(run.triggeredBy) ? ( - - Never started - - ) : ( - - View run - - )} - - - ))} - -
- )} -
+ + ); } -/** The whole page body, given a resolved routine — pure, so the layout is - * testable without a fetch or a router. */ export function RoutineDetailPage({ row, - now, - workflowName, - onRunNow, onToggleEnabled, - onSaveSchedule, - onEdit, + onRunNow, }: { readonly row: GlobalRoutineRow; - readonly now: number; - readonly workflowName: string; - readonly onRunNow: () => Promise; readonly onToggleEnabled: (enabled: boolean) => void; - readonly onSaveSchedule: (expression: string) => Promise; - readonly onEdit: () => void; + readonly onRunNow: () => Promise; }) { - const health = routineHealth( - row.routine, - row.runs.map((run) => withListingAbandoned(run, now)), - now, - ); - const latestRunId = - row.runs.find((run) => !fireNeverStarted(run.triggeredBy))?.runId ?? null; + const enabled = row.definition.status === "deployed"; + const sentence = scheduleSentence(row.definition.cron); return (
- {!row.routine.enabled || health.state === "paused" ? ( - - ) : ( - <> - - - - )} - - - } - /> - -
-
- {/* Keyed on the saved expression: once a save lands, the - editor's draft is stale by definition, so it remounts - against the schedule that is now real. */} - - - +
- -
-
-
- ); -} - -/** A routine-shaped screen with nothing to show: the trail still reads - * Routines, and the way out is a link, never a dead end. */ -function RoutineNotice({ - title, - description, - children, -}: { - readonly title: string; - readonly description: string; - readonly children?: ReactNode; -}) { - return ( -
- - - } - title={title} - description={description} - action={ - - } - /> - {children} + +

{row.definition.name}

+

+ {row.tenantName} +

+

{sentence}

); } -/** The target's own display name for `definitionId`. Falls back to the - * id only while targets are still loading or when the definition is no - * longer offered — a routine pointing at a retired workflow still has to - * render. */ -function useWorkflowName(row: GlobalRoutineRow | undefined): string { - const tenantId = row?.tenantId ?? ""; - const targets = useTenantQuery( - [...tenantKeys.routines(tenantId), "targets"], - tenantId !== "", - () => listAllRoutineTargets(tenantId), - ); - if (targets.kind !== "ready" || row === undefined) { - return row?.routine.definitionAssetId ?? ""; - } - const match = targets.data.find( - (target) => target.definitionAssetId === row.routine.definitionAssetId, - ); - return match?.name ?? row.routine.definitionAssetId ?? ""; -} - -/** - * What `/routines/` resolves to. - * - * The id is the canonical address and renders the page directly — a - * routine has no slug column, so a name-derived slug is exactly the "soft - * convention a migration can violate" DESIGN.md forbids in a route, and - * the opaque id is the fallback that section prescribes. A name still - * resolves, as a convenience: it redirects to the id path, so what ends - * up in the address bar, in a bookmark, and in a shared link is the - * address that cannot break when someone renames the routine. - * - * A name two routines answer to resolves to neither — it offers both by - * id instead. A segment nothing answers to says the routine is gone, - * rather than quietly showing the roster under a URL that no longer means - * anything. - */ -export type RoutineResolution = - | { readonly kind: "found"; readonly row: GlobalRoutineRow } - | { readonly kind: "redirect"; readonly to: string } - | { readonly kind: "ambiguous"; readonly rows: readonly GlobalRoutineRow[] } - | { readonly kind: "gone" }; - export function resolveRoutineSegment( rows: readonly GlobalRoutineRow[], segment: string, -): RoutineResolution { - const byId = rows.find((row) => row.routine.id === segment); - if (byId !== undefined) return { kind: "found", row: byId }; - const byName = rowsForSlug(rows, segment); - const only = byName.length === 1 ? byName[0] : undefined; - if (only !== undefined) { - return { kind: "redirect", to: routineDetailPath(only.routine.id) }; - } - if (byName.length > 1) return { kind: "ambiguous", rows: byName }; - return { kind: "gone" }; +): GlobalRoutineRow | undefined { + return rows.find((row) => row.definition.definitionId === segment); } -export function RoutineDetailRoute({ - segment, - navigate, -}: { - readonly segment: string; - readonly navigate: (to: string) => void; -}) { +export function RoutineDetailRoute({ segment }: { readonly segment: string }) { const routinesQuery = useGlobalRoutines(); const actions = useRoutineActions(); - const openRoutine = useOpenRoutineInCanvas(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; - const resolution = resolveRoutineSegment(rows, segment); - const row = resolution.kind === "found" ? resolution.row : undefined; - const workflowName = useWorkflowName(row); - const now = Date.now(); - const redirectTo = - routinesQuery.kind === "ready" && resolution.kind === "redirect" - ? resolution.to - : null; - - useEffect(() => { - if (redirectTo === null) return; - navigate(redirectTo); - }, [redirectTo, navigate]); + const resolved = + routinesQuery.kind === "ready" + ? resolveRoutineSegment(rows, segment) + : undefined; if (routinesQuery.kind === "loading") { - return ( -
- - - } title="Loading routine…" /> - -
- ); + return ; } if (routinesQuery.kind === "error") { return ( ); } - if (resolution.kind === "redirect") { + if (resolved === undefined) { return ( - ); - } - if (resolution.kind === "ambiguous") { - return ( - -
    - {resolution.rows.map((candidate) => ( -
  • - - {candidate.routine.name} · {candidate.tenantName} - -
  • - ))} -
-
- ); - } - if (row === undefined) { - return ( - ); } - const resolved = row; return ( actions.runNow(resolved)} onToggleEnabled={(enabled) => { void actions.setEnabled(resolved, enabled); }} - onSaveSchedule={(expression) => - actions.saveCronSchedule(resolved, expression) - } - onEdit={() => openRoutine({ routineId: resolved.routine.id })} + onRunNow={() => actions.runNow(resolved)} /> ); } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 7b76b70cf4..57fcf31c0a 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -1,28 +1,9 @@ -// Routines: one global list, every automation across every workbench the -// signed-in account is a member of (CL-6362). Per-workbench routines -// chrome (the header's Routines button, the `/run` composer command, and -// the canvas pane's list/runs views) is gone — this page is the only -// place to browse routines now; a routine's own workbench still shows it -// "where it was made" via in-room notices and run-now approval cards, -// which this page never touches. -// -// The list is an ops table, not a browse list (CL-6418): a routine is a -// workflow on a schedule, so the columns are the questions an operator -// actually arrives with — when does it run next, is it healthy, when did -// it last run and how did that go. Row detail is a real page -// (`/routines/`, `routine-detail-page.tsx`), never an inline -// expansion: run history, the target workflow, and the schedule editor -// all outgrew a row long ago. -// -// Every schedule reads as a sentence (`routineScheduleSentence`), never a -// cron expression — DESIGN.md, Copy. +// 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. import { - Badge, - Button, EmptyState, - formatRelativeTime, RichEmptyState, - RUN_STATUS_TONE, RunNowButton, Switch, Table, @@ -32,250 +13,36 @@ import { TableHeader, TableRow, } from "@corbits/react-ui"; -import type { BadgeTone, RunStatus } from "@corbits/react-ui"; -import { Clock, PencilSimple, PlayCircle, Plus } from "@corbits/icons"; -import type { KeyboardEvent } from "react"; -import { - fireOutcomeStatus, - routineHealth, - routineScheduleSentence, - runStatusLabel, - triggeredByLabel, - withListingAbandoned, -} from "@corbits/routines/client"; -import type { RoutineHealth } from "@corbits/routines/client"; +import { Clock } from "@corbits/icons"; +import { cronSentence } from "@corbits/workflows/client"; -import { useBench } from "../bench-context"; -import { - routineDetailPath, - useGlobalRoutines, - useRoutineActions, -} from "../global-routines"; +import { useGlobalRoutines, useRoutineActions } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; +import { routineDetailPath } from "../global-routines"; import { Link } from "../navigation"; -import { workbenchPath } from "../workbench-path"; -import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; -import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; import { StageTopBar } from "../shell/stage-top-bar"; -import type { RoutineRun } from "../routines-api"; export type { GlobalRoutineRow } from "../global-routines"; -/** Platform run status strings that don't spell their canonical - * `RunStatus` name the same way — everything else already matches - * react-ui's `RunStatus` vocabulary and passes through unchanged. */ -const RUN_STATUS_ALIAS: Readonly> = { - cancelled: "stopped", - queued: "provisioning", - pending: "provisioning", -}; - -/** Tone for a platform run status, normalized onto `RunStatus` first so - * this reads the same map every other surface does - * (`RUN_STATUS_TONE` in react-ui's `workflow-run.ts`) instead of a second, - * drifting opinion. */ -export function runStatusTone(status: string): BadgeTone { - const canonicalStatus = RUN_STATUS_ALIAS[status] ?? (status as RunStatus); - return RUN_STATUS_TONE[canonicalStatus] ?? "neutral"; -} - -/** - * Recent-run rows deep-link to the workbench the routine delivers to — a - * routine has one `deliveryWorkbenchId`, not a per-run one, so every row - * in a given table shares the same destination. Rows render as plain data - * when there is nowhere to deep-link (`deliveryWorkbenchId` absent or no - * `onOpenWorkbench` handler wired). Exported: the canvas routine editor - * panel (`shell/routine-panel.tsx`) reuses this exact rendering for its - * own "Recent runs" section — one run table, never two drifting ones. - */ -export function RunsTable({ - runs, - now, - emptyTitle, - emptyDescription, - deliveryWorkbenchId = null, - onOpenWorkbench, -}: { - readonly runs: readonly RoutineRun[]; - readonly now: number; - readonly emptyTitle: string; - readonly emptyDescription: string; - readonly deliveryWorkbenchId?: string | null; - readonly onOpenWorkbench?: (workbenchId: string) => void; -}) { - if (runs.length === 0) { - return ( - } - title={emptyTitle} - description={emptyDescription} - /> - ); - } - const workbenchId = - deliveryWorkbenchId !== null && onOpenWorkbench !== undefined - ? deliveryWorkbenchId - : null; - return ( - - - - Triggered by - Status - When - - - - {runs.map((run) => { - const rowProps = - workbenchId !== null - ? { - role: "link" as const, - tabIndex: 0, - className: "routine-run-row-linked", - onClick: () => onOpenWorkbench?.(workbenchId), - onKeyDown: (event: KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onOpenWorkbench?.(workbenchId); - }, - } - : {}; - return ( - - - - - - - - {formatRelativeTime(run.createdAt, now)} - - ); - })} - -
- ); -} - -/** What started a fire, plus the launch failure's own message when the - * fire never produced a run at all. Shared by the roster's run table and - * the detail page's history table. Words, not the `triggered_by` column's - * enum (DESIGN.md, Copy). */ -export function TriggeredByCell({ run }: { readonly run: RoutineRun }) { - const hasError = run.error !== undefined && run.error !== null; - return ( - <> - - {triggeredByLabel(run.triggeredBy)} - - {hasError ? ( -

- {run.error} -

- ) : null} - - ); -} - -/** A fire's outcome in words, or a dash when the platform has no run to - * report (a launch that never got that far). Reads `fireOutcomeStatus`, - * not the raw `run.status` — warm-keep (CL-6681) leaves a fire's delivery - * agent deployed after it replies, so the raw column never settles out of - * `running` on its own. */ -export function RunStatusCell({ - run, - now, -}: { - readonly run: RoutineRun; - readonly now: number; -}) { - const status = fireOutcomeStatus(withListingAbandoned(run, now), now); - if (status === null) { - return —; - } - return {runStatusLabel(status)}; -} - -/** A routine's health, from the telemetry the scheduler already records — - * the same reading the detail page's health rail shows, never a second - * opinion. */ -export function routineRowHealth( - row: GlobalRoutineRow, - now: number, -): RoutineHealth { - return routineHealth( - row.routine, - row.runs.map((run) => withListingAbandoned(run, now)), - now, - ); -} - -/** "At 09:00, Monday through Friday (UTC)" — the schedule as a sentence, - * for every routine shape including a raw cron expression. */ -export function scheduleSentence(row: GlobalRoutineRow): string { - return routineScheduleSentence(row.routine.trigger); -} - -/** - * When this routine fires next, read off the scheduler's own `nextFireAt` - * clock rather than re-derived in the browser — a routine that is off, - * dead-lettered, manual, or webhook-driven honestly has no next run, and - * says so. An absent field reads the same as `null`: an un-upgraded hub - * has told us nothing, which is not a licence to guess (see the wire - * schema's own note). - * - * A due time in the past is not "2h ago" — the fire has not happened, the - * scheduler is behind, and the word for that is overdue. - */ -export function nextRunLabel(row: GlobalRoutineRow, now: number): string { - if (!row.routine.enabled) return "Paused"; - const nextFireAt = row.routine.nextFireAt ?? null; - if (nextFireAt === null) return "Not scheduled"; - const due = Date.parse(nextFireAt); - if (Number.isNaN(due)) return "Not scheduled"; - if (due <= now) return "Overdue"; - return formatRelativeTime(nextFireAt, now); -} - -/** The newest fire on record — the one definition of "last run", shared - * with the detail page's health rail through `routineHealth`. */ -export function latestFire(row: GlobalRoutineRow): RoutineRun | undefined { - return row.runs[0]; -} - -function HealthCell({ health }: { readonly health: RoutineHealth }) { - return ( -
- {health.label} - - {health.caption} - -
- ); +export function scheduleSentence(cron: string): string { + return cronSentence(cron) ?? cron; } export function GlobalRoutinesList({ rows, - now, onToggleEnabled, onRunNow, - onOpenWorkbench, - onEditRoutine, }: { readonly rows: readonly GlobalRoutineRow[]; - readonly now: number; readonly onToggleEnabled: (row: GlobalRoutineRow, enabled: boolean) => void; readonly onRunNow: (row: GlobalRoutineRow) => Promise; - readonly onOpenWorkbench: (workbenchId: string) => void; - readonly onEditRoutine: (row: GlobalRoutineRow) => void; }) { if (rows.length === 0) { return ( } - title="No routines yet" - description="Create one from a workflow or a prompt, in any workbench." + title="No scheduled workflows yet" + description="A workflow with a schedule shows up here. Pause, resume, or run it now." /> ); } @@ -285,27 +52,26 @@ export function GlobalRoutinesList({ Routine Schedule - Next run - Health - Last run - Delivers to On Actions {rows.map((row) => { - const health = routineRowHealth(row, now); - const lastRun = latestFire(row); + const enabled = row.definition.status === "deployed"; return ( - + - {row.routine.name} + {row.definition.name} {row.tenantName} @@ -313,80 +79,23 @@ export function GlobalRoutinesList({ - {scheduleSentence(row)} - - - {nextRunLabel(row, now)} - - - - - - {lastRun === undefined ? ( - - Never - - ) : ( - - - {formatRelativeTime(lastRun.createdAt, now)} - - - - )} - - - {row.routine.deliveryWorkbenchId !== null && - row.deliveryWorkbenchName !== null ? ( - - ) : ( - — - )} + + {scheduleSentence(row.definition.cron)} + onToggleEnabled(row, enabled)} + checked={enabled} + label={`${enabled ? "On" : "Off"} ${row.definition.name}`} + onCheckedChange={(next) => onToggleEnabled(row, next)} /> -
- {health.state === "paused" || !row.routine.enabled ? ( - - ) : ( - onRunNow(row)} - /> - )} - -
+ onRunNow(row)} + />
); @@ -396,29 +105,16 @@ export function GlobalRoutinesList({ ); } -export function RoutinesRoute({ - navigate, -}: { - readonly navigate: (to: string) => void; -}) { +export function RoutinesRoute() { const routinesQuery = useGlobalRoutines(); const actions = useRoutineActions(); - const openRoutine = useOpenRoutineInCanvas(); - const { selectTenant } = useBench(); - const now = Date.now(); - const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; return (
openRoutine({ routineId: null })}> - New routine - - } + subtitle="Scheduled workflows. Pause, resume, or run now." />
{routinesQuery.kind === "loading" ? ( @@ -436,20 +132,10 @@ export function RoutinesRoute({ ) : ( { void actions.setEnabled(row, enabled); }} onRunNow={(row) => actions.runNow(row)} - onEditRoutine={(row) => openRoutine({ routineId: row.routine.id })} - onOpenWorkbench={(workbenchId) => { - const row = rows.find( - (r) => r.routine.deliveryWorkbenchId === workbenchId, - ); - if (row === undefined) return; - selectTenant(row.tenantId); - navigate(workbenchPath(workbenchId)); - }} /> )}
diff --git a/apps/web/src/pages/run-status-tone-parity.test.ts b/apps/web/src/pages/run-status-tone-parity.test.ts index 4c92abe6b6..805870ebb3 100644 --- a/apps/web/src/pages/run-status-tone-parity.test.ts +++ b/apps/web/src/pages/run-status-tone-parity.test.ts @@ -19,7 +19,6 @@ import { describe, expect, test } from "bun:test"; import { RUN_STATUS_TONE } from "@corbits/react-ui"; import { statusTone } from "./insights-page"; -import { runStatusTone } from "./routines-page"; import { computeInFlightRows } from "./mission-control-page"; import { AGENT_ROSTER_STATUS_TONE } from "./agents-page"; import type { RoutineActivityItem } from "../shell/routine-activity"; @@ -32,12 +31,6 @@ describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { expect(statusTone("stopped")).toBe(RUN_STATUS_TONE.stopped); }); - test("Routines' runStatusTone agrees with canonical for every shared status", () => { - expect(runStatusTone("running")).toBe(RUN_STATUS_TONE.running); - expect(runStatusTone("completed")).toBe(RUN_STATUS_TONE.completed); - expect(runStatusTone("failed")).toBe(RUN_STATUS_TONE.failed); - }); - test("Mission Control's in-flight rows agree with canonical for every shared status", () => { const routine: RoutineActivityItem = { id: "rtn_1", diff --git a/apps/web/src/pages/workbench-timeline.tsx b/apps/web/src/pages/workbench-timeline.tsx index ce4b2fe954..de08e7c6f4 100644 --- a/apps/web/src/pages/workbench-timeline.tsx +++ b/apps/web/src/pages/workbench-timeline.tsx @@ -1,38 +1,25 @@ // Per-workbench Insights view (CL-6224): one wall-clock spine merging chat -// messages, thread forks, routine runs, and approvals for a single workbench +// messages, thread forks, and approvals for a single workbench // (== workbench, per docs/GLOSSARY.md), oldest to newest with day dividers. // No new backend — every fetch here is an existing route this app already -// reads elsewhere (chat-ui, routines-api, api.ts); the new work is +// reads elsewhere (chat-ui, api.ts); the new work is // `../workbench-timeline-merge.ts`'s pure merge, plus this render. -import { - Badge, - Button, - RichEmptyState, - Skeleton, - type BadgeTone, -} from "@corbits/react-ui"; +import { Badge, RichEmptyState, Skeleton } from "@corbits/react-ui"; import { listMessages, listThreads } from "@corbits/chat-ui"; import { Clock } from "@corbits/icons"; import { useMemo, useState } from "react"; import { usePendingApprovals } from "../pending-approvals"; -import { - listRoutineRuns, - listRoutines, - useTenantQuery, - type RoutineRun, -} from "../routines-api"; +import { useTenantQuery } from "../routines-api"; import { tenantKeys } from "../query-client"; import { computeTimelineDayKpis, filterTimelineEvents, groupTimelineByDay, mergeTimelineEvents, - routinesForWorkbench, toApprovalEvents, toMessageEvents, - toRoutineRunEvents, toThreadForkEvents, type TimelineEvent, type TimelineFilter, @@ -44,7 +31,6 @@ const FILTERS: readonly { }[] = [ { id: "all", label: "All" }, { id: "messages", label: "Messages" }, - { id: "runs", label: "Runs" }, { id: "approvals", label: "Approvals" }, ]; @@ -57,17 +43,6 @@ function timeOfDay(iso: string): string { }); } -function routineRunTone(status: "ok" | "failed" | "running"): BadgeTone { - switch (status) { - case "ok": - return "success"; - case "failed": - return "danger"; - case "running": - return "info"; - } -} - function markerClass(event: TimelineEvent): string { switch (event.kind) { case "message": @@ -76,24 +51,12 @@ function markerClass(event: TimelineEvent): string { : "workbench-timeline-marker-neutral"; case "thread-fork": return "workbench-timeline-marker-neutral"; - case "routine-run": - return event.status === "ok" - ? "workbench-timeline-marker-success" - : event.status === "failed" - ? "workbench-timeline-marker-danger" - : "workbench-timeline-marker-info"; case "approval": return "workbench-timeline-marker-warn"; } } -function TimelineRowBody({ - event, - onOpenRun, -}: { - readonly event: TimelineEvent; - readonly onOpenRun: (runId: string) => void; -}) { +function TimelineRowBody({ event }: { readonly event: TimelineEvent }) { switch (event.kind) { case "message": return ( @@ -113,26 +76,6 @@ function TimelineRowBody({
); - case "routine-run": - return ( -
- - Routine {event.routineName} ran ·{" "} - {event.durationMs === null - ? "duration unknown" - : `${(event.durationMs / 1000).toFixed(1)}s`} - - {event.status} - -
- ); case "approval": return (
@@ -145,13 +88,7 @@ function TimelineRowBody({ } } -function TimelineRow({ - event, - onOpenRun, -}: { - readonly event: TimelineEvent; - readonly onOpenRun: (runId: string) => void; -}) { +function TimelineRow({ event }: { readonly event: TimelineEvent }) { const indented = event.kind === "thread-fork"; return (
  • {timeOfDay(event.at)} - +
  • ); } @@ -188,10 +125,6 @@ function DayKpiRow({
    Agent turns
    {kpi.agentTurns}
    -
    -
    Routine runs
    -
    {kpi.routineRuns}
    -
    Approvals
    {kpi.approvals}
    @@ -204,11 +137,9 @@ function DayKpiRow({ export function WorkbenchTimelineView({ events, loading, - onOpenRun, }: { readonly events: readonly TimelineEvent[]; readonly loading: boolean; - readonly onOpenRun: (runId: string) => void; }) { const [filter, setFilter] = useState("all"); const filtered = useMemo( @@ -227,7 +158,7 @@ export function WorkbenchTimelineView({ } title="Nothing on this workbench's timeline yet" - description="Messages, routine runs, and approvals will show up here as they happen." + description="Messages and approvals will show up here as they happen." /> ); } @@ -267,7 +198,6 @@ export function WorkbenchTimelineView({ ))} @@ -294,12 +224,11 @@ export function WorkbenchTimelineView({ * `workbenchId` is the workbench's own id, resolved to that workbench tenant * one level up in `InsightsWorkbenchPage` (`../insights-workbench-scope.ts`) * for the tenant-scoped Insights endpoints; this component only ever reads - * messages/threads/routines/approvals off the owning bench. + * messages/threads/approvals off the owning bench. */ export function WorkbenchTimelineRoute({ benchTenantId, workbenchId, - onOpenRun, }: { readonly benchTenantId: string | null; readonly workbenchId: string; @@ -325,49 +254,15 @@ export function WorkbenchTimelineRoute({ (page) => page.items, ), ); - const routinesQuery = useTenantQuery( - benchTenantId === null - ? ["tenant", "none", "routines"] - : tenantKeys.routines(benchTenantId), - benchTenantId !== null, - () => listRoutines(benchTenantId as string), - ); - const routines = - routinesQuery.kind === "ready" - ? routinesForWorkbench(routinesQuery.data, workbenchId) - : []; - const routineRunsQuery = useTenantQuery< - ReadonlyMap - >( - benchTenantId === null - ? ["tenant", "none", "workbench-timeline-routine-runs", workbenchId] - : tenantKeys.workbenchTimelineRoutineRuns(benchTenantId, workbenchId), - benchTenantId !== null && routines.length > 0, - async () => { - const entries = await Promise.all( - routines.map( - async (routine) => - [ - routine.id, - await listRoutineRuns(benchTenantId as string, routine.id), - ] as const, - ), - ); - return new Map(entries); - }, - ); const approvalsQuery = usePendingApprovals(benchTenantId); const loading = messagesQuery.kind === "loading" || threadsQuery.kind === "loading" || - routinesQuery.kind === "loading" || approvalsQuery.kind === "loading"; const messages = messagesQuery.kind === "ready" ? messagesQuery.data : []; const threads = threadsQuery.kind === "ready" ? threadsQuery.data : []; - const routineRunsByRoutineId = - routineRunsQuery.kind === "ready" ? routineRunsQuery.data : new Map(); // An approval carries no workbench id (a known v1 gap — see // workbench-timeline-merge.ts's toApprovalEvents), so this is every // pending approval on the owning bench, not just this workbench's own. @@ -376,15 +271,8 @@ export function WorkbenchTimelineRoute({ const events = mergeTimelineEvents({ messages: toMessageEvents(messages), threadForks: toThreadForkEvents(threads), - routineRuns: toRoutineRunEvents(routines, routineRunsByRoutineId), approvals: toApprovalEvents(approvals), }); - return ( - - ); + return ; } diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index c85c09a684..e948be2327 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -314,20 +314,15 @@ export const APP_ROUTES: readonly AppRoute[] = [ path: ROUTINE_DETAIL_PATH, label: "Routine", icon: , - render: (path: string, navigate: (to: string) => void) => ( - + render: (path: string) => ( + ), }, { path: "/routines", label: "Routines", icon: , - render: (_path: string, navigate: (to: string) => void) => ( - - ), + render: () => , }, { // A workflow definition's own page (CL-7371) — no roster of its own diff --git a/apps/web/src/routine-health-tone.ts b/apps/web/src/routine-health-tone.ts deleted file mode 100644 index c961eb3183..0000000000 --- a/apps/web/src/routine-health-tone.ts +++ /dev/null @@ -1,27 +0,0 @@ -// One state → tone table for a routine's health, shared by the Routines -// list's pill and the detail page's health rail. DESIGN.md's State Pills -// rule is what makes this a table and not two inline ternaries: the four -// live states never share a colour, so the mapping has to exist in exactly -// one place or two screens will disagree about what "failing" looks like. -// -// `failing` (still scheduled, still retrying) reads warning; `paused` -// (dead-lettered, the scheduler gave up) reads danger — a routine that has -// stopped for good is not the same signal as one having a bad morning. -// -// This table belongs next to `health.ts` in the routines package, since -// the state→tone pairing is as much a product rule as the states -// themselves; it sits here only because `BadgeTone` is a react-ui type and -// the package has no react-ui dependency yet. Moving both is ticketed. -import type { BadgeTone } from "@corbits/react-ui"; -import type { RoutineHealthState } from "@corbits/routines/client"; - -export const ROUTINE_HEALTH_TONE: Readonly< - Record -> = { - off: "neutral", - idle: "neutral", - ok: "success", - running: "info", - failing: "warning", - paused: "danger", -}; diff --git a/apps/web/src/routine-schedule.tsx b/apps/web/src/routine-schedule.tsx deleted file mode 100644 index 8a9e2b7563..0000000000 --- a/apps/web/src/routine-schedule.tsx +++ /dev/null @@ -1,334 +0,0 @@ -// The routine panel's inline schedule editor (CL-6139): replaces the old -// multi-click "On a schedule" → cadence menu → sub-menu chain -// (`routines-page.tsx`'s now-removed `TriggerPicker`) with one surface — -// five one-click presets plus a custom row, both committing on the same -// blur/select interactions the panel's Name/Instruction fields already -// use. Every cadence this renders goes through `@corbits/routines/trigger`'s -// own cron builders (`cronTriggerForWeekdays`, `cronExpressionForTrigger` -// via `routineCadenceLabel`'s live summary) — this module hand-rolls no -// cron of its own. - -import { useState } from "react"; -import { - Button, - Input, - Menu, - MenuContent, - MenuItem, - MenuTrigger, -} from "@corbits/react-ui"; -import { - cronTriggerForWeekdays, - routineScheduleSentence, - ROUTINE_WEEKDAY_NAMES, - type RoutineTriggerT, -} from "@corbits/routines/client"; - -type ScheduleTrigger = Exclude; - -export type SchedulePreset = { - readonly id: string; - readonly label: string; - readonly trigger: () => ScheduleTrigger; -}; - -const WEEKDAY_SHORT_NAMES = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; - -/** The five cadences a person reaches for most often — each one commits - * the whole schedule in a single click, no follow-up menu. */ -export const SCHEDULE_PRESETS: readonly SchedulePreset[] = [ - { - id: "every-15-min", - label: "Every 15 min", - trigger: () => ({ kind: "interval", unit: "minutes", every: 15 }), - }, - { - id: "hourly", - label: "Hourly", - trigger: () => ({ kind: "interval", unit: "hours", every: 1 }), - }, - { - id: "daily-9", - label: "Daily 9:00", - trigger: () => ({ kind: "daily", hour: 9, minute: 0 }), - }, - { - id: "weekdays-9", - label: "Weekdays 9:00", - trigger: () => cronTriggerForWeekdays([1, 2, 3, 4, 5], 9, 0), - }, - { - id: "weekly-mon-9", - label: "Weekly Mon 9:00", - trigger: () => ({ kind: "weekly", dayOfWeek: 1, hour: 9, minute: 0 }), - }, -]; - -function triggerEquals(a: ScheduleTrigger, b: ScheduleTrigger): boolean { - return JSON.stringify(a) === JSON.stringify(b); -} - -/** Which preset (if any) `trigger` currently matches — drives the pressed - * state on the preset row. `null` when it's a custom cadence. */ -export function matchingPresetId(trigger: ScheduleTrigger): string | null { - return ( - SCHEDULE_PRESETS.find((preset) => triggerEquals(preset.trigger(), trigger)) - ?.id ?? null - ); -} - -/** Custom "Every N minutes/hours/days" — the interval branch of the custom - * row. `every` is clamped to a positive integer the same way the old - * `TriggerPicker`'s number input was. */ -export function customIntervalTrigger( - every: number, - unit: "minutes" | "hours" | "days", -): ScheduleTrigger { - return { - kind: "interval", - unit, - every: Math.max(1, Math.trunc(every) || 1), - }; -} - -/** Custom "at HH:MM on one or more days" — a single day renders as the - * `weekly` preset shape, more than one goes through - * `cronTriggerForWeekdays` (the shape `weekly`'s single `dayOfWeek` can't - * express). `null` for no day picked yet — nothing to commit. */ -export function customAtTrigger( - hour: number, - minute: number, - days: readonly number[], -): ScheduleTrigger | null { - if (days.length === 0) return null; - if (days.length === 1) { - return { kind: "weekly", dayOfWeek: days[0] as number, hour, minute }; - } - return cronTriggerForWeekdays(days, hour, minute); -} - -type CustomMode = "interval" | "at"; - -function initialCustomMode(trigger: ScheduleTrigger | null): CustomMode { - return trigger !== null && trigger.kind === "interval" ? "interval" : "at"; -} - -function initialEvery(trigger: ScheduleTrigger | null): number { - return trigger !== null && trigger.kind === "interval" ? trigger.every : 15; -} - -function initialUnit( - trigger: ScheduleTrigger | null, -): "minutes" | "hours" | "days" { - return trigger !== null && trigger.kind === "interval" - ? trigger.unit - : "minutes"; -} - -function initialHour(trigger: ScheduleTrigger | null): number { - if ( - trigger !== null && - (trigger.kind === "daily" || trigger.kind === "weekly") - ) { - return trigger.hour; - } - return 9; -} - -function initialMinute(trigger: ScheduleTrigger | null): number { - if ( - trigger !== null && - (trigger.kind === "daily" || trigger.kind === "weekly") - ) { - return trigger.minute; - } - return 0; -} - -function initialDays(trigger: ScheduleTrigger | null): readonly number[] { - if (trigger !== null && trigger.kind === "weekly") return [trigger.dayOfWeek]; - return []; -} - -/** - * The panel's "When to run" schedule surface: preset row, custom row, live - * summary. Every interaction commits immediately (a preset click, a - * number/time blur, a unit or day pick) — the same one-step contract the - * panel's own Name/Instruction fields use, never a second "Save" step. - */ -export function ScheduleEditor({ - value, - onChange, - disabled = false, -}: { - readonly value: ScheduleTrigger | null; - readonly onChange: (next: ScheduleTrigger) => void; - readonly disabled?: boolean; -}) { - const [customMode, setCustomMode] = useState( - initialCustomMode(value), - ); - const [every, setEvery] = useState(initialEvery(value)); - const [unit, setUnit] = useState<"minutes" | "hours" | "days">( - initialUnit(value), - ); - const [hour, setHour] = useState(initialHour(value)); - const [minute, setMinute] = useState(initialMinute(value)); - const [days, setDays] = useState(initialDays(value)); - - const selectedPreset = value === null ? null : matchingPresetId(value); - - function toggleDay(day: number) { - const nextDays = days.includes(day) - ? days.filter((d) => d !== day) - : [...days, day]; - setDays(nextDays); - const next = customAtTrigger(hour, minute, nextDays); - if (next !== null) onChange(next); - } - - return ( -
    -
    - {SCHEDULE_PRESETS.map((preset) => ( - - ))} -
    - -
    - - Custom - -
    - - - - - - setCustomMode("interval")}> - Every… - - setCustomMode("at")}> - At a time… - - - - - {customMode === "interval" ? ( - <> - - setEvery( - Math.max(1, Math.trunc(event.target.valueAsNumber) || 1), - ) - } - onBlur={() => onChange(customIntervalTrigger(every, unit))} - /> - - - - - - {(["minutes", "hours", "days"] as const).map((option) => ( - { - setUnit(option); - onChange(customIntervalTrigger(every, option)); - }} - > - {option} - - ))} - - - - ) : ( - <> - { - const [nextHour, nextMinute] = event.target.value - .split(":") - .map(Number); - setHour(nextHour ?? 0); - setMinute(nextMinute ?? 0); - }} - onBlur={() => { - const next = customAtTrigger(hour, minute, days); - if (next !== null) onChange(next); - }} - /> - UTC - - )} -
    - - {customMode === "at" ? ( -
    - {ROUTINE_WEEKDAY_NAMES.map((name, index) => ( - - ))} -
    - ) : null} -
    - - {value !== null ? ( -

    - {routineScheduleSentence(value)} -

    - ) : null} -
    - ); -} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index f4cbc13742..a9831ca682 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -1,17 +1,6 @@ -// The Routines page's one seam to `@corbits/routines`' HTTP routes (see -// packages/routines/src/routes.ts): fetch composition only. Wire schemas, -// path builders, and pure display helpers live in `@corbits/routines/client` -// — browser-safe and shared with any UI over this data, not tied to this -// app's fetch machinery (mirrors `insights-api.ts` / `@corbits/insights/client` -// and `agents-directory.ts` / `@corbits/agent-directory/client`). -// `@corbits/routines` itself is never imported directly — its public -// surface also exports Drizzle schema tables and a Postgres-backed store, -// none of which belong in a browser bundle. Targets a routine may -// reference come from `GET /api/tenants/:tenantId/workflows/targets` -// (`@corbits/routines`' targets.ts): deployed, frozen, authorized for the -// signed-in principal, already filtered to what the product offers, with -// display names attached — the browser never re-derives that list from -// the platform's raw definitions listing. +// The Routines page's seam to authored workflow definitions that carry a +// ScheduleTrigger: list, run-now, and pause/resume. Pause/resume is the +// same agent-directory status PUT seed uses (`stopped` / `deployed`). import { type } from "arktype"; import type { ArkErrors } from "arktype"; @@ -22,39 +11,28 @@ import { UnauthenticatedError, toAPIQuery, } from "@corbits/api-query"; -import { - Routine, - RoutineRun, - RoutinesResponse, - RoutineRunsResponse, - RoutineTargetsResponse, - routineCreatedToast, - routinePath, - routineRunNowPath, - routineRunStartedToast, - routineRunsPath, - routinesPath, - routineTargetsPath, -} from "@corbits/routines/client"; -import type { - CreateRoutineInput, - RoutineTarget, - UpdateRoutineInput, -} from "@corbits/routines/client"; -export { - type CreateRoutineInput, - type Routine, - type RoutineRun, - type RoutineTarget, - type RoutineTargetKind, - type RoutineTriggerT as RoutineTrigger, - type UpdateRoutineInput, -} from "@corbits/routines/client"; +import { setAgentDefinitionStatus } from "./agents-api"; + +export const ScheduledWorkflowDefinition = type({ + definitionId: "string", + assetId: "string", + name: "string", + tenantId: "string", + status: "'deployed' | 'stopped'", + cron: "string", + createdAt: "string", + updatedAt: "string", +}); + +export type ScheduledWorkflowDefinition = + typeof ScheduledWorkflowDefinition.infer; -/** One page is enough for a seeded bench; `listAllRoutineTargets` walks - * cursors so a large tenant never silently truncates options. */ -const TARGETS_PAGE_LIMIT = 100; +const ScheduledWorkflowsResponse = type({ + items: ScheduledWorkflowDefinition.array(), +}); + +const RunNowResponse = type({ runId: "string" }); type Validator = (data: unknown) => T | ArkErrors; @@ -80,9 +58,6 @@ async function request( throw new ApiQueryError("Not signed in.", 401, path); } if (!response.ok) { - // Envelope-first: the hub's own `error.userMessage` is already plain, - // human copy — kept verbatim. Only the fallback (no envelope message) - // is synthesized here, and it never repeats the request path. const detail = await response .json() .then( @@ -109,90 +84,46 @@ async function request( return parsed; } -export function listRoutines(tenantId: string): Promise { - return request(routinesPath(tenantId), RoutinesResponse).then( - (page) => page.items, - ); -} - -export function getRoutine(tenantId: string, id: string): Promise { - return request(routinePath(tenantId, id), Routine); +export function scheduledWorkflowsPath(tenantId: string): string { + return `/api/tenants/${tenantId}/workflows/scheduled`; } -export function createRoutine( +export function scheduledWorkflowRunPath( tenantId: string, - input: CreateRoutineInput, -): Promise { - return request(routinesPath(tenantId), Routine, { - method: "POST", - body: JSON.stringify(input), - }); + definitionId: string, +): string { + return `/api/tenants/${tenantId}/workflows/scheduled/${encodeURIComponent(definitionId)}/run`; } -export function updateRoutine( +export function listScheduledWorkflows( tenantId: string, - id: string, - patch: UpdateRoutineInput, -): Promise { - return request(routinePath(tenantId, id), Routine, { - method: "PATCH", - body: JSON.stringify(patch), - }); -} - -export function deleteRoutine(tenantId: string, id: string): Promise { - return request(routinePath(tenantId, id), type("unknown"), { - method: "DELETE", - }).then(() => undefined); +): Promise { + return request( + scheduledWorkflowsPath(tenantId), + ScheduledWorkflowsResponse, + ).then((page) => page.items); } -export function runRoutineNow( +export function runScheduledWorkflowNow( tenantId: string, - id: string, + definitionId: string, ): Promise<{ runId: string }> { - return request(routineRunNowPath(tenantId, id), type({ runId: "string" }), { - method: "POST", - body: JSON.stringify({}), - }); -} - -export function listRoutineRuns( - tenantId: string, - id: string, -): Promise { - return request(routineRunsPath(tenantId, id), RoutineRunsResponse).then( - (page) => page.items, - ); -} - -/** One page of definitions the signed-in principal may target from a - * routine, ordered by name; pass the previous page's `nextCursor` to - * continue. */ -export function listRoutineTargets( - tenantId: string, - cursor?: string, -): Promise { return request( - routineTargetsPath(tenantId, { - limit: TARGETS_PAGE_LIMIT, - ...(cursor !== undefined ? { cursor } : {}), - }), - RoutineTargetsResponse, + scheduledWorkflowRunPath(tenantId, definitionId), + RunNowResponse, + { + method: "POST", + body: JSON.stringify({}), + }, ); } -/** Every routine target in the tenant, cursor-walked. */ -export async function listAllRoutineTargets( +export function setScheduledWorkflowStatus( tenantId: string, -): Promise { - const collected: RoutineTarget[] = []; - let cursor: string | undefined; - for (;;) { - const page = await listRoutineTargets(tenantId, cursor); - collected.push(...page.items); - if (page.nextCursor === null) return collected; - cursor = page.nextCursor; - } + definitionId: string, + status: "deployed" | "stopped", +): Promise<{ readonly status: string }> { + return setAgentDefinitionStatus(tenantId, definitionId, status); } /** @@ -223,5 +154,3 @@ export function useTenantQuery( }); return toAPIQuery(result); } - -export { routineCreatedToast, routineRunStartedToast }; diff --git a/apps/web/src/shell/canvas-column.tsx b/apps/web/src/shell/canvas-column.tsx index 7733d26cba..4e4cd6be4f 100644 --- a/apps/web/src/shell/canvas-column.tsx +++ b/apps/web/src/shell/canvas-column.tsx @@ -60,7 +60,6 @@ import type { RoutinePanelSubject, } from "./canvas-availability"; import { useInsertIntoComposer } from "./composer-insertion"; -import { RoutinePanel } from "./routine-panel"; /** * One co-viewer's cursor, in the artifact pane's own fractional coordinate @@ -81,7 +80,6 @@ export function CanvasColumn({ open, profile, artifact, - routine, focus, onClose, onToggleFocus, @@ -156,8 +154,6 @@ export function CanvasColumn({ {...(onArtifactTyping !== undefined ? { onArtifactTyping } : {})} presenceConnection={presenceConnection} /> - ) : routine !== null ? ( - ) : ( } diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index 7f1997d6e0..d7d674914a 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -1,8 +1,9 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { spyOnReactUiToast } from "../../../test/react-ui-toast-mock"; import type { ContextMenuEntry } from "@corbits/context-menu"; import { WORKBENCHES_MUTATED_EVENT } from "@corbits/chat-ui"; +import * as errorSink from "@corbits/error-sink"; const toastMock = spyOnReactUiToast(); @@ -211,6 +212,7 @@ describe("shellContextMenuFor: routine", () => { test("run-now does not invalidate routine queries when the run fails to start", async () => { const realFetch = globalThis.fetch; + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); globalThis.fetch = mock(() => Promise.resolve( new Response(JSON.stringify({ error: { message: "boom" } }), { @@ -228,7 +230,13 @@ describe("shellContextMenuFor: routine", () => { } expect(toastMock).toHaveBeenCalledWith("Couldn't start the routine"); expect(onRoutineRan).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalled(); + expect(report.mock.calls[0]?.[1]).toEqual({ + operation: "scheduled_workflow_run_now", + tenantId: "tenant-1", + }); } finally { + report.mockRestore(); globalThis.fetch = realFetch; } }); diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index 52f6d1d20d..ca7e01480b 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -11,6 +11,7 @@ import { import type { ProfileSubject } from "@corbits/chat-ui"; import { contextMenuItem, contextMenuSeparator } from "@corbits/context-menu"; import type { ContextMenu, ContextMenuEntry } from "@corbits/context-menu"; +import { reportError } from "@corbits/error-sink"; import { ArrowSquareOut, Hash, @@ -35,7 +36,7 @@ import { import { workbenchPath } from "../../workbench-path"; import { requestWorkbenchRename } from "../../workbench-rename-events"; import { openCommandPalette } from "../../command-palette-open-store"; -import { runRoutineNow } from "../../routines-api"; +import { runScheduledWorkflowNow } from "../../routines-api"; import { SETTINGS_PATH } from "../../routes"; import type { ShellContextMenuTarget } from "./targets"; @@ -155,13 +156,19 @@ function routineMenu( label: "Run now", icon: , onSelect: () => { - void runRoutineNow(tenantId, target.id).then( - () => { + void (async () => { + try { + await runScheduledWorkflowNow(tenantId, target.id); toast(`${target.name} started`); actions.onRoutineRan(tenantId); - }, - () => toast("Couldn't start the routine"), - ); + } catch (cause) { + reportError(cause, { + operation: "scheduled_workflow_run_now", + tenantId, + }); + toast("Couldn't start the routine"); + } + })(); }, }), ); diff --git a/apps/web/src/shell/definition-target-picker.tsx b/apps/web/src/shell/definition-target-picker.tsx deleted file mode 100644 index 9d80cfa2d9..0000000000 --- a/apps/web/src/shell/definition-target-picker.tsx +++ /dev/null @@ -1,245 +0,0 @@ -// An explicit picker over `GET /api/tenants/:tenantId/workflows/targets` -// (CL-7355): the routine panel used to infer a create target from the -// conversation's own agent (`listWorkbenchAgents(...)[0]`) — silently wrong -// the moment a workbench hosted more than one agent, or none. This picker -// instead lists every deployed, frozen definition the signed-in principal -// may target, grouped presentationally by `kind` (Agents / Workflows), and -// leaves the choice to the person — it never auto-selects the first item. -import { useEffect, useState } from "react"; -import { Button, EmptyState, Select, Skeleton } from "@corbits/react-ui"; -import { Robot } from "@corbits/icons"; - -import { useNavigate } from "../navigation"; -import { listAllRoutineTargets } from "../routines-api"; -import type { RoutineTarget } from "../routines-api"; - -type LoadState = - | { readonly kind: "loading" } - | { readonly kind: "error"; readonly message: string } - | { readonly kind: "loaded"; readonly targets: readonly RoutineTarget[] }; - -/** `value`/`onChange` carry a `definitionAssetId` — the stable identity a - * routine stores. `preselectedAssetId` lets a caller that already knows - * which target it wants seed the initial selection explicitly; the picker - * itself never guesses one on the person's behalf. `onStaleChange` reports - * whether `value` is currently absent from the loaded target list (CL-7358) - * — a caller that needs to gate another affordance (e.g. disabling - * "Run now" on an edited routine's picker) on that without refetching or - * re-deriving the target list itself. */ -export function DefinitionTargetPicker({ - tenantId, - value, - onChange, - preselectedAssetId, - onStaleChange, -}: { - readonly tenantId: string | null; - readonly value: string | null; - readonly onChange: (definitionAssetId: string) => void; - readonly preselectedAssetId?: string; - readonly onStaleChange?: (stale: boolean) => void; -}) { - const navigate = useNavigate(); - const [state, setState] = useState({ kind: "loading" }); - // Bumping this re-runs the load effect below without depending on - // `tenantId` changing — the retry button's only job. - const [retryTick, setRetryTick] = useState(0); - - // Deps are `[tenantId, retryTick]` only, not `[tenantId, value, onChange, - // preselectedAssetId]` — this picker has exactly one caller today - // (`RoutinePanel`), which remounts on subject change and passes a stable - // `onChange` (`useState`+ref, per its own comment), so a stale closure - // over those three can't happen in practice. If a second caller ever - // reuses this component without remounting on `preselectedAssetId` - // changes, add it to this array (and accept the extra re-fetch that - // implies) rather than relying on this invariant silently. - useEffect(() => { - if (tenantId === null) return; - let cancelled = false; - setState({ kind: "loading" }); - // A tenant/bench switch invalidates whatever the previous tenant's - // target list said about `value`'s staleness immediately, rather - // than leaving `onStaleChange`'s last answer (about a different - // tenant's targets) standing until this reload completes — a narrow - // window where "Run now" could otherwise stay wrongly enabled or - // disabled. - onStaleChange?.(false); - void listAllRoutineTargets(tenantId).then( - (targets) => { - if (cancelled) return; - setState({ kind: "loaded", targets }); - if ( - preselectedAssetId !== undefined && - value === null && - targets.some((t) => t.definitionAssetId === preselectedAssetId) - ) { - onChange(preselectedAssetId); - } - }, - (cause: unknown) => { - if (!cancelled) { - setState({ - kind: "error", - message: cause instanceof Error ? cause.message : String(cause), - }); - } - }, - ); - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment above - }, [tenantId, retryTick]); - - // The one place "is `value` stale" is computed — both the effect that - // reports it to `onStaleChange` and the render below (the `stale` - // local) read this, so a future change to the staleness rule can't - // drift between the two. - const isTargetStale = ( - loaded: Extract, - candidate: string | null, - ): boolean => - candidate !== null && - !loaded.targets.some((t) => t.definitionAssetId === candidate); - - useEffect(() => { - if (state.kind !== "loaded") return; - // `onStaleChange` is read here but deliberately not in the deps - // array — see this component's doc comment: the single caller - // passes a stable reference. A future caller passing an inline - // arrow would need `onStaleChange` added here (and would then - // re-run this effect on every render of that caller). - onStaleChange?.(isTargetStale(state, value)); - // eslint-disable-next-line react-hooks/exhaustive-deps -- onStaleChange must be stable; see comment above - }, [state, value]); - - if (state.kind === "loading") { - return ( -
    - - What should this routine run? - - -
    - ); - } - - if (state.kind === "error") { - return ( -
    - - What should this routine run? - -

    - {state.message} -

    - -
    - ); - } - - const { targets } = state; - - if (targets.length === 0) { - return ( - } - title="No deployable workflows yet — author or install one" - action={ - - } - /> - ); - } - - const nameCounts = new Map(); - for (const target of targets) { - nameCounts.set(target.name, (nameCounts.get(target.name) ?? 0) + 1); - } - const labelFor = (target: RoutineTarget): string => - (nameCounts.get(target.name) ?? 0) > 1 - ? `${target.name} (${target.assetName})` - : target.name; - - const agents = targets.filter((t) => t.kind === "agent"); - const workflows = targets.filter((t) => t.kind === "workflow"); - const selected = targets.find((t) => t.definitionAssetId === value) ?? null; - const stale = isTargetStale(state, value); - - return ( -
    - - - {stale ? ( -

    - This routine's target is no longer available — pick a new one. -

    - ) : selected?.description !== null && - selected?.description !== undefined ? ( -

    - {selected.description} -

    - ) : null} -
    - ); -} diff --git a/apps/web/src/shell/routine-activity.ts b/apps/web/src/shell/routine-activity.ts index 518706f842..626ec6d940 100644 --- a/apps/web/src/shell/routine-activity.ts +++ b/apps/web/src/shell/routine-activity.ts @@ -13,8 +13,8 @@ import { runOutcomeStatus, withListingAbandoned, -} from "@corbits/routines/client"; -import type { ListingTurn } from "@corbits/routines/client"; +} from "@corbits/workflows/client"; +import type { ListingTurn } from "@corbits/workflows/client"; import { listRoutineRunFires } from "../agents-api"; import type { RunFire } from "../agents-api"; diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx deleted file mode 100644 index 0ed33ac38a..0000000000 --- a/apps/web/src/shell/routine-panel.tsx +++ /dev/null @@ -1,799 +0,0 @@ -// The routine panel (CL-6125, reworked CL-6139, trimmed to editor-only by -// CL-6362): a create/edit pane in the canvas column, beside the -// conversation — never a route hop. Browsing and running existing -// routines lives on the global `/routines` page now (the shell rail's -// Routines row) — this pane only ever opens straight to -// `RoutineEditorPanel`, for a specific routine (`routineId`) or a -// brand-new one. Back closes the canvas — there is no list view to step -// back to anymore. -// -// There is no Save button — every field autosaves on blur/select, and -// every write (create or update) is serialized through one queue -// (`runWrite` in `RoutineEditorPanel`) so two near-simultaneous commits — -// Name's blur and Instruction's blur landing in the same tick, say — can -// never both decide "no routine yet, create one" and fire two POSTs. The -// second write's create-vs-update decision is made only once the first -// has actually finished, not from a state snapshot taken before either -// ran. `saveState` ("saving…" / "saved" / an honest inline error) reflects -// that same queue, not any individual field's own fetch. A write ack -// never clobbers dirty draft fields (CL-6755): typing instruction or -// picking a schedule while another field's save is in flight keeps the -// in-progress values. -// -// The routine's delivery destination is the conversation this panel was -// opened beside — its own id is where the routine delivers back into. A -// panel opened with no workbench in scope (a deliberate `/routines` visit) -// falls back to the workbench's own default Myra workbench -// (`ensureMyraWorkbench`, the one deliberate find-or-create path in the -// product). What the routine *runs* is a separate, explicit choice (CL-7355): -// the panel no longer infers it from the conversation's own agent — a -// person picks a target from `DefinitionTargetPicker`, backed by -// `GET /api/tenants/:tenantId/workflows/targets`. -import { useEffect, useRef, useState } from "react"; -import type { ChangeEvent } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Button, - ConfirmButton, - EmptyState, - Input, - Menu, - MenuContent, - MenuItem, - MenuTrigger, - RunNowButton, - Switch, - Textarea, - toast, -} from "@corbits/react-ui"; -import { Clock, X } from "@corbits/icons"; - -import { useBench } from "../bench-context"; -import { useNavigate } from "../navigation"; -import { ensureMyraWorkbench } from "../myra-workbench"; -import { routineScheduleSentence } from "@corbits/routines/client"; -import { ScheduleEditor } from "../routine-schedule"; -import { DefinitionTargetPicker } from "./definition-target-picker"; -import { - createRoutine, - deleteRoutine, - getRoutine, - listRoutineRuns, - routineCreatedToast, - routineRunStartedToast, - runRoutineNow, - updateRoutine, -} from "../routines-api"; -import type { Routine, RoutineRun, RoutineTrigger } from "../routines-api"; -import { RunsTable } from "../pages/routines-page"; -import { - createWebhookTrigger, - DEFAULT_WEBHOOK_INPUT_TEMPLATE, -} from "../webhook-triggers-api"; -import { - useDeploymentCapabilities, - slackTriggerOffered, -} from "../deployment-capabilities-api"; -import { useGranolaPluginConnected } from "../granola-plugin-availability"; -import { invalidateRoutineQueries } from "../query-client"; -import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; -import type { RoutinePanelSubject } from "./canvas-availability"; -import { CanvasPaneHeader } from "./canvas-column"; - -function instructionFromInput(input: Record): string { - const value = input["instruction"]; - return typeof value === "string" ? value : ""; -} - -function triggersEqual(a: RoutineTrigger, b: RoutineTrigger): boolean { - return JSON.stringify(a) === JSON.stringify(b); -} - -/** Live draft + last-acked server values — `ackRoutine` reads this ref (not a - * render closure) so a write that resolves after the user kept typing still - * sees the current dirty state (CL-6755). */ -type DraftSnapshot = { - name: string; - savedName: string; - instruction: string; - savedInstruction: string; - trigger: RoutineTrigger; - savedTrigger: RoutineTrigger; - enabled: boolean; -}; - -/** A trigger's row summary — `sourceLabel` (this panel session's own memory - * of which "+ Add trigger" option minted a webhook trigger) wins over the - * generic cadence label for a webhook, since a freshly-reloaded panel has - * no way to recover which source it was bound to. */ -function triggerRowSummary( - trigger: Exclude, - sourceLabel: string | null, -): string { - if (trigger.kind === "webhook") return sourceLabel ?? "On webhook"; - return routineScheduleSentence(trigger); -} - -/** - * `+ Add trigger` popover contents: schedule is always offered; Granola - * call notes is offered only when this tenant has Granola connected (CL-6759) - * — an unconnected plugin must never look like a working trigger; Slack is - * offered only when this deployment's Slack tag ingress is actually mounted - * (see `deployment-capabilities-api.ts`) — an unconfigured deployment must - * never offer a trigger that can't honestly fire. A probe failure is not the - * same as unconfigured (CL-6835): the affordance stays offered rather than - * vanishing with no error. - */ -function AddTriggerMenu({ - slackAvailable, - granolaAvailable, - onSchedule, - onGranola, - onSlack, - disabled, -}: { - readonly slackAvailable: boolean; - readonly granolaAvailable: boolean; - readonly onSchedule: () => void; - readonly onGranola: () => void; - readonly onSlack: () => void; - readonly disabled: boolean; -}) { - return ( - - - - - - On a schedule › - {granolaAvailable ? ( - Granola call notes - ) : null} - {slackAvailable ? Slack : null} - - - ); -} - -/** Opens straight to the routine editor — create (no `routineId`) or edit - * an existing one. Routines' list/browse surface (name, cadence, enabled, - * recent runs) is the global `/routines` page now (CL-6362); this pane is - * only ever reached from a creation entry point (the composer's - * `/routine` command, "New routine in this space", "Make this a - * routine") or an "Edit" action already carrying a `routineId`. */ -export function RoutinePanel() { - const subject = useCanvasColumnRoutine(); - const close = useCloseCanvas(); - - if (subject === null) return null; - - return ( - - ); -} - -type SaveState = "idle" | "saving" | "saved" | "error"; - -/** The editor view: create/edit one routine. Self-fetching — handed only - * the subject, loads the rest itself, exactly like `ProfileCanvasPane`. */ -function RoutineEditorPanel({ - subject, - onBack, - onClose, -}: { - readonly subject: RoutinePanelSubject; - readonly onBack: () => void; - readonly onClose: () => void; -}) { - const navigate = useNavigate(); - const { selectedTenantId: tenantId } = useBench(); - const capabilities = useDeploymentCapabilities(); - const slackAvailable = slackTriggerOffered(capabilities); - const granolaConnected = useGranolaPluginConnected(tenantId); - const queryClient = useQueryClient(); - - const invalidateRoutines = () => { - if (tenantId === null) return; - invalidateRoutineQueries(queryClient, tenantId); - }; - - const [routineId, setRoutineId] = useState( - subject.routineId ?? null, - ); - const [name, setName] = useState(""); - const [savedName, setSavedName] = useState(""); - const [instruction, setInstruction] = useState(""); - const [savedInstruction, setSavedInstruction] = useState(""); - const [trigger, setTrigger] = useState(null); - const [savedTrigger, setSavedTrigger] = useState(null); - const [triggerSourceLabel, setTriggerSourceLabel] = useState( - null, - ); - const [addingSchedule, setAddingSchedule] = useState(false); - const [enabled, setEnabled] = useState(false); - const [saveState, setSaveState] = useState("idle"); - const [error, setError] = useState(null); - const [runs, setRuns] = useState([]); - const [targetAssetId, setTargetAssetId] = useState(null); - // Set when a field blur bails out because no target is picked yet on a - // brand-new routine — a silent `return` there gives the person no - // feedback that their edit didn't save, so this drives an inline hint - // under the picker instead. - const [needsTargetHint, setNeedsTargetHint] = useState(false); - const [targetStale, setTargetStale] = useState(false); - - // Every write (create or update) this panel session makes runs through - // this one chain — never two in flight at once. `routineIdRef` is the - // decision the chained tasks read: it only updates once a write actually - // resolves, so a second commit queued before the first finished sees the - // *post*-first-write id, not a stale snapshot from before either ran. - const routineIdRef = useRef(routineId); - const targetAssetIdRef = useRef(targetAssetId); - targetAssetIdRef.current = targetAssetId; - const writeChainRef = useRef>(Promise.resolve()); - const draftRef = useRef({ - name: "", - savedName: "", - instruction: "", - savedInstruction: "", - trigger: null, - savedTrigger: null, - enabled: false, - }); - draftRef.current = { - name, - savedName, - instruction, - savedInstruction, - trigger, - savedTrigger, - enabled, - }; - - // A new subject (a different routine, or a fresh "New routine") replaces - // this pane's entire local draft — nothing from the last session leaks - // into the next, including any write still chained from it. - useEffect(() => { - routineIdRef.current = subject.routineId ?? null; - writeChainRef.current = Promise.resolve(); - setRoutineId(subject.routineId ?? null); - setName(subject.routineId == null ? (subject.initialName ?? "") : ""); - setSavedName(""); - setInstruction( - subject.routineId == null ? (subject.initialInstruction ?? "") : "", - ); - setSavedInstruction(""); - setTrigger(null); - setSavedTrigger(null); - setTriggerSourceLabel(null); - setAddingSchedule(false); - setEnabled(false); - setSaveState("idle"); - setError(null); - setRuns([]); - setTargetAssetId(null); - setTargetStale(false); - }, [subject]); - - const loadRuns = (id: string) => { - if (tenantId === null) return; - void listRoutineRuns(tenantId, id).then( - (loaded) => setRuns(loaded), - () => setRuns([]), - ); - }; - - /** First load of an existing routine — replace the whole draft. */ - const hydrateRoutine = (routine: Routine) => { - setRoutineId(routine.id); - routineIdRef.current = routine.id; - setName(routine.name); - setSavedName(routine.name); - const loadedInstruction = instructionFromInput(routine.input); - setInstruction(loadedInstruction); - setSavedInstruction(loadedInstruction); - setTrigger(routine.trigger); - setSavedTrigger(routine.trigger); - setEnabled(routine.enabled); - setTargetAssetId(routine.definitionAssetId); - draftRef.current = { - name: routine.name, - savedName: routine.name, - instruction: loadedInstruction, - savedInstruction: loadedInstruction, - trigger: routine.trigger, - savedTrigger: routine.trigger, - enabled: routine.enabled, - }; - }; - - /** After a create/update resolves: refresh saved-* bookkeeping and the - * routine id, but leave any still-dirty draft field alone so an in-flight - * name create cannot wipe instruction/schedule the user typed meanwhile - * (CL-6755). */ - const ackRoutine = (routine: Routine) => { - const draft = draftRef.current; - const loadedInstruction = instructionFromInput(routine.input); - - setRoutineId(routine.id); - routineIdRef.current = routine.id; - - if (draft.name.trim() === draft.savedName) { - setName(routine.name); - draft.name = routine.name; - } - if (draft.instruction.trim() === draft.savedInstruction) { - setInstruction(loadedInstruction); - draft.instruction = loadedInstruction; - } - if (triggersEqual(draft.trigger, draft.savedTrigger)) { - setTrigger(routine.trigger); - draft.trigger = routine.trigger; - } - - setSavedName(routine.name); - setSavedInstruction(loadedInstruction); - setSavedTrigger(routine.trigger); - setEnabled(routine.enabled); - draft.savedName = routine.name; - draft.savedInstruction = loadedInstruction; - draft.savedTrigger = routine.trigger; - draft.enabled = routine.enabled; - }; - - // Loads an existing routine's real fields once the tenant resolves — - // mirrors `ProfileCanvasPane`'s own "fetch once open" effect. The - // existing-routine target is now owned by `DefinitionTargetPicker` - // itself (CL-7358: editing a routine can retarget it), which reports - // staleness back via `onStaleChange` — this effect no longer fetches - // the target list a second time. - useEffect(() => { - if (tenantId === null || subject.routineId == null) return; - let cancelled = false; - void getRoutine(tenantId, subject.routineId).then( - (routine) => { - if (cancelled) return; - hydrateRoutine(routine); - loadRuns(routine.id); - }, - (cause: unknown) => { - if (!cancelled) { - setError(cause instanceof Error ? cause.message : String(cause)); - } - }, - ); - return () => { - cancelled = true; - }; - }, [tenantId, subject.routineId]); - - /** This routine's delivery workbench: the conversation the panel was - * opened beside (its own id), or, with no conversation in scope, this - * workbench's own default Myra workbench. Never mints a new workbench — - * `ensureMyraWorkbench` finds-or-creates the one singleton Myra - * conversation this tenant already has. Delivery is independent of what - * the routine runs (CL-7355) — that's `targetAssetId`, picked explicitly. */ - const resolveDeliveryWorkbenchId = async (): Promise => { - if (tenantId === null) { - throw new Error("No workbench to create this in yet"); - } - if (subject.workbenchId !== undefined) return subject.workbenchId; - const result = await ensureMyraWorkbench(tenantId); - if (result.kind === "error") throw new Error(result.message); - return result.workbenchId; - }; - - /** Every create/update this panel makes funnels through this one chain — - * `task` reads the *current* routine id only when it actually runs, so - * two commits queued in the same tick still execute, and decide - * create-vs-update, strictly one after the other. */ - const runWrite = ( - task: (currentId: string | null) => Promise, - ): Promise => { - const next = writeChainRef.current.then(async () => { - setSaveState("saving"); - setError(null); - try { - const routine = await task(routineIdRef.current); - ackRoutine(routine); - invalidateRoutines(); - setSaveState("saved"); - } catch (cause) { - setSaveState("error"); - setError(cause instanceof Error ? cause.message : String(cause)); - } - }); - writeChainRef.current = next; - return next; - }; - - const doCreate = async (fields: { - readonly name: string; - readonly instruction: string; - readonly trigger: RoutineTrigger; - }): Promise => { - const definitionAssetId = targetAssetIdRef.current; - if (definitionAssetId === null) { - throw new Error("Pick what this routine runs before saving."); - } - const deliveryWorkbenchId = await resolveDeliveryWorkbenchId(); - const routine = await createRoutine(tenantId as string, { - name: fields.name, - definitionAssetId, - deliveryWorkbenchId, - scope: "personal", - trigger: fields.trigger, - runOnceNow: false, - ...(fields.instruction.trim() !== "" - ? { input: { instruction: fields.instruction.trim() } } - : {}), - }); - toast(routineCreatedToast(routine.name)); - return routine; - }; - - // The create-vs-update decision is made *inside* each queued task, from - // the `id` `runWrite` hands it at the moment it actually runs — never - // from `routineIdRef.current` read here, before either write's turn in - // the chain. Name's blur and Instruction's blur can both fire in the - // same tick, both see "no routine yet" at read time here, and both - // still enqueue — but only the first task to actually execute creates; - // by the time the second runs, the chain has already applied the first - // write's result, so it correctly updates instead. The bail-outs below - // (blank name, unchanged value) are safe to read eagerly — being a tick - // stale there costs at most one redundant PATCH, never a second POST. - // A brand-new routine requires a picked target before its first write can - // fire at all (CL-7355) — a blur with no target selected yet is not a - // failed save, it's simply not ready to submit. - const commitName = () => { - const trimmed = name.trim(); - if (trimmed === "") return; - if (routineIdRef.current !== null && trimmed === savedName) return; - if (routineIdRef.current === null && targetAssetIdRef.current === null) { - setNeedsTargetHint(true); - return; - } - void runWrite((id) => { - if (id === null) return doCreate({ name: trimmed, instruction, trigger }); - return updateRoutine(tenantId as string, id, { name: trimmed }); - }); - }; - - const commitInstruction = () => { - const trimmed = instruction.trim(); - if (routineIdRef.current === null && name.trim() === "") return; - if (routineIdRef.current !== null && trimmed === savedInstruction) return; - if (routineIdRef.current === null && targetAssetIdRef.current === null) { - setNeedsTargetHint(true); - return; - } - void runWrite((id) => { - if (id === null) { - return doCreate({ name: name.trim(), instruction, trigger }); - } - return updateRoutine(tenantId as string, id, { - input: { instruction: trimmed }, - }); - }); - }; - - const commitSchedule = (next: Exclude) => { - setTrigger(next); - draftRef.current.trigger = next; - setTriggerSourceLabel(null); - setAddingSchedule(false); - if (routineIdRef.current === null && targetAssetIdRef.current === null) { - setNeedsTargetHint(true); - return; - } - void runWrite((id) => { - if (id === null) { - return doCreate({ - name: name.trim() || "Untitled routine", - instruction, - trigger: next, - }); - } - return updateRoutine(tenantId as string, id, { trigger: next }); - }); - }; - - // Picking a target reads `routineIdRef.current` itself — the same "is - // there a routine yet" decision `runWrite`'s own `task` closures make — - // rather than the panel branching create-vs-edit at the picker's call - // site (`routineId === null ? pickTarget : pickTargetForEdit`, the - // shape this replaced). On a brand-new routine (no id yet) picking a - // target never itself submits — it only clears the "no target yet" - // bail-out so the next field blur (Name/Instruction) can create; - // `targetAssetIdRef` is set synchronously so a blur landing in the very - // same tick already sees the pick, not a stale pre-render snapshot. On - // an existing routine (CL-7358), picking a target autosaves immediately, - // like every other field, as a target-only PATCH — no other draft field - // rides along. A server rejection (404/409/403 — cross-tenant, unfrozen/ - // undeployed, forbidden; see `routineTargetRejection`) surfaces through - // the panel's normal inline error path and reverts the picker to the - // last-saved target, without touching any other unsaved input. - const pickTarget = (definitionAssetId: string) => { - const id = routineIdRef.current; - if (id === null) { - targetAssetIdRef.current = definitionAssetId; - setTargetAssetId(definitionAssetId); - setNeedsTargetHint(false); - return; - } - const previous = targetAssetIdRef.current; - targetAssetIdRef.current = definitionAssetId; - setTargetAssetId(definitionAssetId); - void runWrite(() => - // This catch reverts ONLY `targetAssetIdRef`/`targetAssetId` — the - // two fields this PATCH ever sends. Do not fold another field's - // revert into this same catch if a future change widens this PATCH - // beyond target-only; give it its own previous/revert pair instead, - // the way this one is scoped to just the target. - updateRoutine(tenantId as string, id, { definitionAssetId }).catch( - (cause: unknown) => { - targetAssetIdRef.current = previous; - setTargetAssetId(previous); - throw cause; - }, - ), - ); - }; - - const removeTrigger = () => { - setTrigger(null); - draftRef.current.trigger = null; - setTriggerSourceLabel(null); - void runWrite((id) => { - if (id === null) { - return Promise.reject(new Error("No routine to update yet")); - } - return updateRoutine(tenantId as string, id, { trigger: null }); - }); - }; - - const addWebhookTrigger = (sourceLabel: string) => { - void runWrite(async (id) => { - if (tenantId === null) { - throw new Error("No workbench to create this in yet"); - } - let targetRoutineId = id; - let definitionAssetId: string; - if (targetRoutineId === null) { - const created = await doCreate({ - name: name.trim() || "Untitled routine", - instruction, - trigger: null, - }); - targetRoutineId = created.id; - definitionAssetId = created.definitionAssetId; - } else { - const existing = targetAssetIdRef.current; - if (existing === null) { - throw new Error("Pick what this routine runs before saving."); - } - definitionAssetId = existing; - } - const binding = await createWebhookTrigger(tenantId, { - name: `${name.trim() || "Untitled routine"} — ${sourceLabel}`, - workflowDefinitionId: definitionAssetId, - inputTemplate: DEFAULT_WEBHOOK_INPUT_TEMPLATE, - }); - setTriggerSourceLabel(sourceLabel); - return updateRoutine(tenantId, targetRoutineId, { - trigger: { kind: "webhook", webhookTriggerId: binding.id }, - }); - }); - }; - - const toggleActive = (next: boolean) => { - setEnabled(next); - void runWrite((id) => { - if (id === null) { - setEnabled(!next); - return Promise.reject(new Error("No routine to update yet")); - } - return updateRoutine(tenantId as string, id, { enabled: next }).catch( - (cause: unknown) => { - setEnabled(!next); - throw cause; - }, - ); - }); - }; - - const busy = saveState === "saving"; - - return ( -
    - - Saving… - - ) : saveState === "saved" ? ( - - Saved - - ) : null - } - /> -
    - - {routineId !== null ? ( - { - if (tenantId === null || routineId === null) return; - void deleteRoutine(tenantId, routineId).then(() => { - invalidateRoutines(); - onClose(); - }); - }} - > - Delete - - ) : null} - { - if (tenantId === null || routineId === null) return; - return runRoutineNow(tenantId, routineId).then(() => { - toast(routineRunStartedToast(name)); - loadRuns(routineId); - invalidateRoutines(); - }); - }} - /> -
    - -
    -
    - - { - setName(event.target.value); - draftRef.current.name = event.target.value; - }} - onBlur={commitName} - /> -
    - -
    - - {needsTargetHint && targetAssetId === null ? ( -

    - Pick what this routine runs before the rest can save. -

    - ) : null} -
    - -
    - -